227 lines
6.1 KiB
Go
227 lines
6.1 KiB
Go
package agent
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"time"
|
|
|
|
"ollie/paths"
|
|
)
|
|
|
|
// PromptEnv returns the standard PRIME_* environment variables for prompt
|
|
// template resolution. For local sessions, values are detected from the
|
|
// filesystem. For remote sessions, callers should override these with
|
|
// values from remote.HostInfo.
|
|
func PromptEnv(cwd string) []string {
|
|
platform := runtime.GOOS
|
|
isGitRepo := "false"
|
|
if cwd != "" {
|
|
if _, err := os.Stat(filepath.Join(cwd, ".git")); err == nil {
|
|
isGitRepo = "true"
|
|
}
|
|
}
|
|
return []string{
|
|
"PRIME_CWD=" + cwd,
|
|
"PRIME_PLATFORM=" + platform,
|
|
"PRIME_DATE=" + time.Now().Format("2006-01-02"),
|
|
"PRIME_IS_GIT_REPO=" + isGitRepo,
|
|
}
|
|
}
|
|
|
|
// resolvePrompt interprets a Prompt from an agent config.
|
|
//
|
|
// If the prompt was parsed from a JSON array, each element is executed as a
|
|
// shell command and the combined stdout (joined by newlines) is returned.
|
|
//
|
|
// If the prompt was parsed from a JSON string, the existing single-string
|
|
// semantics apply: environment variables are expanded, then:
|
|
// - If the string contains a newline, it is treated as literal text.
|
|
// - If the string starts with '!', the rest is executed as a shell command.
|
|
// - If the expanded string names an existing file, the file is read.
|
|
// - Otherwise the string is used as-is.
|
|
func resolvePrompt(p Prompt, cwd string, env []string) (string, error) {
|
|
if len(p.Value) == 0 {
|
|
return "", nil
|
|
}
|
|
if p.IsExec {
|
|
return resolveExecPrompt(p.Value, cwd, env)
|
|
}
|
|
return resolveStringPrompt(p.Value[0], cwd, env)
|
|
}
|
|
|
|
func resolveExecPrompt(cmds []string, cwd string, env []string) (string, error) {
|
|
// Build env map for variable expansion.
|
|
fullEnv := append(os.Environ(), env...)
|
|
envMap := make(map[string]string, len(fullEnv))
|
|
for _, e := range fullEnv {
|
|
if k, v, ok := strings.Cut(e, "="); ok {
|
|
envMap[k] = v
|
|
}
|
|
}
|
|
expand := func(s string) string {
|
|
return os.Expand(s, func(key string) string {
|
|
return envMap[key]
|
|
})
|
|
}
|
|
|
|
var parts []string
|
|
for _, entry := range cmds {
|
|
entry = strings.TrimSpace(entry)
|
|
if entry == "" {
|
|
continue
|
|
}
|
|
// Try resolving as a prompt file first.
|
|
if content, ok := resolvePromptFile(entry, envMap, expand); ok {
|
|
if content != "" {
|
|
parts = append(parts, content)
|
|
}
|
|
continue
|
|
}
|
|
// Fall back to shell execution.
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
cmd := exec.CommandContext(ctx, "sh", "-c", entry)
|
|
if cwd != "" {
|
|
if info, err := os.Stat(cwd); err == nil && info.IsDir() {
|
|
cmd.Dir = cwd
|
|
}
|
|
}
|
|
cmd.Env = fullEnv
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
err := cmd.Run()
|
|
cancel()
|
|
if err != nil {
|
|
return "", fmt.Errorf("prompt command %q failed: %v: %s", entry, err, stderr.String())
|
|
}
|
|
if out := strings.TrimRight(stdout.String(), "\n"); out != "" {
|
|
parts = append(parts, out)
|
|
}
|
|
}
|
|
return strings.Join(parts, "\n"), nil
|
|
}
|
|
|
|
// resolvePromptFile attempts to resolve entry as a prompt file.
|
|
// It first checks if the entry (after env expansion) is an absolute path to an
|
|
// existing file. Otherwise it searches CfgDir()/prompts for entry and entry.md.
|
|
// Returns the file content (with env vars expanded) and true if found.
|
|
func resolvePromptFile(entry string, envMap map[string]string, expand func(string) string) (string, bool) {
|
|
expanded := expand(entry)
|
|
|
|
// If it contains shell metacharacters, it's a command, not a file name.
|
|
if strings.ContainsAny(expanded, "|&;<>()$`\\\"' \t") {
|
|
return "", false
|
|
}
|
|
|
|
// Try as absolute path.
|
|
if filepath.IsAbs(expanded) {
|
|
if info, err := os.Stat(expanded); err == nil && !info.IsDir() {
|
|
return readAndExpand(expanded, expand)
|
|
}
|
|
// Try with .md suffix.
|
|
if info, err := os.Stat(expanded + ".md"); err == nil && !info.IsDir() {
|
|
return readAndExpand(expanded+".md", expand)
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// Search CfgDir()/prompts.
|
|
for _, dir := range paths.PromptsDirs() {
|
|
if dir == "" {
|
|
continue
|
|
}
|
|
for _, candidate := range []string{
|
|
filepath.Join(dir, expanded),
|
|
filepath.Join(dir, expanded+".md"),
|
|
} {
|
|
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
|
return readAndExpand(candidate, expand)
|
|
}
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func readAndExpand(path string, expand func(string) string) (string, bool) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
content := strings.TrimRight(expand(string(data)), "\n")
|
|
return content, true
|
|
}
|
|
|
|
func resolveStringPrompt(prompt, cwd string, env []string) (string, error) {
|
|
if prompt == "" {
|
|
return "", nil
|
|
}
|
|
envMap := make(map[string]string, len(env))
|
|
for _, e := range env {
|
|
if k, v, ok := strings.Cut(e, "="); ok {
|
|
envMap[k] = v
|
|
}
|
|
}
|
|
expanded := os.Expand(prompt, func(key string) string {
|
|
if val, ok := envMap[key]; ok {
|
|
return val
|
|
}
|
|
if val := os.Getenv(key); val != "" {
|
|
return val
|
|
}
|
|
return ""
|
|
})
|
|
expanded = strings.TrimSpace(expanded)
|
|
if expanded == "" {
|
|
return "", nil
|
|
}
|
|
if strings.Contains(expanded, "\n") {
|
|
return expanded, nil
|
|
}
|
|
if strings.HasPrefix(expanded, "!") {
|
|
cmdStr := strings.TrimPrefix(expanded, "!")
|
|
cmdStr = strings.TrimSpace(cmdStr)
|
|
if cmdStr == "" {
|
|
return "", nil
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(ctx, "sh", "-c", cmdStr)
|
|
var cmdEnv []string
|
|
if cwd != "" {
|
|
if info, err := os.Stat(cwd); err == nil && info.IsDir() {
|
|
cmd.Dir = cwd
|
|
} else {
|
|
cmdEnv = append(cmdEnv, "PWD="+cwd)
|
|
}
|
|
}
|
|
if len(cmdEnv) > 0 {
|
|
cmd.Env = append(os.Environ(), cmdEnv...)
|
|
}
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return "", fmt.Errorf("prompt command failed: %v: %s", err, stderr.String())
|
|
}
|
|
return strings.TrimRight(stdout.String(), "\n"), nil
|
|
}
|
|
path := expanded
|
|
if !filepath.IsAbs(path) && cwd != "" {
|
|
path = filepath.Join(cwd, path)
|
|
}
|
|
if info, err := os.Stat(path); err == nil && !info.IsDir() {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return "", fmt.Errorf("read prompt file: %w", err)
|
|
}
|
|
return strings.TrimRight(string(data), "\n"), nil
|
|
}
|
|
return expanded, nil
|
|
}
|