@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
@@ -0,0 +1,75 @@
1
+ // Tests for subagent discovery, resolution, and frontmatter parsing.
2
+ //
3
+ // Run with: node --import tsx/esm --test src/core/__tests__/subagents.test.ts
4
+ import { test, describe, before, after } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import { listSubagents, resolveSubagent, subagentId } from '../subagents.js';
10
+ import { resetScopeCache } from '../scope.js';
11
+ import { parseFrontmatterGeneric } from '../frontmatter.js';
12
+ describe('parseFrontmatterGeneric', () => {
13
+ test('returns raw record including tools/model fields skills ignore', () => {
14
+ const src = '---\nname: scout\ndescription: recon\nmodel: haiku\ntools: read, grep, bash\n---\nBody here.\n';
15
+ const { data, body } = parseFrontmatterGeneric(src);
16
+ assert.ok(data !== null);
17
+ assert.equal(data['name'], 'scout');
18
+ assert.equal(data['description'], 'recon');
19
+ assert.equal(data['model'], 'haiku');
20
+ assert.equal(data['tools'], 'read, grep, bash');
21
+ assert.equal(body, 'Body here.\n');
22
+ });
23
+ test('list-style tools parse to an array', () => {
24
+ const src = '---\nname: x\ndescription: d\ntools:\n - read\n - bash\n---\nb\n';
25
+ const { data } = parseFrontmatterGeneric(src);
26
+ assert.deepEqual(data['tools'], ['read', 'bash']);
27
+ });
28
+ test('no frontmatter yields null data', () => {
29
+ const { data, body } = parseFrontmatterGeneric('just a body');
30
+ assert.equal(data, null);
31
+ assert.equal(body, 'just a body');
32
+ });
33
+ });
34
+ describe('subagent discovery (project scope)', () => {
35
+ let dir;
36
+ const origCwd = process.cwd();
37
+ before(() => {
38
+ dir = mkdtempSync(join(tmpdir(), 'crtr-subagents-'));
39
+ const agents = join(dir, '.crouter', 'agents');
40
+ mkdirSync(agents, { recursive: true });
41
+ writeFileSync(join(dir, '.crouter', 'config.json'), '{}');
42
+ writeFileSync(join(agents, 'scout.md'), '---\nname: scout\ndescription: Fast recon\nmodel: haiku\ntools: read, grep\n---\nYou are a scout.\n');
43
+ writeFileSync(join(agents, 'reviewer.md'), '---\nname: reviewer\ndescription: Code review\n---\nYou review code.\n');
44
+ // Missing description → skipped from listings.
45
+ writeFileSync(join(agents, 'broken.md'), '---\nname: broken\n---\nno description\n');
46
+ // Name defaults to filename stem when frontmatter omits it.
47
+ writeFileSync(join(agents, 'stemmed.md'), '---\ndescription: named by file\n---\nbody\n');
48
+ process.chdir(dir);
49
+ resetScopeCache();
50
+ });
51
+ after(() => {
52
+ process.chdir(origCwd);
53
+ resetScopeCache();
54
+ rmSync(dir, { recursive: true, force: true });
55
+ });
56
+ test('listSubagents finds valid agents and skips description-less files', () => {
57
+ const ids = listSubagents('project').map(subagentId).sort();
58
+ assert.deepEqual(ids, ['reviewer', 'scout', 'stemmed']);
59
+ });
60
+ test('frontmatter tools comma-string coerces to array; model carried', () => {
61
+ const scout = resolveSubagent('scout', { scope: 'project' });
62
+ assert.deepEqual(scout.frontmatter.tools, ['read', 'grep']);
63
+ assert.equal(scout.frontmatter.model, 'haiku');
64
+ assert.equal(scout.systemPrompt.trim(), 'You are a scout.');
65
+ assert.equal(scout.plugin, '_');
66
+ });
67
+ test('name defaults to filename stem', () => {
68
+ const a = resolveSubagent('stemmed', { scope: 'project' });
69
+ assert.equal(a.name, 'stemmed');
70
+ assert.equal(a.frontmatter.description, 'named by file');
71
+ });
72
+ test('resolveSubagent throws notFound for unknown name', () => {
73
+ assert.throws(() => resolveSubagent('nope', { scope: 'project' }), /subagent not found/);
74
+ });
75
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,52 @@
1
+ // Regression tests for unknown-subcommand error recovery hints.
2
+ // Run with: node --import tsx/esm --test src/core/__tests__/unknown-path.test.ts
3
+ //
4
+ // The `next` road sign must name a command that actually exists: the FULL path
5
+ // to the deepest matched node, not just its local name. A prior bug emitted
6
+ // `crtr find -h` (dropping the `skill` parent) when `crtr skill find bogus` was
7
+ // invoked, sending the caller to a nonexistent top-level command.
8
+ import { test, describe } from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import { defineRoot, defineBranch, defineLeaf, walk, unknownPathError } from '../command.js';
11
+ const leaf = defineLeaf({
12
+ name: 'search',
13
+ help: { name: 'search', summary: 'search', output: [], outputKind: 'object', effects: ['None. Read-only.'] },
14
+ run: async () => ({}),
15
+ });
16
+ const findBranch = defineBranch({
17
+ name: 'find',
18
+ help: { name: 'find', summary: 'find', children: [{ name: 'search', desc: 'search', useWhen: 'x' }] },
19
+ children: [leaf],
20
+ });
21
+ const skillBranch = defineBranch({
22
+ name: 'skill',
23
+ help: { name: 'skill', summary: 'skill', children: [{ name: 'find', desc: 'find', useWhen: 'x' }] },
24
+ rootEntry: { concept: 'skill', desc: 'skill', useWhen: 'x' },
25
+ children: [findBranch],
26
+ });
27
+ const root = defineRoot({
28
+ tagline: 'test runtime',
29
+ globals: [],
30
+ subtrees: [skillBranch],
31
+ });
32
+ function nextHint(...tokens) {
33
+ const { node, path, remaining } = walk(root, tokens);
34
+ const err = unknownPathError(node, path, remaining[0]);
35
+ return err.details.next;
36
+ }
37
+ describe('unknown-path error: recovery hint names the full valid path', () => {
38
+ test('root-level unknown points at `crtr -h`', () => {
39
+ assert.match(nextHint('bogus'), /Run `crtr -h`/);
40
+ });
41
+ test('one-level unknown points at `crtr skill -h`', () => {
42
+ assert.match(nextHint('skill', 'bogus'), /Run `crtr skill -h`/);
43
+ });
44
+ test('two-level unknown points at `crtr skill find -h`, not `crtr find -h`', () => {
45
+ const hint = nextHint('skill', 'find', 'bogus');
46
+ assert.match(hint, /Run `crtr skill find -h`/);
47
+ assert.doesNotMatch(hint, /Run `crtr find -h`/);
48
+ });
49
+ test('valid children of the matched node are listed', () => {
50
+ assert.match(nextHint('skill', 'find', 'bogus'), /Valid children: search\./);
51
+ });
52
+ });
@@ -1,6 +1,8 @@
1
+ import type { RootDef } from './command.js';
1
2
  export declare const OFFICIAL_MARKETPLACE_NAME = "crouter-official-marketplace";
2
3
  export declare const OFFICIAL_MARKETPLACE_URL = "https://github.com/crouton-labs/crouter-official-marketplace.git";
3
4
  export declare const OFFICIAL_MARKETPLACE_REF = "main";
4
5
  export declare function ensureBootSkill(argv: string[]): void;
6
+ export declare function ensureSlashCommands(root: RootDef, argv: string[]): void;
5
7
  export declare function ensureOfficialMarketplace(argv: string[]): void;
6
8
  export declare function ensureProjectScope(argv: string[]): void;
@@ -6,6 +6,7 @@ import { ensureDir, pathExists, readText, removePath, nowIso } from './fs-utils.
6
6
  import { readConfig, readState, updateConfig, updateState, ensureScopeInitialized } from './config.js';
7
7
  import { clone } from './git.js';
8
8
  import { readMarketplaceManifest } from './manifest.js';
9
+ import { collectSlashSpecs } from './command.js';
9
10
  import { CRTR_DIR_NAME } from '../types.js';
10
11
  export const OFFICIAL_MARKETPLACE_NAME = 'crouter-official-marketplace';
11
12
  export const OFFICIAL_MARKETPLACE_URL = 'https://github.com/crouton-labs/crouter-official-marketplace.git';
@@ -94,6 +95,71 @@ export function ensureBootSkill(argv) {
94
95
  }
95
96
  }
96
97
  }
98
+ // ---------------------------------------------------------------------------
99
+ // Slash commands (editor prompt templates) auto-installed for opted-in nodes.
100
+ //
101
+ // Any command that declares a `slash` SlashSpec is rendered to a markdown
102
+ // template and dropped into the host's command dirs on each crtr run — pi reads
103
+ // `~/.pi/agent/prompts/<name>.md`, Claude Code reads `~/.claude/commands/<name>.md`,
104
+ // so `/name` becomes available. Marker-guarded (never clobbers a user-edited
105
+ // file) and version-rolled like the boot skill. Kill switch: CRTR_NO_MODE_CMDS=1.
106
+ // ---------------------------------------------------------------------------
107
+ const SLASH_CMD_MARKER = '<!-- crtr-mode-cmd v1 -->';
108
+ const SLASH_CMD_MARKER_PREFIX = '<!-- crtr-mode-cmd v';
109
+ /** Render a SlashSpec to a full template file (frontmatter + marker + body). */
110
+ function renderSlashTemplate(spec) {
111
+ const hint = spec.argumentHint !== undefined
112
+ ? `argument-hint: ${JSON.stringify(spec.argumentHint)}\n`
113
+ : '';
114
+ return `---\ndescription: ${spec.description}\n${hint}---\n\n${SLASH_CMD_MARKER}\n\n${spec.body}\n`;
115
+ }
116
+ /** Write `content` to `file` unless a user-customized file is already there.
117
+ * Rolls forward our own (marker-bearing) versions; skips if identical. */
118
+ function writeSlashFileIfOurs(dir, name, content) {
119
+ const file = join(dir, `${name}.md`);
120
+ if (pathExists(file)) {
121
+ const existing = readText(file);
122
+ if (!existing.includes(SLASH_CMD_MARKER_PREFIX))
123
+ return; // user's own file
124
+ if (existing === content)
125
+ return; // already current
126
+ }
127
+ ensureDir(dir);
128
+ writeFileSync(file, content, 'utf8');
129
+ }
130
+ export function ensureSlashCommands(root, argv) {
131
+ try {
132
+ if (process.env.CRTR_NO_MODE_CMDS === '1')
133
+ return;
134
+ if (shouldSkipForArgv(argv))
135
+ return;
136
+ const specs = collectSlashSpecs(root);
137
+ if (specs.length === 0)
138
+ return;
139
+ // Target each host's command dir, but only when that host is actually in use
140
+ // (its root dir exists). We never create ~/.pi or ~/.claude ourselves.
141
+ const targets = [];
142
+ if (pathExists(join(homedir(), '.pi', 'agent'))) {
143
+ targets.push(join(homedir(), '.pi', 'agent', 'prompts'));
144
+ }
145
+ if (pathExists(join(homedir(), '.claude'))) {
146
+ targets.push(join(homedir(), '.claude', 'commands'));
147
+ }
148
+ if (targets.length === 0)
149
+ return;
150
+ for (const spec of specs) {
151
+ const content = renderSlashTemplate(spec);
152
+ for (const dir of targets)
153
+ writeSlashFileIfOurs(dir, spec.name, content);
154
+ }
155
+ }
156
+ catch (e) {
157
+ if (process.env.CRTR_DEBUG === '1') {
158
+ const msg = e instanceof Error ? e.message : String(e);
159
+ process.stderr.write(`crtr: slash-command error: ${msg}\n`);
160
+ }
161
+ }
162
+ }
97
163
  export function ensureOfficialMarketplace(argv) {
98
164
  try {
99
165
  if (process.env.CRTR_NO_BOOTSTRAP === '1')
@@ -1,14 +1,39 @@
1
- import type { RootHelp, BranchHelp, LeafHelp, InputParam } from './help.js';
1
+ import type { RootHelp, RootEntry, BranchHelp, LeafHelp, InputParam } from './help.js';
2
+ import { CrtrError } from './errors.js';
3
+ /** Opt-in flag that surfaces a node as an editor slash command (a pi prompt
4
+ * template / Claude Code command). When set, the bootstrap auto-writes a
5
+ * markdown template named `<name>.md` to the host's command dirs on each crtr
6
+ * run, so `/name` becomes available. The body is thin — it points the agent at
7
+ * the live `crtr` workflow so the CLI stays the source of truth. */
8
+ export interface SlashSpec {
9
+ /** Command name → `/<name>` and the template filename. */
10
+ name: string;
11
+ /** Frontmatter description shown in the autocomplete dropdown. */
12
+ description: string;
13
+ /** Optional autocomplete hint, e.g. `<url>` or `[topic]`. */
14
+ argumentHint?: string;
15
+ /** Markdown body (no frontmatter). Bootstrap wraps it with frontmatter + a
16
+ * version marker. Use `$ARGUMENTS` for the invocation's free text. */
17
+ body: string;
18
+ }
2
19
  export interface LeafDef {
3
20
  kind: 'leaf';
4
21
  name: string;
5
22
  help: LeafHelp;
23
+ /** Opt into editor slash-command exposure (see SlashSpec). */
24
+ slash?: SlashSpec;
6
25
  run: (input: Record<string, unknown>) => Promise<Record<string, unknown> | void>;
7
26
  }
8
27
  export interface BranchDef {
9
28
  kind: 'branch';
10
29
  name: string;
11
30
  help: BranchHelp;
31
+ /** How this subtree represents itself one level up. Present on top-level
32
+ * subtrees (assembled into root -h by defineRoot); omitted on nested
33
+ * branches, whose parent representation is the branch's own children list. */
34
+ rootEntry?: RootEntry;
35
+ /** Opt into editor slash-command exposure (see SlashSpec). */
36
+ slash?: SlashSpec;
12
37
  children: (LeafDef | BranchDef)[];
13
38
  }
14
39
  export interface RootDef {
@@ -19,18 +44,49 @@ export interface RootDef {
19
44
  export declare function defineLeaf(opts: {
20
45
  name: string;
21
46
  help: LeafHelp;
47
+ slash?: SlashSpec;
22
48
  run: (input: Record<string, unknown>) => Promise<Record<string, unknown> | void>;
23
49
  }): LeafDef;
24
50
  export declare function defineBranch(opts: {
25
51
  name: string;
26
52
  help: BranchHelp;
53
+ rootEntry?: RootEntry;
54
+ slash?: SlashSpec;
27
55
  children: (LeafDef | BranchDef)[];
28
56
  }): BranchDef;
57
+ /** Walk the whole tree and collect every node's SlashSpec (depth-first). Used
58
+ * by the bootstrap to discover which commands opted into slash exposure. */
59
+ export declare function collectSlashSpecs(root: RootDef): SlashSpec[];
60
+ /** Assemble root -h from the subtrees themselves. Root owns only the tagline
61
+ * and globals; every subtree's concept line, selection rubric, and dynamic
62
+ * block come from its own RootEntry. A subtree without a rootEntry does not
63
+ * appear in root -h — declaring the parent-level representation is how a
64
+ * subtree opts into being listed. */
29
65
  export declare function defineRoot(opts: {
30
- help: RootHelp;
66
+ tagline: string;
67
+ globals: {
68
+ name: string;
69
+ desc: string;
70
+ }[];
31
71
  subtrees: BranchDef[];
32
72
  }): RootDef;
73
+ type AnyNode = RootDef | BranchDef | LeafDef;
74
+ /** Walk argv tokens to the deepest matched node.
75
+ * Returns { node, path, remaining } where path is the sequence of matched node
76
+ * names from root (excluding root itself) and remaining are unconsumed tokens.
77
+ * -h / --help tokens are NOT consumed here — the caller checks for them. */
78
+ export declare function walk(root: RootDef, tokens: string[]): {
79
+ node: AnyNode;
80
+ path: string[];
81
+ remaining: string[];
82
+ };
83
+ /** Build a structured unknown-path error. Names valid children of the deepest
84
+ * matched node and names the entry command per the spec. The entry command is
85
+ * the full path to the matched node (not just its local name), so the recovery
86
+ * hint is a command that actually exists. No fuzzy matching. */
87
+ export declare function unknownPathError(node: AnyNode, path: string[], bad: string): CrtrError;
33
88
  /** Parse remaining argv tokens against the leaf's InputParam schema.
34
89
  * Returns a plain object whose keys are camelCase parameter names. */
35
90
  export declare function parseArgv(params: InputParam[], tokens: string[]): Promise<Record<string, unknown>>;
36
91
  export declare function runCli(root: RootDef, argv: string[]): Promise<void>;
92
+ export {};
@@ -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 { kind: 'branch', name: opts.name, help: opts.help, children: opts.children };
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
- return { kind: 'root', help: opts.help, subtrees: opts.subtrees };
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 remaining are unconsumed tokens.
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. No fuzzy matching. */
81
- function unknownPathError(node, bad) {
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 = node.kind === 'root'
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);
@@ -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
- return mergeConfig(existing);
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;
@@ -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: parseSimpleYaml(raw), body, raw };
10
+ return { data: toSkillFrontmatter(parseYamlRecord(raw)), body, raw };
11
11
  }
12
- function parseSimpleYaml(yaml) {
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
- const fm = {
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("'"))) {
@@ -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
- /** Vocabulary block rendered before subtrees. */
53
- concepts: {
54
- name: string;
55
- desc: string;
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, e.g. "Current: 2 draft, 1 active".
73
- * Renderer soft-fails to omission if this returns null or throws. */
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;