@crouton-kit/crouter 0.2.6 → 0.3.2

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 (80) hide show
  1. package/dist/builtin-skills/skills/crouter-development/marketplaces/SKILL.md +9 -9
  2. package/dist/builtin-skills/skills/crouter-development/plugins/SKILL.md +19 -19
  3. package/dist/cli.js +42 -37
  4. package/dist/commands/__tests__/human.test.d.ts +1 -0
  5. package/dist/commands/__tests__/human.test.js +214 -0
  6. package/dist/commands/__tests__/skill.test.d.ts +1 -0
  7. package/dist/commands/__tests__/skill.test.js +294 -0
  8. package/dist/commands/debug.d.ts +3 -0
  9. package/dist/commands/debug.js +179 -0
  10. package/dist/commands/flow.d.ts +2 -0
  11. package/dist/commands/flow.js +24 -0
  12. package/dist/commands/human.d.ts +2 -0
  13. package/dist/commands/human.js +480 -0
  14. package/dist/commands/job.d.ts +2 -0
  15. package/dist/commands/job.js +669 -0
  16. package/dist/commands/pkg.d.ts +2 -0
  17. package/dist/commands/pkg.js +1021 -0
  18. package/dist/commands/plan.d.ts +4 -2
  19. package/dist/commands/plan.js +306 -22
  20. package/dist/commands/skill.d.ts +2 -2
  21. package/dist/commands/skill.js +613 -456
  22. package/dist/commands/spec.d.ts +3 -2
  23. package/dist/commands/spec.js +283 -10
  24. package/dist/commands/sys.d.ts +2 -0
  25. package/dist/commands/sys.js +712 -0
  26. package/dist/core/__tests__/argv-parser.test.d.ts +1 -0
  27. package/dist/core/__tests__/argv-parser.test.js +199 -0
  28. package/dist/core/__tests__/flow-leaves.test.d.ts +1 -0
  29. package/dist/core/__tests__/flow-leaves.test.js +248 -0
  30. package/dist/core/__tests__/job.test.d.ts +1 -0
  31. package/dist/core/__tests__/job.test.js +346 -0
  32. package/dist/core/__tests__/pkg.test.d.ts +1 -0
  33. package/dist/core/__tests__/pkg.test.js +218 -0
  34. package/dist/core/__tests__/sys.test.d.ts +1 -0
  35. package/dist/core/__tests__/sys.test.js +208 -0
  36. package/dist/core/artifact.d.ts +29 -18
  37. package/dist/core/artifact.js +78 -221
  38. package/dist/core/auto-update.js +11 -4
  39. package/dist/core/command.d.ts +36 -0
  40. package/dist/core/command.js +287 -0
  41. package/dist/core/errors.d.ts +3 -0
  42. package/dist/core/errors.js +5 -0
  43. package/dist/core/fs-utils.d.ts +1 -0
  44. package/dist/core/fs-utils.js +4 -0
  45. package/dist/core/help.d.ts +98 -0
  46. package/dist/core/help.js +163 -0
  47. package/dist/core/io.d.ts +29 -0
  48. package/dist/core/io.js +83 -0
  49. package/dist/core/jobs.d.ts +87 -0
  50. package/dist/core/jobs.js +353 -0
  51. package/dist/core/pagination.d.ts +33 -0
  52. package/dist/core/pagination.js +89 -0
  53. package/dist/core/self-update.d.ts +21 -0
  54. package/dist/core/self-update.js +105 -0
  55. package/dist/core/spawn.d.ts +47 -65
  56. package/dist/core/spawn.js +78 -228
  57. package/dist/prompts/agent.d.ts +10 -5
  58. package/dist/prompts/agent.js +51 -74
  59. package/dist/prompts/debug.d.ts +8 -0
  60. package/dist/prompts/debug.js +37 -0
  61. package/dist/prompts/review.js +4 -11
  62. package/dist/prompts/skill.d.ts +0 -1
  63. package/dist/prompts/skill.js +95 -149
  64. package/package.json +4 -2
  65. package/dist/commands/agent.d.ts +0 -2
  66. package/dist/commands/agent.js +0 -265
  67. package/dist/commands/config.d.ts +0 -2
  68. package/dist/commands/config.js +0 -146
  69. package/dist/commands/doctor.d.ts +0 -2
  70. package/dist/commands/doctor.js +0 -268
  71. package/dist/commands/marketplace.d.ts +0 -2
  72. package/dist/commands/marketplace.js +0 -365
  73. package/dist/commands/plugin.d.ts +0 -2
  74. package/dist/commands/plugin.js +0 -367
  75. package/dist/commands/update.d.ts +0 -4
  76. package/dist/commands/update.js +0 -140
  77. package/dist/prompts/plan.d.ts +0 -1
  78. package/dist/prompts/plan.js +0 -175
  79. package/dist/prompts/spec.d.ts +0 -1
  80. package/dist/prompts/spec.js +0 -153
@@ -1,37 +1,24 @@
1
+ // `crtr skill` subtree handlers — P3 implementation.
2
+ // Sub-branches: find {list, search, grep}, read {show, where}, author {guide, scaffold}, state {enable, disable}.
1
3
  import { join } from 'node:path';
2
4
  import { writeFileSync } from 'node:fs';
3
- import { SCOPE_SKILL_PLUGIN, SKILL_ENTRY_FILE, SKILLS_DIR, SKILL_TYPES, isSkillType, } from '../types.js';
4
- import { skillConfigKey } from '../types.js';
5
- import { notFound, usage, general } from '../core/errors.js';
6
- import { out, hint, info, jsonOut, handleError, } from '../core/output.js';
7
- import { listScopes, requireScopeRoot, resolveScopeArg, projectScopeRoot, scopeSkillsDir, } from '../core/scope.js';
5
+ import { defineBranch, defineLeaf } from '../core/command.js';
6
+ import { usage, general, notFound } from '../core/errors.js';
7
+ import { SCOPE_SKILL_PLUGIN, SKILL_ENTRY_FILE, SKILLS_DIR, SKILL_TYPES, isSkillType, skillConfigKey, } from '../types.js';
8
8
  import { resolveSkill, listAllSkills, listInstalledPlugins, findPluginByName, parseSkillQualifier, listSkillSiblings, listSkillChildren, } from '../core/resolver.js';
9
- import { updateConfig, ensureScopeInitialized } from '../core/config.js';
9
+ import { listScopes, resolveScopeArg, requireScopeRoot, scopeSkillsDir, projectScopeRoot, } from '../core/scope.js';
10
10
  import { parseFrontmatter, serializeFrontmatter } from '../core/frontmatter.js';
11
+ import { updateConfig, ensureScopeInitialized } from '../core/config.js';
12
+ import { paginate } from '../core/pagination.js';
11
13
  import { ensureDir, pathExists, readText, walkFiles } from '../core/fs-utils.js';
12
- import { skillPrompt, skillCreatePrompt, skillTemplatePrompt } from '../prompts/skill.js';
13
- const KNOWN_VERBS = new Set([
14
- 'list',
15
- 'show',
16
- 'path',
17
- 'grep',
18
- 'new',
19
- 'create',
20
- 'template',
21
- 'where',
22
- 'enable',
23
- 'disable',
24
- 'search',
25
- ]);
26
- function buildShowFooter(skillPath) {
27
- return (`crtr: edit this skill directly at ${skillPath} — ` +
28
- `for SKILL.md format + authoring workflow run \`crtr skill\` (no args)`);
29
- }
30
- function wrapSkill(name, path, content) {
31
- return `<skill name="${name}" path="${path}">\n${content.endsWith('\n') ? content : content + '\n'}</skill>`;
32
- }
14
+ import { skillCreatePrompt, skillTemplatePrompt } from '../prompts/skill.js';
15
+ // ---------------------------------------------------------------------------
16
+ // Neighbors section (ported from old impl)
17
+ // ---------------------------------------------------------------------------
33
18
  function formatNeighborQualifier(s) {
34
- return s.plugin === SCOPE_SKILL_PLUGIN ? `${s.scope}:${s.name}` : `${s.plugin}/${s.name}`;
19
+ return s.plugin === SCOPE_SKILL_PLUGIN
20
+ ? `${s.scope}:${s.name}`
21
+ : `${s.plugin}/${s.name}`;
35
22
  }
36
23
  function formatNeighborKeywords(s) {
37
24
  const kw = s.frontmatter.keywords;
@@ -46,8 +33,7 @@ function buildNeighborsSection(skill) {
46
33
  return null;
47
34
  const lines = [
48
35
  '## Neighbors',
49
- '*Auto-discovered from filesystem. Use `--no-neighbors` to suppress. ' +
50
- 'Run `crtr skill show <name>` for full description + body.*',
36
+ '*Auto-discovered from filesystem. Run `crtr skill read show <name>` for full description + body.*',
51
37
  '',
52
38
  ];
53
39
  if (siblings.length > 0) {
@@ -66,473 +52,644 @@ function buildNeighborsSection(skill) {
66
52
  }
67
53
  return lines.join('\n');
68
54
  }
69
- function appendNeighbors(skill, body, suppress) {
70
- if (suppress)
71
- return body;
55
+ function appendNeighbors(skill, body) {
72
56
  const section = buildNeighborsSection(skill);
73
57
  if (section === null)
74
58
  return body;
75
59
  const sep = body.endsWith('\n') ? '\n' : '\n\n';
76
60
  return body + sep + `<neighbors>\n${section}\n</neighbors>\n`;
77
61
  }
78
- const SKILL_IDENTIFIER_HELP = 'Skill identifier forms (accepted by show, path, where, enable, disable):\n' +
79
- ' <name> bare name — resolves scope-root first, then plugins\n' +
80
- ' <plugin>:<name> explicit plugin (canonical)\n' +
81
- ' <scope>:<name> scope-root skill in a specific scope (user|project)\n' +
82
- ' <scope>:<plugin>/<name> fully qualified matches `skill list` / `skill search` output\n' +
83
- ' <plugin>/<name> shorthand for <plugin>:<name> when unambiguous';
84
- export function registerSkillCommands(program) {
85
- const skill = program
86
- .command('skill [nameOrVerb] [rest...]')
87
- .description('manage and inspect skills')
88
- .option('--frontmatter', 'include YAML frontmatter in the printed body')
89
- .addHelpText('before', '\n' + skillPrompt() + '\n')
90
- .addHelpText('after', '\n' + SKILL_IDENTIFIER_HELP)
91
- .action(async (nameOrVerb, _rest, opts) => {
92
- if (nameOrVerb === undefined) {
93
- skill.help();
94
- return;
62
+ // ---------------------------------------------------------------------------
63
+ // Resolve scope for enable/disable/scaffold
64
+ // ---------------------------------------------------------------------------
65
+ function resolveWriteScope(scopeStr) {
66
+ if (scopeStr !== undefined) {
67
+ const resolved = resolveScopeArg(scopeStr);
68
+ if (resolved === 'all') {
69
+ throw usage('scope must be user or project, not all');
95
70
  }
96
- if (!KNOWN_VERBS.has(nameOrVerb)) {
97
- try {
98
- const skillObj = resolveSkill(nameOrVerb);
99
- const content = readText(skillObj.path);
100
- const rawBody = opts.frontmatter ? content : parseFrontmatter(content).body;
101
- const body = appendNeighbors(skillObj, rawBody, false);
102
- out(wrapSkill(skillObj.name, skillObj.path, body));
103
- hint(buildShowFooter(skillObj.path));
104
- }
105
- catch (e) {
106
- handleError(e);
71
+ return resolved;
72
+ }
73
+ return projectScopeRoot() !== null ? 'project' : 'user';
74
+ }
75
+ // ---------------------------------------------------------------------------
76
+ // find sub-branch
77
+ // ---------------------------------------------------------------------------
78
+ const findList = defineLeaf({
79
+ name: 'list',
80
+ help: {
81
+ name: 'skill find list',
82
+ summary: 'paginated list of installed skills',
83
+ params: [
84
+ { kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project', 'all'], required: false, constraint: 'Default: all.' },
85
+ { kind: 'flag', name: 'plugin', type: 'string', required: false, constraint: 'Filter to a single plugin name.' },
86
+ { kind: 'flag', name: 'include-disabled', type: 'bool', required: false, constraint: 'When present, includes disabled skills.' },
87
+ { kind: 'flag', name: 'limit', type: 'int', required: false, default: 50, constraint: 'Max 200.' },
88
+ { kind: 'flag', name: 'cursor', type: 'string', required: false, constraint: 'Opaque token from next_cursor. Omit on first call.' },
89
+ { kind: 'flag', name: 'full', type: 'bool', required: false, constraint: 'When present, includes each skill\'s description in items. Off by default to keep enumerations cheap; pair with --plugin or --limit to bound cost.' },
90
+ ],
91
+ output: [
92
+ { name: 'items', type: 'object[]', required: true, constraint: 'Each: {name, plugin, scope, enabled, disabled_in?}. With --full, each item also includes description. Sorted by scope then plugin then name ascending.' },
93
+ { name: 'next_cursor', type: 'string | null', required: true, constraint: 'null means no more items.' },
94
+ { name: 'total', type: 'integer | null', required: true, constraint: 'Exact when cheap; null otherwise.' },
95
+ { name: 'follow_up', type: 'string', required: true, constraint: 'Concrete next commands for drilling into an item or refining the list.' },
96
+ ],
97
+ outputKind: 'object',
98
+ effects: ['None. Read-only.'],
99
+ },
100
+ run: async (input) => {
101
+ const scopeStr = input['scope'];
102
+ const pluginFilter = input['plugin'];
103
+ const includeDisabled = input['includeDisabled'];
104
+ const limitRaw = input['limit'];
105
+ const limit = Math.min(Math.max(1, limitRaw), 200);
106
+ const cursor = input['cursor'];
107
+ const full = input['full'];
108
+ const scopes = listScopes(scopeStr);
109
+ const skills = scopes
110
+ .flatMap((s) => listAllSkills(s))
111
+ .filter((sk) => {
112
+ if (pluginFilter !== undefined && sk.plugin !== pluginFilter)
113
+ return false;
114
+ if (!includeDisabled && !sk.enabled)
115
+ return false;
116
+ return true;
117
+ });
118
+ // Sort by scope then plugin then name ascending
119
+ const scopeOrder = { project: 0, user: 1, builtin: 2 };
120
+ skills.sort((a, b) => {
121
+ const so = (scopeOrder[a.scope] !== undefined ? scopeOrder[a.scope] : 3) -
122
+ (scopeOrder[b.scope] !== undefined ? scopeOrder[b.scope] : 3);
123
+ if (so !== 0)
124
+ return so;
125
+ const po = a.plugin.localeCompare(b.plugin);
126
+ if (po !== 0)
127
+ return po;
128
+ return a.name.localeCompare(b.name);
129
+ });
130
+ const keyOf = (sk) => `${sk.scope}:${sk.plugin}/${sk.name}`;
131
+ const params = {};
132
+ if (limit !== undefined)
133
+ params.limit = limit;
134
+ if (cursor !== undefined)
135
+ params.cursor = cursor;
136
+ const result = paginate(skills, params, {
137
+ defaultLimit: 50,
138
+ maxLimit: 200,
139
+ keyOf,
140
+ total: 'count',
141
+ });
142
+ return {
143
+ items: result.items.map((sk) => {
144
+ const base = {
145
+ name: sk.name,
146
+ plugin: sk.plugin,
147
+ scope: sk.scope,
148
+ enabled: sk.enabled,
149
+ disabled_in: sk.disabledIn !== undefined ? sk.disabledIn : null,
150
+ };
151
+ if (full) {
152
+ base['description'] = sk.frontmatter.description !== undefined ? sk.frontmatter.description : null;
153
+ }
154
+ return base;
155
+ }),
156
+ next_cursor: result.next_cursor,
157
+ total: result.total,
158
+ follow_up: 'Use `crtr skill read show <name>` for the full SKILL.md body. Run `crtr skill find list -h` for filters and verbosity.',
159
+ };
160
+ },
161
+ });
162
+ const findSearch = defineLeaf({
163
+ name: 'search',
164
+ help: {
165
+ name: 'skill find search',
166
+ summary: 'search skills by name, description, and keywords',
167
+ params: [
168
+ { kind: 'positional', name: 'query', required: true, constraint: 'Whitespace-separated terms matched case-insensitively against name, description, and keywords; skills matching more terms rank higher.' },
169
+ { kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project', 'all'], required: false, constraint: 'Default: all.' },
170
+ { kind: 'flag', name: 'plugin', type: 'string', required: false, constraint: 'Filter to a single plugin name.' },
171
+ { kind: 'flag', name: 'include-disabled', type: 'bool', required: false, constraint: 'When present, includes disabled skills.' },
172
+ { kind: 'flag', name: 'search-body', type: 'bool', required: false, constraint: 'When present, also searches inside SKILL.md body text.' },
173
+ ],
174
+ output: [
175
+ { name: 'query', type: 'string', required: true, constraint: 'Echo of the input query.' },
176
+ { name: 'hits', type: 'object[]', required: true, constraint: 'Each: {name, plugin, scope, score, description}. Sorted by score descending. description is the frontmatter line — the discriminator for picking which hit to read in full.' },
177
+ { name: 'follow_up', type: 'string', required: true, constraint: 'Concrete next commands for drilling into a hit or refining the search.' },
178
+ ],
179
+ outputKind: 'object',
180
+ effects: ['None. Read-only.'],
181
+ },
182
+ run: async (input) => {
183
+ const query = input['query'];
184
+ const scopeStr = input['scope'];
185
+ const pluginFilter = input['plugin'];
186
+ const includeDisabled = input['includeDisabled'];
187
+ const searchBody = input['searchBody'];
188
+ const terms = query
189
+ .toLowerCase()
190
+ .split(/\s+/)
191
+ .filter((t) => t.length > 0);
192
+ if (terms.length === 0)
193
+ throw usage('query must contain at least one non-whitespace term');
194
+ const scopes = listScopes(scopeStr);
195
+ const candidates = scopes
196
+ .flatMap((s) => listAllSkills(s))
197
+ .filter((sk) => {
198
+ if (pluginFilter !== undefined && sk.plugin !== pluginFilter)
199
+ return false;
200
+ if (!includeDisabled && !sk.enabled)
201
+ return false;
202
+ return true;
203
+ });
204
+ const hits = [];
205
+ for (const sk of candidates) {
206
+ const matchedSet = new Set();
207
+ let score = 0;
208
+ const nameLc = sk.name.toLowerCase();
209
+ const descLc = sk.frontmatter.description !== undefined ? sk.frontmatter.description.toLowerCase() : null;
210
+ const kwsLc = sk.frontmatter.keywords !== undefined ? sk.frontmatter.keywords.map((k) => k.toLowerCase()) : null;
211
+ const bodyLc = searchBody ? readText(sk.path).toLowerCase() : null;
212
+ for (const term of terms) {
213
+ if (nameLc.includes(term)) {
214
+ score += 10;
215
+ matchedSet.add('name');
216
+ }
217
+ if (descLc !== null && descLc.includes(term)) {
218
+ score += 4;
219
+ matchedSet.add('description');
220
+ }
221
+ if (kwsLc !== null && kwsLc.some((k) => k.includes(term))) {
222
+ score += 6;
223
+ matchedSet.add('keywords');
224
+ }
225
+ if (bodyLc !== null && bodyLc.includes(term)) {
226
+ score += 1;
227
+ matchedSet.add('body');
228
+ }
107
229
  }
230
+ if (score > 0)
231
+ hits.push({ skill: sk, score, matched: Array.from(matchedSet) });
108
232
  }
109
- });
110
- // list
111
- skill
112
- .command('list')
113
- .description('list installed skills (disabled hidden unless -a)')
114
- .option('--scope <scope>', 'user|project|all (default: all)')
115
- .option('--plugin <name>', 'filter by plugin name')
116
- .option('-a, --all', 'include disabled skills')
117
- .option('--json', 'emit JSON')
118
- .addHelpText('after', '\nOutput format: <scope>:<plugin>/<name> — paste this identifier into ' +
119
- '`crtr skill show` to read the skill.')
120
- .action(async (opts) => {
233
+ hits.sort((a, b) => b.score - a.score || a.skill.name.localeCompare(b.skill.name));
234
+ return {
235
+ query,
236
+ hits: hits.map((h) => ({
237
+ name: h.skill.name,
238
+ plugin: h.skill.plugin,
239
+ scope: h.skill.scope,
240
+ score: h.score,
241
+ description: h.skill.frontmatter.description !== undefined ? h.skill.frontmatter.description : null,
242
+ })),
243
+ follow_up: 'Use `crtr skill read show <name>` for the full SKILL.md body. Run `crtr skill find search -h` for filters.',
244
+ };
245
+ },
246
+ });
247
+ const findGrep = defineLeaf({
248
+ name: 'grep',
249
+ help: {
250
+ name: 'skill find grep',
251
+ summary: 'search skill file contents for a regex pattern',
252
+ params: [
253
+ { kind: 'positional', name: 'pattern', required: true, constraint: 'ECMAScript regex. Applied to each line of every SKILL.md file.' },
254
+ { kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project', 'all'], required: false, constraint: 'Default: all.' },
255
+ { kind: 'flag', name: 'plugin', type: 'string', required: false, constraint: 'Filter to a single plugin name.' },
256
+ ],
257
+ output: [
258
+ { name: 'matches', type: 'object[]', required: true, constraint: 'Each: {path, line, text}. path is absolute. Sorted by path then line ascending.' },
259
+ ],
260
+ outputKind: 'object',
261
+ effects: ['None. Read-only.'],
262
+ },
263
+ run: async (input) => {
264
+ const pattern = input['pattern'];
265
+ const scopeStr = input['scope'];
266
+ const pluginFilter = input['plugin'];
267
+ let regex;
121
268
  try {
122
- const scopes = listScopes(opts.scope);
123
- const skills = scopes
124
- .flatMap((s) => listAllSkills(s))
125
- .filter((sk) => {
126
- if (opts.plugin !== undefined && sk.plugin !== opts.plugin)
127
- return false;
128
- if (!opts.all && !sk.enabled)
129
- return false;
130
- return true;
131
- });
132
- if (opts.json) {
133
- jsonOut({
134
- skills: skills.map((sk) => ({
135
- name: sk.name,
136
- plugin: sk.plugin,
137
- scope: sk.scope,
138
- path: sk.path,
139
- description: sk.frontmatter.description,
140
- enabled: sk.enabled,
141
- disabled_in: sk.disabledIn,
142
- })),
143
- });
144
- return;
145
- }
146
- for (const sk of skills) {
147
- const desc = sk.frontmatter.description !== undefined ? sk.frontmatter.description : '';
148
- const marker = sk.enabled ? '' : ` [disabled${sk.disabledIn ? `@${sk.disabledIn}` : ''}]`;
149
- const qualified = sk.plugin === SCOPE_SKILL_PLUGIN
150
- ? `${sk.scope}:${sk.name}`
151
- : `${sk.scope}:${sk.plugin}/${sk.name}`;
152
- out(`${qualified}${marker}${desc ? ` — ${desc}` : ''}`);
153
- }
269
+ regex = new RegExp(pattern);
154
270
  }
155
- catch (e) {
156
- handleError(e, { json: opts.json });
271
+ catch {
272
+ throw usage(`invalid regex pattern: ${pattern}`);
157
273
  }
158
- });
159
- // show
160
- skill
161
- .command('show <name>')
162
- .description('print SKILL.md body to stdout (default verb)')
163
- .option('--scope <scope>', 'user|project')
164
- .option('--plugin <name>', 'filter by plugin name')
165
- .option('--frontmatter', 'include YAML frontmatter in the printed body')
166
- .option('--no-neighbors', 'suppress the auto-appended ## Neighbors section')
167
- .option('--json', 'emit JSON')
168
- .addHelpText('after', '\nExamples:\n' +
169
- ' crtr skill show rules # bare name\n' +
170
- ' crtr skill show claude-authoring:rules # plugin:name (canonical)\n' +
171
- ' crtr skill show user:claude-authoring/rules # scope:plugin/name (matches search/list output)\n' +
172
- ' crtr skill show claude-authoring/rules # plugin/name shorthand\n\n' +
173
- SKILL_IDENTIFIER_HELP)
174
- .action(async (name, opts) => {
175
- try {
176
- const scopeArg = resolveScopeArg(opts.scope);
177
- const resolveOpts = scopeArg !== 'all' ? { scope: scopeArg } : {};
178
- if (opts.plugin !== undefined) {
179
- Object.assign(resolveOpts, { pluginFilter: opts.plugin });
274
+ const scopes = listScopes(scopeStr);
275
+ const skillsDirs = [];
276
+ for (const s of scopes) {
277
+ if (pluginFilter === undefined || pluginFilter === SCOPE_SKILL_PLUGIN) {
278
+ const root = scopeSkillsDir(s);
279
+ if (root)
280
+ skillsDirs.push(root);
180
281
  }
181
- const skillObj = resolveSkill(name, resolveOpts);
182
- const content = readText(skillObj.path);
183
- const rawBody = opts.frontmatter ? content : parseFrontmatter(content).body;
184
- const suppressNeighbors = opts.neighbors === false;
185
- const body = appendNeighbors(skillObj, rawBody, suppressNeighbors);
186
- if (opts.json) {
187
- jsonOut({
188
- name: skillObj.name,
189
- plugin: skillObj.plugin,
190
- scope: skillObj.scope,
191
- path: skillObj.path,
192
- content,
193
- authoring_guide_command: `crtr skill`,
282
+ for (const plugin of listInstalledPlugins(s)) {
283
+ if (!plugin.enabled)
284
+ continue;
285
+ if (pluginFilter !== undefined && plugin.name !== pluginFilter)
286
+ continue;
287
+ skillsDirs.push(join(plugin.root, SKILLS_DIR));
288
+ }
289
+ }
290
+ const matchLines = [];
291
+ for (const skillsDir of skillsDirs) {
292
+ const files = walkFiles(skillsDir);
293
+ for (const file of files) {
294
+ const content = readText(file);
295
+ const lines = content.split('\n');
296
+ lines.forEach((lineText, idx) => {
297
+ if (regex.test(lineText)) {
298
+ matchLines.push({ path: file, line: idx + 1, text: lineText });
299
+ }
194
300
  });
195
- return;
196
301
  }
197
- out(wrapSkill(skillObj.name, skillObj.path, body));
198
- hint(buildShowFooter(skillObj.path));
199
302
  }
200
- catch (e) {
201
- handleError(e, { json: opts.json });
303
+ // Sort by path then line ascending
304
+ matchLines.sort((a, b) => {
305
+ const pc = a.path.localeCompare(b.path);
306
+ return pc !== 0 ? pc : a.line - b.line;
307
+ });
308
+ return { matches: matchLines };
309
+ },
310
+ });
311
+ const findBranch = defineBranch({
312
+ name: 'find',
313
+ help: {
314
+ name: 'skill find',
315
+ summary: 'discover skills by listing, keyword search, or body grep',
316
+ children: [
317
+ { name: 'list', desc: 'paginated list of installed skills', useWhen: 'enumerating all available skills' },
318
+ { name: 'search', desc: 'keyword search across name/description/keywords', useWhen: 'looking for skills matching a topic' },
319
+ { name: 'grep', desc: 'regex search across SKILL.md bodies', useWhen: 'finding skills containing specific text or patterns' },
320
+ ],
321
+ },
322
+ children: [findList, findSearch, findGrep],
323
+ });
324
+ // ---------------------------------------------------------------------------
325
+ // read sub-branch
326
+ // ---------------------------------------------------------------------------
327
+ const readShow = defineLeaf({
328
+ name: 'show',
329
+ help: {
330
+ name: 'skill read show',
331
+ summary: 'print SKILL.md body for a named skill',
332
+ params: [
333
+ { kind: 'positional', name: 'name', required: true, constraint: 'Skill identifier. Forms: <name>, <plugin>:<name>, <scope>:<name>, <scope>:<plugin>/<name>.' },
334
+ { kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Narrows resolution when name is ambiguous.' },
335
+ { kind: 'flag', name: 'plugin', type: 'string', required: false, constraint: 'Narrows resolution to a specific plugin.' },
336
+ { kind: 'flag', name: 'frontmatter', type: 'bool', required: false, constraint: 'When present, includes YAML frontmatter in the output.' },
337
+ ],
338
+ output: [
339
+ { name: 'name', type: 'string', required: true, constraint: 'Resolved skill name.' },
340
+ { name: 'plugin', type: 'string', required: true, constraint: 'Plugin the skill belongs to.' },
341
+ { name: 'scope', type: 'string', required: true, constraint: 'Scope the skill was resolved from.' },
342
+ { name: 'path', type: 'string', required: true, constraint: 'Absolute path to SKILL.md.' },
343
+ { name: 'content', type: 'string', required: true, constraint: 'SKILL.md body (with or without frontmatter per the --frontmatter flag).' },
344
+ ],
345
+ outputKind: 'object',
346
+ effects: ['None. Read-only.'],
347
+ },
348
+ run: async (input) => {
349
+ const nameRaw = input['name'];
350
+ const scopeStr = input['scope'];
351
+ const pluginFilter = input['plugin'];
352
+ const includeFrontmatter = input['frontmatter'];
353
+ const resolveOpts = {};
354
+ if (scopeStr !== undefined) {
355
+ const resolved = resolveScopeArg(scopeStr);
356
+ if (resolved !== 'all')
357
+ resolveOpts.scope = resolved;
202
358
  }
203
- });
204
- // path
205
- skill
206
- .command('path <name>')
207
- .description('print absolute path to SKILL.md')
208
- .option('--scope <scope>', 'user|project')
209
- .option('--plugin <name>', 'filter by plugin name')
210
- .action(async (name, opts) => {
211
- try {
212
- const scopeArg = resolveScopeArg(opts.scope);
213
- const resolveOpts = scopeArg !== 'all' ? { scope: scopeArg } : {};
214
- if (opts.plugin !== undefined) {
215
- Object.assign(resolveOpts, { pluginFilter: opts.plugin });
216
- }
217
- const skillObj = resolveSkill(name, resolveOpts);
218
- out(skillObj.path);
359
+ if (pluginFilter !== undefined)
360
+ resolveOpts.pluginFilter = pluginFilter;
361
+ const skillObj = resolveSkill(nameRaw, resolveOpts);
362
+ const rawContent = readText(skillObj.path);
363
+ const rawBody = includeFrontmatter ? rawContent : parseFrontmatter(rawContent).body;
364
+ const content = appendNeighbors(skillObj, rawBody);
365
+ return {
366
+ name: skillObj.name,
367
+ plugin: skillObj.plugin,
368
+ scope: skillObj.scope,
369
+ path: skillObj.path,
370
+ content,
371
+ };
372
+ },
373
+ });
374
+ const readWhere = defineLeaf({
375
+ name: 'where',
376
+ help: {
377
+ name: 'skill read where',
378
+ summary: 'show resolution metadata for a named skill without reading its body',
379
+ params: [
380
+ { kind: 'positional', name: 'name', required: true, constraint: 'Skill identifier. Same forms as skill read show.' },
381
+ { kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Narrows resolution.' },
382
+ { kind: 'flag', name: 'plugin', type: 'string', required: false, constraint: 'Narrows resolution to a specific plugin.' },
383
+ ],
384
+ output: [
385
+ { name: 'name', type: 'string', required: true, constraint: 'Resolved skill name.' },
386
+ { name: 'plugin', type: 'string', required: true, constraint: 'Plugin the skill belongs to.' },
387
+ { name: 'scope', type: 'string', required: true, constraint: 'Scope the skill was resolved from.' },
388
+ { name: 'path', type: 'string', required: true, constraint: 'Absolute path to SKILL.md.' },
389
+ ],
390
+ outputKind: 'object',
391
+ effects: ['None. Read-only.'],
392
+ },
393
+ run: async (input) => {
394
+ const nameRaw = input['name'];
395
+ const scopeStr = input['scope'];
396
+ const pluginFilter = input['plugin'];
397
+ const resolveOpts = {};
398
+ if (scopeStr !== undefined) {
399
+ const resolved = resolveScopeArg(scopeStr);
400
+ if (resolved !== 'all')
401
+ resolveOpts.scope = resolved;
219
402
  }
220
- catch (e) {
221
- handleError(e);
403
+ if (pluginFilter !== undefined)
404
+ resolveOpts.pluginFilter = pluginFilter;
405
+ const skillObj = resolveSkill(nameRaw, resolveOpts);
406
+ return {
407
+ name: skillObj.name,
408
+ plugin: skillObj.plugin,
409
+ scope: skillObj.scope,
410
+ path: skillObj.path,
411
+ };
412
+ },
413
+ });
414
+ const readBranch = defineBranch({
415
+ name: 'read',
416
+ help: {
417
+ name: 'skill read',
418
+ summary: 'read skill content or resolve its location',
419
+ children: [
420
+ { name: 'show', desc: 'print SKILL.md body', useWhen: 'loading skill content to act on it' },
421
+ { name: 'where', desc: 'show resolution metadata only', useWhen: 'verifying which skill resolves and from where, without loading its body' },
422
+ ],
423
+ },
424
+ children: [readShow, readWhere],
425
+ });
426
+ // ---------------------------------------------------------------------------
427
+ // author sub-branch
428
+ // ---------------------------------------------------------------------------
429
+ const VALID_TYPES = ['playbook', 'primer', 'reference', 'runbook', 'freeform'];
430
+ const authorGuide = defineLeaf({
431
+ name: 'guide',
432
+ help: {
433
+ name: 'skill author guide',
434
+ summary: 'load the skill authoring workflow — two stages: omit type to pick one, pass type for its full skeleton',
435
+ params: [
436
+ { kind: 'flag', name: 'type', type: 'enum', choices: [...VALID_TYPES], required: false, constraint: 'OMIT to receive the template-picker guide first; pass on the second call for that type\'s full workflow + skeleton.' },
437
+ { kind: 'flag', name: 'topic', type: 'string', required: false, constraint: 'Optional topic context injected into the guide.' },
438
+ ],
439
+ output: [
440
+ { name: 'guide', type: 'string', required: true, constraint: 'Stage 1 (no type): the template-picker workflow. Stage 2 (type given): that type\'s authoring workflow + skeleton.' },
441
+ { name: 'type', type: 'string | null', required: true, constraint: 'Echo of the requested type, or null on the picker stage.' },
442
+ ],
443
+ outputKind: 'object',
444
+ effects: ['None. Read-only.'],
445
+ },
446
+ run: async (input) => {
447
+ const type = input['type'];
448
+ const topic = input['topic'];
449
+ const topicArg = topic !== undefined ? topic : '';
450
+ // Progressive disclosure: no type → template picker (stage 1);
451
+ // type given → that type's full workflow + skeleton (stage 2).
452
+ if (type === undefined) {
453
+ return { guide: skillCreatePrompt(topicArg), type: null };
222
454
  }
223
- });
224
- // grep
225
- skill
226
- .command('grep <pattern>')
227
- .description('search skill file contents for a regex pattern')
228
- .option('--scope <scope>', 'user|project|all')
229
- .option('--plugin <name>', 'filter by plugin name')
230
- .option('--json', 'emit JSON')
231
- .action(async (pattern, opts) => {
232
- try {
233
- let regex;
234
- try {
235
- regex = new RegExp(pattern);
236
- }
237
- catch {
238
- throw usage(`invalid regex pattern: ${pattern}`);
239
- }
240
- const scopes = listScopes(opts.scope);
241
- const skillsDirs = [];
242
- for (const s of scopes) {
243
- if (opts.plugin === undefined || opts.plugin === SCOPE_SKILL_PLUGIN) {
244
- const root = scopeSkillsDir(s);
245
- if (root)
246
- skillsDirs.push(root);
247
- }
248
- for (const plugin of listInstalledPlugins(s)) {
249
- if (!plugin.enabled)
250
- continue;
251
- if (opts.plugin !== undefined && plugin.name !== opts.plugin)
252
- continue;
253
- skillsDirs.push(join(plugin.root, SKILLS_DIR));
254
- }
255
- }
256
- const matchLines = [];
257
- for (const skillsDir of skillsDirs) {
258
- const files = walkFiles(skillsDir);
259
- for (const file of files) {
260
- const content = readText(file);
261
- const lines = content.split('\n');
262
- lines.forEach((lineText, idx) => {
263
- if (regex.test(lineText)) {
264
- matchLines.push({ path: file, line: idx + 1, text: lineText });
265
- }
266
- });
267
- }
268
- }
269
- if (opts.json) {
270
- jsonOut({ matches: matchLines });
271
- return;
272
- }
273
- for (const m of matchLines) {
274
- out(`${m.path}:${m.line}: ${m.text}`);
275
- }
455
+ return { guide: skillTemplatePrompt(type, topicArg), type };
456
+ },
457
+ });
458
+ const authorScaffold = defineLeaf({
459
+ name: 'scaffold',
460
+ help: {
461
+ name: 'skill author scaffold',
462
+ summary: 'create an empty SKILL.md stub at the given qualifier',
463
+ params: [
464
+ { kind: 'positional', name: 'qualifier', required: true, constraint: 'Skill identifier in <plugin>:<skill> form.' },
465
+ { kind: 'flag', name: 'type', type: 'enum', choices: [...VALID_TYPES], required: false, constraint: 'One of: playbook, primer, reference, runbook, freeform.' },
466
+ { kind: 'flag', name: 'description', type: 'string', required: false, constraint: 'Short description written to frontmatter.' },
467
+ { kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Default: project if available, else user.' },
468
+ ],
469
+ output: [
470
+ { name: 'path', type: 'string', required: true, constraint: 'Absolute path to the scaffolded SKILL.md.' },
471
+ { name: 'follow_up', type: 'string', required: true, constraint: 'Recommended next call to load the authoring guide.' },
472
+ ],
473
+ outputKind: 'object',
474
+ effects: [
475
+ 'Creates the skill directory and SKILL.md stub at the resolved location.',
476
+ 'Writes frontmatter with name, description (if provided), and type (if provided).',
477
+ ],
478
+ },
479
+ run: async (input) => {
480
+ const qualifier = input['qualifier'];
481
+ const typeStr = input['type'];
482
+ const description = input['description'];
483
+ const scopeStr = input['scope'];
484
+ const { plugin: pluginName, name: skillName } = parseSkillQualifier(qualifier);
485
+ if (!skillName) {
486
+ throw usage('skill name required in qualifier');
276
487
  }
277
- catch (e) {
278
- handleError(e, { json: opts.json });
488
+ if (typeStr !== undefined && !isSkillType(typeStr)) {
489
+ throw usage(`unknown skill type: ${typeStr} / valid: ${SKILL_TYPES.join(' | ')}`);
279
490
  }
280
- });
281
- // new
282
- skill
283
- .command('new <qualifier>')
284
- .description('scaffold a new skill — <name> (scope-direct) or <plugin>:<name>')
285
- .option('--scope <scope>', 'user|project (default: project then user)')
286
- .option('--description <text>', 'skill description for frontmatter')
287
- .option('--type <type>', `skill type for frontmatter — one of: ${SKILL_TYPES.join(' | ')}`)
288
- .action(async (qualifier, opts) => {
289
- try {
290
- const { plugin: pluginName, name: skillName } = parseSkillQualifier(qualifier);
291
- if (!skillName) {
292
- throw usage('skill name required');
491
+ const skillType = typeStr !== undefined && isSkillType(typeStr) ? typeStr : undefined;
492
+ let skillFile;
493
+ // Scope-direct: no plugin qualifier, or explicit `_:` sentinel
494
+ if (pluginName === undefined || pluginName === SCOPE_SKILL_PLUGIN) {
495
+ const scope = resolveWriteScope(scopeStr);
496
+ const scopeRootPath = requireScopeRoot(scope);
497
+ ensureScopeInitialized(scope, scopeRootPath);
498
+ const skillsRoot = scopeSkillsDir(scope);
499
+ if (!skillsRoot) {
500
+ throw general(`no skills dir for scope ${scope}`);
293
501
  }
294
- let skillType;
295
- if (opts.type !== undefined) {
296
- if (!isSkillType(opts.type)) {
297
- throw usage(`unknown skill type: ${opts.type} / valid: ${SKILL_TYPES.join(' | ')}`);
298
- }
299
- skillType = opts.type;
300
- }
301
- const scopeArg = opts.scope !== undefined ? resolveScopeArg(opts.scope) : undefined;
302
- // Scope-direct: no plugin qualifier, or explicit `_:` sentinel
303
- if (pluginName === undefined || pluginName === SCOPE_SKILL_PLUGIN) {
304
- let scope;
305
- if (scopeArg !== undefined && scopeArg !== 'all') {
306
- scope = scopeArg;
307
- }
308
- else {
309
- scope = projectScopeRoot() !== null ? 'project' : 'user';
310
- }
311
- const scopeRootPath = requireScopeRoot(scope);
312
- ensureScopeInitialized(scope, scopeRootPath);
313
- const skillsRoot = scopeSkillsDir(scope);
314
- if (!skillsRoot) {
315
- throw general(`no skills dir for scope ${scope}`);
316
- }
317
- const skillDir = join(skillsRoot, ...skillName.split('/'));
318
- const skillFile = join(skillDir, SKILL_ENTRY_FILE);
319
- if (pathExists(skillFile)) {
320
- throw general(`skill already exists: ${skillFile}`);
321
- }
322
- ensureDir(skillDir);
323
- const fm = serializeFrontmatter({
324
- name: skillName,
325
- description: opts.description,
326
- type: skillType,
327
- });
328
- writeFileSync(skillFile, fm, 'utf8');
329
- out(skillFile);
330
- const templateHint = skillType !== undefined ? skillType : '<type>';
331
- hint(`crtr: scaffolded ${scope}-scope skill ${skillName} — edit directly; ` +
332
- `\`crtr skill template ${templateHint}\` for body skeleton`);
333
- return;
334
- }
335
- let plugin;
336
- if (scopeArg !== undefined && scopeArg !== 'all') {
337
- plugin = findPluginByName(pluginName, scopeArg);
338
- }
339
- else {
340
- plugin = findPluginByName(pluginName);
502
+ const skillDir = join(skillsRoot, ...skillName.split('/'));
503
+ skillFile = join(skillDir, SKILL_ENTRY_FILE);
504
+ if (pathExists(skillFile)) {
505
+ throw general(`skill already exists: ${skillFile}`);
341
506
  }
507
+ ensureDir(skillDir);
508
+ const fm = serializeFrontmatter({
509
+ name: skillName,
510
+ description,
511
+ type: skillType,
512
+ });
513
+ writeFileSync(skillFile, fm, 'utf8');
514
+ }
515
+ else {
516
+ // Plugin-scoped scaffold
517
+ const scopeForLookup = scopeStr !== undefined
518
+ ? (() => {
519
+ const r = resolveScopeArg(scopeStr);
520
+ return r !== 'all' ? r : undefined;
521
+ })()
522
+ : undefined;
523
+ const plugin = scopeForLookup !== undefined
524
+ ? findPluginByName(pluginName, scopeForLookup)
525
+ : findPluginByName(pluginName);
342
526
  if (!plugin) {
343
527
  throw notFound(`plugin not found: ${pluginName}`);
344
528
  }
345
529
  const skillDir = join(plugin.root, SKILLS_DIR, ...skillName.split('/'));
346
- const skillFile = join(skillDir, SKILL_ENTRY_FILE);
530
+ skillFile = join(skillDir, SKILL_ENTRY_FILE);
347
531
  if (pathExists(skillFile)) {
348
532
  throw general(`skill already exists: ${skillFile}`);
349
533
  }
350
534
  ensureDir(skillDir);
351
535
  const fm = serializeFrontmatter({
352
536
  name: skillName,
353
- description: opts.description,
537
+ description,
354
538
  type: skillType,
355
539
  });
356
540
  writeFileSync(skillFile, fm, 'utf8');
357
- out(skillFile);
358
- const templateHint = skillType !== undefined ? skillType : '<type>';
359
- hint(`crtr: scaffolded ${skillFile} — edit directly; ` +
360
- `\`crtr skill template ${templateHint}\` for body skeleton`);
361
- }
362
- catch (e) {
363
- handleError(e);
364
- }
365
- });
366
- // create — pick a template type
367
- skill
368
- .command('create [topic...]')
369
- .description(`pick a template type for a new skill (${SKILL_TYPES.join(' | ')})`)
370
- .action(async (topic) => {
371
- const arg = topic && topic.length > 0 ? topic.join(' ') : '';
372
- out(skillCreatePrompt(arg));
373
- });
374
- // template — full workflow + skeleton for one template type
375
- skill
376
- .command('template <type> [topic...]')
377
- .description(`full workflow + skeleton for a template type (${SKILL_TYPES.join(' | ')})`)
378
- .action(async (type, topic) => {
379
- const arg = topic && topic.length > 0 ? topic.join(' ') : '';
380
- out(skillTemplatePrompt(type, arg));
381
- });
382
- // where
383
- skill
384
- .command('where <name>')
385
- .description('show resolution info as JSON')
386
- .option('--scope <scope>', 'user|project')
387
- .option('--plugin <name>', 'filter by plugin name')
388
- .action(async (name, opts) => {
389
- try {
390
- const scopeArg = resolveScopeArg(opts.scope);
391
- const resolveOpts = scopeArg !== 'all' ? { scope: scopeArg } : {};
392
- if (opts.plugin !== undefined) {
393
- Object.assign(resolveOpts, { pluginFilter: opts.plugin });
394
- }
395
- const skillObj = resolveSkill(name, resolveOpts);
396
- jsonOut({
397
- name: skillObj.name,
398
- plugin: skillObj.plugin,
399
- scope: skillObj.scope,
400
- path: skillObj.path,
401
- });
402
- }
403
- catch (e) {
404
- handleError(e);
405
- }
406
- });
407
- // enable
408
- skill
409
- .command('enable <name>')
410
- .description('enable a skill (clears any disable in the chosen scope)')
411
- .option('--scope <scope>', 'user|project (default: project if available, else user)')
412
- .action(async (name, opts) => {
413
- try {
414
- await toggleSkill(name, true, opts.scope);
415
- }
416
- catch (e) {
417
- handleError(e);
418
- }
419
- });
420
- // disable
421
- skill
422
- .command('disable <name>')
423
- .description('disable a skill (hides from list and agent discovery)')
424
- .option('--scope <scope>', 'user|project (default: project if available, else user)')
425
- .action(async (name, opts) => {
426
- try {
427
- await toggleSkill(name, false, opts.scope);
428
- }
429
- catch (e) {
430
- handleError(e);
431
541
  }
432
- });
433
- // search
434
- skill
435
- .command('search <query>')
436
- .description('search skills by name, description, and keywords')
437
- .option('--scope <scope>', 'user|project|all (default: all)')
438
- .option('--plugin <name>', 'filter by plugin name')
439
- .option('-a, --all', 'include disabled skills')
440
- .option('--body', 'also search SKILL.md body')
441
- .option('--json', 'emit JSON')
442
- .addHelpText('after', '\nOutput columns (tab-separated): <scope>:<plugin>/<name> <matched-fields> <description>\n' +
443
- 'The identifier is pasteable into `crtr skill show`.')
444
- .action(async (query, opts) => {
445
- try {
446
- const needle = query.toLowerCase();
447
- const scopes = listScopes(opts.scope);
448
- const candidates = scopes
449
- .flatMap((s) => listAllSkills(s))
450
- .filter((sk) => {
451
- if (opts.plugin !== undefined && sk.plugin !== opts.plugin)
452
- return false;
453
- if (!opts.all && !sk.enabled)
454
- return false;
455
- return true;
456
- });
457
- const hits = [];
458
- for (const sk of candidates) {
459
- const matched = [];
460
- let score = 0;
461
- if (sk.name.toLowerCase().includes(needle)) {
462
- score += 10;
463
- matched.push('name');
464
- }
465
- const desc = sk.frontmatter.description;
466
- if (desc !== undefined && desc.toLowerCase().includes(needle)) {
467
- score += 4;
468
- matched.push('description');
469
- }
470
- const kws = sk.frontmatter.keywords;
471
- if (kws && kws.some((k) => k.toLowerCase().includes(needle))) {
472
- score += 6;
473
- matched.push('keywords');
474
- }
475
- if (opts.body) {
476
- const text = readText(sk.path).toLowerCase();
477
- if (text.includes(needle)) {
478
- score += 1;
479
- matched.push('body');
480
- }
481
- }
482
- if (score > 0)
483
- hits.push({ skill: sk, score, matched });
484
- }
485
- hits.sort((a, b) => b.score - a.score || a.skill.name.localeCompare(b.skill.name));
486
- if (opts.json) {
487
- jsonOut({
488
- query,
489
- hits: hits.map((h) => ({
490
- name: h.skill.name,
491
- plugin: h.skill.plugin,
492
- scope: h.skill.scope,
493
- path: h.skill.path,
494
- description: h.skill.frontmatter.description,
495
- keywords: h.skill.frontmatter.keywords,
496
- enabled: h.skill.enabled,
497
- score: h.score,
498
- matched: h.matched,
499
- })),
500
- });
501
- return;
502
- }
503
- for (const h of hits) {
504
- const desc = h.skill.frontmatter.description !== undefined
505
- ? h.skill.frontmatter.description
506
- : '';
507
- const marker = h.skill.enabled ? '' : ' [disabled]';
508
- const qualified = h.skill.plugin === SCOPE_SKILL_PLUGIN
509
- ? `${h.skill.scope}:${h.skill.name}`
510
- : `${h.skill.scope}:${h.skill.plugin}/${h.skill.name}`;
511
- out(`${qualified}${marker}\t${h.matched.join(',')}\t${desc}`);
512
- }
513
- }
514
- catch (e) {
515
- handleError(e, { json: opts.json });
516
- }
517
- });
518
- }
519
- async function toggleSkill(name, enabled, scopeArgRaw) {
520
- let scope;
521
- if (scopeArgRaw !== undefined) {
522
- const resolved = resolveScopeArg(scopeArgRaw);
523
- if (resolved === 'all')
524
- throw usage('--scope must be user or project for enable/disable');
525
- scope = resolved;
526
- }
527
- else {
528
- scope = projectScopeRoot() !== null ? 'project' : 'user';
529
- }
530
- const skillObj = resolveSkill(name);
542
+ const typeHint = skillType !== undefined ? `--type ${skillType} ` : '';
543
+ const follow_up = `crtr skill author guide ${typeHint}--topic "${skillName}"`;
544
+ return { path: skillFile, follow_up };
545
+ },
546
+ });
547
+ const authorBranch = defineBranch({
548
+ name: 'author',
549
+ help: {
550
+ name: 'skill author',
551
+ summary: 'create and scaffold new skills',
552
+ children: [
553
+ { name: 'guide', desc: 'load authoring workflow + skeleton for a type', useWhen: 'writing a new skill and need the template and instructions' },
554
+ { name: 'scaffold', desc: 'create an empty SKILL.md stub', useWhen: 'initializing the file before writing content' },
555
+ ],
556
+ },
557
+ children: [authorGuide, authorScaffold],
558
+ });
559
+ // ---------------------------------------------------------------------------
560
+ // state sub-branch
561
+ // ---------------------------------------------------------------------------
562
+ async function toggleSkill(input, enabled) {
563
+ const nameRaw = input['name'];
564
+ const scopeStr = input['scope'];
565
+ const scope = resolveWriteScope(scopeStr);
566
+ const skillObj = resolveSkill(nameRaw);
531
567
  const key = skillConfigKey(skillObj.plugin, skillObj.name);
532
568
  const scopeRootPath = requireScopeRoot(scope);
533
569
  ensureScopeInitialized(scope, scopeRootPath);
534
570
  updateConfig(scope, (cfg) => {
535
571
  cfg.skills[key] = { enabled };
536
572
  });
537
- info(`${enabled ? 'enabled' : 'disabled'} ${skillObj.plugin}:${skillObj.name} in ${scope} scope`);
573
+ return { name: skillObj.name, scope, enabled };
574
+ }
575
+ const stateEnable = defineLeaf({
576
+ name: 'enable',
577
+ help: {
578
+ name: 'skill state enable',
579
+ summary: 'enable a skill in the given scope',
580
+ params: [
581
+ { kind: 'positional', name: 'name', required: true, constraint: 'Skill identifier. Same forms as skill read show.' },
582
+ { kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Default: project if available, else user.' },
583
+ ],
584
+ output: [
585
+ { name: 'name', type: 'string', required: true, constraint: 'Resolved skill name.' },
586
+ { name: 'scope', type: 'string', required: true, constraint: 'Scope where the enable was applied.' },
587
+ { name: 'enabled', type: 'boolean', required: true, constraint: 'Always true.' },
588
+ ],
589
+ outputKind: 'object',
590
+ effects: ['Writes the skill enable flag to config.json in the target scope.'],
591
+ },
592
+ run: async (input) => toggleSkill(input, true),
593
+ });
594
+ const stateDisable = defineLeaf({
595
+ name: 'disable',
596
+ help: {
597
+ name: 'skill state disable',
598
+ summary: 'disable a skill in the given scope, hiding it from list and agent discovery',
599
+ params: [
600
+ { kind: 'positional', name: 'name', required: true, constraint: 'Skill identifier. Same forms as skill read show.' },
601
+ { kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Default: project if available, else user.' },
602
+ ],
603
+ output: [
604
+ { name: 'name', type: 'string', required: true, constraint: 'Resolved skill name.' },
605
+ { name: 'scope', type: 'string', required: true, constraint: 'Scope where the disable was applied.' },
606
+ { name: 'enabled', type: 'boolean', required: true, constraint: 'Always false.' },
607
+ ],
608
+ outputKind: 'object',
609
+ effects: ['Writes the skill disable flag to config.json in the target scope.'],
610
+ },
611
+ run: async (input) => toggleSkill(input, false),
612
+ });
613
+ const stateBranch = defineBranch({
614
+ name: 'state',
615
+ help: {
616
+ name: 'skill state',
617
+ summary: 'enable or disable skills',
618
+ children: [
619
+ { name: 'enable', desc: 'enable a skill', useWhen: 'making a previously disabled skill available again' },
620
+ { name: 'disable', desc: 'disable a skill', useWhen: 'hiding a skill from list and agent discovery without removing it' },
621
+ ],
622
+ },
623
+ children: [stateEnable, stateDisable],
624
+ });
625
+ // ---------------------------------------------------------------------------
626
+ // Loaded-skills catalog (dynamicState for `skill -h`)
627
+ // ---------------------------------------------------------------------------
628
+ // A skill is a forest root within its source (scope+plugin) when no other
629
+ // skill in that same source is its ancestor (`name` prefix + '/'). Nested
630
+ // children stay discoverable via `skill find list` and the Neighbors section.
631
+ function buildSkillCatalog() {
632
+ let skills;
633
+ try {
634
+ skills = listAllSkills().filter((s) => s.enabled);
635
+ }
636
+ catch {
637
+ return null;
638
+ }
639
+ if (skills.length === 0)
640
+ return null;
641
+ const bySource = new Map();
642
+ for (const s of skills) {
643
+ const key = `${s.scope}${s.plugin}`;
644
+ const arr = bySource.get(key);
645
+ if (arr)
646
+ arr.push(s);
647
+ else
648
+ bySource.set(key, [s]);
649
+ }
650
+ const byPrefix = new Map();
651
+ for (const group of bySource.values()) {
652
+ const names = group.map((g) => g.name);
653
+ for (const s of group) {
654
+ const isChild = names.some((n) => n !== s.name && s.name.startsWith(n + '/'));
655
+ if (isChild)
656
+ continue;
657
+ const prefix = s.plugin === SCOPE_SKILL_PLUGIN ? `${s.scope}:` : `${s.plugin}/`;
658
+ let set = byPrefix.get(prefix);
659
+ if (!set) {
660
+ set = new Set();
661
+ byPrefix.set(prefix, set);
662
+ }
663
+ set.add(s.name);
664
+ }
665
+ }
666
+ const prefixes = [...byPrefix.keys()].sort();
667
+ const prefixW = prefixes.reduce((m, p) => (p.length > m ? p.length : m), 0);
668
+ const lines = [`Loaded skills (${skills.length})`];
669
+ for (const prefix of prefixes) {
670
+ const names = [...byPrefix.get(prefix)].sort();
671
+ lines.push(` ${prefix.padEnd(prefixW)} ${names.join(', ')}`);
672
+ }
673
+ return lines.join('\n');
674
+ }
675
+ // ---------------------------------------------------------------------------
676
+ // Root export
677
+ // ---------------------------------------------------------------------------
678
+ export function registerSkill() {
679
+ return defineBranch({
680
+ name: 'skill',
681
+ help: {
682
+ name: 'skill',
683
+ summary: 'discover, read, author, and manage skill state',
684
+ model: '`find` when you do not yet know which skill applies — it locates candidates by topic, keyword, or body text. `read` when you have a name and need the SKILL.md content or its on-disk location. `author` when you are writing a new skill — it carries the template workflow and the scaffolder. `state` when a skill should be hidden from discovery without being removed. Append `-h` at any branch or leaf for its full schema.',
685
+ dynamicState: buildSkillCatalog,
686
+ children: [
687
+ { name: 'find', desc: 'list, search, or grep skills', useWhen: 'discovering what skills are available' },
688
+ { name: 'read', desc: 'read skill content or resolve location', useWhen: 'loading a skill to act on it' },
689
+ { name: 'author', desc: 'create and scaffold skills', useWhen: 'writing a new skill' },
690
+ { name: 'state', desc: 'enable or disable skills', useWhen: 'toggling skill visibility' },
691
+ ],
692
+ },
693
+ children: [findBranch, readBranch, authorBranch, stateBranch],
694
+ });
538
695
  }