298 lines
8.2 KiB
Go
298 lines
8.2 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"
|
|
}
|
|
|
|
// DetectLanguage is the exported version of detectLanguage.
|
|
func DetectLanguage(code string) string {
|
|
return detectLanguage(code)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// InjectArgs is the exported version of injectArgs.
|
|
func InjectArgs(language, name string, args []string, code string) string {
|
|
return injectArgs(language, name, args, 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 and short descriptions.
|
|
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
|
|
}
|
|
script := string(data)
|
|
prompt := ExtractPrompt(script)
|
|
desc := extractShortDescription(prompt)
|
|
infos = append(infos, tools.ToolInfo{
|
|
Name: e.Name(),
|
|
Description: desc,
|
|
Prompt: prompt,
|
|
})
|
|
}
|
|
return infos
|
|
}
|
|
|
|
// extractShortDescription gets the first non-heading, non-empty line from a prompt.
|
|
func extractShortDescription(prompt string) string {
|
|
for _, line := range strings.Split(prompt, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
return line
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ExtractShortDescription is the exported version of extractShortDescription.
|
|
func ExtractShortDescription(prompt string) string {
|
|
return extractShortDescription(prompt)
|
|
}
|
|
|
|
// ToolPrompt returns the full ollie:prompt content for a named tool.
|
|
// Returns "" if the tool has no prompt or doesn't exist.
|
|
func ToolPrompt(name string) string {
|
|
if strings.Contains(name, "/") || strings.Contains(name, "..") {
|
|
return ""
|
|
}
|
|
path := filepath.Join(ToolsPath(), name)
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return ExtractPrompt(string(data))
|
|
}
|
|
|
|
// 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
|
|
}
|