@crouton-kit/crouter 0.2.6 → 0.3.1

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