@crouton-kit/crouter 0.3.3 → 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 (57) hide show
  1. package/README.md +2 -2
  2. package/dist/builtin-skills/skills/crouter-development/marketplaces/SKILL.md +1 -1
  3. package/dist/builtin-skills/skills/crouter-development/plugins/SKILL.md +3 -3
  4. package/dist/cli.js +16 -26
  5. package/dist/commands/__tests__/skill.test.js +24 -28
  6. package/dist/commands/agent.d.ts +6 -0
  7. package/dist/commands/agent.js +585 -0
  8. package/dist/commands/debug.d.ts +1 -1
  9. package/dist/commands/debug.js +20 -7
  10. package/dist/commands/human.js +51 -19
  11. package/dist/commands/job.d.ts +9 -0
  12. package/dist/commands/job.js +100 -385
  13. package/dist/commands/{flow.d.ts → mode.d.ts} +1 -1
  14. package/dist/commands/mode.js +231 -0
  15. package/dist/commands/pkg.js +5 -0
  16. package/dist/commands/plan.d.ts +1 -1
  17. package/dist/commands/plan.js +24 -11
  18. package/dist/commands/skill.js +130 -107
  19. package/dist/commands/spec.d.ts +1 -1
  20. package/dist/commands/spec.js +24 -11
  21. package/dist/commands/sys.js +5 -0
  22. package/dist/core/__tests__/job.test.js +38 -74
  23. package/dist/core/__tests__/jobs.test.d.ts +1 -0
  24. package/dist/core/__tests__/jobs.test.js +98 -0
  25. package/dist/core/__tests__/resolver.test.d.ts +1 -0
  26. package/dist/core/__tests__/resolver.test.js +181 -0
  27. package/dist/core/__tests__/spawn.test.d.ts +1 -0
  28. package/dist/core/__tests__/spawn.test.js +138 -0
  29. package/dist/core/__tests__/subagents.test.d.ts +1 -0
  30. package/dist/core/__tests__/subagents.test.js +75 -0
  31. package/dist/core/__tests__/unknown-path.test.d.ts +1 -0
  32. package/dist/core/__tests__/unknown-path.test.js +52 -0
  33. package/dist/core/bootstrap.d.ts +2 -0
  34. package/dist/core/bootstrap.js +66 -0
  35. package/dist/core/command.d.ts +58 -2
  36. package/dist/core/command.js +62 -14
  37. package/dist/core/config.js +20 -2
  38. package/dist/core/frontmatter.d.ts +10 -0
  39. package/dist/core/frontmatter.js +24 -9
  40. package/dist/core/help.d.ts +39 -8
  41. package/dist/core/help.js +64 -32
  42. package/dist/core/jobs.d.ts +33 -13
  43. package/dist/core/jobs.js +259 -47
  44. package/dist/core/resolver.d.ts +1 -2
  45. package/dist/core/resolver.js +111 -47
  46. package/dist/core/spawn.d.ts +150 -10
  47. package/dist/core/spawn.js +493 -41
  48. package/dist/core/subagents.d.ts +18 -0
  49. package/dist/core/subagents.js +163 -0
  50. package/dist/prompts/agent.d.ts +12 -3
  51. package/dist/prompts/agent.js +51 -18
  52. package/dist/prompts/debug.js +14 -7
  53. package/dist/prompts/skill.js +16 -16
  54. package/dist/types.d.ts +22 -1
  55. package/dist/types.js +5 -2
  56. package/package.json +2 -2
  57. package/dist/commands/flow.js +0 -24
@@ -1,13 +1,10 @@
1
- // Tests for the job subtree argv migration.
2
- // Exercises parseArgv against each leaf's param schema directly — no subprocess,
3
- // no tmux, no filesystem side-effects from the handler.
1
+ // Tests for the job subtree (monitoring) and agent/mode spawn leaves argv
2
+ // migration. Exercises parseArgv against each leaf's param schema directly —
3
+ // no subprocess, no tmux, no filesystem side-effects from the handler.
4
4
  //
5
5
  // Run with: node --import tsx/esm --test src/core/__tests__/job.test.ts
6
6
  import { test, describe } from 'node:test';
7
7
  import assert from 'node:assert/strict';
8
- import { writeFileSync, mkdirSync, rmSync } from 'node:fs';
9
- import { tmpdir } from 'node:os';
10
- import { join } from 'node:path';
11
8
  import { parseArgv } from '../command.js';
12
9
  // ---------------------------------------------------------------------------
13
10
  // Param schemas extracted from each leaf (mirrors job.ts exactly)
@@ -51,9 +48,16 @@ const readLogsParams = [
51
48
  { kind: 'flag', name: 'level', type: 'enum', choices: ['debug', 'info', 'warn', 'error'], required: false, default: 'info', constraint: '' },
52
49
  { kind: 'flag', name: 'follow', type: 'bool', required: false, constraint: '' },
53
50
  ];
51
+ // NOTE: the real leaf also declares a `stdin` param for `body`. We omit it
52
+ // from the test schema because parseArgv reads stdin to EOF whenever a stdin
53
+ // param is declared — and under `node --test`, stdin is piped with no EOF, so
54
+ // the call hangs forever. The body-required-on-status=done check lives in the
55
+ // leaf's `run` handler, not in parseArgv, so the schema tests below cover
56
+ // everything parseArgv can see without needing stdin.
54
57
  const submitParams = [
55
58
  { kind: 'positional', name: 'job_id', type: 'string', required: true, constraint: '' },
56
- { kind: 'context-file', name: 'result', required: true, constraint: '' },
59
+ { kind: 'flag', name: 'status', type: 'enum', choices: ['done', 'failed'], required: false, default: 'done', constraint: '' },
60
+ { kind: 'flag', name: 'reason', type: 'string', required: false, constraint: '' },
57
61
  { kind: 'flag', name: 'kill-pane', type: 'bool', required: false, constraint: '' },
58
62
  ];
59
63
  const cancelParams = [
@@ -63,27 +67,9 @@ const failParams = [
63
67
  { kind: 'positional', name: 'job_id', type: 'string', required: true, constraint: '' },
64
68
  ];
65
69
  // ---------------------------------------------------------------------------
66
- // Helpers
70
+ // agent new (formerly job start prompt)
67
71
  // ---------------------------------------------------------------------------
68
- let tmpDir;
69
- function setup() {
70
- tmpDir = join(tmpdir(), `crtr-job-test-${Date.now()}`);
71
- mkdirSync(tmpDir, { recursive: true });
72
- }
73
- function teardown() {
74
- if (tmpDir !== undefined) {
75
- rmSync(tmpDir, { recursive: true, force: true });
76
- }
77
- }
78
- function writeTmpJson(name, obj) {
79
- const p = join(tmpDir, name);
80
- writeFileSync(p, JSON.stringify(obj), 'utf8');
81
- return p;
82
- }
83
- // ---------------------------------------------------------------------------
84
- // job start prompt
85
- // ---------------------------------------------------------------------------
86
- describe('job start prompt', () => {
72
+ describe('agent new', () => {
87
73
  // stdin is handled by readStdinRaw() which requires actual stdin — we only
88
74
  // test the non-stdin flag parsing here.
89
75
  test('--cwd flag parsed as string', async () => {
@@ -105,9 +91,9 @@ describe('job start prompt', () => {
105
91
  });
106
92
  });
107
93
  // ---------------------------------------------------------------------------
108
- // job start fork
94
+ // agent fork (formerly job start fork)
109
95
  // ---------------------------------------------------------------------------
110
- describe('job start fork', () => {
96
+ describe('agent fork', () => {
111
97
  test('no args parses cleanly', async () => {
112
98
  const result = await parseArgv(startForkParams, []);
113
99
  assert.equal(result['cwd'], undefined);
@@ -121,9 +107,9 @@ describe('job start fork', () => {
121
107
  });
122
108
  });
123
109
  // ---------------------------------------------------------------------------
124
- // job start planner
110
+ // mode planner (formerly job start planner)
125
111
  // ---------------------------------------------------------------------------
126
- describe('job start planner', () => {
112
+ describe('mode planner', () => {
127
113
  test('positional spec_path required', async () => {
128
114
  await assert.rejects(() => parseArgv(startPlannerParams, []), (err) => { assert.match(err.message, /required parameter is missing/); return true; });
129
115
  });
@@ -139,9 +125,9 @@ describe('job start planner', () => {
139
125
  });
140
126
  });
141
127
  // ---------------------------------------------------------------------------
142
- // job start implementer
128
+ // mode implementer (formerly job start implementer)
143
129
  // ---------------------------------------------------------------------------
144
- describe('job start implementer', () => {
130
+ describe('mode implementer', () => {
145
131
  test('positional plan_path required', async () => {
146
132
  await assert.rejects(() => parseArgv(startImplementerParams, []), (err) => { assert.match(err.message, /required parameter is missing/); return true; });
147
133
  });
@@ -151,9 +137,9 @@ describe('job start implementer', () => {
151
137
  });
152
138
  });
153
139
  // ---------------------------------------------------------------------------
154
- // job start reviewer
140
+ // mode reviewer (formerly job start reviewer)
155
141
  // ---------------------------------------------------------------------------
156
- describe('job start reviewer', () => {
142
+ describe('mode reviewer', () => {
157
143
  test('positional + --kind required', async () => {
158
144
  await assert.rejects(() => parseArgv(startReviewerParams, ['/tmp/artifact.md']), (err) => { assert.match(err.message, /required parameter is missing/); return true; });
159
145
  });
@@ -273,51 +259,29 @@ describe('job read logs', () => {
273
259
  // job submit
274
260
  // ---------------------------------------------------------------------------
275
261
  describe('job submit', () => {
276
- // setup/teardown wraps tests via explicit calls since node:test lacks
277
- // per-describe lifecycle hooks in older versions.
278
- test('--context-file with valid JSON object', async () => {
279
- setup();
280
- try {
281
- const p = writeTmpJson('result.json', { status: 'done', summary: 'all good' });
282
- const result = await parseArgv(submitParams, ['job-abc', '--context-file', p]);
283
- assert.equal(result['job_id'], 'job-abc');
284
- assert.deepEqual(result['result'], { status: 'done', summary: 'all good' });
285
- assert.equal(result['killPane'], false);
286
- }
287
- finally {
288
- teardown();
289
- }
262
+ test('positional job_id + defaults: status=done, killPane=false', async () => {
263
+ const result = await parseArgv(submitParams, ['job-abc']);
264
+ assert.equal(result['job_id'], 'job-abc');
265
+ assert.equal(result['status'], 'done');
266
+ assert.equal(result['killPane'], false);
267
+ });
268
+ test('--status failed parsed', async () => {
269
+ const result = await parseArgv(submitParams, ['job-abc', '--status', 'failed', '--reason', 'broken']);
270
+ assert.equal(result['status'], 'failed');
271
+ assert.equal(result['reason'], 'broken');
272
+ });
273
+ test('--status invalid enum throws', async () => {
274
+ await assert.rejects(() => parseArgv(submitParams, ['job-abc', '--status', 'bogus']), (err) => { assert.match(err.message, /must be one of/); return true; });
290
275
  });
291
276
  test('--kill-pane presence = true, killPane key', async () => {
292
- setup();
293
- try {
294
- const p = writeTmpJson('result.json', { status: 'done' });
295
- const result = await parseArgv(submitParams, ['job-abc', '--context-file', p, '--kill-pane']);
296
- assert.equal(result['killPane'], true);
297
- }
298
- finally {
299
- teardown();
300
- }
301
- });
302
- test('missing --context-file throws missing_parameter', async () => {
303
- await assert.rejects(() => parseArgv(submitParams, ['job-abc']), (err) => { assert.match(err.message, /required parameter is missing/); return true; });
277
+ const result = await parseArgv(submitParams, ['job-abc', '--kill-pane']);
278
+ assert.equal(result['killPane'], true);
304
279
  });
305
280
  test('missing positional job_id throws missing_parameter', async () => {
306
281
  await assert.rejects(() => parseArgv(submitParams, []), (err) => { assert.match(err.message, /required parameter is missing/); return true; });
307
282
  });
308
- test('--context-file with non-existent file throws invalid_type', async () => {
309
- await assert.rejects(() => parseArgv(submitParams, ['job-abc', '--context-file', '/no/such/file.json']), (err) => { assert.match(err.message, /cannot read file/); return true; });
310
- });
311
- test('--context-file with invalid JSON throws invalid_type', async () => {
312
- setup();
313
- try {
314
- const p = join(tmpDir, 'bad.json');
315
- writeFileSync(p, 'not json', 'utf8');
316
- await assert.rejects(() => parseArgv(submitParams, ['job-abc', '--context-file', p]), (err) => { assert.match(err.message, /not valid JSON/); return true; });
317
- }
318
- finally {
319
- teardown();
320
- }
283
+ test('--context-file no longer accepted', async () => {
284
+ await assert.rejects(() => parseArgv(submitParams, ['job-abc', '--context-file', '/tmp/anything']), (err) => { assert.match(err.message, /unknown flag: --context-file/); return true; });
321
285
  });
322
286
  });
323
287
  // ---------------------------------------------------------------------------
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,98 @@
1
+ // Tests for jobs.ts result-file storage (markdown vs json paths).
2
+ //
3
+ // Run with: node --import tsx/esm --test src/core/__tests__/jobs.test.ts
4
+ import { test, describe, before, after } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { mkdirSync, rmSync, existsSync, readFileSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import { createJob, writeResult, writeMarkdownResult, readResult, recordJobPane, jobStatus, } from '../jobs.js';
10
+ let stateDir;
11
+ let origXdg;
12
+ before(() => {
13
+ stateDir = join(tmpdir(), `crtr-jobs-test-${Date.now()}`);
14
+ mkdirSync(stateDir, { recursive: true });
15
+ origXdg = process.env['XDG_STATE_HOME'];
16
+ process.env['XDG_STATE_HOME'] = stateDir;
17
+ });
18
+ after(() => {
19
+ if (origXdg === undefined) {
20
+ delete process.env['XDG_STATE_HOME'];
21
+ }
22
+ else {
23
+ process.env['XDG_STATE_HOME'] = origXdg;
24
+ }
25
+ rmSync(stateDir, { recursive: true, force: true });
26
+ });
27
+ describe('writeMarkdownResult + readResult round-trip', () => {
28
+ test('done with body writes result.md, parses frontmatter back', async () => {
29
+ const { jobId, dir } = createJob('prompt', { cwd: '/tmp' });
30
+ const body = '**Summary:** all good\n\nMore details on the next line.\n';
31
+ writeMarkdownResult(jobId, body, 'done');
32
+ assert.ok(existsSync(join(dir, 'result.md')), 'result.md should exist');
33
+ assert.ok(!existsSync(join(dir, 'result.json')), 'result.json should NOT exist on md path');
34
+ const raw = readFileSync(join(dir, 'result.md'), 'utf8');
35
+ assert.match(raw, /^---\nstatus: done\nwritten_at: \d{4}-\d{2}-\d{2}T/);
36
+ assert.ok(raw.endsWith(body), 'body preserved at end of file');
37
+ const r = await readResult(jobId, { waitMs: 0 });
38
+ assert.equal(r.status, 'done');
39
+ assert.equal(r.result_md, body);
40
+ assert.equal(r.reason, undefined);
41
+ assert.equal(r.result, undefined);
42
+ });
43
+ test('failed with reason writes reason into frontmatter and reads it back', async () => {
44
+ const { jobId } = createJob('prompt', { cwd: '/tmp' });
45
+ writeMarkdownResult(jobId, '', 'failed', 'broke: had "quoted" parts and a\nnewline');
46
+ const r = await readResult(jobId, { waitMs: 0 });
47
+ assert.equal(r.status, 'failed');
48
+ assert.equal(r.result_md, '');
49
+ assert.equal(r.reason, 'broke: had "quoted" parts and a\nnewline');
50
+ });
51
+ test('writeResult (JSON) writes result.json and read still works', async () => {
52
+ const { jobId, dir } = createJob('prompt', { cwd: '/tmp' });
53
+ writeResult(jobId, { feedback: 'approved', n: 3 }, 'done');
54
+ assert.ok(existsSync(join(dir, 'result.json')));
55
+ assert.ok(!existsSync(join(dir, 'result.md')));
56
+ const r = await readResult(jobId, { waitMs: 0 });
57
+ assert.equal(r.status, 'done');
58
+ assert.deepEqual(r.result, { feedback: 'approved', n: 3 });
59
+ assert.equal(r.result_md, undefined);
60
+ });
61
+ test('readResult with no result file and waitMs=0 returns timeout', async () => {
62
+ const { jobId } = createJob('prompt', { cwd: '/tmp' });
63
+ const r = await readResult(jobId, { waitMs: 0 });
64
+ assert.equal(r.status, 'timeout');
65
+ });
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
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,181 @@
1
+ // Tests for parseSkillQualifier (slash-only form) and config migration.
2
+ //
3
+ // Run with: node --import tsx/esm --test 'src/core/__tests__/resolver.test.ts'
4
+ import { test, describe, before, after } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import { parseSkillQualifier, resolveSkill } from '../resolver.js';
10
+ import { resetScopeCache } from '../scope.js';
11
+ import { CrtrError } from '../errors.js';
12
+ import { InputError } from '../io.js';
13
+ import { readConfig } from '../config.js';
14
+ import { SCHEMA_VERSION } from '../../types.js';
15
+ // ---------------------------------------------------------------------------
16
+ // parseSkillQualifier — slash-only form
17
+ // ---------------------------------------------------------------------------
18
+ describe('parseSkillQualifier', () => {
19
+ test('bare name returns single segment', () => {
20
+ const r = parseSkillQualifier('foo');
21
+ assert.deepEqual(r, { segments: ['foo'] });
22
+ });
23
+ test('plugin/name returns two segments', () => {
24
+ const r = parseSkillQualifier('ai/interface');
25
+ assert.deepEqual(r, { segments: ['ai', 'interface'] });
26
+ });
27
+ test('plugin/nested/name returns three segments', () => {
28
+ const r = parseSkillQualifier('ai/interface/cli-design');
29
+ assert.deepEqual(r, { segments: ['ai', 'interface', 'cli-design'] });
30
+ });
31
+ test('scope-qualified user/name sets scope, single segment', () => {
32
+ const r = parseSkillQualifier('user/my-skill');
33
+ assert.deepEqual(r, { scope: 'user', segments: ['my-skill'] });
34
+ });
35
+ test('scope-qualified project/name sets scope', () => {
36
+ const r = parseSkillQualifier('project/my-skill');
37
+ assert.deepEqual(r, { scope: 'project', segments: ['my-skill'] });
38
+ });
39
+ test('scope-qualified user/ai/x/y sets scope and leaves rest as segments', () => {
40
+ const r = parseSkillQualifier('user/ai/x/y');
41
+ assert.deepEqual(r, { scope: 'user', segments: ['ai', 'x', 'y'] });
42
+ });
43
+ test('colon in input throws InputError with invalid_qualifier', () => {
44
+ assert.throws(() => parseSkillQualifier('ai:interface/cli-design'), (e) => {
45
+ assert.ok(e instanceof InputError, 'should be InputError');
46
+ assert.equal(e.payload.error, 'invalid_qualifier');
47
+ assert.equal(e.payload.received, 'ai:interface/cli-design');
48
+ assert.ok(e.payload.next.includes('ai/interface/cli-design'), `next should contain slash form, got: ${e.payload.next}`);
49
+ return true;
50
+ });
51
+ });
52
+ test('legacy scope:name form throws invalid_qualifier', () => {
53
+ assert.throws(() => parseSkillQualifier('user:my-skill'), (e) => {
54
+ assert.ok(e instanceof InputError);
55
+ assert.equal(e.payload.error, 'invalid_qualifier');
56
+ assert.ok(e.payload.next.includes('user/my-skill'));
57
+ return true;
58
+ });
59
+ });
60
+ test('legacy scope:plugin/name form throws invalid_qualifier', () => {
61
+ assert.throws(() => parseSkillQualifier('user:ai/interface/cli-design'), (e) => {
62
+ assert.ok(e instanceof InputError);
63
+ assert.equal(e.payload.error, 'invalid_qualifier');
64
+ assert.ok(e.payload.next.includes('user/ai/interface/cli-design'));
65
+ return true;
66
+ });
67
+ });
68
+ });
69
+ // ---------------------------------------------------------------------------
70
+ // Config migration: colon keys → slash keys on readConfig with old schema_version
71
+ // ---------------------------------------------------------------------------
72
+ describe('config migration: skill keys colon → slash', () => {
73
+ let testHomeDir;
74
+ let origHome;
75
+ let crouterDir;
76
+ before(() => {
77
+ testHomeDir = join(tmpdir(), `crtr-resolver-test-${Date.now()}`);
78
+ mkdirSync(testHomeDir, { recursive: true });
79
+ origHome = process.env['HOME'];
80
+ process.env['HOME'] = testHomeDir;
81
+ crouterDir = join(testHomeDir, '.crouter');
82
+ mkdirSync(crouterDir, { recursive: true });
83
+ });
84
+ after(() => {
85
+ if (origHome === undefined) {
86
+ delete process.env['HOME'];
87
+ }
88
+ else {
89
+ process.env['HOME'] = origHome;
90
+ }
91
+ rmSync(testHomeDir, { recursive: true, force: true });
92
+ });
93
+ test('colon keys are migrated to slash keys and written with new schema_version', () => {
94
+ const oldConfig = {
95
+ schema_version: 1,
96
+ marketplaces: {},
97
+ plugins: {},
98
+ skills: {
99
+ 'ai:foo': { enabled: true },
100
+ 'ai:interface/cli-design': { enabled: false },
101
+ },
102
+ auto_update: { crtr: 'notify', content: 'notify', interval_hours: 24 },
103
+ max_panes_per_window: 3,
104
+ };
105
+ writeFileSync(join(crouterDir, 'config.json'), JSON.stringify(oldConfig), 'utf8');
106
+ const cfg = readConfig('user');
107
+ assert.ok(!('ai:foo' in cfg.skills), 'old colon key should be gone');
108
+ assert.ok('ai/foo' in cfg.skills, 'slash key should exist');
109
+ assert.equal(cfg.skills['ai/foo']?.enabled, true, 'enabled state preserved');
110
+ assert.ok(!('ai:interface/cli-design' in cfg.skills), 'old colon key should be gone');
111
+ assert.ok('ai/interface/cli-design' in cfg.skills, 'slash key should exist for nested');
112
+ assert.equal(cfg.skills['ai/interface/cli-design']?.enabled, false, 'disabled state preserved');
113
+ assert.equal(cfg.schema_version, SCHEMA_VERSION, 'schema_version bumped to current');
114
+ });
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 {};