diff --git a/agent/agent_config.go b/agent/agent_config.go index 83aabfc..a2bfa21 100644 --- a/agent/agent_config.go +++ b/agent/agent_config.go @@ -7,35 +7,22 @@ import ( "ollie/backend" ) -// Prompt holds the agent prompt. It unmarshals from either a JSON string -// (treated as literal text, with the existing resolvePrompt semantics) or -// an array of strings (each element is a shell command whose stdout is -// concatenated with newlines). +// Prompt holds the agent prompt — a list of file paths to read and concatenate. +// Unmarshals from either a JSON string (single path) or array of strings. type Prompt struct { - Value []string // len==1 for a plain string; len>1 for command list - IsExec bool // true when the value came from an array (execute mode) + Value []string } func (p *Prompt) UnmarshalJSON(data []byte) error { var s string if err := json.Unmarshal(data, &s); err == nil { p.Value = []string{s} - p.IsExec = false return nil } - var ss []string - if err := json.Unmarshal(data, &ss); err != nil { - return err - } - p.Value = ss - p.IsExec = true - return nil + return json.Unmarshal(data, &p.Value) } func (p Prompt) MarshalJSON() ([]byte, error) { - if p.IsExec { - return json.Marshal(p.Value) - } if len(p.Value) == 1 { return json.Marshal(p.Value[0]) } diff --git a/agent/config_paths.go b/agent/config_paths.go index 74ebc4d..93fadd0 100644 --- a/agent/config_paths.go +++ b/agent/config_paths.go @@ -6,16 +6,6 @@ import ( "ollie/paths" ) -// defaultPromptsDir returns the default directory for prompt templates. -func defaultPromptsDir() string { - return paths.CfgDir() + "/prompts" -} - -// PromptsDirs returns all prompt directories. Currently just the default. -func PromptsDirs() []string { - return []string{defaultPromptsDir()} -} - // AgentsDirs returns all agent directories. Currently just the default. func AgentsDirs() []string { return []string{paths.CfgDir() + "/agents"} @@ -33,7 +23,6 @@ func AgentConfigPath(agentsDir, name string) string { } // LoadConfig loads an agent configuration file by profile name. -// If open is nil, os.Open is used. func LoadConfig(agentsDir, name string, open func(string) (*os.File, error)) *AgentConfig { if open == nil { open = os.Open diff --git a/agent/prompt_resolver.go b/agent/prompt_resolver.go index 0214a40..32c6e98 100644 --- a/agent/prompt_resolver.go +++ b/agent/prompt_resolver.go @@ -1,9 +1,6 @@ package agent import ( - "bytes" - "context" - "fmt" "os" "os/exec" "path/filepath" @@ -32,176 +29,76 @@ func PromptEnv(cwd string) []string { } } -// 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. +// resolvePrompt reads each file path in p.Value, expands env vars in both +// the path and the file content, and joins the results with newlines. 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] - }) - } + lookup := envLookup(env) + expand := func(s string) string { return os.Expand(s, lookup) } var parts []string - for _, entry := range cmds { + for _, entry := range p.Value { entry = strings.TrimSpace(entry) if entry == "" { continue } - // Try resolving as a prompt file first. - if content, ok := resolvePromptFile(entry, expand); ok { - if content != "" { - parts = append(parts, content) + path := expand(entry) + + // Support !command for backward compat (used in some user configs) + if strings.HasPrefix(path, "!") { + out, err := runPromptCmd(strings.TrimSpace(path[1:]), cwd) + if err == nil && out != "" { + parts = append(parts, out) } continue } - // Not a prompt file — skip. - continue + + data, err := os.ReadFile(path) + if err != nil { + // Try with .md suffix + data, err = os.ReadFile(path + ".md") + if err != nil { + continue + } + } + content := strings.TrimRight(expand(string(data)), "\n") + if content != "" { + parts = append(parts, content) + } } 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 the default prompts directory for -// entry and entry.md. Returns the file content (with env vars expanded) and -// true if found. -func resolvePromptFile(entry 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 the default prompts directory. - for _, dir := range 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)) +// envLookup returns a lookup function that checks env slice first, then os env. +func envLookup(env []string) func(string) string { + m := make(map[string]string, len(env)) for _, e := range env { if k, v, ok := strings.Cut(e, "="); ok { - envMap[k] = v + m[k] = v } } - expanded := os.Expand(prompt, func(key string) string { - if val, ok := envMap[key]; ok { - return val + return func(key string) string { + if v, ok := m[key]; ok { + return v } - if val := os.Getenv(key); val != "" { - return val - } - return "" - }) - expanded = strings.TrimSpace(expanded) - if expanded == "" { + return os.Getenv(key) + } +} + +// runPromptCmd executes a shell command and returns its stdout. +func runPromptCmd(cmdStr, cwd string) (string, error) { + if cmdStr == "" { return "", nil } - if strings.Contains(expanded, "\n") { - return expanded, nil + cmd := exec.Command("sh", "-c", cmdStr) + if cwd != "" { + cmd.Dir = cwd } - 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 + out, err := cmd.Output() + if err != nil { + return "", err } - 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 + return strings.TrimRight(string(out), "\n"), nil }