@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
|
@@ -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
|
+
});
|
package/dist/core/bootstrap.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/bootstrap.js
CHANGED
|
@@ -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')
|
package/dist/core/command.d.ts
CHANGED
|
@@ -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
|
-
|
|
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 {};
|
package/dist/core/command.js
CHANGED
|
@@ -17,14 +17,60 @@ export function defineLeaf(opts) {
|
|
|
17
17
|
kind: 'leaf',
|
|
18
18
|
name: opts.name,
|
|
19
19
|
help: opts.help,
|
|
20
|
+
slash: opts.slash,
|
|
20
21
|
run: opts.run,
|
|
21
22
|
};
|
|
22
23
|
}
|
|
23
24
|
export function defineBranch(opts) {
|
|
24
|
-
return {
|
|
25
|
+
return {
|
|
26
|
+
kind: 'branch',
|
|
27
|
+
name: opts.name,
|
|
28
|
+
help: opts.help,
|
|
29
|
+
rootEntry: opts.rootEntry,
|
|
30
|
+
slash: opts.slash,
|
|
31
|
+
children: opts.children,
|
|
32
|
+
};
|
|
25
33
|
}
|
|
34
|
+
/** Walk the whole tree and collect every node's SlashSpec (depth-first). Used
|
|
35
|
+
* by the bootstrap to discover which commands opted into slash exposure. */
|
|
36
|
+
export function collectSlashSpecs(root) {
|
|
37
|
+
const out = [];
|
|
38
|
+
const visit = (node) => {
|
|
39
|
+
if (node.slash !== undefined)
|
|
40
|
+
out.push(node.slash);
|
|
41
|
+
if (node.kind === 'branch')
|
|
42
|
+
for (const c of node.children)
|
|
43
|
+
visit(c);
|
|
44
|
+
};
|
|
45
|
+
for (const s of root.subtrees)
|
|
46
|
+
visit(s);
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
/** Assemble root -h from the subtrees themselves. Root owns only the tagline
|
|
50
|
+
* and globals; every subtree's concept line, selection rubric, and dynamic
|
|
51
|
+
* block come from its own RootEntry. A subtree without a rootEntry does not
|
|
52
|
+
* appear in root -h — declaring the parent-level representation is how a
|
|
53
|
+
* subtree opts into being listed. */
|
|
26
54
|
export function defineRoot(opts) {
|
|
27
|
-
|
|
55
|
+
// Each listed subtree becomes one <name> block at root, assembled straight
|
|
56
|
+
// from its RootEntry. Root composes nothing and hardcodes nothing: add a
|
|
57
|
+
// subtree with a rootEntry and it surfaces; its concept, rubric, state tag,
|
|
58
|
+
// and dynamic block all travel with it.
|
|
59
|
+
const commands = opts.subtrees
|
|
60
|
+
.filter((s) => s.rootEntry !== undefined)
|
|
61
|
+
.map((s) => ({
|
|
62
|
+
name: s.name,
|
|
63
|
+
concept: s.rootEntry.concept,
|
|
64
|
+
desc: s.rootEntry.desc,
|
|
65
|
+
useWhen: s.rootEntry.useWhen,
|
|
66
|
+
dynamicState: s.rootEntry.dynamicState,
|
|
67
|
+
}));
|
|
68
|
+
const help = {
|
|
69
|
+
tagline: opts.tagline,
|
|
70
|
+
commands,
|
|
71
|
+
globals: opts.globals,
|
|
72
|
+
};
|
|
73
|
+
return { kind: 'root', help, subtrees: opts.subtrees };
|
|
28
74
|
}
|
|
29
75
|
/** Validate and return child names for an unknown-path error. */
|
|
30
76
|
function childNames(node) {
|
|
@@ -35,10 +81,12 @@ function childNames(node) {
|
|
|
35
81
|
return [];
|
|
36
82
|
}
|
|
37
83
|
/** Walk argv tokens to the deepest matched node.
|
|
38
|
-
* Returns { node, remaining } where
|
|
84
|
+
* Returns { node, path, remaining } where path is the sequence of matched node
|
|
85
|
+
* names from root (excluding root itself) and remaining are unconsumed tokens.
|
|
39
86
|
* -h / --help tokens are NOT consumed here — the caller checks for them. */
|
|
40
|
-
function walk(root, tokens) {
|
|
87
|
+
export function walk(root, tokens) {
|
|
41
88
|
let current = root;
|
|
89
|
+
const path = [];
|
|
42
90
|
let i = 0;
|
|
43
91
|
while (i < tokens.length) {
|
|
44
92
|
const token = tokens[i];
|
|
@@ -50,6 +98,7 @@ function walk(root, tokens) {
|
|
|
50
98
|
if (nextNode === undefined)
|
|
51
99
|
break;
|
|
52
100
|
current = nextNode;
|
|
101
|
+
path.push(nextNode.name);
|
|
53
102
|
i++;
|
|
54
103
|
}
|
|
55
104
|
else if (current.kind === 'branch') {
|
|
@@ -57,6 +106,7 @@ function walk(root, tokens) {
|
|
|
57
106
|
if (nextNode === undefined)
|
|
58
107
|
break;
|
|
59
108
|
current = nextNode;
|
|
109
|
+
path.push(nextNode.name);
|
|
60
110
|
i++;
|
|
61
111
|
}
|
|
62
112
|
else {
|
|
@@ -64,7 +114,7 @@ function walk(root, tokens) {
|
|
|
64
114
|
break;
|
|
65
115
|
}
|
|
66
116
|
}
|
|
67
|
-
return { node: current, remaining: tokens.slice(i) };
|
|
117
|
+
return { node: current, path, remaining: tokens.slice(i) };
|
|
68
118
|
}
|
|
69
119
|
function renderNode(node) {
|
|
70
120
|
if (node.kind === 'root')
|
|
@@ -77,15 +127,13 @@ function helpRequested(remaining) {
|
|
|
77
127
|
return remaining.some((t) => t === '-h' || t === '--help');
|
|
78
128
|
}
|
|
79
129
|
/** Build a structured unknown-path error. Names valid children of the deepest
|
|
80
|
-
* matched node and names the entry command per the spec.
|
|
81
|
-
|
|
130
|
+
* matched node and names the entry command per the spec. The entry command is
|
|
131
|
+
* the full path to the matched node (not just its local name), so the recovery
|
|
132
|
+
* hint is a command that actually exists. No fuzzy matching. */
|
|
133
|
+
export function unknownPathError(node, path, bad) {
|
|
82
134
|
const valid = childNames(node);
|
|
83
135
|
const validStr = valid.length > 0 ? valid.join(', ') : '(none)';
|
|
84
|
-
const entryCmd =
|
|
85
|
-
? 'crtr -h'
|
|
86
|
-
: node.kind === 'branch'
|
|
87
|
-
? `crtr ${node.name} -h`
|
|
88
|
-
: 'crtr -h';
|
|
136
|
+
const entryCmd = path.length > 0 ? `crtr ${path.join(' ')} -h` : 'crtr -h';
|
|
89
137
|
return new CrtrError('unknown_path', `unknown subcommand: ${bad}`, ExitCode.USAGE, {
|
|
90
138
|
received: bad,
|
|
91
139
|
next: `Valid children: ${validStr}. Run \`${entryCmd}\` for the full list.`,
|
|
@@ -257,7 +305,7 @@ export async function runCli(root, argv) {
|
|
|
257
305
|
process.stdout.write(renderRoot(root.help) + '\n');
|
|
258
306
|
process.exit(ExitCode.SUCCESS);
|
|
259
307
|
}
|
|
260
|
-
const { node, remaining } = walk(root, tokens);
|
|
308
|
+
const { node, path, remaining } = walk(root, tokens);
|
|
261
309
|
try {
|
|
262
310
|
// Help anywhere in remaining tokens → print node help and exit
|
|
263
311
|
if (helpRequested(remaining)) {
|
|
@@ -267,7 +315,7 @@ export async function runCli(root, argv) {
|
|
|
267
315
|
// Bare branch or bare root (no -h, but no leaf selected) → help surface
|
|
268
316
|
if (node.kind === 'root' || node.kind === 'branch') {
|
|
269
317
|
if (remaining.length > 0) {
|
|
270
|
-
throw unknownPathError(node, remaining[0]);
|
|
318
|
+
throw unknownPathError(node, path, remaining[0]);
|
|
271
319
|
}
|
|
272
320
|
process.stdout.write(renderNode(node) + '\n');
|
|
273
321
|
process.exit(ExitCode.SUCCESS);
|
package/dist/core/config.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
-
import { CONFIG_FILE, STATE_FILE, defaultScopeConfig, defaultScopeState } from '../types.js';
|
|
2
|
+
import { CONFIG_FILE, STATE_FILE, SCHEMA_VERSION, defaultScopeConfig, defaultScopeState } from '../types.js';
|
|
3
3
|
import { readJsonIfExists, writeJson, ensureDir } from './fs-utils.js';
|
|
4
4
|
import { scopeRoot, requireScopeRoot } from './scope.js';
|
|
5
|
+
import { diag } from './io.js';
|
|
5
6
|
function configPathFor(root) {
|
|
6
7
|
return join(root, CONFIG_FILE);
|
|
7
8
|
}
|
|
@@ -23,7 +24,24 @@ export function readConfig(scope) {
|
|
|
23
24
|
const existing = readJsonIfExists(configPathFor(root));
|
|
24
25
|
if (!existing)
|
|
25
26
|
return defaultScopeConfig();
|
|
26
|
-
|
|
27
|
+
const cfg = mergeConfig(existing);
|
|
28
|
+
if ((existing.schema_version ?? 0) < SCHEMA_VERSION) {
|
|
29
|
+
migrateSkillConfigKeys(cfg, scope, root);
|
|
30
|
+
}
|
|
31
|
+
return cfg;
|
|
32
|
+
}
|
|
33
|
+
function migrateSkillConfigKeys(cfg, scope, root) {
|
|
34
|
+
const colonKeys = Object.keys(cfg.skills).filter((k) => k.includes(':'));
|
|
35
|
+
if (colonKeys.length > 0) {
|
|
36
|
+
for (const key of colonKeys) {
|
|
37
|
+
const newKey = key.replace(/:/g, '/');
|
|
38
|
+
cfg.skills[newKey] = cfg.skills[key];
|
|
39
|
+
delete cfg.skills[key];
|
|
40
|
+
}
|
|
41
|
+
diag(`crtr: migrated ${colonKeys.length} skill config keys to slash form in ${scope}`);
|
|
42
|
+
}
|
|
43
|
+
cfg.schema_version = SCHEMA_VERSION;
|
|
44
|
+
writeJson(configPathFor(root), cfg);
|
|
27
45
|
}
|
|
28
46
|
export function readState(scope) {
|
|
29
47
|
const root = scopeRoot(scope);
|
|
@@ -5,4 +5,14 @@ export interface ParsedFrontmatter {
|
|
|
5
5
|
raw: string;
|
|
6
6
|
}
|
|
7
7
|
export declare function parseFrontmatter(source: string): ParsedFrontmatter;
|
|
8
|
+
export interface ParsedFrontmatterGeneric {
|
|
9
|
+
/** Raw, uncoerced key/value record from the YAML block (null when absent). */
|
|
10
|
+
data: Record<string, unknown> | null;
|
|
11
|
+
body: string;
|
|
12
|
+
raw: string;
|
|
13
|
+
}
|
|
14
|
+
/** Like parseFrontmatter but returns the raw key/value record instead of
|
|
15
|
+
* coercing to SkillFrontmatter. Used by consumers (e.g. subagents) that read
|
|
16
|
+
* fields skills don't declare, such as `tools` and `model`. */
|
|
17
|
+
export declare function parseFrontmatterGeneric(source: string): ParsedFrontmatterGeneric;
|
|
8
18
|
export declare function serializeFrontmatter(data: SkillFrontmatter): string;
|
package/dist/core/frontmatter.js
CHANGED
|
@@ -7,9 +7,30 @@ export function parseFrontmatter(source) {
|
|
|
7
7
|
}
|
|
8
8
|
const raw = match[1];
|
|
9
9
|
const body = source.slice(match[0].length);
|
|
10
|
-
return { data:
|
|
10
|
+
return { data: toSkillFrontmatter(parseYamlRecord(raw)), body, raw };
|
|
11
11
|
}
|
|
12
|
-
|
|
12
|
+
/** Like parseFrontmatter but returns the raw key/value record instead of
|
|
13
|
+
* coercing to SkillFrontmatter. Used by consumers (e.g. subagents) that read
|
|
14
|
+
* fields skills don't declare, such as `tools` and `model`. */
|
|
15
|
+
export function parseFrontmatterGeneric(source) {
|
|
16
|
+
const match = source.match(FRONTMATTER_RE);
|
|
17
|
+
if (!match) {
|
|
18
|
+
return { data: null, body: source, raw: '' };
|
|
19
|
+
}
|
|
20
|
+
const raw = match[1];
|
|
21
|
+
const body = source.slice(match[0].length);
|
|
22
|
+
return { data: parseYamlRecord(raw), body, raw };
|
|
23
|
+
}
|
|
24
|
+
function toSkillFrontmatter(out) {
|
|
25
|
+
const fm = {
|
|
26
|
+
name: typeof out.name === 'string' ? out.name : '',
|
|
27
|
+
description: typeof out.description === 'string' ? out.description : undefined,
|
|
28
|
+
keywords: Array.isArray(out.keywords) ? out.keywords : undefined,
|
|
29
|
+
type: isSkillType(out.type) ? out.type : undefined,
|
|
30
|
+
};
|
|
31
|
+
return fm;
|
|
32
|
+
}
|
|
33
|
+
function parseYamlRecord(yaml) {
|
|
13
34
|
const lines = yaml.split(/\r?\n/);
|
|
14
35
|
const out = {};
|
|
15
36
|
let i = 0;
|
|
@@ -123,13 +144,7 @@ function parseSimpleYaml(yaml) {
|
|
|
123
144
|
out[key] = stripQuotes(rest);
|
|
124
145
|
i++;
|
|
125
146
|
}
|
|
126
|
-
|
|
127
|
-
name: typeof out.name === 'string' ? out.name : '',
|
|
128
|
-
description: typeof out.description === 'string' ? out.description : undefined,
|
|
129
|
-
keywords: Array.isArray(out.keywords) ? out.keywords : undefined,
|
|
130
|
-
type: isSkillType(out.type) ? out.type : undefined,
|
|
131
|
-
};
|
|
132
|
-
return fm;
|
|
147
|
+
return out;
|
|
133
148
|
}
|
|
134
149
|
function stripQuotes(s) {
|
|
135
150
|
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
package/dist/core/help.d.ts
CHANGED
|
@@ -47,17 +47,40 @@ export interface ContextFileParam {
|
|
|
47
47
|
shape?: string;
|
|
48
48
|
}
|
|
49
49
|
export type InputParam = PositionalParam | FlagParam | StdinParam | ContextFileParam;
|
|
50
|
+
/** A subtree's self-description at the parent (root) level. Each subtree owns
|
|
51
|
+
* the content that represents it one level up: its vocabulary line, its
|
|
52
|
+
* selection rubric, and any bounded block it contributes to the parent's -h.
|
|
53
|
+
* defineRoot assembles the root help from these — root never hardcodes a
|
|
54
|
+
* subtree's representation. See cli-design "Each node owns its parent-level
|
|
55
|
+
* representation". */
|
|
56
|
+
export interface RootEntry {
|
|
57
|
+
/** One-line vocabulary desc — what this subtree is. Rendered first in the
|
|
58
|
+
* subtree's <name> block at root. */
|
|
59
|
+
concept: string;
|
|
60
|
+
/** Operations summary (verb list). Carried for completeness; the root block
|
|
61
|
+
* leads with concept + rubric, so this is available but not rendered. */
|
|
62
|
+
desc: string;
|
|
63
|
+
/** The selection rubric — `use when X` in the subtree's <name> block. */
|
|
64
|
+
useWhen: string;
|
|
65
|
+
/** Optional bounded block this subtree contributes to its <name> block at
|
|
66
|
+
* root. Returns a complete self-named state element (build it with
|
|
67
|
+
* stateBlock), e.g. `<skills count="42">…</skills>`. Aggregate, never an
|
|
68
|
+
* unbounded enumeration on a cold path. Soft-fails to omission on
|
|
69
|
+
* null/throw. */
|
|
70
|
+
dynamicState?: () => string | null;
|
|
71
|
+
}
|
|
50
72
|
export interface RootHelp {
|
|
51
73
|
tagline: string;
|
|
52
|
-
/**
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
subtrees: {
|
|
74
|
+
/** One entry per listed subtree. Each renders as its own <name> XML block at
|
|
75
|
+
* root, carrying the subtree's concept, selection rubric, and any nested
|
|
76
|
+
* runtime-state block. Assembled from subtrees' RootEntry by defineRoot;
|
|
77
|
+
* root hardcodes none of it. */
|
|
78
|
+
commands: {
|
|
58
79
|
name: string;
|
|
80
|
+
concept: string;
|
|
59
81
|
desc: string;
|
|
60
82
|
useWhen: string;
|
|
83
|
+
dynamicState?: () => string | null;
|
|
61
84
|
}[];
|
|
62
85
|
globals: {
|
|
63
86
|
name: string;
|
|
@@ -69,8 +92,9 @@ export interface BranchHelp {
|
|
|
69
92
|
summary: string;
|
|
70
93
|
/** Local lifecycle/model line that extends the parent definition. */
|
|
71
94
|
model?: string;
|
|
72
|
-
/** Bounded runtime aggregate
|
|
73
|
-
*
|
|
95
|
+
/** Bounded runtime aggregate as a complete self-named state element (build
|
|
96
|
+
* it with stateBlock), e.g. `<skills count="42">…</skills>`. Renderer
|
|
97
|
+
* soft-fails to omission if this returns null or throws. */
|
|
74
98
|
dynamicState?: () => string | null;
|
|
75
99
|
children: {
|
|
76
100
|
name: string;
|
|
@@ -93,6 +117,13 @@ export interface LeafHelp {
|
|
|
93
117
|
* leaves use exactly: ["None. Read-only."] */
|
|
94
118
|
effects: string[];
|
|
95
119
|
}
|
|
120
|
+
/** Build a self-named runtime-state element: `<tag attr="v">body</tag>`. The
|
|
121
|
+
* subtree that owns the state authors it through this, so the tag name and any
|
|
122
|
+
* scalar metadata (e.g. a count) travel with the data and render identically
|
|
123
|
+
* at every level the block appears. The tag name carries the label, so the
|
|
124
|
+
* body never repeats it. Attribute values are controlled (counts, short
|
|
125
|
+
* tokens) and not escaped. */
|
|
126
|
+
export declare function stateBlock(tag: string, attrs: Record<string, string | number>, body: string): string;
|
|
96
127
|
export declare function renderRoot(h: RootHelp): string;
|
|
97
128
|
export declare function renderBranch(h: BranchHelp): string;
|
|
98
129
|
export declare function renderLeafArgv(h: LeafHelp): string;
|