@crouton-kit/crouter 0.1.8 → 0.2.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.
@@ -1,11 +1,11 @@
1
1
  import { join } from 'node:path';
2
2
  import { writeFileSync } from 'node:fs';
3
- import { SCOPE_SKILL_PLUGIN, SKILL_ENTRY_FILE, SKILLS_DIR, } from '../types.js';
3
+ import { SCOPE_SKILL_PLUGIN, SKILL_ENTRY_FILE, SKILLS_DIR, SKILL_TYPES, isSkillType, } from '../types.js';
4
4
  import { skillConfigKey } from '../types.js';
5
5
  import { notFound, usage, general } from '../core/errors.js';
6
6
  import { out, hint, info, jsonOut, handleError, } from '../core/output.js';
7
7
  import { listScopes, requireScopeRoot, resolveScopeArg, projectScopeRoot, scopeSkillsDir, } from '../core/scope.js';
8
- import { resolveSkill, listAllSkills, listInstalledPlugins, findPluginByName, parseSkillQualifier, } from '../core/resolver.js';
8
+ import { resolveSkill, listAllSkills, listInstalledPlugins, findPluginByName, parseSkillQualifier, listSkillSiblings, listSkillChildren, } from '../core/resolver.js';
9
9
  import { updateConfig, ensureScopeInitialized } from '../core/config.js';
10
10
  import { parseFrontmatter, serializeFrontmatter } from '../core/frontmatter.js';
11
11
  import { ensureDir, pathExists, readText, walkFiles } from '../core/fs-utils.js';
@@ -23,7 +23,7 @@ const KNOWN_VERBS = new Set([
23
23
  'disable',
24
24
  'search',
25
25
  ]);
26
- const AUTHORING_GUIDE_SKILL = 'authoring-skills';
26
+ const AUTHORING_GUIDE_SKILL = 'crouter-development/skills';
27
27
  function buildShowFooter(skillPath) {
28
28
  return (`crtr: edit this skill directly at ${skillPath} — ` +
29
29
  `for SKILL.md authoring guidance run \`crtr skill ${AUTHORING_GUIDE_SKILL}\``);
@@ -31,11 +31,58 @@ function buildShowFooter(skillPath) {
31
31
  function wrapSkill(name, path, content) {
32
32
  return `<skill name="${name}" path="${path}">\n${content.endsWith('\n') ? content : content + '\n'}</skill>`;
33
33
  }
34
+ function formatNeighborQualifier(s) {
35
+ return s.plugin === SCOPE_SKILL_PLUGIN ? `${s.scope}:${s.name}` : `${s.plugin}/${s.name}`;
36
+ }
37
+ function buildNeighborsSection(skill) {
38
+ const siblings = listSkillSiblings(skill);
39
+ const children = listSkillChildren(skill);
40
+ if (siblings.length === 0 && children.length === 0)
41
+ return null;
42
+ const lines = [
43
+ '## Neighbors',
44
+ '*Auto-discovered from filesystem. Use `--no-neighbors` to suppress.*',
45
+ '',
46
+ ];
47
+ if (siblings.length > 0) {
48
+ lines.push('**Siblings:**');
49
+ for (const s of siblings) {
50
+ const desc = s.frontmatter.description !== undefined ? s.frontmatter.description : '';
51
+ lines.push(`- \`${formatNeighborQualifier(s)}\`${desc ? ` — ${desc}` : ''}`);
52
+ }
53
+ if (children.length > 0)
54
+ lines.push('');
55
+ }
56
+ if (children.length > 0) {
57
+ lines.push('**Nested:**');
58
+ for (const s of children) {
59
+ const desc = s.frontmatter.description !== undefined ? s.frontmatter.description : '';
60
+ lines.push(`- \`${formatNeighborQualifier(s)}\`${desc ? ` — ${desc}` : ''}`);
61
+ }
62
+ }
63
+ return lines.join('\n');
64
+ }
65
+ function appendNeighbors(skill, body, suppress) {
66
+ if (suppress)
67
+ return body;
68
+ const section = buildNeighborsSection(skill);
69
+ if (section === null)
70
+ return body;
71
+ const sep = body.endsWith('\n') ? '\n' : '\n\n';
72
+ return body + sep + `<neighbors>\n${section}\n</neighbors>\n`;
73
+ }
74
+ const SKILL_IDENTIFIER_HELP = 'Skill identifier forms (accepted by show, path, where, enable, disable):\n' +
75
+ ' <name> bare name — resolves scope-root first, then plugins\n' +
76
+ ' <plugin>:<name> explicit plugin (canonical)\n' +
77
+ ' <scope>:<name> scope-root skill in a specific scope (user|project)\n' +
78
+ ' <scope>:<plugin>/<name> fully qualified — matches `skill list` / `skill search` output\n' +
79
+ ' <plugin>/<name> shorthand for <plugin>:<name> when unambiguous';
34
80
  export function registerSkillCommands(program) {
35
81
  const skill = program
36
82
  .command('skill [nameOrVerb] [rest...]')
37
83
  .description('manage and inspect skills')
38
84
  .option('--frontmatter', 'include YAML frontmatter in the printed body')
85
+ .addHelpText('after', '\n' + SKILL_IDENTIFIER_HELP)
39
86
  .action(async (nameOrVerb, _rest, opts) => {
40
87
  if (nameOrVerb === undefined) {
41
88
  out(skillPrompt());
@@ -45,7 +92,8 @@ export function registerSkillCommands(program) {
45
92
  try {
46
93
  const skillObj = resolveSkill(nameOrVerb);
47
94
  const content = readText(skillObj.path);
48
- const body = opts.frontmatter ? content : parseFrontmatter(content).body;
95
+ const rawBody = opts.frontmatter ? content : parseFrontmatter(content).body;
96
+ const body = appendNeighbors(skillObj, rawBody, false);
49
97
  out(wrapSkill(skillObj.name, skillObj.path, body));
50
98
  hint(buildShowFooter(skillObj.path));
51
99
  }
@@ -62,6 +110,8 @@ export function registerSkillCommands(program) {
62
110
  .option('--plugin <name>', 'filter by plugin name')
63
111
  .option('-a, --all', 'include disabled skills')
64
112
  .option('--json', 'emit JSON')
113
+ .addHelpText('after', '\nOutput format: <scope>:<plugin>/<name> — paste this identifier into ' +
114
+ '`crtr skill show` to read the skill.')
65
115
  .action(async (opts) => {
66
116
  try {
67
117
  const scopes = listScopes(opts.scope);
@@ -108,7 +158,14 @@ export function registerSkillCommands(program) {
108
158
  .option('--scope <scope>', 'user|project')
109
159
  .option('--plugin <name>', 'filter by plugin name')
110
160
  .option('--frontmatter', 'include YAML frontmatter in the printed body')
161
+ .option('--no-neighbors', 'suppress the auto-appended ## Neighbors section')
111
162
  .option('--json', 'emit JSON')
163
+ .addHelpText('after', '\nExamples:\n' +
164
+ ' crtr skill show rules # bare name\n' +
165
+ ' crtr skill show claude-authoring:rules # plugin:name (canonical)\n' +
166
+ ' crtr skill show user:claude-authoring/rules # scope:plugin/name (matches search/list output)\n' +
167
+ ' crtr skill show claude-authoring/rules # plugin/name shorthand\n\n' +
168
+ SKILL_IDENTIFIER_HELP)
112
169
  .action(async (name, opts) => {
113
170
  try {
114
171
  const scopeArg = resolveScopeArg(opts.scope);
@@ -118,7 +175,9 @@ export function registerSkillCommands(program) {
118
175
  }
119
176
  const skillObj = resolveSkill(name, resolveOpts);
120
177
  const content = readText(skillObj.path);
121
- const body = opts.frontmatter ? content : parseFrontmatter(content).body;
178
+ const rawBody = opts.frontmatter ? content : parseFrontmatter(content).body;
179
+ const suppressNeighbors = opts.neighbors === false;
180
+ const body = appendNeighbors(skillObj, rawBody, suppressNeighbors);
122
181
  if (opts.json) {
123
182
  jsonOut({
124
183
  name: skillObj.name,
@@ -220,12 +279,20 @@ export function registerSkillCommands(program) {
220
279
  .description('scaffold a new skill — <name> (scope-direct) or <plugin>:<name>')
221
280
  .option('--scope <scope>', 'user|project (default: project then user)')
222
281
  .option('--description <text>', 'skill description for frontmatter')
282
+ .option('--type <type>', `skill type for frontmatter — one of: ${SKILL_TYPES.join(' | ')}`)
223
283
  .action(async (qualifier, opts) => {
224
284
  try {
225
285
  const { plugin: pluginName, name: skillName } = parseSkillQualifier(qualifier);
226
286
  if (!skillName) {
227
287
  throw usage('skill name required');
228
288
  }
289
+ let skillType;
290
+ if (opts.type !== undefined) {
291
+ if (!isSkillType(opts.type)) {
292
+ throw usage(`unknown skill type: ${opts.type} / valid: ${SKILL_TYPES.join(' | ')}`);
293
+ }
294
+ skillType = opts.type;
295
+ }
229
296
  const scopeArg = opts.scope !== undefined ? resolveScopeArg(opts.scope) : undefined;
230
297
  // Scope-direct: no plugin qualifier, or explicit `_:` sentinel
231
298
  if (pluginName === undefined || pluginName === SCOPE_SKILL_PLUGIN) {
@@ -251,6 +318,7 @@ export function registerSkillCommands(program) {
251
318
  const fm = serializeFrontmatter({
252
319
  name: skillName,
253
320
  description: opts.description,
321
+ type: skillType,
254
322
  });
255
323
  writeFileSync(skillFile, fm, 'utf8');
256
324
  out(skillFile);
@@ -277,6 +345,7 @@ export function registerSkillCommands(program) {
277
345
  const fm = serializeFrontmatter({
278
346
  name: skillName,
279
347
  description: opts.description,
348
+ type: skillType,
280
349
  });
281
350
  writeFileSync(skillFile, fm, 'utf8');
282
351
  out(skillFile);
@@ -290,7 +359,7 @@ export function registerSkillCommands(program) {
290
359
  // create — pick a template type
291
360
  skill
292
361
  .command('create [topic...]')
293
- .description('pick a template type for a new skill (primer | playbook | freeform)')
362
+ .description(`pick a template type for a new skill (${SKILL_TYPES.join(' | ')})`)
294
363
  .action(async (topic) => {
295
364
  const arg = topic && topic.length > 0 ? topic.join(' ') : '';
296
365
  out(skillCreatePrompt(arg));
@@ -298,7 +367,7 @@ export function registerSkillCommands(program) {
298
367
  // template — full workflow + skeleton for one template type
299
368
  skill
300
369
  .command('template <type> [topic...]')
301
- .description('full workflow + skeleton for a template type (primer | playbook | freeform)')
370
+ .description(`full workflow + skeleton for a template type (${SKILL_TYPES.join(' | ')})`)
302
371
  .action(async (type, topic) => {
303
372
  const arg = topic && topic.length > 0 ? topic.join(' ') : '';
304
373
  out(skillTemplatePrompt(type, arg));
@@ -363,6 +432,8 @@ export function registerSkillCommands(program) {
363
432
  .option('-a, --all', 'include disabled skills')
364
433
  .option('--body', 'also search SKILL.md body')
365
434
  .option('--json', 'emit JSON')
435
+ .addHelpText('after', '\nOutput columns (tab-separated): <scope>:<plugin>/<name> <matched-fields> <description>\n' +
436
+ 'The identifier is pasteable into `crtr skill show`.')
366
437
  .action(async (query, opts) => {
367
438
  try {
368
439
  const needle = query.toLowerCase();
@@ -6,7 +6,8 @@ import { CRTR_DIR_NAME } from '../types.js';
6
6
  import { ensureDir, pathExists, readText, walkFiles } from './fs-utils.js';
7
7
  import { usage, notFound, general } from './errors.js';
8
8
  import { out, hint, jsonOut, handleError, info } from './output.js';
9
- import { spawnSidePaneReview, DEFAULT_PANE_OPTS, } from './spawn.js';
9
+ import { spawnSidePaneReview, countPanesInCurrentWindow, DEFAULT_PANE_OPTS, } from './spawn.js';
10
+ import { readConfig } from './config.js';
10
11
  export function mangleCwd(cwd = process.cwd()) {
11
12
  return cwd.replace(/\//g, '-');
12
13
  }
@@ -32,7 +33,17 @@ export function inTmux() {
32
33
  return Boolean(process.env.TMUX);
33
34
  }
34
35
  export function openInTmuxPane(path) {
35
- const result = spawnSync('termrender', ['--tmux', path], {
36
+ // Always pass --watch so the pane live-updates when the agent edits the
37
+ // file. The watcher re-detects width on resize and survives parse errors.
38
+ const args = ['--tmux', '--watch'];
39
+ // If the current tmux window is already at the configured pane budget,
40
+ // open in a new window instead of cramping the existing split further.
41
+ const maxPanes = readConfig('user').max_panes_per_window;
42
+ if (countPanesInCurrentWindow() >= maxPanes) {
43
+ args.push('--tmux-new-window');
44
+ }
45
+ args.push(path);
46
+ const result = spawnSync('termrender', args, {
36
47
  stdio: ['ignore', 'pipe', 'pipe'],
37
48
  });
38
49
  if (result.error) {
@@ -52,7 +63,7 @@ export function openInTmuxPane(path) {
52
63
  }
53
64
  const paneId = result.stdout.toString().trim();
54
65
  if (paneId)
55
- hint(`opened in tmux pane ${paneId}`);
66
+ hint(`opened in tmux pane ${paneId} (live — edits to the file refresh the view)`);
56
67
  }
57
68
  async function readStdin() {
58
69
  const chunks = [];
@@ -1,4 +1,4 @@
1
- import type { SkillFrontmatter } from '../types.js';
1
+ import { type SkillFrontmatter } from '../types.js';
2
2
  export interface ParsedFrontmatter {
3
3
  data: SkillFrontmatter | null;
4
4
  body: string;
@@ -1,3 +1,4 @@
1
+ import { isSkillType } from '../types.js';
1
2
  const FRONTMATTER_RE = /^---\s*\r?\n([\s\S]*?)\r?\n---\s*\r?\n?/;
2
3
  export function parseFrontmatter(source) {
3
4
  const match = source.match(FRONTMATTER_RE);
@@ -126,6 +127,7 @@ function parseSimpleYaml(yaml) {
126
127
  name: typeof out.name === 'string' ? out.name : '',
127
128
  description: typeof out.description === 'string' ? out.description : undefined,
128
129
  keywords: Array.isArray(out.keywords) ? out.keywords : undefined,
130
+ type: isSkillType(out.type) ? out.type : undefined,
129
131
  };
130
132
  return fm;
131
133
  }
@@ -141,6 +143,9 @@ export function serializeFrontmatter(data) {
141
143
  if (data.description !== undefined) {
142
144
  lines.push(`description: ${quoteIfNeeded(data.description)}`);
143
145
  }
146
+ if (data.type !== undefined) {
147
+ lines.push(`type: ${data.type}`);
148
+ }
144
149
  if (data.keywords && data.keywords.length) {
145
150
  const inline = `[${data.keywords.map(quoteIfNeeded).join(', ')}]`;
146
151
  lines.push(`keywords: ${inline}`);
@@ -13,15 +13,19 @@ export declare function effectiveSkillEnabled(pluginName: string, skillName: str
13
13
  export declare function listSkillsInPlugin(plugin: InstalledPlugin, cfgs?: ScopeConfigs): Skill[];
14
14
  export declare function listScopeRootSkills(scope: Scope, cfgs?: ScopeConfigs): Skill[];
15
15
  export declare function listAllSkills(scopeFilter?: Scope): Skill[];
16
+ export declare function listSkillSiblings(skill: Skill): Skill[];
17
+ export declare function listSkillChildren(skill: Skill): Skill[];
16
18
  export interface SkillResolutionOpts {
17
19
  scope?: Scope;
18
20
  pluginFilter?: string;
19
21
  }
20
22
  export declare function resolveSkill(rawName: string, opts?: SkillResolutionOpts): Skill;
21
- export declare function parseSkillQualifier(raw: string): {
23
+ export interface ParsedSkillQualifier {
24
+ scope?: Scope;
22
25
  plugin?: string;
23
26
  name: string;
24
- };
27
+ }
28
+ export declare function parseSkillQualifier(raw: string): ParsedSkillQualifier;
25
29
  export declare function listInstalledMarketplaces(scope: Scope): InstalledMarketplace[];
26
30
  export declare function listAllMarketplaces(): InstalledMarketplace[];
27
31
  export declare function findMarketplaceByName(name: string, scope?: Scope): InstalledMarketplace | null;
@@ -4,9 +4,30 @@ import { readConfig } from './config.js';
4
4
  import { listDirs, pathExists, readText, walkFiles, } from './fs-utils.js';
5
5
  import { readMarketplaceManifest, readPluginManifest } from './manifest.js';
6
6
  import { parseFrontmatter } from './frontmatter.js';
7
- import { ambiguous, notFound } from './errors.js';
8
- import { marketplacesDir, pluginsDir, projectScopeRoot, scopeSkillsDir, userScopeRoot, } from './scope.js';
7
+ import { ambiguous, notFound, usage } from './errors.js';
8
+ import { builtinSkillsRoot, marketplacesDir, pluginsDir, projectScopeRoot, scopeSkillsDir, userScopeRoot, } from './scope.js';
9
+ function getBuiltinPlugin() {
10
+ const root = builtinSkillsRoot();
11
+ if (!pathExists(root))
12
+ return null;
13
+ const manifest = readPluginManifest(root);
14
+ if (!manifest)
15
+ return null;
16
+ return {
17
+ name: manifest.name,
18
+ scope: 'builtin',
19
+ root,
20
+ manifest,
21
+ enabled: true,
22
+ builtin: true,
23
+ version: manifest.version,
24
+ };
25
+ }
9
26
  export function listInstalledPlugins(scope) {
27
+ if (scope === 'builtin') {
28
+ const builtin = getBuiltinPlugin();
29
+ return builtin ? [builtin] : [];
30
+ }
10
31
  const dir = pluginsDir(scope);
11
32
  if (!dir || !pathExists(dir))
12
33
  return [];
@@ -40,13 +61,14 @@ export function listAllPlugins() {
40
61
  if (projectScopeRoot())
41
62
  scopes.push('project');
42
63
  scopes.push('user');
64
+ scopes.push('builtin');
43
65
  return scopes.flatMap(listInstalledPlugins);
44
66
  }
45
67
  export function findPluginByName(name, scope) {
46
68
  if (scope) {
47
69
  return listInstalledPlugins(scope).find((p) => p.name === name) ?? null;
48
70
  }
49
- for (const s of ['project', 'user'].filter((sc) => sc === 'project' ? projectScopeRoot() !== null : true)) {
71
+ for (const s of ['project', 'user', 'builtin'].filter((sc) => sc === 'project' ? projectScopeRoot() !== null : true)) {
50
72
  const match = listInstalledPlugins(s).find((p) => p.name === name);
51
73
  if (match)
52
74
  return match;
@@ -100,6 +122,8 @@ export function listSkillsInPlugin(plugin, cfgs) {
100
122
  return skills.sort((a, b) => a.name.localeCompare(b.name));
101
123
  }
102
124
  export function listScopeRootSkills(scope, cfgs) {
125
+ if (scope === 'builtin')
126
+ return [];
103
127
  const skillsRoot = scopeSkillsDir(scope);
104
128
  if (!skillsRoot || !pathExists(skillsRoot))
105
129
  return [];
@@ -138,17 +162,83 @@ export function listAllSkills(scopeFilter) {
138
162
  ...plugins.filter((p) => p.enabled).flatMap((p) => listSkillsInPlugin(p, cfgs)),
139
163
  ];
140
164
  }
165
+ function enumerateNeighborPool(skill) {
166
+ if (skill.plugin === SCOPE_SKILL_PLUGIN) {
167
+ return listScopeRootSkills(skill.scope);
168
+ }
169
+ const plugin = listInstalledPlugins(skill.scope).find((p) => p.name === skill.plugin);
170
+ if (!plugin)
171
+ return [];
172
+ return listSkillsInPlugin(plugin);
173
+ }
174
+ export function listSkillSiblings(skill) {
175
+ const pool = enumerateNeighborPool(skill);
176
+ const segs = skill.name.split('/');
177
+ const depth = segs.length;
178
+ const parentPrefix = segs.slice(0, -1).join('/');
179
+ return pool.filter((s) => {
180
+ if (s.name === skill.name)
181
+ return false;
182
+ const sSegs = s.name.split('/');
183
+ if (sSegs.length !== depth)
184
+ return false;
185
+ if (parentPrefix === '')
186
+ return !s.name.includes('/');
187
+ return s.name.startsWith(parentPrefix + '/');
188
+ });
189
+ }
190
+ export function listSkillChildren(skill) {
191
+ const pool = enumerateNeighborPool(skill);
192
+ const prefix = skill.name + '/';
193
+ return pool.filter((s) => {
194
+ if (!s.name.startsWith(prefix))
195
+ return false;
196
+ const rest = s.name.slice(prefix.length);
197
+ return rest.length > 0 && !rest.includes('/');
198
+ });
199
+ }
141
200
  export function resolveSkill(rawName, opts = {}) {
142
- const { plugin: pluginQualifier, name } = parseSkillQualifier(rawName);
143
- const plugins = opts.scope ? listInstalledPlugins(opts.scope) : listAllPlugins();
201
+ const parsed = parseSkillQualifier(rawName);
202
+ if (parsed.scope && opts.scope && parsed.scope !== opts.scope) {
203
+ throw usage(`scope conflict: identifier "${rawName}" uses scope "${parsed.scope}" but --scope is "${opts.scope}"`);
204
+ }
205
+ if (parsed.plugin && opts.pluginFilter && parsed.plugin !== opts.pluginFilter) {
206
+ throw usage(`plugin conflict: identifier "${rawName}" uses plugin "${parsed.plugin}" but --plugin is "${opts.pluginFilter}"`);
207
+ }
208
+ const effectiveScope = opts.scope ?? parsed.scope;
209
+ const effectivePluginFilter = opts.pluginFilter ?? parsed.plugin;
210
+ const direct = findSkillMatches(parsed.name, parsed.plugin, effectiveScope, effectivePluginFilter);
211
+ if (direct.length > 0)
212
+ return pickMatch(direct, parsed.name, parsed.plugin);
213
+ // Fallback: bare `plugin/name` (no colon) — try splitting on first `/`.
214
+ // Disambiguates "claude-authoring/rules" (which the search/list display also emits as
215
+ // "user:claude-authoring/rules") from a nested scope-root skill of the same shape.
216
+ if (!parsed.plugin && parsed.name.includes('/')) {
217
+ const slashIdx = parsed.name.indexOf('/');
218
+ const maybePlugin = parsed.name.slice(0, slashIdx);
219
+ const rest = parsed.name.slice(slashIdx + 1);
220
+ if (effectivePluginFilter === undefined || effectivePluginFilter === maybePlugin) {
221
+ const fallback = findSkillMatches(rest, maybePlugin, effectiveScope, maybePlugin);
222
+ if (fallback.length > 0)
223
+ return pickMatch(fallback, rest, maybePlugin);
224
+ }
225
+ }
226
+ throw notFound(formatNotFoundMessage(rawName, parsed), {
227
+ skill: parsed.name,
228
+ plugin: parsed.plugin,
229
+ scope: parsed.scope,
230
+ });
231
+ }
232
+ function findSkillMatches(name, pluginQualifier, scope, pluginFilter) {
233
+ const plugins = scope ? listInstalledPlugins(scope) : listAllPlugins();
144
234
  const enabledPlugins = plugins.filter((p) => p.enabled);
145
235
  const cfgs = loadScopeConfigs();
146
236
  const matches = [];
147
237
  // Scope-root skills first — they're the user's own captured knowledge.
148
- if (!opts.pluginFilter &&
238
+ if (!pluginFilter &&
149
239
  (pluginQualifier === undefined || pluginQualifier === SCOPE_SKILL_PLUGIN)) {
150
- const scopes = opts.scope
151
- ? [opts.scope]
240
+ const scopes = scope
241
+ ? [scope]
152
242
  : [projectScopeRoot() ? 'project' : null, 'user'].filter(Boolean);
153
243
  for (const s of scopes) {
154
244
  const skillsRoot = scopeSkillsDir(s);
@@ -176,7 +266,7 @@ export function resolveSkill(rawName, opts = {}) {
176
266
  for (const plugin of ordered) {
177
267
  if (pluginQualifier && plugin.name !== pluginQualifier)
178
268
  continue;
179
- if (opts.pluginFilter && plugin.name !== opts.pluginFilter)
269
+ if (pluginFilter && plugin.name !== pluginFilter)
180
270
  continue;
181
271
  const skillPath = join(plugin.root, SKILLS_DIR, ...name.split('/'), SKILL_ENTRY_FILE);
182
272
  if (!pathExists(skillPath))
@@ -195,20 +285,16 @@ export function resolveSkill(rawName, opts = {}) {
195
285
  disabledIn,
196
286
  });
197
287
  }
198
- if (matches.length === 0) {
199
- throw notFound(pluginQualifier
200
- ? `skill not found: ${pluginQualifier}:${name}`
201
- : `skill not found: ${name}`, { skill: name, plugin: pluginQualifier });
202
- }
288
+ return matches;
289
+ }
290
+ function pickMatch(matches, name, pluginQualifier) {
203
291
  if (matches.length === 1)
204
292
  return matches[0];
205
293
  const sameScopeAndPlugin = matches.every((m) => m.plugin === matches[0].plugin && m.scope === matches[0].scope);
206
294
  if (sameScopeAndPlugin)
207
295
  return matches[0];
208
- // Resolution order picks the first; flag ambiguity only if user didn't qualify.
209
- if (!pluginQualifier) {
296
+ if (!pluginQualifier)
210
297
  return matches[0];
211
- }
212
298
  throw ambiguous(`ambiguous skill: ${name}`, {
213
299
  skill: name,
214
300
  candidates: matches.map((m) => ({
@@ -218,14 +304,116 @@ export function resolveSkill(rawName, opts = {}) {
218
304
  })),
219
305
  });
220
306
  }
307
+ function formatNotFoundMessage(rawName, parsed) {
308
+ const suggestions = suggestSkills(parsed.name, parsed.plugin);
309
+ const lines = [`skill not found: ${rawName}`];
310
+ lines.push(' expected forms: <name>, <plugin>:<name>, <scope>:<plugin>/<name>');
311
+ if (suggestions.length > 0) {
312
+ const formatted = suggestions
313
+ .map((s) => s.plugin === SCOPE_SKILL_PLUGIN ? s.name : `${s.plugin}:${s.name}`)
314
+ .slice(0, 3);
315
+ lines.push(` did you mean: ${formatted.join(', ')}`);
316
+ }
317
+ else {
318
+ lines.push(' run `crtr skill list` or `crtr skill search <query>` to discover skills');
319
+ }
320
+ return lines.join('\n');
321
+ }
322
+ function suggestSkills(name, plugin) {
323
+ let all;
324
+ try {
325
+ all = listAllSkills();
326
+ }
327
+ catch {
328
+ return [];
329
+ }
330
+ const target = name.toLowerCase();
331
+ const targetBase = target.split('/').pop() ?? target;
332
+ const targetPluginGuess = target.includes('/') ? target.split('/')[0] : undefined;
333
+ const exactName = all.filter((s) => s.name.toLowerCase() === target);
334
+ if (exactName.length > 0)
335
+ return exactName;
336
+ const exactBase = all.filter((s) => {
337
+ const sBase = s.name.toLowerCase().split('/').pop() ?? s.name.toLowerCase();
338
+ return sBase === targetBase;
339
+ });
340
+ if (exactBase.length > 0)
341
+ return exactBase;
342
+ const scored = all
343
+ .map((s) => {
344
+ const sName = s.name.toLowerCase();
345
+ const sBase = sName.split('/').pop() ?? sName;
346
+ const sPlugin = s.plugin.toLowerCase();
347
+ let score = 0;
348
+ if (plugin !== undefined && sPlugin === plugin.toLowerCase())
349
+ score += 5;
350
+ if (targetPluginGuess !== undefined && sPlugin === targetPluginGuess)
351
+ score += 5;
352
+ if (sName.includes(target) || target.includes(sName))
353
+ score += 4;
354
+ if (sBase.includes(targetBase) || targetBase.includes(sBase))
355
+ score += 3;
356
+ if (editDistance(sBase, targetBase) <= 2)
357
+ score += 4;
358
+ return { skill: s, score };
359
+ })
360
+ .filter((x) => x.score > 0)
361
+ .sort((a, b) => b.score - a.score);
362
+ return scored.slice(0, 3).map((x) => x.skill);
363
+ }
364
+ function editDistance(a, b) {
365
+ if (a === b)
366
+ return 0;
367
+ if (a.length === 0)
368
+ return b.length;
369
+ if (b.length === 0)
370
+ return a.length;
371
+ const prev = new Array(b.length + 1);
372
+ const curr = new Array(b.length + 1);
373
+ for (let j = 0; j <= b.length; j++)
374
+ prev[j] = j;
375
+ for (let i = 1; i <= a.length; i++) {
376
+ curr[0] = i;
377
+ for (let j = 1; j <= b.length; j++) {
378
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
379
+ curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
380
+ }
381
+ for (let j = 0; j <= b.length; j++)
382
+ prev[j] = curr[j];
383
+ }
384
+ return prev[b.length];
385
+ }
386
+ const SCOPE_QUALIFIERS = new Set(['user', 'project']);
387
+ // Accepted identifier forms:
388
+ // <name> — bare name; scope-root first, then plugins
389
+ // <plugin>:<name> — explicit plugin
390
+ // <scope>:<name> — scope-root in a specific scope
391
+ // <scope>:<plugin>/<name> — fully qualified (matches `skill list` / `skill search` display)
392
+ // Bare `<plugin>/<name>` (no colon) is handled as a fallback inside resolveSkill.
221
393
  export function parseSkillQualifier(raw) {
222
- const idx = raw.indexOf(':');
223
- if (idx === -1)
394
+ const colonIdx = raw.indexOf(':');
395
+ if (colonIdx === -1)
224
396
  return { name: raw };
225
- return { plugin: raw.slice(0, idx), name: raw.slice(idx + 1) };
397
+ const before = raw.slice(0, colonIdx);
398
+ const after = raw.slice(colonIdx + 1);
399
+ if (SCOPE_QUALIFIERS.has(before)) {
400
+ const scope = before;
401
+ const slashIdx = after.indexOf('/');
402
+ if (slashIdx !== -1) {
403
+ return {
404
+ scope,
405
+ plugin: after.slice(0, slashIdx),
406
+ name: after.slice(slashIdx + 1),
407
+ };
408
+ }
409
+ return { scope, name: after };
410
+ }
411
+ return { plugin: before, name: after };
226
412
  }
227
413
  function orderPluginsByResolution(plugins) {
228
414
  const score = (p) => {
415
+ if (p.scope === 'builtin')
416
+ return 4;
229
417
  const fromMarketplace = Boolean(p.sourceMarketplace);
230
418
  if (p.scope === 'project' && !fromMarketplace)
231
419
  return 0;
@@ -1,4 +1,5 @@
1
1
  import type { Scope } from '../types.js';
2
+ export declare function builtinSkillsRoot(): string;
2
3
  export declare function userScopeRoot(): string;
3
4
  export declare function findProjectScopeRoot(startDir?: string): string | null;
4
5
  export declare function projectScopeRoot(startDir?: string): string | null;
@@ -1,9 +1,17 @@
1
1
  import { homedir } from 'node:os';
2
2
  import { existsSync, statSync } from 'node:fs';
3
3
  import { join, resolve, dirname } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
4
5
  import { CRTR_DIR_NAME } from '../types.js';
5
6
  import { usage } from './errors.js';
6
7
  let cachedProjectRoot;
8
+ export function builtinSkillsRoot() {
9
+ // Resolve relative to this file: src/core/scope.ts → src/builtin-skills/ OR dist/core/scope.js → dist/builtin-skills/
10
+ const thisFile = fileURLToPath(import.meta.url);
11
+ const coreDir = dirname(thisFile);
12
+ const pkgDir = dirname(coreDir); // src/ or dist/
13
+ return join(pkgDir, 'builtin-skills');
14
+ }
7
15
  export function userScopeRoot() {
8
16
  return join(homedir(), CRTR_DIR_NAME);
9
17
  }
@@ -37,6 +45,8 @@ export function projectScopeRoot(startDir) {
37
45
  return findProjectScopeRoot(startDir);
38
46
  }
39
47
  export function scopeRoot(scope) {
48
+ if (scope === 'builtin')
49
+ return builtinSkillsRoot();
40
50
  return scope === 'user' ? userScopeRoot() : projectScopeRoot();
41
51
  }
42
52
  export function requireScopeRoot(scope) {
@@ -71,9 +81,9 @@ export function resolveScopeArg(scopeArg) {
71
81
  if (scopeArg === undefined)
72
82
  return 'all';
73
83
  const value = scopeArg.toLowerCase();
74
- if (value === 'user' || value === 'project' || value === 'all')
84
+ if (value === 'user' || value === 'project' || value === 'builtin' || value === 'all')
75
85
  return value;
76
- throw usage(`invalid --scope: ${scopeArg} (expected user|project|all)`);
86
+ throw usage(`invalid --scope: ${scopeArg} (expected user|project|builtin|all)`);
77
87
  }
78
88
  export function listScopes(scopeArg) {
79
89
  const v = resolveScopeArg(scopeArg);
@@ -82,6 +92,7 @@ export function listScopes(scopeArg) {
82
92
  if (projectScopeRoot())
83
93
  out.push('project');
84
94
  out.push('user');
95
+ out.push('builtin');
85
96
  return out;
86
97
  }
87
98
  return [v];