Use built-in agent preset defaults

This commit is contained in:
2026-05-18 11:28:00 +01:00
parent 67b994f629
commit de60b93bc6
11 changed files with 402 additions and 108 deletions

View File

@@ -7,6 +7,12 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
### Changed ### 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 - Auto-summarization settings now save as soon as a changed row is
applied, including cadence/provider/toggle changes and model edits, applied, including cadence/provider/toggle changes and model edits,
without requiring a separate save step. 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. using an ellipsized single-line value.
### Fixed ### 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 - Removed the redundant "Back to Settings" row from the
Agents / Auto-summarization settings screen. Agents / Auto-summarization settings screen.

23
SPEC.md
View File

@@ -39,7 +39,7 @@ The tool is one Go process that owns: the TUI, all PTYs, vt-emulated grids, sess
## 3. Project state layout ## 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/ $XDG_DATA_HOME/patterm/
@@ -53,12 +53,12 @@ $XDG_DATA_HOME/patterm/
└── <agent-written>.md └── <agent-written>.md
$XDG_CONFIG_HOME/patterm/ $XDG_CONFIG_HOME/patterm/
├── config.json # global settings (theme, default keymap, etc.) ├── settings.json # global settings, written only after the user changes settings
└── presets/ └── presets/
├── agents/ ├── agents/
│ ├── claude.json # ships as default │ ├── claude.json # optional overlay for built-in claude
│ ├── codex.json # ships as default │ ├── codex.json # optional overlay for built-in codex
│ ├── opencode.json # ships as default │ ├── opencode.json # optional overlay for built-in opencode
│ └── <user-defined>.json │ └── <user-defined>.json
└── processes/ └── processes/
├── dev.json # e.g. { "name": "bun run dev", "argv": ["bun", "run", "dev"] } ├── dev.json # e.g. { "name": "bun run dev", "argv": ["bun", "run", "dev"] }
@@ -66,7 +66,7 @@ $XDG_CONFIG_HOME/patterm/
└── <user-defined>.json └── <user-defined>.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. 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: 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. - **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. 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 ## 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 ### Agent presets
`$XDG_CONFIG_HOME/patterm/presets/agents/<name>.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/<name>.json` can overlay a built-in by `name` or define a new agent preset.
| Field | Purpose | | 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"]`) | | `argv` | Full launch argv (e.g. `["claude"]`, `["codex", "--no-tui-banner"]`) |
| `env` | Env vars to set (merged over inherited env) | | `env` | Env vars to set (merged over inherited env) |
| `working_dir` | Defaults to the project root | | `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" }` | | `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. | | `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 | | `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 <pid-sock> --identity <token>`) 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. 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 <pid-sock> --identity <token>`) 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 ### Process presets
`$XDG_CONFIG_HOME/patterm/presets/processes/<name>.json`. Launches a raw command in a PTY — no MCP, no addendum, no system prompt. `$XDG_CONFIG_HOME/patterm/presets/processes/<name>.json`. Launches a raw command in a PTY — no MCP, no addendum, no system prompt. There are no built-in process presets.
| Field | Purpose | | Field | Purpose |
|---|---| |---|---|

View File

@@ -0,0 +1 @@
- [ ] We should show idle state in the top tab bar as well

View File

@@ -50,8 +50,14 @@ func (s *Session) classifyOne(c *Child) {
idleMS := c.IdleMS() idleMS := c.IdleMS()
titleIdleMS := c.TitleIdleMS() titleIdleMS := c.TitleIdleMS()
title := c.Title() title := c.Title()
tail := c.tailBytes(classifierTailBytes) tail := stripANSIBytes(nil, c.tailBytes(classifierTailBytes))
state, reason := classify(c.idleDetection, exited, exitNonZero, idleMS, titleIdleMS, title, tail) 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) { if c.setIdleState(state, reason) {
s.emitStateChanged(c.ID, state) s.emitStateChanged(c.ID, state)
} }

View File

@@ -118,7 +118,8 @@ func compilePatterns(ps []string) []*regexp.Regexp {
// - titleIdleMS: ms since the last OSC title change (0 if no title yet) // - titleIdleMS: ms since the last OSC title change (0 if no title yet)
// - title: current OSC title // - title: current OSC title
// - tail: recent output bytes for regex matching // - 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 exited {
if exitNonZero { if exitNonZero {
return StateError, "process exited non-zero" return StateError, "process exited non-zero"
@@ -128,14 +129,14 @@ func classify(cfg *resolvedIdleDetection, exited, exitNonZero bool, idleMS, titl
if cfg == nil { if cfg == nil {
cfg = &resolvedIdleDetection{strategy: StrategyOutputActivity, idleThresholdMS: defaultIdleThresholdMS} cfg = &resolvedIdleDetection{strategy: StrategyOutputActivity, idleThresholdMS: defaultIdleThresholdMS}
} }
if len(tail) > 0 { if len(tail) > 0 || len(screen) > 0 {
if matchAny(cfg.errorRegexes, tail) { if matchAny(cfg.errorRegexes, tail, screen) {
return StateError, "error regex matched" return StateError, "error regex matched"
} }
if matchAny(cfg.permissionRegexes, tail) { if matchAny(cfg.permissionRegexes, tail, screen) {
return StatePermission, "permission regex matched" return StatePermission, "permission regex matched"
} }
if matchAny(cfg.thinkingRegexes, tail) { if matchAny(cfg.thinkingRegexes, tail, screen) {
return StateThinking, "thinking regex matched" return StateThinking, "thinking regex matched"
} }
} }
@@ -172,10 +173,12 @@ func baseStateFromIdleMS(idleMS, threshold int64) (IdleState, string) {
return StateIdle, "quiet for threshold" 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 { for _, re := range res {
if re.Match(tail) { for _, text := range texts {
return true if len(text) > 0 && re.Match(text) {
return true
}
} }
} }
return false return false

View File

@@ -30,7 +30,7 @@ func TestClassifyOutputActivity(t *testing.T) {
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { 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 { if got != tc.want {
t.Fatalf("got %q want %q", 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) { func TestClassifyTitleStability(t *testing.T) {
cfg := &resolvedIdleDetection{strategy: StrategyOSCTitleStability, idleThresholdMS: 2000} cfg := &resolvedIdleDetection{strategy: StrategyOSCTitleStability, idleThresholdMS: 2000}
// Title change recent → working. // 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) t.Fatalf("recent title change: got %q", got)
} }
// Title stable past threshold → idle. // 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) t.Fatalf("stable title: got %q", got)
} }
// No title yet: fall back to output activity. // 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) 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) t.Fatalf("no title yet, output idle: got %q", got)
} }
} }
@@ -67,46 +67,51 @@ func TestClassifyTitleStatus(t *testing.T) {
"error": StateError, "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) 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) t.Fatalf("permission title: got %q", got)
} }
// No match in map → fall back to stability. // 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) t.Fatalf("unmatched title, stable: got %q", got)
} }
} }
func TestClassifyPromoterRegex(t *testing.T) { func TestClassifyPromoterRegex(t *testing.T) {
cfg := &resolvedIdleDetection{ cfg := &resolvedIdleDetection{
strategy: StrategyOutputActivity, strategy: StrategyOutputActivity,
idleThresholdMS: 2000, idleThresholdMS: 2000,
permissionRegexes: []*regexp.Regexp{mustCompile(t, `Approve\?`)}, permissionRegexes: []*regexp.Regexp{mustCompile(t, `Approve\?`)},
errorRegexes: []*regexp.Regexp{mustCompile(t, `panic:`)}, errorRegexes: []*regexp.Regexp{mustCompile(t, `panic:`)},
thinkingRegexes: []*regexp.Regexp{mustCompile(t, `Thinking`)}, thinkingRegexes: []*regexp.Regexp{mustCompile(t, `Thinking`)},
} }
// Permission promoter beats idle. // 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) t.Fatalf("permission promoter: got %q", got)
} }
// Error trumps permission. // 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) t.Fatalf("error promoter beats permission: got %q", got)
} }
// Thinking promoter on idle output. // 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) 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) { func TestClassifyExitTerminal(t *testing.T) {
cfg := &resolvedIdleDetection{strategy: StrategyOutputActivity, idleThresholdMS: 2000} 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) 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) t.Fatalf("clean exit: got %q", got)
} }
} }

View File

@@ -261,15 +261,11 @@ func (l *Launcher) LaunchTerminal(argv []string, displayName, parentID, workDir
} }
func (l *Launcher) writeMCPConfig(identity string) (string, error) { func (l *Launcher) writeMCPConfig(identity string) (string, error) {
dir, err := preset.ConfigDir() dir, err := mcpRuntimeDir(identity)
if err != nil { if err != nil {
return "", err return "", err
} }
dir = filepath.Join(dir, "mcp") path := filepath.Join(dir, "mcp.json")
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", err
}
path := filepath.Join(dir, identity+".json")
cfg := map[string]any{ cfg := map[string]any{
"mcpServers": map[string]any{ "mcpServers": map[string]any{
"patterm": map[string]any{ "patterm": map[string]any{

View File

@@ -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)
}
}

View File

@@ -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
}
]
}

View File

@@ -4,6 +4,7 @@
package preset package preset
import ( import (
"bytes"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -35,15 +36,16 @@ type Preset struct {
Argv []string `json:"argv"` Argv []string `json:"argv"`
Env map[string]string `json:"env,omitempty"` Env map[string]string `json:"env,omitempty"`
WorkingDir string `json:"working_dir,omitempty"` WorkingDir string `json:"working_dir,omitempty"`
Disabled bool `json:"disabled,omitempty"`
// Process-only. // Process-only.
Shell bool `json:"shell,omitempty"` Shell bool `json:"shell,omitempty"`
// Agent-only. SPEC §10. // Agent-only. SPEC §10.
MCPInjection *MCPInjection `json:"mcp_injection,omitempty"` MCPInjection *MCPInjection `json:"mcp_injection,omitempty"`
ReadySignal *ReadySignal `json:"ready_signal,omitempty"` ReadySignal *ReadySignal `json:"ready_signal,omitempty"`
ChromeTrimHints []string `json:"chrome_trim_hints,omitempty"` ChromeTrimHints []string `json:"chrome_trim_hints,omitempty"`
IdleDetection *IdleDetection `json:"idle_detection,omitempty"` IdleDetection *IdleDetection `json:"idle_detection,omitempty"`
} }
// IdleDetection configures steady-state idle classification for an // IdleDetection configures steady-state idle classification for an
@@ -119,28 +121,22 @@ type Set struct {
Processes []*Preset Processes []*Preset
} }
// Load scans the standard locations under $XDG_CONFIG_HOME/patterm/ // Load returns the built-in presets plus user overlays from
// presets/{agents,processes}/*.json. Unknown files are skipped with a // $XDG_CONFIG_HOME/patterm/presets/{agents,processes}/*.json. Startup
// warning to stderr; the spec is forgiving here. // 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) { func Load() (Set, error) {
base, err := ConfigDir() base, err := ConfigDir()
if err != nil { if err != nil {
return Set{}, err 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. agents, err := loadWithDefaults(filepath.Join(base, "presets", "agents"), KindAgent, defaultAgentPresets())
if err := ensureDefaults(base); err != nil {
return Set{}, err
}
agents, err := loadDir(filepath.Join(base, "presets", "agents"), KindAgent)
if err != nil { if err != nil {
return Set{}, err 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 { if err != nil {
return Set{}, err return Set{}, err
} }
@@ -160,51 +156,154 @@ func ConfigDir() (string, error) {
return filepath.Join(home, ".config", "patterm"), nil return filepath.Join(home, ".config", "patterm"), nil
} }
func loadDir(dir string, kind Kind) ([]*Preset, error) { func loadWithDefaults(dir string, kind Kind, defaults []*Preset) ([]*Preset, error) {
if err := os.MkdirAll(dir, 0o700); err != nil { byName := make(map[string]*Preset, len(defaults))
return nil, fmt.Errorf("preset: mkdir %s: %w", dir, err) for _, p := range defaults {
cp := clonePreset(p)
cp.Kind = kind
byName[cp.Name] = cp
} }
entries, err := os.ReadDir(dir) entries, err := os.ReadDir(dir)
if err != nil { if err != nil {
if os.IsNotExist(err) {
return sortedPresets(byName), nil
}
return nil, fmt.Errorf("preset: read %s: %w", dir, err) return nil, fmt.Errorf("preset: read %s: %w", dir, err)
} }
var out []*Preset
for _, e := range entries { for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue continue
} }
path := filepath.Join(dir, e.Name()) path := filepath.Join(dir, e.Name())
p, err := loadFile(path, kind) p, err := loadFileOverlay(path, kind, byName)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "patterm: preset %s: %v\n", path, err) fmt.Fprintf(os.Stderr, "patterm: preset %s: %v\n", path, err)
continue 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) out = append(out, p)
} }
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) 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) b, err := os.ReadFile(path)
if err != nil { if err != nil {
return nil, err 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 var p Preset
if err := json.Unmarshal(b, &p); err != nil { if err := json.Unmarshal(b, &p); err != nil {
return nil, err 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 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 // ResolvedArgv returns the argv to actually exec, handling the
// process-preset "shell: true" case (SPEC §10). // process-preset "shell: true" case (SPEC §10).
func (p *Preset) ResolvedArgv() []string { func (p *Preset) ResolvedArgv() []string {
@@ -214,17 +313,9 @@ func (p *Preset) ResolvedArgv() []string {
return p.Argv return p.Argv
} }
// ensureDefaults writes default agent presets (claude/codex/opencode) func defaultAgentPresets() []*Preset {
// and a sample process preset on first run. Never overwrites existing bodies := []string{
// user files. `{
func ensureDefaults(base string) error {
defaults := []struct {
rel string
body string
}{
{
"presets/agents/claude.json",
`{
"name": "claude", "name": "claude",
"argv": ["claude"], "argv": ["claude"],
"mcp_injection": { "kind": "flag", "flag": "--mcp-config" }, "mcp_injection": { "kind": "flag", "flag": "--mcp-config" },
@@ -249,10 +340,7 @@ func ensureDefaults(base string) error {
] ]
} }
`, `,
}, `{
{
"presets/agents/codex.json",
`{
"name": "codex", "name": "codex",
"argv": ["codex"], "argv": ["codex"],
"mcp_injection": { "mcp_injection": {
@@ -275,10 +363,7 @@ func ensureDefaults(base string) error {
] ]
} }
`, `,
}, `{
{
"presets/agents/opencode.json",
`{
"name": "opencode", "name": "opencode",
"argv": ["opencode"], "argv": ["opencode"],
"mcp_injection": { "mcp_injection": {
@@ -301,19 +386,15 @@ func ensureDefaults(base string) error {
] ]
} }
`, `,
},
} }
for _, d := range defaults { out := make([]*Preset, 0, len(bodies))
full := filepath.Join(base, d.rel) for _, body := range bodies {
if _, err := os.Stat(full); err == nil { var p Preset
continue if err := json.Unmarshal([]byte(body), &p); err != nil {
} panic(err)
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
} }
p.Kind = KindAgent
out = append(out, &p)
} }
return nil return out
} }

View File

@@ -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)
}
}