// Package preset loads user-editable JSON files that describe how to // launch agents and processes. SPEC §10: every spawnable thing is a // preset; patterm has no hard-coded agent or process types. package preset import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "sort" "strings" ) // Kind is "agent" or "command". Process presets are SPEC §7's // `command` kind (session-persistent). Terminal entries don't have // presets — they're created freeform with `kind: terminal` via // spawn_process. type Kind string const ( KindAgent Kind = "agent" KindCommand Kind = "command" ) // Preset is one loaded preset file. Agent-only fields stay zero on // process presets and vice versa. type Preset struct { // Source path (informational; not serialized). Path string `json:"-"` Kind Kind `json:"-"` Name string `json:"name"` Argv []string `json:"argv"` Env map[string]string `json:"env,omitempty"` WorkingDir string `json:"working_dir,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"` } // MCPInjection covers the strategies SPEC §10 enumerates plus // `cli_override` for agents (like codex) that accept inline config // overrides via repeated CLI flags, and `config_env` for agents (like // opencode) that read their config from an env var. The fields used // depend on Kind. type MCPInjection struct { Kind string `json:"kind"` // "flag" | "config_file" | "env_var" | "cli_override" | "config_env" Flag string `json:"flag,omitempty"` ConfigPath string `json:"config_path,omitempty"` Var string `json:"var,omitempty"` // config_file fields. patterm reads the file at Path, merges in a // `patterm` entry under MergeKey, writes the result inside a temp // directory laid out so HomeVar + HomePath points at the merged // file, and exports HomeVar to the child. Format is inferred from // Path's extension (toml or json) when blank. Path string `json:"path,omitempty"` MergeKey string `json:"merge_key,omitempty"` Format string `json:"format,omitempty"` HomeVar string `json:"home_var,omitempty"` HomePath string `json:"home_path,omitempty"` // cli_override fields. patterm emits one ` .=` // pair per MCP setting (command, args) so the agent merges them // into its in-memory config without touching any file on disk. Used // for codex's `-c key=value`. KeyPrefix string `json:"key_prefix,omitempty"` } // ReadySignal lets a preset override the default 1s-idle heuristic. type ReadySignal struct { IdleMS int `json:"idle_ms,omitempty"` Pattern string `json:"pattern,omitempty"` } // Set is what the palette consumes. type Set struct { Agents []*Preset 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. 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) if err != nil { return Set{}, err } procs, err := loadDir(filepath.Join(base, "presets", "processes"), KindCommand) if err != nil { return Set{}, err } return Set{Agents: agents, Processes: procs}, nil } // ConfigDir resolves $XDG_CONFIG_HOME/patterm (with the conventional // fallback to ~/.config/patterm). func ConfigDir() (string, error) { if h := os.Getenv("XDG_CONFIG_HOME"); h != "" { return filepath.Join(h, "patterm"), nil } home, err := os.UserHomeDir() if err != nil { return "", err } 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) } entries, err := os.ReadDir(dir) if err != 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) if err != nil { fmt.Fprintf(os.Stderr, "patterm: preset %s: %v\n", path, err) continue } out = append(out, p) } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out, nil } func loadFile(path string, kind Kind) (*Preset, error) { b, err := os.ReadFile(path) 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 } // ResolvedArgv returns the argv to actually exec, handling the // process-preset "shell: true" case (SPEC §10). func (p *Preset) ResolvedArgv() []string { if p.Shell && len(p.Argv) > 0 { return []string{"sh", "-lc", strings.Join(p.Argv, " ")} } 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", `{ "name": "claude", "argv": ["claude"], "mcp_injection": { "kind": "flag", "flag": "--mcp-config" }, "ready_signal": { "idle_ms": 1000 }, "chrome_trim_hints": [ "^Welcome to Claude Code", "^/help for help", "^cwd:", "^\\s*│\\s*>", "^\\s*╭─+╮$", "^\\s*╰─+╯$", "^\\? for shortcuts" ] } `, }, { "presets/agents/codex.json", `{ "name": "codex", "argv": ["codex"], "mcp_injection": { "kind": "cli_override", "flag": "-c", "key_prefix": "mcp_servers.patterm", "format": "toml" }, "ready_signal": { "idle_ms": 1000 }, "chrome_trim_hints": [ "^OpenAI Codex", "^\\s*model:", "^\\s*workdir:", "^>_$", "^\\s*▌" ] } `, }, { "presets/agents/opencode.json", `{ "name": "opencode", "argv": ["opencode"], "mcp_injection": { "kind": "config_env", "path": "~/.config/opencode/opencode.json", "merge_key": "mcp", "format": "json", "var": "OPENCODE_CONFIG_CONTENT" }, "ready_signal": { "idle_ms": 1000 }, "chrome_trim_hints": [ "^\\s*█", "^\\s*opencode v", "^\\s*~/", "^\\s*>_" ] } `, }, { "presets/processes/shell.json", `{ "name": "shell", "argv": ["__SHELL__"] } `, }, } 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 } body := d.body if strings.Contains(body, "__SHELL__") { shell := os.Getenv("SHELL") if shell == "" { shell = "/bin/sh" } body = strings.ReplaceAll(body, "__SHELL__", shell) } if err := os.WriteFile(full, []byte(body), 0o600); err != nil { return err } } return nil }