Use built-in agent preset defaults
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
30
internal/app/launch_test.go
Normal file
30
internal/app/launch_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
124
internal/preset/preset_test.go
Normal file
124
internal/preset/preset_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user