From de60b93bc6b266d672597cab3f1666ae386d92d9 Mon Sep 17 00:00:00 2001 From: Harry Bayliss Date: Mon, 18 May 2026 11:28:00 +0100 Subject: [PATCH] Use built-in agent preset defaults --- CHANGELOG.md | 10 + SPEC.md | 23 +- TODO.md | 1 + internal/app/classifier.go | 10 +- internal/app/idle.go | 19 +- internal/app/idle_test.go | 41 ++-- internal/app/launch.go | 8 +- internal/app/launch_test.go | 30 +++ .../idle_screen_permission_prompt.json | 37 ++++ internal/preset/preset.go | 207 ++++++++++++------ internal/preset/preset_test.go | 124 +++++++++++ 11 files changed, 402 insertions(+), 108 deletions(-) create mode 100644 internal/app/launch_test.go create mode 100644 internal/harness/scenarios/idle_screen_permission_prompt.json create mode 100644 internal/preset/preset_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 294a3a5..8657129 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Changed +- Built-in agent presets (`claude`, `codex`, `opencode`) now live in + memory and user preset files merge over them by name instead of + patterm writing default preset files into `$XDG_CONFIG_HOME`. Add + `"disabled": true` in a matching user preset to hide a built-in. +- Generated MCP config files for agent launches now live under the + runtime agent directory instead of `$XDG_CONFIG_HOME/patterm/mcp`. - Auto-summarization settings now save as soon as a changed row is applied, including cadence/provider/toggle changes and model edits, without requiring a separate save step. @@ -21,6 +27,10 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). using an ellipsized single-line value. ### Fixed +- Claude permission prompts are now detected from the rendered pane as + well as the recent output tail, so the sidebar marks the pane as + waiting for permission even while `Calling patterm...` continues to + repaint. - Removed the redundant "Back to Settings" row from the Agents / Auto-summarization settings screen. diff --git a/SPEC.md b/SPEC.md index 1a5256b..4faaaa0 100644 --- a/SPEC.md +++ b/SPEC.md @@ -39,7 +39,7 @@ The tool is one Go process that owns: the TUI, all PTYs, vt-emulated grids, sess ## 3. Project state layout -Scratchpads (user data) live under `$XDG_DATA_HOME`; presets and config live under `$XDG_CONFIG_HOME`. +Scratchpads (user data) live under `$XDG_DATA_HOME`; user-authored preset overlays and config live under `$XDG_CONFIG_HOME`. ``` $XDG_DATA_HOME/patterm/ @@ -53,12 +53,12 @@ $XDG_DATA_HOME/patterm/ └── .md $XDG_CONFIG_HOME/patterm/ -├── config.json # global settings (theme, default keymap, etc.) +├── settings.json # global settings, written only after the user changes settings └── presets/ ├── agents/ - │ ├── claude.json # ships as default - │ ├── codex.json # ships as default - │ ├── opencode.json # ships as default + │ ├── claude.json # optional overlay for built-in claude + │ ├── codex.json # optional overlay for built-in codex + │ ├── opencode.json # optional overlay for built-in opencode │ └── .json └── processes/ ├── dev.json # e.g. { "name": "bun run dev", "argv": ["bun", "run", "dev"] } @@ -66,7 +66,7 @@ $XDG_CONFIG_HOME/patterm/ └── .json ``` -Both preset directories are scanned at startup; every file found becomes a palette entry ("Spawn agent: claude", "Run process: bun run dev", …). Presets are project-agnostic in v1 — the same set is available in every project. Per-project overrides can be added later. +patterm always has built-in agent presets for `claude`, `codex`, and `opencode`. User preset files are scanned at startup and merged into matching built-ins by `name`, or added as standalone custom presets when the name is new. A matching file with `"disabled": true` hides a built-in. Startup does not write default preset files. Presets are project-agnostic in v1 — the same set is available in every project. Per-project overrides can be added later. Project key = `sha256(realpath(project_dir))[:16]`. Used only as a scratchpad directory name — there is no daemon to look up. @@ -121,7 +121,7 @@ Scratchpads and command-preset trust grants persist across runs. Sessions and ch Almost all application functions are driven through a single command palette opened with `Ctrl-K`. The palette is a fuzzy-searchable list of commands, scoped to whatever makes sense for the current focus. Two kinds of entries appear: - **Built-in commands** — "Switch to session…", "Focus pane…", "Take input control", "Release control to orchestrator", "Open scratchpad…", "Kill child…", "Quit", etc. -- **Preset commands** — one entry per file under `$XDG_CONFIG_HOME/patterm/presets/`. Agent presets surface as "Spawn agent: codex" / "Spawn agent: claude" / …; process presets surface as "Run process: bun run dev" / "Run process: vitest" / …. The label comes from the preset's `name` field; the action is "launch this preset into a new pane." +- **Preset commands** — one entry per built-in or user-defined preset. Agent presets surface as "Spawn agent: codex" / "Spawn agent: claude" / …; process presets surface as "Run process: bun run dev" / "Run process: vitest" / …. The label comes from the preset's `name` field; the action is "launch this preset into a new pane." Selecting a preset either launches it immediately (no required args) or opens a sub-palette for optional args — namely an **initial prompt** (agent presets only), which patterm injects into the spawned PTY's input after the agent is ready (§8). The orchestrator equivalent of this — `spawn_agent` / `spawn_process` MCP tools — uses the exact same machinery: pick a preset by name, optionally supply an initial prompt, patterm handles the rest. @@ -365,11 +365,11 @@ Risks acknowledged: the orchestrator's reading of the prompt is a vision/parsing ## 10. Presets -Presets are user-editable JSON files that describe how to launch something. patterm itself has no hard-coded agent or process types — every spawnable thing is a preset. Two flavours: +Presets describe how to launch something. patterm has built-in defaults for common agent CLIs, and user-editable JSON files can override, disable, or add presets. Two flavours: ### Agent presets -`$XDG_CONFIG_HOME/patterm/presets/agents/.json`. Launches a vendor LLM CLI with MCP wired up and the conversation-protocol addendum injected. +Built-in agent presets launch vendor LLM CLIs with MCP wired up and the conversation-protocol addendum injected. `$XDG_CONFIG_HOME/patterm/presets/agents/.json` can overlay a built-in by `name` or define a new agent preset. | Field | Purpose | |---|---| @@ -377,17 +377,18 @@ Presets are user-editable JSON files that describe how to launch something. patt | `argv` | Full launch argv (e.g. `["claude"]`, `["codex", "--no-tui-banner"]`) | | `env` | Env vars to set (merged over inherited env) | | `working_dir` | Defaults to the project root | +| `disabled` | If `true`, hides a built-in preset with the same `name` | | `mcp_injection` | How to point this CLI at patterm's stdio proxy. One of: `{ "kind": "flag", "flag": "--mcp-config", "config_path": "..." }`, `{ "kind": "config_file", "path": "~/.codex/config.toml", "merge_key": "mcp_servers" }`, `{ "kind": "env_var", "var": "MCP_CONFIG_PATH" }` | | `ready_signal` | How to detect the TUI is ready (default: 1s idle after launch). Override per-CLI if needed. | | `chrome_trim_hints` | Optional regexes / row ranges for stripping vendor chrome in grid reads | -Default presets shipped: `claude`, `codex`, `opencode`. Authoring these is per-vendor research — each CLI has its own MCP config conventions, ready states, and TUI chrome. Users can copy and edit them, or add new ones (e.g. a second `claude` preset that launches with a specific model or system prompt file). +Built-in presets: `claude`, `codex`, `opencode`. Authoring these is per-vendor research — each CLI has its own MCP config conventions, ready states, idle detection, and TUI chrome. Users can add small overlay files for built-ins, disable built-ins, or add new presets (e.g. a second `claude-sonnet` preset that launches with a specific model or system prompt file). MCP config flow: at startup, for each agent preset, patterm renders a small JSON pointing at its own `mcp-stdio` proxy subcommand (`patterm mcp-stdio --socket --identity `) into a per-preset temp file. The launch then uses the preset's `mcp_injection` strategy to hand that path to the CLI. The user's global vendor config is never mutated. ### Process presets -`$XDG_CONFIG_HOME/patterm/presets/processes/.json`. Launches a raw command in a PTY — no MCP, no addendum, no system prompt. +`$XDG_CONFIG_HOME/patterm/presets/processes/.json`. Launches a raw command in a PTY — no MCP, no addendum, no system prompt. There are no built-in process presets. | Field | Purpose | |---|---| diff --git a/TODO.md b/TODO.md index e69de29..0d8a6ad 100644 --- a/TODO.md +++ b/TODO.md @@ -0,0 +1 @@ +- [ ] We should show idle state in the top tab bar as well diff --git a/internal/app/classifier.go b/internal/app/classifier.go index ef8680c..5d49f77 100644 --- a/internal/app/classifier.go +++ b/internal/app/classifier.go @@ -50,8 +50,14 @@ func (s *Session) classifyOne(c *Child) { idleMS := c.IdleMS() titleIdleMS := c.TitleIdleMS() title := c.Title() - tail := c.tailBytes(classifierTailBytes) - state, reason := classify(c.idleDetection, exited, exitNonZero, idleMS, titleIdleMS, title, tail) + tail := stripANSIBytes(nil, c.tailBytes(classifierTailBytes)) + var screen []byte + if em := c.Emulator(); em != nil { + if txt, err := em.ScreenText(); err == nil { + screen = []byte(txt) + } + } + state, reason := classify(c.idleDetection, exited, exitNonZero, idleMS, titleIdleMS, title, tail, screen) if c.setIdleState(state, reason) { s.emitStateChanged(c.ID, state) } diff --git a/internal/app/idle.go b/internal/app/idle.go index cc80c73..1e696c0 100644 --- a/internal/app/idle.go +++ b/internal/app/idle.go @@ -118,7 +118,8 @@ func compilePatterns(ps []string) []*regexp.Regexp { // - titleIdleMS: ms since the last OSC title change (0 if no title yet) // - title: current OSC title // - tail: recent output bytes for regex matching -func classify(cfg *resolvedIdleDetection, exited, exitNonZero bool, idleMS, titleIdleMS int64, title string, tail []byte) (IdleState, string) { +// - screen: current rendered screen text for persistent prompt matching +func classify(cfg *resolvedIdleDetection, exited, exitNonZero bool, idleMS, titleIdleMS int64, title string, tail, screen []byte) (IdleState, string) { if exited { if exitNonZero { return StateError, "process exited non-zero" @@ -128,14 +129,14 @@ func classify(cfg *resolvedIdleDetection, exited, exitNonZero bool, idleMS, titl if cfg == nil { cfg = &resolvedIdleDetection{strategy: StrategyOutputActivity, idleThresholdMS: defaultIdleThresholdMS} } - if len(tail) > 0 { - if matchAny(cfg.errorRegexes, tail) { + if len(tail) > 0 || len(screen) > 0 { + if matchAny(cfg.errorRegexes, tail, screen) { return StateError, "error regex matched" } - if matchAny(cfg.permissionRegexes, tail) { + if matchAny(cfg.permissionRegexes, tail, screen) { return StatePermission, "permission regex matched" } - if matchAny(cfg.thinkingRegexes, tail) { + if matchAny(cfg.thinkingRegexes, tail, screen) { return StateThinking, "thinking regex matched" } } @@ -172,10 +173,12 @@ func baseStateFromIdleMS(idleMS, threshold int64) (IdleState, string) { return StateIdle, "quiet for threshold" } -func matchAny(res []*regexp.Regexp, tail []byte) bool { +func matchAny(res []*regexp.Regexp, texts ...[]byte) bool { for _, re := range res { - if re.Match(tail) { - return true + for _, text := range texts { + if len(text) > 0 && re.Match(text) { + return true + } } } return false diff --git a/internal/app/idle_test.go b/internal/app/idle_test.go index d448a45..784adf4 100644 --- a/internal/app/idle_test.go +++ b/internal/app/idle_test.go @@ -30,7 +30,7 @@ func TestClassifyOutputActivity(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - got, _ := classify(cfg, false, false, tc.idleMS, 0, "", nil) + got, _ := classify(cfg, false, false, tc.idleMS, 0, "", nil, nil) if got != tc.want { t.Fatalf("got %q want %q", got, tc.want) } @@ -41,18 +41,18 @@ func TestClassifyOutputActivity(t *testing.T) { func TestClassifyTitleStability(t *testing.T) { cfg := &resolvedIdleDetection{strategy: StrategyOSCTitleStability, idleThresholdMS: 2000} // Title change recent → working. - if got, _ := classify(cfg, false, false, 9999, 500, "step 3", nil); got != StateWorking { + if got, _ := classify(cfg, false, false, 9999, 500, "step 3", nil, nil); got != StateWorking { t.Fatalf("recent title change: got %q", got) } // Title stable past threshold → idle. - if got, _ := classify(cfg, false, false, 9999, 5000, "step 3", nil); got != StateIdle { + if got, _ := classify(cfg, false, false, 9999, 5000, "step 3", nil, nil); got != StateIdle { t.Fatalf("stable title: got %q", got) } // No title yet: fall back to output activity. - if got, _ := classify(cfg, false, false, 100, 0, "", nil); got != StateWorking { + if got, _ := classify(cfg, false, false, 100, 0, "", nil, nil); got != StateWorking { t.Fatalf("no title yet, recent output: got %q", got) } - if got, _ := classify(cfg, false, false, 5000, 0, "", nil); got != StateIdle { + if got, _ := classify(cfg, false, false, 5000, 0, "", nil, nil); got != StateIdle { t.Fatalf("no title yet, output idle: got %q", got) } } @@ -67,46 +67,51 @@ func TestClassifyTitleStatus(t *testing.T) { "error": StateError, }, } - if got, _ := classify(cfg, false, false, 9999, 500, "Thinking…", nil); got != StateThinking { + if got, _ := classify(cfg, false, false, 9999, 500, "Thinking…", nil, nil); got != StateThinking { t.Fatalf("thinking title: got %q", got) } - if got, _ := classify(cfg, false, false, 9999, 500, "Waiting for permission", nil); got != StatePermission { + if got, _ := classify(cfg, false, false, 9999, 500, "Waiting for permission", nil, nil); got != StatePermission { t.Fatalf("permission title: got %q", got) } // No match in map → fall back to stability. - if got, _ := classify(cfg, false, false, 9999, 5000, "ready", nil); got != StateIdle { + if got, _ := classify(cfg, false, false, 9999, 5000, "ready", nil, nil); got != StateIdle { t.Fatalf("unmatched title, stable: got %q", got) } } func TestClassifyPromoterRegex(t *testing.T) { cfg := &resolvedIdleDetection{ - strategy: StrategyOutputActivity, - idleThresholdMS: 2000, - permissionRegexes: []*regexp.Regexp{mustCompile(t, `Approve\?`)}, - errorRegexes: []*regexp.Regexp{mustCompile(t, `panic:`)}, - thinkingRegexes: []*regexp.Regexp{mustCompile(t, `Thinking`)}, + strategy: StrategyOutputActivity, + idleThresholdMS: 2000, + permissionRegexes: []*regexp.Regexp{mustCompile(t, `Approve\?`)}, + errorRegexes: []*regexp.Regexp{mustCompile(t, `panic:`)}, + thinkingRegexes: []*regexp.Regexp{mustCompile(t, `Thinking`)}, } // Permission promoter beats idle. - if got, _ := classify(cfg, false, false, 5000, 0, "", []byte("Approve? [y/n]")); got != StatePermission { + if got, _ := classify(cfg, false, false, 5000, 0, "", []byte("Approve? [y/n]"), nil); got != StatePermission { t.Fatalf("permission promoter: got %q", got) } // Error trumps permission. - if got, _ := classify(cfg, false, false, 5000, 0, "", []byte("panic: bad\nApprove?")); got != StateError { + if got, _ := classify(cfg, false, false, 5000, 0, "", []byte("panic: bad\nApprove?"), nil); got != StateError { t.Fatalf("error promoter beats permission: got %q", got) } // Thinking promoter on idle output. - if got, _ := classify(cfg, false, false, 5000, 0, "", []byte("Thinking…")); got != StateThinking { + if got, _ := classify(cfg, false, false, 5000, 0, "", []byte("Thinking…"), nil); got != StateThinking { t.Fatalf("thinking promoter: got %q", got) } + // Rendered-screen prompts still promote even when the raw tail no + // longer contains the original prompt bytes. + if got, _ := classify(cfg, false, false, 100, 0, "", []byte("Calling patterm..."), []byte("Approve? [y/n]")); got != StatePermission { + t.Fatalf("screen permission promoter: got %q", got) + } } func TestClassifyExitTerminal(t *testing.T) { cfg := &resolvedIdleDetection{strategy: StrategyOutputActivity, idleThresholdMS: 2000} - if got, _ := classify(cfg, true, true, 0, 0, "", nil); got != StateError { + if got, _ := classify(cfg, true, true, 0, 0, "", nil, nil); got != StateError { t.Fatalf("non-zero exit: got %q", got) } - if got, _ := classify(cfg, true, false, 0, 0, "", nil); got != StateIdle { + if got, _ := classify(cfg, true, false, 0, 0, "", nil, nil); got != StateIdle { t.Fatalf("clean exit: got %q", got) } } diff --git a/internal/app/launch.go b/internal/app/launch.go index f3e7f56..54142b7 100644 --- a/internal/app/launch.go +++ b/internal/app/launch.go @@ -261,15 +261,11 @@ func (l *Launcher) LaunchTerminal(argv []string, displayName, parentID, workDir } func (l *Launcher) writeMCPConfig(identity string) (string, error) { - dir, err := preset.ConfigDir() + dir, err := mcpRuntimeDir(identity) if err != nil { return "", err } - dir = filepath.Join(dir, "mcp") - if err := os.MkdirAll(dir, 0o700); err != nil { - return "", err - } - path := filepath.Join(dir, identity+".json") + path := filepath.Join(dir, "mcp.json") cfg := map[string]any{ "mcpServers": map[string]any{ "patterm": map[string]any{ diff --git a/internal/app/launch_test.go b/internal/app/launch_test.go new file mode 100644 index 0000000..f53e7fd --- /dev/null +++ b/internal/app/launch_test.go @@ -0,0 +1,30 @@ +package app + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWriteMCPConfigUsesRuntimeDir(t *testing.T) { + runtimeDir := t.TempDir() + configHome := filepath.Join(t.TempDir(), "config") + t.Setenv("XDG_RUNTIME_DIR", runtimeDir) + t.Setenv("XDG_CONFIG_HOME", configHome) + + l := &Launcher{bin: "patterm", mcpSocket: "/tmp/patterm.sock"} + path, err := l.writeMCPConfig("abc123") + if err != nil { + t.Fatalf("writeMCPConfig: %v", err) + } + if !strings.HasPrefix(path, filepath.Join(runtimeDir, "patterm", "agents", "abc123")) { + t.Fatalf("path = %q, want under runtime dir", path) + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("config file stat: %v", err) + } + if _, err := os.Stat(filepath.Join(configHome, "patterm")); !os.IsNotExist(err) { + t.Fatalf("writeMCPConfig created XDG config dir or unexpected stat error: %v", err) + } +} diff --git a/internal/harness/scenarios/idle_screen_permission_prompt.json b/internal/harness/scenarios/idle_screen_permission_prompt.json new file mode 100644 index 0000000..cde2053 --- /dev/null +++ b/internal/harness/scenarios/idle_screen_permission_prompt.json @@ -0,0 +1,37 @@ +{ + "name": "idle_screen_permission_prompt", + "presets": { + "processes": [ + { + "name": "screen-permission", + "argv": [ + "sh", + "-lc", + "printf '\\033[2J\\033[HCalling patterm...\\n\\nTool use\\n\\nDo you want to proceed?\\n 1. Yes\\n'; i=0; while [ $i -lt 300 ]; do printf '\\033[HCalling patterm... %03d' $i; i=$((i+1)); done; sleep 60" + ], + "idle_detection": { + "strategy": "output_activity", + "idle_threshold_ms": 500, + "permission_patterns": ["Do you want to proceed\\?"] + } + } + ] + }, + "trust": ["screen-permission"], + "steps": [ + { + "type": "mcp_call", + "method": "spawn_process", + "params": {"kind": "command", "preset": "screen-permission", "name": "screen-permission"}, + "save_as": "proc" + }, + { + "type": "wait_until_mcp", + "method": "get_process_status", + "params": {"process_id": "{{proc.process_id}}"}, + "path": "idle_state", + "equals": "permission", + "timeout_ms": 4000 + } + ] +} diff --git a/internal/preset/preset.go b/internal/preset/preset.go index ec73469..84b5be1 100644 --- a/internal/preset/preset.go +++ b/internal/preset/preset.go @@ -4,6 +4,7 @@ package preset import ( + "bytes" "encoding/json" "errors" "fmt" @@ -35,15 +36,16 @@ type Preset struct { Argv []string `json:"argv"` Env map[string]string `json:"env,omitempty"` WorkingDir string `json:"working_dir,omitempty"` + Disabled bool `json:"disabled,omitempty"` // Process-only. Shell bool `json:"shell,omitempty"` // Agent-only. SPEC §10. - MCPInjection *MCPInjection `json:"mcp_injection,omitempty"` - ReadySignal *ReadySignal `json:"ready_signal,omitempty"` - ChromeTrimHints []string `json:"chrome_trim_hints,omitempty"` - IdleDetection *IdleDetection `json:"idle_detection,omitempty"` + MCPInjection *MCPInjection `json:"mcp_injection,omitempty"` + ReadySignal *ReadySignal `json:"ready_signal,omitempty"` + ChromeTrimHints []string `json:"chrome_trim_hints,omitempty"` + IdleDetection *IdleDetection `json:"idle_detection,omitempty"` } // IdleDetection configures steady-state idle classification for an @@ -119,28 +121,22 @@ type Set struct { Processes []*Preset } -// Load scans the standard locations under $XDG_CONFIG_HOME/patterm/ -// presets/{agents,processes}/*.json. Unknown files are skipped with a -// warning to stderr; the spec is forgiving here. +// Load returns the built-in presets plus user overlays from +// $XDG_CONFIG_HOME/patterm/presets/{agents,processes}/*.json. Startup +// does not write default files; user files only override or extend the +// in-memory defaults. A user overlay with {"disabled": true} hides a +// built-in preset of the same name. func Load() (Set, error) { base, err := ConfigDir() if err != nil { return Set{}, err } - if err := os.MkdirAll(base, 0o700); err != nil { - return Set{}, fmt.Errorf("preset: mkdir %s: %w", base, err) - } - // Make sure the default-preset files exist on first run. Idempotent. - if err := ensureDefaults(base); err != nil { - return Set{}, err - } - - agents, err := loadDir(filepath.Join(base, "presets", "agents"), KindAgent) + agents, err := loadWithDefaults(filepath.Join(base, "presets", "agents"), KindAgent, defaultAgentPresets()) if err != nil { return Set{}, err } - procs, err := loadDir(filepath.Join(base, "presets", "processes"), KindCommand) + procs, err := loadWithDefaults(filepath.Join(base, "presets", "processes"), KindCommand, nil) if err != nil { return Set{}, err } @@ -160,51 +156,154 @@ func ConfigDir() (string, error) { return filepath.Join(home, ".config", "patterm"), nil } -func loadDir(dir string, kind Kind) ([]*Preset, error) { - if err := os.MkdirAll(dir, 0o700); err != nil { - return nil, fmt.Errorf("preset: mkdir %s: %w", dir, err) +func loadWithDefaults(dir string, kind Kind, defaults []*Preset) ([]*Preset, error) { + byName := make(map[string]*Preset, len(defaults)) + for _, p := range defaults { + cp := clonePreset(p) + cp.Kind = kind + byName[cp.Name] = cp } + entries, err := os.ReadDir(dir) if err != nil { + if os.IsNotExist(err) { + return sortedPresets(byName), nil + } return nil, fmt.Errorf("preset: read %s: %w", dir, err) } - var out []*Preset for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { continue } path := filepath.Join(dir, e.Name()) - p, err := loadFile(path, kind) + p, err := loadFileOverlay(path, kind, byName) if err != nil { fmt.Fprintf(os.Stderr, "patterm: preset %s: %v\n", path, err) continue } + if p.Disabled { + delete(byName, p.Name) + continue + } + byName[p.Name] = p + } + return sortedPresets(byName), nil +} + +func sortedPresets(byName map[string]*Preset) []*Preset { + out := make([]*Preset, 0, len(byName)) + for _, p := range byName { out = append(out, p) } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) - return out, nil + return out } -func loadFile(path string, kind Kind) (*Preset, error) { +func loadFileOverlay(path string, kind Kind, defaults map[string]*Preset) (*Preset, error) { b, err := os.ReadFile(path) if err != nil { return nil, err } + var header struct { + Name string `json:"name"` + Disabled bool `json:"disabled,omitempty"` + } + if err := json.Unmarshal(b, &header); err != nil { + return nil, err + } + if header.Name == "" { + return nil, errors.New("missing 'name'") + } + if def := defaults[header.Name]; def != nil { + p, err := mergePreset(def, b) + if err != nil { + return nil, err + } + p.Path = path + p.Kind = kind + return p, validatePreset(p) + } + var p Preset + if err := json.Unmarshal(b, &p); err != nil { + return nil, err + } + p.Path = path + p.Kind = kind + return &p, validatePreset(&p) +} + +func validatePreset(p *Preset) error { + if p.Name == "" { + return errors.New("missing 'name'") + } + if p.Disabled { + return nil + } + if len(p.Argv) == 0 && !p.Shell { + return errors.New("missing 'argv'") + } + return nil +} + +func mergePreset(def *Preset, overlay []byte) (*Preset, error) { + base, err := presetMap(def) + if err != nil { + return nil, err + } + var over map[string]any + dec := json.NewDecoder(bytes.NewReader(overlay)) + dec.UseNumber() + if err := dec.Decode(&over); err != nil { + return nil, err + } + deepMerge(base, over) + b, err := json.Marshal(base) + if err != nil { + return nil, err + } var p Preset if err := json.Unmarshal(b, &p); err != nil { return nil, err } - if p.Name == "" { - return nil, errors.New("missing 'name'") - } - if len(p.Argv) == 0 && !p.Shell { - return nil, errors.New("missing 'argv'") - } - p.Path = path - p.Kind = kind return &p, nil } +func presetMap(p *Preset) (map[string]any, error) { + b, err := json.Marshal(p) + if err != nil { + return nil, err + } + var m map[string]any + dec := json.NewDecoder(bytes.NewReader(b)) + dec.UseNumber() + if err := dec.Decode(&m); err != nil { + return nil, err + } + return m, nil +} + +func deepMerge(dst, src map[string]any) { + for k, v := range src { + if sm, ok := v.(map[string]any); ok { + if dm, ok := dst[k].(map[string]any); ok { + deepMerge(dm, sm) + continue + } + } + dst[k] = v + } +} + +func clonePreset(p *Preset) *Preset { + if p == nil { + return nil + } + b, _ := json.Marshal(p) + var out Preset + _ = json.Unmarshal(b, &out) + return &out +} + // ResolvedArgv returns the argv to actually exec, handling the // process-preset "shell: true" case (SPEC §10). func (p *Preset) ResolvedArgv() []string { @@ -214,17 +313,9 @@ func (p *Preset) ResolvedArgv() []string { return p.Argv } -// ensureDefaults writes default agent presets (claude/codex/opencode) -// and a sample process preset on first run. Never overwrites existing -// user files. -func ensureDefaults(base string) error { - defaults := []struct { - rel string - body string - }{ - { - "presets/agents/claude.json", - `{ +func defaultAgentPresets() []*Preset { + bodies := []string{ + `{ "name": "claude", "argv": ["claude"], "mcp_injection": { "kind": "flag", "flag": "--mcp-config" }, @@ -249,10 +340,7 @@ func ensureDefaults(base string) error { ] } `, - }, - { - "presets/agents/codex.json", - `{ + `{ "name": "codex", "argv": ["codex"], "mcp_injection": { @@ -275,10 +363,7 @@ func ensureDefaults(base string) error { ] } `, - }, - { - "presets/agents/opencode.json", - `{ + `{ "name": "opencode", "argv": ["opencode"], "mcp_injection": { @@ -301,19 +386,15 @@ func ensureDefaults(base string) error { ] } `, - }, } - for _, d := range defaults { - full := filepath.Join(base, d.rel) - if _, err := os.Stat(full); err == nil { - continue - } - if err := os.MkdirAll(filepath.Dir(full), 0o700); err != nil { - return err - } - if err := os.WriteFile(full, []byte(d.body), 0o600); err != nil { - return err + out := make([]*Preset, 0, len(bodies)) + for _, body := range bodies { + var p Preset + if err := json.Unmarshal([]byte(body), &p); err != nil { + panic(err) } + p.Kind = KindAgent + out = append(out, &p) } - return nil + return out } diff --git a/internal/preset/preset_test.go b/internal/preset/preset_test.go new file mode 100644 index 0000000..835b837 --- /dev/null +++ b/internal/preset/preset_test.go @@ -0,0 +1,124 @@ +package preset + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadUsesBuiltInDefaultsWithoutWritingConfig(t *testing.T) { + configHome := filepath.Join(t.TempDir(), "config") + t.Setenv("XDG_CONFIG_HOME", configHome) + + set, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if _, err := os.Stat(filepath.Join(configHome, "patterm")); !os.IsNotExist(err) { + t.Fatalf("Load created config dir or unexpected stat error: %v", err) + } + if len(set.Agents) != 3 { + t.Fatalf("agents len = %d, want 3", len(set.Agents)) + } + claude := presetByName(set.Agents, "claude") + if claude == nil { + t.Fatal("missing built-in claude preset") + } + if claude.IdleDetection == nil || len(claude.IdleDetection.PermissionPatterns) == 0 { + t.Fatalf("built-in claude missing permission patterns: %+v", claude.IdleDetection) + } +} + +func TestLoadMergesUserOverlayIntoBuiltInPreset(t *testing.T) { + configHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configHome) + dir := filepath.Join(configHome, "patterm", "presets", "agents") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(dir, "claude.json"), `{ + "name": "claude", + "argv": ["claude", "--model", "sonnet"], + "idle_detection": { "idle_threshold_ms": 3500 } +}`) + + set, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + claude := presetByName(set.Agents, "claude") + if claude == nil { + t.Fatal("missing claude preset") + } + if got := claude.Argv; len(got) != 3 || got[0] != "claude" || got[2] != "sonnet" { + t.Fatalf("argv = %#v", got) + } + if claude.IdleDetection.IdleThresholdMS != 3500 { + t.Fatalf("idle threshold = %d", claude.IdleDetection.IdleThresholdMS) + } + if len(claude.IdleDetection.PermissionPatterns) == 0 { + t.Fatalf("permission patterns were not inherited: %+v", claude.IdleDetection) + } + if claude.MCPInjection == nil || claude.MCPInjection.Kind != "flag" { + t.Fatalf("mcp injection was not inherited: %+v", claude.MCPInjection) + } +} + +func TestLoadCanDisableBuiltInPreset(t *testing.T) { + configHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configHome) + dir := filepath.Join(configHome, "patterm", "presets", "agents") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(dir, "opencode.json"), `{"name":"opencode","disabled":true}`) + + set, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if presetByName(set.Agents, "opencode") != nil { + t.Fatal("opencode preset was not disabled") + } + if presetByName(set.Agents, "claude") == nil || presetByName(set.Agents, "codex") == nil { + t.Fatalf("other built-ins missing: %+v", set.Agents) + } +} + +func TestLoadAddsCustomUserPreset(t *testing.T) { + configHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configHome) + dir := filepath.Join(configHome, "patterm", "presets", "processes") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(dir, "test.json"), `{"name":"test","argv":["go","test","./..."]}`) + + set, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + proc := presetByName(set.Processes, "test") + if proc == nil { + t.Fatal("missing custom process preset") + } + if proc.Kind != KindCommand { + t.Fatalf("kind = %q", proc.Kind) + } +} + +func presetByName(ps []*Preset, name string) *Preset { + for _, p := range ps { + if p.Name == name { + return p + } + } + return nil +} + +func writeFile(t *testing.T, path, body string) { + t.Helper() + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } +}