This repository has been archived on 2026-08-16. You can view files and clone it, but cannot push or open issues or pull requests.
ollie-core/pkg/tools/execute/tool.go

256 lines
7.0 KiB
Go

package execute
import (
"fmt"
"os"
"path/filepath"
"strings"
"ollie/pkg/paths"
"ollie/pkg/tools"
)
// ToolsPath returns the directory to search for named tool scripts.
// Resolved in order: first entry of OLLIE_TOOLS_PATH (colon-separated),
// then ~/.config/ollie/tools.
func ToolsPath() string {
if p := os.Getenv("OLLIE_TOOLS_PATH"); p != "" {
if i := strings.Index(p, ":"); i >= 0 {
p = p[:i]
}
return p
}
return paths.CfgDir() + "/tools"
}
// PluginsPath returns the directory for server-invoked plugin scripts.
// Resolved in order: first entry of OLLIE_PLUGINS_PATH (colon-separated),
// then ~/.config/ollie/scripts/x.
func PluginsPath() string {
if p := os.Getenv("OLLIE_PLUGINS_PATH"); p != "" {
if i := strings.Index(p, ":"); i >= 0 {
p = p[:i]
}
return p
}
return paths.CfgDir() + "/scripts/x"
}
// detectLanguage infers the script language from the shebang line.
// Returns "python3", "perl", "awk", "sed", "ed", or "bash".
func detectLanguage(code string) string {
line, _, _ := strings.Cut(code, "\n")
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "#!") {
return "bash"
}
fields := strings.Fields(strings.TrimPrefix(line, "#!"))
if len(fields) == 0 {
return "bash"
}
// When the interpreter is /usr/bin/env, the actual interpreter is the next argument.
names := fields
if filepath.Base(fields[0]) == "env" && len(fields) > 1 {
names = fields[1:]
}
switch filepath.Base(names[0]) {
case "python", "python3":
return "python3"
case "perl":
return "perl"
case "awk", "gawk":
return "awk"
case "sed", "gsed":
return "sed"
case "ed":
return "ed"
case "jq":
return "jq"
case "expect":
return "expect"
case "bc":
return "bc"
case "lua", "lua5.1", "lua5.2", "lua5.3", "lua5.4":
return "lua"
}
return "bash"
}
// injectArgs prepends language-appropriate argument binding to code.
func injectArgs(language, name string, args []string, code string) string {
switch language {
case "python3":
quoted := make([]string, len(args))
for i, a := range args {
quoted[i] = fmt.Sprintf("%q", a)
}
return fmt.Sprintf("import sys\nsys.argv = [%q, %s]\n%s", name, strings.Join(quoted, ", "), code)
case "perl":
quoted := make([]string, len(args))
for i, a := range args {
quoted[i] = "'" + strings.ReplaceAll(a, "'", "\\'") + "'"
}
return fmt.Sprintf("@ARGV = (%s);\n%s", strings.Join(quoted, ", "), code)
case "awk":
// awk args are input filenames; produce a bash snippet: gawk -e $'prog' -- file ...
fileArgs := make([]string, len(args))
for i, a := range args {
fileArgs[i] = "'" + strings.ReplaceAll(a, "'", "'\\''") + "'"
}
return fmt.Sprintf("gawk -e $'%s' -- %s", ansiCEscape(code), strings.Join(fileArgs, " "))
case "sed":
// sed args are input filenames (use -i in the script for in-place editing).
fileArgs := make([]string, len(args))
for i, a := range args {
fileArgs[i] = "'" + strings.ReplaceAll(a, "'", "'\\''") + "'"
}
return fmt.Sprintf("sed -e $'%s' %s", ansiCEscape(code), strings.Join(fileArgs, " "))
case "ed":
// ed reads commands from stdin; first arg is the file to edit.
file := ""
if len(args) > 0 {
file = " '" + strings.ReplaceAll(args[0], "'", "'\\''") + "'"
}
return fmt.Sprintf("printf '%%s' $'%s' | ed -s%s", ansiCEscape(code), file)
case "jq":
// jq args are JSON input files; filter is the program.
fileArgs := make([]string, len(args))
for i, a := range args {
fileArgs[i] = "'" + strings.ReplaceAll(a, "'", "'\\''") + "'"
}
return fmt.Sprintf("jq $'%s' %s", ansiCEscape(code), strings.Join(fileArgs, " "))
case "expect":
// expect reads script from stdin via expect -.
return fmt.Sprintf("printf '%%s' $'%s' | expect -", ansiCEscape(code))
case "bc":
// bc reads from stdin; -ql for quiet mode + math library.
return fmt.Sprintf("printf '%%s' $'%s' | bc -ql", ansiCEscape(code))
case "lua":
// Inject args as the arg table, matching lua's scriptfile convention.
quoted := make([]string, len(args))
for i, a := range args {
quoted[i] = fmt.Sprintf("%q", a)
}
return fmt.Sprintf("arg={%s}\n%s", strings.Join(quoted, ", "), code)
default: // bash
escaped := make([]string, len(args))
for i, a := range args {
escaped[i] = "'" + strings.ReplaceAll(a, "'", "'\\''") + "'"
}
return fmt.Sprintf("set -- %s\n%s", strings.Join(escaped, " "), code)
}
}
// ansiCEscape escapes a string for embedding in a bash $'...' literal.
func ansiCEscape(s string) string {
var b strings.Builder
for _, r := range s {
switch r {
case '\\':
b.WriteString(`\\`)
case '\'':
b.WriteString(`\'`)
case '\n':
b.WriteString(`\n`)
case '\r':
b.WriteString(`\r`)
case '\t':
b.WriteString(`\t`)
default:
b.WriteRune(r)
}
}
return b.String()
}
// ExtractPrompt parses the ollie:prompt ... ollie:end block from a tool
// script's header comments. Returns the prompt text with comment prefixes
// stripped, or "" if no prompt block is found.
func ExtractPrompt(script string) string {
var lines []string
var inPrompt bool
for _, line := range strings.Split(script, "\n") {
trimmed := strings.TrimSpace(line)
// Strip comment prefix (# or //)
bare := trimmed
if strings.HasPrefix(bare, "# ") {
bare = bare[2:]
} else if strings.HasPrefix(bare, "#") {
bare = bare[1:]
} else if strings.HasPrefix(bare, "// ") {
bare = bare[3:]
} else if strings.HasPrefix(bare, "//") {
bare = bare[2:]
} else if inPrompt {
// Non-comment line while in prompt block — end of header
break
} else {
// Non-comment line before prompt block found
if trimmed != "" && !strings.HasPrefix(trimmed, "#!") {
break
}
continue
}
if strings.Contains(bare, "ollie:prompt") {
inPrompt = true
continue
}
if inPrompt && strings.Contains(bare, "ollie:end") {
break
}
if inPrompt {
lines = append(lines, bare)
}
}
if len(lines) == 0 {
return ""
}
return strings.Join(lines, "\n")
}
// DiscoverTools scans the tools directory and returns metadata for all scripts,
// including their ollie:prompt blocks. Used to populate the /tools file and
// inject tool prompts into the system prompt.
func DiscoverTools() []tools.ToolInfo {
dir := ToolsPath()
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var infos []tools.ToolInfo
for _, e := range entries {
if e.IsDir() || e.Name() == "idx" || strings.HasPrefix(e.Name(), ".") {
continue
}
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
continue
}
prompt := ExtractPrompt(string(data))
infos = append(infos, tools.ToolInfo{
Name: e.Name(),
Prompt: prompt,
})
}
return infos
}
// ReadTool reads a named tool script from the tools directory.
func ReadTool(name string) (string, error) {
if strings.Contains(name, "/") || strings.Contains(name, "..") {
return "", fmt.Errorf("invalid tool name")
}
path := filepath.Join(ToolsPath(), name)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return "", fmt.Errorf("tool not found: %s", name)
}
return "", fmt.Errorf("read tool %s: %w", name, err)
}
return string(data), nil
}