Initial patterm project
This commit is contained in:
254
internal/preset/preset.go
Normal file
254
internal/preset/preset.go
Normal file
@@ -0,0 +1,254 @@
|
||||
// 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 "process".
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
KindAgent Kind = "agent"
|
||||
KindProcess Kind = "process"
|
||||
)
|
||||
|
||||
// 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 three strategies SPEC §10 enumerates: a CLI
|
||||
// flag (claude --mcp-config ...), an external config file we merge into
|
||||
// (codex ~/.codex/config.toml), or an env var.
|
||||
type MCPInjection struct {
|
||||
Kind string `json:"kind"` // "flag" | "config_file" | "env_var"
|
||||
Flag string `json:"flag,omitempty"`
|
||||
ConfigPath string `json:"config_path,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
MergeKey string `json:"merge_key,omitempty"`
|
||||
Var string `json:"var,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"), KindProcess)
|
||||
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": "config_file", "path": "~/.codex/config.toml", "merge_key": "mcp_servers" },
|
||||
"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_file", "path": "~/.config/opencode/opencode.json", "merge_key": "mcp" },
|
||||
"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
|
||||
}
|
||||
Reference in New Issue
Block a user