@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.
- package/README.md +2 -2
- package/dist/builtin-skills/skills/crouter-development/marketplaces/SKILL.md +1 -1
- package/dist/builtin-skills/skills/crouter-development/plugins/SKILL.md +3 -3
- package/dist/cli.js +16 -26
- package/dist/commands/__tests__/skill.test.js +24 -28
- package/dist/commands/agent.d.ts +6 -0
- package/dist/commands/agent.js +585 -0
- package/dist/commands/debug.d.ts +1 -1
- package/dist/commands/debug.js +20 -7
- package/dist/commands/human.js +51 -19
- package/dist/commands/job.d.ts +9 -0
- package/dist/commands/job.js +100 -385
- package/dist/commands/{flow.d.ts → mode.d.ts} +1 -1
- package/dist/commands/mode.js +231 -0
- package/dist/commands/pkg.js +5 -0
- package/dist/commands/plan.d.ts +1 -1
- package/dist/commands/plan.js +24 -11
- package/dist/commands/skill.js +130 -107
- package/dist/commands/spec.d.ts +1 -1
- package/dist/commands/spec.js +24 -11
- package/dist/commands/sys.js +5 -0
- package/dist/core/__tests__/job.test.js +38 -74
- package/dist/core/__tests__/jobs.test.d.ts +1 -0
- package/dist/core/__tests__/jobs.test.js +98 -0
- package/dist/core/__tests__/resolver.test.d.ts +1 -0
- package/dist/core/__tests__/resolver.test.js +181 -0
- package/dist/core/__tests__/spawn.test.d.ts +1 -0
- package/dist/core/__tests__/spawn.test.js +138 -0
- package/dist/core/__tests__/subagents.test.d.ts +1 -0
- package/dist/core/__tests__/subagents.test.js +75 -0
- package/dist/core/__tests__/unknown-path.test.d.ts +1 -0
- package/dist/core/__tests__/unknown-path.test.js +52 -0
- package/dist/core/bootstrap.d.ts +2 -0
- package/dist/core/bootstrap.js +66 -0
- package/dist/core/command.d.ts +58 -2
- package/dist/core/command.js +62 -14
- package/dist/core/config.js +20 -2
- package/dist/core/frontmatter.d.ts +10 -0
- package/dist/core/frontmatter.js +24 -9
- package/dist/core/help.d.ts +39 -8
- package/dist/core/help.js +64 -32
- package/dist/core/jobs.d.ts +33 -13
- package/dist/core/jobs.js +259 -47
- package/dist/core/resolver.d.ts +1 -2
- package/dist/core/resolver.js +111 -47
- package/dist/core/spawn.d.ts +150 -10
- package/dist/core/spawn.js +493 -41
- package/dist/core/subagents.d.ts +18 -0
- package/dist/core/subagents.js +163 -0
- package/dist/prompts/agent.d.ts +12 -3
- package/dist/prompts/agent.js +51 -18
- package/dist/prompts/debug.js +14 -7
- package/dist/prompts/skill.js +16 -16
- package/dist/types.d.ts +22 -1
- package/dist/types.js +5 -2
- package/package.json +2 -2
- package/dist/commands/flow.js +0 -24
|
@@ -1,13 +1,10 @@
|
|
|
1
|
-
// Tests for the job subtree argv
|
|
2
|
-
// Exercises parseArgv against each leaf's param schema directly —
|
|
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: '
|
|
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
|
-
//
|
|
70
|
+
// agent new (formerly job start prompt)
|
|
67
71
|
// ---------------------------------------------------------------------------
|
|
68
|
-
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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
|
|
309
|
-
await assert.rejects(() => parseArgv(submitParams, ['job-abc', '--context-file', '/
|
|
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 {};
|