@crouton-kit/crouter 0.3.8 → 0.3.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/cli.js +14 -24
  2. package/dist/commands/agent.d.ts +4 -0
  3. package/dist/commands/agent.js +444 -243
  4. package/dist/commands/debug.d.ts +1 -1
  5. package/dist/commands/debug.js +20 -7
  6. package/dist/commands/human.js +51 -19
  7. package/dist/commands/job.d.ts +9 -0
  8. package/dist/commands/job.js +50 -10
  9. package/dist/commands/mode.d.ts +2 -0
  10. package/dist/commands/mode.js +231 -0
  11. package/dist/commands/pkg.js +5 -0
  12. package/dist/commands/plan.d.ts +1 -1
  13. package/dist/commands/plan.js +24 -11
  14. package/dist/commands/skill.js +20 -4
  15. package/dist/commands/spec.d.ts +1 -1
  16. package/dist/commands/spec.js +24 -11
  17. package/dist/commands/sys.js +5 -0
  18. package/dist/core/__tests__/job.test.js +11 -11
  19. package/dist/core/__tests__/jobs.test.js +33 -1
  20. package/dist/core/__tests__/resolver.test.js +69 -1
  21. package/dist/core/__tests__/spawn.test.d.ts +1 -0
  22. package/dist/core/__tests__/spawn.test.js +138 -0
  23. package/dist/core/__tests__/subagents.test.d.ts +1 -0
  24. package/dist/core/__tests__/subagents.test.js +75 -0
  25. package/dist/core/__tests__/unknown-path.test.d.ts +1 -0
  26. package/dist/core/__tests__/unknown-path.test.js +52 -0
  27. package/dist/core/bootstrap.d.ts +2 -0
  28. package/dist/core/bootstrap.js +66 -0
  29. package/dist/core/command.d.ts +58 -2
  30. package/dist/core/command.js +62 -14
  31. package/dist/core/frontmatter.d.ts +10 -0
  32. package/dist/core/frontmatter.js +24 -9
  33. package/dist/core/help.d.ts +39 -8
  34. package/dist/core/help.js +64 -32
  35. package/dist/core/jobs.d.ts +8 -2
  36. package/dist/core/jobs.js +109 -6
  37. package/dist/core/resolver.js +51 -1
  38. package/dist/core/spawn.d.ts +140 -23
  39. package/dist/core/spawn.js +392 -73
  40. package/dist/core/subagents.d.ts +18 -0
  41. package/dist/core/subagents.js +163 -0
  42. package/dist/prompts/agent.d.ts +10 -1
  43. package/dist/prompts/agent.js +34 -3
  44. package/dist/types.d.ts +21 -0
  45. package/dist/types.js +3 -0
  46. package/package.json +2 -2
@@ -6,7 +6,7 @@ import assert from 'node:assert/strict';
6
6
  import { mkdirSync, rmSync, existsSync, readFileSync } from 'node:fs';
7
7
  import { tmpdir } from 'node:os';
8
8
  import { join } from 'node:path';
9
- import { createJob, writeResult, writeMarkdownResult, readResult, } from '../jobs.js';
9
+ import { createJob, writeResult, writeMarkdownResult, readResult, recordJobPane, jobStatus, } from '../jobs.js';
10
10
  let stateDir;
11
11
  let origXdg;
12
12
  before(() => {
@@ -64,3 +64,35 @@ describe('writeMarkdownResult + readResult round-trip', () => {
64
64
  assert.equal(r.status, 'timeout');
65
65
  });
66
66
  });
67
+ describe('closed-pane reaping (zombie prevention)', () => {
68
+ test('live job whose recorded pane is gone is reaped to closed on status read', () => {
69
+ const { jobId } = createJob('prompt', { cwd: '/tmp' });
70
+ // A pane id that cannot exist on any tmux server.
71
+ recordJobPane(jobId, '%999999999');
72
+ const status = jobStatus(jobId);
73
+ assert.equal(status.state, 'closed');
74
+ });
75
+ test('reaped job exposes a closed result explaining the closed pane', async () => {
76
+ const { jobId } = createJob('prompt', { cwd: '/tmp' });
77
+ recordJobPane(jobId, '%999999999');
78
+ // Trigger the reaper.
79
+ jobStatus(jobId);
80
+ const r = await readResult(jobId, { waitMs: 0 });
81
+ assert.equal(r.status, 'closed');
82
+ assert.match(r.reason ?? '', /pane closed/);
83
+ });
84
+ test('job with no recorded pane is NOT reaped (stays live)', () => {
85
+ const { jobId } = createJob('prompt', { cwd: '/tmp' });
86
+ const status = jobStatus(jobId);
87
+ assert.equal(status.state, 'live');
88
+ });
89
+ test('already-submitted job is never overwritten by the reaper', async () => {
90
+ const { jobId } = createJob('prompt', { cwd: '/tmp' });
91
+ recordJobPane(jobId, '%999999999');
92
+ writeMarkdownResult(jobId, '**done**\n', 'done');
93
+ jobStatus(jobId);
94
+ const r = await readResult(jobId, { waitMs: 0 });
95
+ assert.equal(r.status, 'done');
96
+ assert.equal(r.result_md, '**done**\n');
97
+ });
98
+ });
@@ -6,7 +6,9 @@ import assert from 'node:assert/strict';
6
6
  import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
7
7
  import { tmpdir } from 'node:os';
8
8
  import { join } from 'node:path';
9
- import { parseSkillQualifier } from '../resolver.js';
9
+ import { parseSkillQualifier, resolveSkill } from '../resolver.js';
10
+ import { resetScopeCache } from '../scope.js';
11
+ import { CrtrError } from '../errors.js';
10
12
  import { InputError } from '../io.js';
11
13
  import { readConfig } from '../config.js';
12
14
  import { SCHEMA_VERSION } from '../../types.js';
@@ -111,3 +113,69 @@ describe('config migration: skill keys colon → slash', () => {
111
113
  assert.equal(cfg.schema_version, SCHEMA_VERSION, 'schema_version bumped to current');
112
114
  });
113
115
  });
116
+ // ---------------------------------------------------------------------------
117
+ // resolveSkill — leaf-name fallback
118
+ // ---------------------------------------------------------------------------
119
+ describe('resolveSkill leaf-name fallback', () => {
120
+ let testHomeDir;
121
+ let origHome;
122
+ function writePluginSkill(plugin, skillPath) {
123
+ const root = join(testHomeDir, '.crouter', 'plugins', plugin);
124
+ mkdirSync(join(root, '.crouter-plugin'), { recursive: true });
125
+ writeFileSync(join(root, '.crouter-plugin', 'plugin.json'), JSON.stringify({ name: plugin, version: '0.0.1' }), 'utf8');
126
+ const skillDir = join(root, 'skills', ...skillPath.split('/'));
127
+ mkdirSync(skillDir, { recursive: true });
128
+ writeFileSync(join(skillDir, 'SKILL.md'), `---\nname: ${skillPath.split('/').pop()}\n---\nbody`, 'utf8');
129
+ }
130
+ before(() => {
131
+ testHomeDir = join(tmpdir(), `crtr-leaf-test-${Date.now()}`);
132
+ mkdirSync(testHomeDir, { recursive: true });
133
+ origHome = process.env['HOME'];
134
+ process.env['HOME'] = testHomeDir;
135
+ resetScopeCache();
136
+ // Unique leaf: only one plugin has it, reached via nested path.
137
+ writePluginSkill('ai', 'interface/cli-design');
138
+ // Colliding leaf: two plugins both expose `dup` at different paths.
139
+ writePluginSkill('pa', 'x/dup');
140
+ writePluginSkill('pb', 'y/dup');
141
+ });
142
+ after(() => {
143
+ if (origHome === undefined)
144
+ delete process.env['HOME'];
145
+ else
146
+ process.env['HOME'] = origHome;
147
+ resetScopeCache();
148
+ rmSync(testHomeDir, { recursive: true, force: true });
149
+ });
150
+ test('bare leaf name resolves to the nested skill', () => {
151
+ const s = resolveSkill('cli-design');
152
+ assert.equal(s.name, 'interface/cli-design');
153
+ assert.equal(s.plugin, 'ai');
154
+ });
155
+ test('full path still resolves directly', () => {
156
+ const s = resolveSkill('ai/interface/cli-design');
157
+ assert.equal(s.name, 'interface/cli-design');
158
+ assert.equal(s.plugin, 'ai');
159
+ });
160
+ test('colliding leaf name throws ambiguous listing full paths', () => {
161
+ assert.throws(() => resolveSkill('dup'), (e) => {
162
+ assert.ok(e instanceof CrtrError, 'should be CrtrError');
163
+ assert.equal(e.code, 'ambiguous');
164
+ assert.match(e.message, /pa\/x\/dup/);
165
+ assert.match(e.message, /pb\/y\/dup/);
166
+ return true;
167
+ });
168
+ });
169
+ test('colliding leaf is resolvable via full path', () => {
170
+ const s = resolveSkill('pb/y/dup');
171
+ assert.equal(s.plugin, 'pb');
172
+ assert.equal(s.name, 'y/dup');
173
+ });
174
+ test('unknown leaf still throws not_found', () => {
175
+ assert.throws(() => resolveSkill('no-such-leaf-xyz'), (e) => {
176
+ assert.ok(e instanceof CrtrError);
177
+ assert.equal(e.code, 'not_found');
178
+ return true;
179
+ });
180
+ });
181
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,138 @@
1
+ // Tests for agent-CLI selection in spawn.ts (detectAgentKind + buildAgentCommand).
2
+ //
3
+ // Run with: node --import tsx/esm --test src/core/__tests__/spawn.test.ts
4
+ import { test, describe, afterEach } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { detectAgentKind, buildAgentCommand, buildAgentPrintArgv, buildAgentPrintCommand, normalizeModelForKind, subagentSessionName, } from '../spawn.js';
7
+ const origPi = process.env['PI_CODING_AGENT'];
8
+ afterEach(() => {
9
+ if (origPi === undefined)
10
+ delete process.env['PI_CODING_AGENT'];
11
+ else
12
+ process.env['PI_CODING_AGENT'] = origPi;
13
+ });
14
+ describe('subagentSessionName', () => {
15
+ test('derives a deterministic, tmux-safe name from a pane id', () => {
16
+ assert.equal(subagentSessionName('%5'), 'crtr-agents-5');
17
+ assert.equal(subagentSessionName('%23'), 'crtr-agents-23');
18
+ });
19
+ test('is stable for the same pane id and distinct across panes', () => {
20
+ assert.equal(subagentSessionName('%7'), subagentSessionName('%7'));
21
+ assert.notEqual(subagentSessionName('%7'), subagentSessionName('%8'));
22
+ });
23
+ test('strips characters tmux would treat specially', () => {
24
+ assert.equal(subagentSessionName('%1.2'), 'crtr-agents-12');
25
+ assert.match(subagentSessionName('%99'), /^crtr-agents-[a-zA-Z0-9]+$/);
26
+ });
27
+ });
28
+ describe('detectAgentKind', () => {
29
+ test('returns pi when PI_CODING_AGENT=true', () => {
30
+ process.env['PI_CODING_AGENT'] = 'true';
31
+ assert.equal(detectAgentKind(), 'pi');
32
+ });
33
+ test('defaults to claude when no signal is present', () => {
34
+ delete process.env['PI_CODING_AGENT'];
35
+ assert.equal(detectAgentKind(), 'claude');
36
+ });
37
+ });
38
+ describe('buildAgentCommand: claude', () => {
39
+ test('fresh prompt uses --dangerously-skip-permissions and quotes the prompt', () => {
40
+ const cmd = buildAgentCommand({ prompt: 'do the thing', name: 'worker-1' }, 'claude');
41
+ assert.equal(cmd, "claude -n 'worker-1' --dangerously-skip-permissions 'do the thing'");
42
+ });
43
+ test('fork uses --resume <id> --fork-session', () => {
44
+ const cmd = buildAgentCommand({ prompt: 'p', fork: { sessionId: 'abc-123' } }, 'claude');
45
+ assert.equal(cmd, "claude --resume 'abc-123' --fork-session --dangerously-skip-permissions 'p'");
46
+ });
47
+ });
48
+ describe('buildAgentCommand: pi', () => {
49
+ test('fresh prompt has no skip-permissions flag (pi has no permission popups)', () => {
50
+ const cmd = buildAgentCommand({ prompt: 'do the thing', name: 'worker-1' }, 'pi');
51
+ assert.equal(cmd, "pi -n 'worker-1' 'do the thing'");
52
+ assert.ok(!cmd.includes('--dangerously-skip-permissions'));
53
+ });
54
+ test('fork uses --fork <id>', () => {
55
+ const cmd = buildAgentCommand({ prompt: 'p', fork: { sessionId: 'abc-123' } }, 'pi');
56
+ assert.equal(cmd, "pi --fork 'abc-123' 'p'");
57
+ });
58
+ test('single-quotes in the prompt are escaped safely', () => {
59
+ const cmd = buildAgentCommand({ prompt: "it's fine" }, 'pi');
60
+ assert.equal(cmd, "pi 'it'\\''s fine'");
61
+ });
62
+ });
63
+ describe('buildAgentCommand: subagent persona (systemPrompt/model/tools)', () => {
64
+ test('pi emits --model, --tools, and --append-system-prompt before the prompt', () => {
65
+ const cmd = buildAgentCommand({ prompt: 'task', name: 'scout', systemPrompt: 'You are a scout.', model: 'haiku', tools: ['read', 'grep'] }, 'pi');
66
+ assert.equal(cmd, "pi -n 'scout' --model 'anthropic/haiku' --tools 'read,grep' --append-system-prompt 'You are a scout.' 'task'");
67
+ });
68
+ test('claude emits --model and --append-system-prompt but NOT --tools (different tool model)', () => {
69
+ const cmd = buildAgentCommand({ prompt: 'task', systemPrompt: 'persona', model: 'sonnet', tools: ['read', 'grep'] }, 'claude');
70
+ assert.ok(cmd.includes("--model 'sonnet'"));
71
+ assert.ok(cmd.includes("--append-system-prompt 'persona'"));
72
+ assert.ok(!cmd.includes('--tools'));
73
+ });
74
+ test('omitted persona fields add no flags', () => {
75
+ const cmd = buildAgentCommand({ prompt: 'task' }, 'pi');
76
+ assert.equal(cmd, "pi 'task'");
77
+ });
78
+ });
79
+ describe('buildAgentPrintArgv: subagent persona', () => {
80
+ test('pi threads model/tools/system prompt as discrete args', () => {
81
+ const { args } = buildAgentPrintArgv({ prompt: 'task', systemPrompt: 'persona', model: 'haiku', tools: ['read', 'bash'] }, 'pi');
82
+ assert.deepEqual(args, ['--model', 'anthropic/haiku', '--tools', 'read,bash', '--append-system-prompt', 'persona', '-p', 'task']);
83
+ });
84
+ test('claude threads model/system prompt but not tools', () => {
85
+ const { args } = buildAgentPrintArgv({ prompt: 'task', systemPrompt: 'persona', model: 'sonnet', tools: ['read'] }, 'claude');
86
+ assert.deepEqual(args, ['--model', 'sonnet', '--append-system-prompt', 'persona', '-p', '--dangerously-skip-permissions', 'task']);
87
+ });
88
+ });
89
+ describe('normalizeModelForKind: pi Claude-alias resolution', () => {
90
+ test('pins bare Claude aliases to the anthropic provider under pi', () => {
91
+ assert.equal(normalizeModelForKind('sonnet', 'pi'), 'anthropic/sonnet');
92
+ assert.equal(normalizeModelForKind('opus', 'pi'), 'anthropic/opus');
93
+ assert.equal(normalizeModelForKind('haiku', 'pi'), 'anthropic/haiku');
94
+ });
95
+ test('preserves a :thinking suffix when pinning the provider', () => {
96
+ assert.equal(normalizeModelForKind('sonnet:high', 'pi'), 'anthropic/sonnet:high');
97
+ });
98
+ test('leaves provider-prefixed and concrete ids untouched under pi', () => {
99
+ assert.equal(normalizeModelForKind('anthropic/sonnet', 'pi'), 'anthropic/sonnet');
100
+ assert.equal(normalizeModelForKind('openai/gpt-4o', 'pi'), 'openai/gpt-4o');
101
+ assert.equal(normalizeModelForKind('claude-sonnet-4-6', 'pi'), 'claude-sonnet-4-6');
102
+ assert.equal(normalizeModelForKind('gpt-4o-mini', 'pi'), 'gpt-4o-mini');
103
+ });
104
+ test('never rewrites for the claude CLI (native alias support)', () => {
105
+ assert.equal(normalizeModelForKind('sonnet', 'claude'), 'sonnet');
106
+ assert.equal(normalizeModelForKind('opus', 'claude'), 'opus');
107
+ });
108
+ });
109
+ describe('buildAgentCommand: defaults to detected kind', () => {
110
+ test('uses pi when PI_CODING_AGENT=true and no explicit kind passed', () => {
111
+ process.env['PI_CODING_AGENT'] = 'true';
112
+ const cmd = buildAgentCommand({ prompt: 'hi' });
113
+ assert.ok(cmd.startsWith('pi '));
114
+ });
115
+ });
116
+ describe('buildAgentPrintArgv (headless print mode)', () => {
117
+ test('pi uses -p and no skip-permissions flag', () => {
118
+ const { cmd, args } = buildAgentPrintArgv({ prompt: 'do it', name: 'w1' }, 'pi');
119
+ assert.equal(cmd, 'pi');
120
+ assert.deepEqual(args, ['-n', 'w1', '-p', 'do it']);
121
+ assert.ok(!args.includes('--dangerously-skip-permissions'));
122
+ });
123
+ test('claude uses -p plus --dangerously-skip-permissions', () => {
124
+ const { cmd, args } = buildAgentPrintArgv({ prompt: 'do it' }, 'claude');
125
+ assert.equal(cmd, 'claude');
126
+ assert.deepEqual(args, ['-p', '--dangerously-skip-permissions', 'do it']);
127
+ });
128
+ test('argv passes the prompt as a discrete arg (no shell quoting)', () => {
129
+ const { args } = buildAgentPrintArgv({ prompt: "it's $weird" }, 'pi');
130
+ assert.equal(args[args.length - 1], "it's $weird");
131
+ });
132
+ });
133
+ describe('buildAgentPrintCommand (shell string)', () => {
134
+ test('pi prints with -p and shell-quotes the prompt', () => {
135
+ const cmd = buildAgentPrintCommand({ prompt: "it's fine" }, 'pi');
136
+ assert.equal(cmd, "pi '-p' 'it'\\''s fine'");
137
+ });
138
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,75 @@
1
+ // Tests for subagent discovery, resolution, and frontmatter parsing.
2
+ //
3
+ // Run with: node --import tsx/esm --test src/core/__tests__/subagents.test.ts
4
+ import { test, describe, before, after } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import { listSubagents, resolveSubagent, subagentId } from '../subagents.js';
10
+ import { resetScopeCache } from '../scope.js';
11
+ import { parseFrontmatterGeneric } from '../frontmatter.js';
12
+ describe('parseFrontmatterGeneric', () => {
13
+ test('returns raw record including tools/model fields skills ignore', () => {
14
+ const src = '---\nname: scout\ndescription: recon\nmodel: haiku\ntools: read, grep, bash\n---\nBody here.\n';
15
+ const { data, body } = parseFrontmatterGeneric(src);
16
+ assert.ok(data !== null);
17
+ assert.equal(data['name'], 'scout');
18
+ assert.equal(data['description'], 'recon');
19
+ assert.equal(data['model'], 'haiku');
20
+ assert.equal(data['tools'], 'read, grep, bash');
21
+ assert.equal(body, 'Body here.\n');
22
+ });
23
+ test('list-style tools parse to an array', () => {
24
+ const src = '---\nname: x\ndescription: d\ntools:\n - read\n - bash\n---\nb\n';
25
+ const { data } = parseFrontmatterGeneric(src);
26
+ assert.deepEqual(data['tools'], ['read', 'bash']);
27
+ });
28
+ test('no frontmatter yields null data', () => {
29
+ const { data, body } = parseFrontmatterGeneric('just a body');
30
+ assert.equal(data, null);
31
+ assert.equal(body, 'just a body');
32
+ });
33
+ });
34
+ describe('subagent discovery (project scope)', () => {
35
+ let dir;
36
+ const origCwd = process.cwd();
37
+ before(() => {
38
+ dir = mkdtempSync(join(tmpdir(), 'crtr-subagents-'));
39
+ const agents = join(dir, '.crouter', 'agents');
40
+ mkdirSync(agents, { recursive: true });
41
+ writeFileSync(join(dir, '.crouter', 'config.json'), '{}');
42
+ writeFileSync(join(agents, 'scout.md'), '---\nname: scout\ndescription: Fast recon\nmodel: haiku\ntools: read, grep\n---\nYou are a scout.\n');
43
+ writeFileSync(join(agents, 'reviewer.md'), '---\nname: reviewer\ndescription: Code review\n---\nYou review code.\n');
44
+ // Missing description → skipped from listings.
45
+ writeFileSync(join(agents, 'broken.md'), '---\nname: broken\n---\nno description\n');
46
+ // Name defaults to filename stem when frontmatter omits it.
47
+ writeFileSync(join(agents, 'stemmed.md'), '---\ndescription: named by file\n---\nbody\n');
48
+ process.chdir(dir);
49
+ resetScopeCache();
50
+ });
51
+ after(() => {
52
+ process.chdir(origCwd);
53
+ resetScopeCache();
54
+ rmSync(dir, { recursive: true, force: true });
55
+ });
56
+ test('listSubagents finds valid agents and skips description-less files', () => {
57
+ const ids = listSubagents('project').map(subagentId).sort();
58
+ assert.deepEqual(ids, ['reviewer', 'scout', 'stemmed']);
59
+ });
60
+ test('frontmatter tools comma-string coerces to array; model carried', () => {
61
+ const scout = resolveSubagent('scout', { scope: 'project' });
62
+ assert.deepEqual(scout.frontmatter.tools, ['read', 'grep']);
63
+ assert.equal(scout.frontmatter.model, 'haiku');
64
+ assert.equal(scout.systemPrompt.trim(), 'You are a scout.');
65
+ assert.equal(scout.plugin, '_');
66
+ });
67
+ test('name defaults to filename stem', () => {
68
+ const a = resolveSubagent('stemmed', { scope: 'project' });
69
+ assert.equal(a.name, 'stemmed');
70
+ assert.equal(a.frontmatter.description, 'named by file');
71
+ });
72
+ test('resolveSubagent throws notFound for unknown name', () => {
73
+ assert.throws(() => resolveSubagent('nope', { scope: 'project' }), /subagent not found/);
74
+ });
75
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,52 @@
1
+ // Regression tests for unknown-subcommand error recovery hints.
2
+ // Run with: node --import tsx/esm --test src/core/__tests__/unknown-path.test.ts
3
+ //
4
+ // The `next` road sign must name a command that actually exists: the FULL path
5
+ // to the deepest matched node, not just its local name. A prior bug emitted
6
+ // `crtr find -h` (dropping the `skill` parent) when `crtr skill find bogus` was
7
+ // invoked, sending the caller to a nonexistent top-level command.
8
+ import { test, describe } from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import { defineRoot, defineBranch, defineLeaf, walk, unknownPathError } from '../command.js';
11
+ const leaf = defineLeaf({
12
+ name: 'search',
13
+ help: { name: 'search', summary: 'search', output: [], outputKind: 'object', effects: ['None. Read-only.'] },
14
+ run: async () => ({}),
15
+ });
16
+ const findBranch = defineBranch({
17
+ name: 'find',
18
+ help: { name: 'find', summary: 'find', children: [{ name: 'search', desc: 'search', useWhen: 'x' }] },
19
+ children: [leaf],
20
+ });
21
+ const skillBranch = defineBranch({
22
+ name: 'skill',
23
+ help: { name: 'skill', summary: 'skill', children: [{ name: 'find', desc: 'find', useWhen: 'x' }] },
24
+ rootEntry: { concept: 'skill', desc: 'skill', useWhen: 'x' },
25
+ children: [findBranch],
26
+ });
27
+ const root = defineRoot({
28
+ tagline: 'test runtime',
29
+ globals: [],
30
+ subtrees: [skillBranch],
31
+ });
32
+ function nextHint(...tokens) {
33
+ const { node, path, remaining } = walk(root, tokens);
34
+ const err = unknownPathError(node, path, remaining[0]);
35
+ return err.details.next;
36
+ }
37
+ describe('unknown-path error: recovery hint names the full valid path', () => {
38
+ test('root-level unknown points at `crtr -h`', () => {
39
+ assert.match(nextHint('bogus'), /Run `crtr -h`/);
40
+ });
41
+ test('one-level unknown points at `crtr skill -h`', () => {
42
+ assert.match(nextHint('skill', 'bogus'), /Run `crtr skill -h`/);
43
+ });
44
+ test('two-level unknown points at `crtr skill find -h`, not `crtr find -h`', () => {
45
+ const hint = nextHint('skill', 'find', 'bogus');
46
+ assert.match(hint, /Run `crtr skill find -h`/);
47
+ assert.doesNotMatch(hint, /Run `crtr find -h`/);
48
+ });
49
+ test('valid children of the matched node are listed', () => {
50
+ assert.match(nextHint('skill', 'find', 'bogus'), /Valid children: search\./);
51
+ });
52
+ });
@@ -1,6 +1,8 @@
1
+ import type { RootDef } from './command.js';
1
2
  export declare const OFFICIAL_MARKETPLACE_NAME = "crouter-official-marketplace";
2
3
  export declare const OFFICIAL_MARKETPLACE_URL = "https://github.com/crouton-labs/crouter-official-marketplace.git";
3
4
  export declare const OFFICIAL_MARKETPLACE_REF = "main";
4
5
  export declare function ensureBootSkill(argv: string[]): void;
6
+ export declare function ensureSlashCommands(root: RootDef, argv: string[]): void;
5
7
  export declare function ensureOfficialMarketplace(argv: string[]): void;
6
8
  export declare function ensureProjectScope(argv: string[]): void;
@@ -6,6 +6,7 @@ import { ensureDir, pathExists, readText, removePath, nowIso } from './fs-utils.
6
6
  import { readConfig, readState, updateConfig, updateState, ensureScopeInitialized } from './config.js';
7
7
  import { clone } from './git.js';
8
8
  import { readMarketplaceManifest } from './manifest.js';
9
+ import { collectSlashSpecs } from './command.js';
9
10
  import { CRTR_DIR_NAME } from '../types.js';
10
11
  export const OFFICIAL_MARKETPLACE_NAME = 'crouter-official-marketplace';
11
12
  export const OFFICIAL_MARKETPLACE_URL = 'https://github.com/crouton-labs/crouter-official-marketplace.git';
@@ -94,6 +95,71 @@ export function ensureBootSkill(argv) {
94
95
  }
95
96
  }
96
97
  }
98
+ // ---------------------------------------------------------------------------
99
+ // Slash commands (editor prompt templates) auto-installed for opted-in nodes.
100
+ //
101
+ // Any command that declares a `slash` SlashSpec is rendered to a markdown
102
+ // template and dropped into the host's command dirs on each crtr run — pi reads
103
+ // `~/.pi/agent/prompts/<name>.md`, Claude Code reads `~/.claude/commands/<name>.md`,
104
+ // so `/name` becomes available. Marker-guarded (never clobbers a user-edited
105
+ // file) and version-rolled like the boot skill. Kill switch: CRTR_NO_MODE_CMDS=1.
106
+ // ---------------------------------------------------------------------------
107
+ const SLASH_CMD_MARKER = '<!-- crtr-mode-cmd v1 -->';
108
+ const SLASH_CMD_MARKER_PREFIX = '<!-- crtr-mode-cmd v';
109
+ /** Render a SlashSpec to a full template file (frontmatter + marker + body). */
110
+ function renderSlashTemplate(spec) {
111
+ const hint = spec.argumentHint !== undefined
112
+ ? `argument-hint: ${JSON.stringify(spec.argumentHint)}\n`
113
+ : '';
114
+ return `---\ndescription: ${spec.description}\n${hint}---\n\n${SLASH_CMD_MARKER}\n\n${spec.body}\n`;
115
+ }
116
+ /** Write `content` to `file` unless a user-customized file is already there.
117
+ * Rolls forward our own (marker-bearing) versions; skips if identical. */
118
+ function writeSlashFileIfOurs(dir, name, content) {
119
+ const file = join(dir, `${name}.md`);
120
+ if (pathExists(file)) {
121
+ const existing = readText(file);
122
+ if (!existing.includes(SLASH_CMD_MARKER_PREFIX))
123
+ return; // user's own file
124
+ if (existing === content)
125
+ return; // already current
126
+ }
127
+ ensureDir(dir);
128
+ writeFileSync(file, content, 'utf8');
129
+ }
130
+ export function ensureSlashCommands(root, argv) {
131
+ try {
132
+ if (process.env.CRTR_NO_MODE_CMDS === '1')
133
+ return;
134
+ if (shouldSkipForArgv(argv))
135
+ return;
136
+ const specs = collectSlashSpecs(root);
137
+ if (specs.length === 0)
138
+ return;
139
+ // Target each host's command dir, but only when that host is actually in use
140
+ // (its root dir exists). We never create ~/.pi or ~/.claude ourselves.
141
+ const targets = [];
142
+ if (pathExists(join(homedir(), '.pi', 'agent'))) {
143
+ targets.push(join(homedir(), '.pi', 'agent', 'prompts'));
144
+ }
145
+ if (pathExists(join(homedir(), '.claude'))) {
146
+ targets.push(join(homedir(), '.claude', 'commands'));
147
+ }
148
+ if (targets.length === 0)
149
+ return;
150
+ for (const spec of specs) {
151
+ const content = renderSlashTemplate(spec);
152
+ for (const dir of targets)
153
+ writeSlashFileIfOurs(dir, spec.name, content);
154
+ }
155
+ }
156
+ catch (e) {
157
+ if (process.env.CRTR_DEBUG === '1') {
158
+ const msg = e instanceof Error ? e.message : String(e);
159
+ process.stderr.write(`crtr: slash-command error: ${msg}\n`);
160
+ }
161
+ }
162
+ }
97
163
  export function ensureOfficialMarketplace(argv) {
98
164
  try {
99
165
  if (process.env.CRTR_NO_BOOTSTRAP === '1')
@@ -1,14 +1,39 @@
1
- import type { RootHelp, BranchHelp, LeafHelp, InputParam } from './help.js';
1
+ import type { RootHelp, RootEntry, BranchHelp, LeafHelp, InputParam } from './help.js';
2
+ import { CrtrError } from './errors.js';
3
+ /** Opt-in flag that surfaces a node as an editor slash command (a pi prompt
4
+ * template / Claude Code command). When set, the bootstrap auto-writes a
5
+ * markdown template named `<name>.md` to the host's command dirs on each crtr
6
+ * run, so `/name` becomes available. The body is thin — it points the agent at
7
+ * the live `crtr` workflow so the CLI stays the source of truth. */
8
+ export interface SlashSpec {
9
+ /** Command name → `/<name>` and the template filename. */
10
+ name: string;
11
+ /** Frontmatter description shown in the autocomplete dropdown. */
12
+ description: string;
13
+ /** Optional autocomplete hint, e.g. `<url>` or `[topic]`. */
14
+ argumentHint?: string;
15
+ /** Markdown body (no frontmatter). Bootstrap wraps it with frontmatter + a
16
+ * version marker. Use `$ARGUMENTS` for the invocation's free text. */
17
+ body: string;
18
+ }
2
19
  export interface LeafDef {
3
20
  kind: 'leaf';
4
21
  name: string;
5
22
  help: LeafHelp;
23
+ /** Opt into editor slash-command exposure (see SlashSpec). */
24
+ slash?: SlashSpec;
6
25
  run: (input: Record<string, unknown>) => Promise<Record<string, unknown> | void>;
7
26
  }
8
27
  export interface BranchDef {
9
28
  kind: 'branch';
10
29
  name: string;
11
30
  help: BranchHelp;
31
+ /** How this subtree represents itself one level up. Present on top-level
32
+ * subtrees (assembled into root -h by defineRoot); omitted on nested
33
+ * branches, whose parent representation is the branch's own children list. */
34
+ rootEntry?: RootEntry;
35
+ /** Opt into editor slash-command exposure (see SlashSpec). */
36
+ slash?: SlashSpec;
12
37
  children: (LeafDef | BranchDef)[];
13
38
  }
14
39
  export interface RootDef {
@@ -19,18 +44,49 @@ export interface RootDef {
19
44
  export declare function defineLeaf(opts: {
20
45
  name: string;
21
46
  help: LeafHelp;
47
+ slash?: SlashSpec;
22
48
  run: (input: Record<string, unknown>) => Promise<Record<string, unknown> | void>;
23
49
  }): LeafDef;
24
50
  export declare function defineBranch(opts: {
25
51
  name: string;
26
52
  help: BranchHelp;
53
+ rootEntry?: RootEntry;
54
+ slash?: SlashSpec;
27
55
  children: (LeafDef | BranchDef)[];
28
56
  }): BranchDef;
57
+ /** Walk the whole tree and collect every node's SlashSpec (depth-first). Used
58
+ * by the bootstrap to discover which commands opted into slash exposure. */
59
+ export declare function collectSlashSpecs(root: RootDef): SlashSpec[];
60
+ /** Assemble root -h from the subtrees themselves. Root owns only the tagline
61
+ * and globals; every subtree's concept line, selection rubric, and dynamic
62
+ * block come from its own RootEntry. A subtree without a rootEntry does not
63
+ * appear in root -h — declaring the parent-level representation is how a
64
+ * subtree opts into being listed. */
29
65
  export declare function defineRoot(opts: {
30
- help: RootHelp;
66
+ tagline: string;
67
+ globals: {
68
+ name: string;
69
+ desc: string;
70
+ }[];
31
71
  subtrees: BranchDef[];
32
72
  }): RootDef;
73
+ type AnyNode = RootDef | BranchDef | LeafDef;
74
+ /** Walk argv tokens to the deepest matched node.
75
+ * Returns { node, path, remaining } where path is the sequence of matched node
76
+ * names from root (excluding root itself) and remaining are unconsumed tokens.
77
+ * -h / --help tokens are NOT consumed here — the caller checks for them. */
78
+ export declare function walk(root: RootDef, tokens: string[]): {
79
+ node: AnyNode;
80
+ path: string[];
81
+ remaining: string[];
82
+ };
83
+ /** Build a structured unknown-path error. Names valid children of the deepest
84
+ * matched node and names the entry command per the spec. The entry command is
85
+ * the full path to the matched node (not just its local name), so the recovery
86
+ * hint is a command that actually exists. No fuzzy matching. */
87
+ export declare function unknownPathError(node: AnyNode, path: string[], bad: string): CrtrError;
33
88
  /** Parse remaining argv tokens against the leaf's InputParam schema.
34
89
  * Returns a plain object whose keys are camelCase parameter names. */
35
90
  export declare function parseArgv(params: InputParam[], tokens: string[]): Promise<Record<string, unknown>>;
36
91
  export declare function runCli(root: RootDef, argv: string[]): Promise<void>;
92
+ export {};