@crouton-kit/crouter 0.1.9 → 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,6 +31,46 @@ 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
+ }
34
74
  const SKILL_IDENTIFIER_HELP = 'Skill identifier forms (accepted by show, path, where, enable, disable):\n' +
35
75
  ' <name> bare name — resolves scope-root first, then plugins\n' +
36
76
  ' <plugin>:<name> explicit plugin (canonical)\n' +
@@ -52,7 +92,8 @@ export function registerSkillCommands(program) {
52
92
  try {
53
93
  const skillObj = resolveSkill(nameOrVerb);
54
94
  const content = readText(skillObj.path);
55
- const body = opts.frontmatter ? content : parseFrontmatter(content).body;
95
+ const rawBody = opts.frontmatter ? content : parseFrontmatter(content).body;
96
+ const body = appendNeighbors(skillObj, rawBody, false);
56
97
  out(wrapSkill(skillObj.name, skillObj.path, body));
57
98
  hint(buildShowFooter(skillObj.path));
58
99
  }
@@ -117,6 +158,7 @@ export function registerSkillCommands(program) {
117
158
  .option('--scope <scope>', 'user|project')
118
159
  .option('--plugin <name>', 'filter by plugin name')
119
160
  .option('--frontmatter', 'include YAML frontmatter in the printed body')
161
+ .option('--no-neighbors', 'suppress the auto-appended ## Neighbors section')
120
162
  .option('--json', 'emit JSON')
121
163
  .addHelpText('after', '\nExamples:\n' +
122
164
  ' crtr skill show rules # bare name\n' +
@@ -133,7 +175,9 @@ export function registerSkillCommands(program) {
133
175
  }
134
176
  const skillObj = resolveSkill(name, resolveOpts);
135
177
  const content = readText(skillObj.path);
136
- 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);
137
181
  if (opts.json) {
138
182
  jsonOut({
139
183
  name: skillObj.name,
@@ -235,12 +279,20 @@ export function registerSkillCommands(program) {
235
279
  .description('scaffold a new skill — <name> (scope-direct) or <plugin>:<name>')
236
280
  .option('--scope <scope>', 'user|project (default: project then user)')
237
281
  .option('--description <text>', 'skill description for frontmatter')
282
+ .option('--type <type>', `skill type for frontmatter — one of: ${SKILL_TYPES.join(' | ')}`)
238
283
  .action(async (qualifier, opts) => {
239
284
  try {
240
285
  const { plugin: pluginName, name: skillName } = parseSkillQualifier(qualifier);
241
286
  if (!skillName) {
242
287
  throw usage('skill name required');
243
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
+ }
244
296
  const scopeArg = opts.scope !== undefined ? resolveScopeArg(opts.scope) : undefined;
245
297
  // Scope-direct: no plugin qualifier, or explicit `_:` sentinel
246
298
  if (pluginName === undefined || pluginName === SCOPE_SKILL_PLUGIN) {
@@ -266,6 +318,7 @@ export function registerSkillCommands(program) {
266
318
  const fm = serializeFrontmatter({
267
319
  name: skillName,
268
320
  description: opts.description,
321
+ type: skillType,
269
322
  });
270
323
  writeFileSync(skillFile, fm, 'utf8');
271
324
  out(skillFile);
@@ -292,6 +345,7 @@ export function registerSkillCommands(program) {
292
345
  const fm = serializeFrontmatter({
293
346
  name: skillName,
294
347
  description: opts.description,
348
+ type: skillType,
295
349
  });
296
350
  writeFileSync(skillFile, fm, 'utf8');
297
351
  out(skillFile);
@@ -305,7 +359,7 @@ export function registerSkillCommands(program) {
305
359
  // create — pick a template type
306
360
  skill
307
361
  .command('create [topic...]')
308
- .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(' | ')})`)
309
363
  .action(async (topic) => {
310
364
  const arg = topic && topic.length > 0 ? topic.join(' ') : '';
311
365
  out(skillCreatePrompt(arg));
@@ -313,7 +367,7 @@ export function registerSkillCommands(program) {
313
367
  // template — full workflow + skeleton for one template type
314
368
  skill
315
369
  .command('template <type> [topic...]')
316
- .description('full workflow + skeleton for a template type (primer | playbook | freeform)')
370
+ .description(`full workflow + skeleton for a template type (${SKILL_TYPES.join(' | ')})`)
317
371
  .action(async (type, topic) => {
318
372
  const arg = topic && topic.length > 0 ? topic.join(' ') : '';
319
373
  out(skillTemplatePrompt(type, arg));
@@ -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,6 +13,8 @@ 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;
@@ -5,8 +5,29 @@ 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
7
  import { ambiguous, notFound, usage } from './errors.js';
8
- import { marketplacesDir, pluginsDir, projectScopeRoot, scopeSkillsDir, userScopeRoot, } from './scope.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,6 +162,41 @@ 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
201
  const parsed = parseSkillQualifier(rawName);
143
202
  if (parsed.scope && opts.scope && parsed.scope !== opts.scope) {
@@ -353,6 +412,8 @@ export function parseSkillQualifier(raw) {
353
412
  }
354
413
  function orderPluginsByResolution(plugins) {
355
414
  const score = (p) => {
415
+ if (p.scope === 'builtin')
416
+ return 4;
356
417
  const fromMarketplace = Boolean(p.sourceMarketplace);
357
418
  if (p.scope === 'project' && !fromMarketplace)
358
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];