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

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