@crustjs/skills 0.0.3 → 0.0.4

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.
package/README.md CHANGED
@@ -106,6 +106,8 @@ crust skills generate ./src/cli.ts -n my-cli -d "My CLI" --no-clean
106
106
 
107
107
  High-level API that runs the full pipeline: introspection, rendering, and writing to disk.
108
108
 
109
+ The `meta.name` must be a valid skill name — lowercase alphanumeric with hyphens, 1–64 characters (validated against the [Agent Skills spec](https://agentskills.io/specification) pattern). Use `isValidSkillName()` to check before calling.
110
+
109
111
  ```ts
110
112
  import { generateSkill } from "@crustjs/skills";
111
113
 
@@ -114,6 +116,7 @@ const result = await generateSkill({
114
116
  meta: { name: "my-cli", description: "My CLI tool", version: "1.0.0" },
115
117
  outDir: "./dist", // default: "."
116
118
  clean: true, // default: true — removes existing skill dir first
119
+ force: false, // default: false — throws SkillConflictError if dir exists without crust.json
117
120
  });
118
121
 
119
122
  // result.outputDir — absolute path to the generated skill directory
@@ -147,6 +150,55 @@ for (const file of files) {
147
150
  }
148
151
  ```
149
152
 
153
+ ### `isValidSkillName(name)`
154
+
155
+ Validates a skill name against the [Agent Skills spec](https://agentskills.io/specification) pattern: 1–64 lowercase alphanumeric characters and hyphens, no leading/trailing/consecutive hyphens.
156
+
157
+ ```ts
158
+ import { isValidSkillName } from "@crustjs/skills";
159
+
160
+ isValidSkillName("my-cli"); // true
161
+ isValidSkillName("My_CLI"); // false — uppercase and underscores not allowed
162
+ isValidSkillName("-leading"); // false — leading hyphen
163
+ isValidSkillName("a".repeat(65)); // false — exceeds 64 characters
164
+ ```
165
+
166
+ > **Note:** `generateSkill()` automatically validates `meta.name` (after prepending `use-`) and throws a descriptive error if the name is invalid.
167
+
168
+ ## Skill Metadata
169
+
170
+ The `SkillMeta` object controls the generated `SKILL.md` frontmatter. Beyond the required `name`, `description`, and `version` fields, several optional fields are supported:
171
+
172
+ ```ts
173
+ const meta: SkillMeta = {
174
+ name: "my-cli",
175
+ description: "CLI tool for managing widgets",
176
+ version: "1.0.0",
177
+
178
+ // Optional fields — emitted in SKILL.md YAML frontmatter when set
179
+ allowedTools: "Bash(my-cli *) Read Grep", // Pre-approved tools (avoids per-use prompts)
180
+ license: "MIT", // License name or reference
181
+ compatibility: "Requires my-cli on PATH", // Environment requirements (max 500 chars)
182
+ disableModelInvocation: false, // true = agent won't auto-load; user must invoke manually
183
+ };
184
+ ```
185
+
186
+ | Field | Frontmatter Key | Description |
187
+ | ----- | --------------- | ----------- |
188
+ | `allowedTools` | `allowed-tools` | Space-delimited list of pre-approved tools (e.g. `Bash(my-cli *) Read Grep`) |
189
+ | `license` | `license` | License name or file reference |
190
+ | `compatibility` | `compatibility` | Environment requirements or compatibility notes |
191
+ | `disableModelInvocation` | `disable-model-invocation` | When `true`, prevents agents from auto-loading the skill |
192
+
193
+ ## Escaping
194
+
195
+ The renderer automatically handles special characters in generated output:
196
+
197
+ - **YAML frontmatter**: Values containing YAML-special characters (`:`, `#`, `*`, `!`, `[`, `{`, `'`, `"`, etc.) are wrapped in double quotes with internal quotes escaped.
198
+ - **Markdown tables**: Literal `|` characters in argument/flag descriptions are escaped as `\|` to prevent broken table rendering.
199
+
200
+ No manual escaping is needed — pass raw values and the renderer handles the rest.
201
+
150
202
  ## Output Structure
151
203
 
152
204
  Generated output goes to `<outDir>/skills/use-<name>/` (the `use-` prefix is applied automatically):
@@ -161,7 +213,7 @@ skills/use-my-cli/
161
213
  db/
162
214
  migrate.md # Nested subcommand
163
215
  seed.md
164
- manifest.json # Machine-readable bundle metadata
216
+ crust.json # Machine-readable bundle metadata (Crust ownership marker)
165
217
  ```
166
218
 
167
219
  ### File Details
@@ -171,7 +223,26 @@ skills/use-my-cli/
171
223
  | `SKILL.md` | Agent entrypoint with YAML frontmatter. Directs agents to load specific command files on demand (lazy loading). |
172
224
  | `command-index.md` | Markdown table listing every command, its type (runnable/group), and documentation path. |
173
225
  | `commands/*.md` | Per-command reference files. Leaf commands include usage, arguments, flags, defaults, and aliases. Group commands list subcommands with links. |
174
- | `manifest.json` | JSON metadata: name, description, version, entrypoint, and list of all command paths. |
226
+ | `crust.json` | Crust-specific JSON metadata: name, description, version, entrypoint, and list of all command paths. Also serves as an ownership marker — its presence indicates the skill was generated by Crust. |
227
+
228
+ ## Conflict Detection
229
+
230
+ Each skill directory contains a `crust.json` file that acts as an ownership marker. If `generateSkill()` encounters an existing directory without `crust.json`, it throws a `SkillConflictError` to prevent overwriting skills created manually or by other tools.
231
+
232
+ Pass `force: true` to overwrite, or handle the error:
233
+
234
+ ```ts
235
+ import { generateSkill, SkillConflictError } from "@crustjs/skills";
236
+
237
+ try {
238
+ await generateSkill({ command, meta, agents });
239
+ } catch (err) {
240
+ if (err instanceof SkillConflictError) {
241
+ console.error(`Conflict: ${err.details.outputDir}`);
242
+ // err.details.agent — the agent where the conflict occurred
243
+ }
244
+ }
245
+ ```
175
246
 
176
247
  ## Installing Generated Skills
177
248
 
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ import { AnyCommand } from "@crustjs/core";
3
3
  * Metadata for the generated skill bundle.
4
4
  *
5
5
  * This information populates the `SKILL.md` frontmatter and distribution
6
- * metadata files (`manifest.json`).
6
+ * metadata files (`crust.json`).
7
7
  *
8
8
  * @example
9
9
  * ```ts
@@ -21,14 +21,52 @@ interface SkillMeta {
21
21
  *
22
22
  * `generateSkill()`, `uninstallSkill()`, and `skillStatus()` automatically
23
23
  * prefix `use-` to this name for output directory paths, SKILL.md frontmatter,
24
- * and manifest.json metadata. For example, `name: "my-cli"` produces output
24
+ * and crust.json metadata. For example, `name: "my-cli"` produces output
25
25
  * under `use-my-cli/`.
26
+ *
27
+ * The resolved name (with `use-` prefix) must conform to the Agent Skills
28
+ * spec: 1–64 lowercase alphanumeric characters and hyphens, no leading/
29
+ * trailing/consecutive hyphens.
26
30
  */
27
31
  name: string;
28
32
  /** Human-readable description of what the CLI does */
29
33
  description: string;
30
34
  /** Version string for the generated skill bundle */
31
35
  version: string;
36
+ /**
37
+ * License name or reference to a bundled license file.
38
+ *
39
+ * Emitted in SKILL.md YAML frontmatter as `license:`.
40
+ */
41
+ license?: string;
42
+ /**
43
+ * Environment requirements or compatibility notes (max 500 chars per spec).
44
+ *
45
+ * Indicates intended product, required system packages, network access, etc.
46
+ * Emitted in SKILL.md YAML frontmatter as `compatibility:`.
47
+ *
48
+ * @example "Requires deploy-cli installed on PATH"
49
+ */
50
+ compatibility?: string;
51
+ /**
52
+ * When `true`, prevents agents from automatically loading this skill.
53
+ * Users must invoke it manually with `/skill-name`.
54
+ *
55
+ * Emitted in SKILL.md YAML frontmatter as `disable-model-invocation: true`.
56
+ * @default false
57
+ */
58
+ disableModelInvocation?: boolean;
59
+ /**
60
+ * Space-delimited list of pre-approved tools the skill may use.
61
+ *
62
+ * For CLI skills, setting this to `Bash(<cli-name> *)` allows agents to
63
+ * execute the CLI without per-use permission prompts.
64
+ *
65
+ * Emitted in SKILL.md YAML frontmatter as `allowed-tools:`.
66
+ *
67
+ * @example "Bash(my-cli *) Read Grep"
68
+ */
69
+ allowedTools?: string;
32
70
  }
33
71
  /** Supported agent targets for skill installation. */
34
72
  type AgentTarget = "claude-code" | "opencode";
@@ -75,6 +113,13 @@ interface GenerateOptions {
75
113
  * @default true
76
114
  */
77
115
  clean?: boolean;
116
+ /**
117
+ * When `true`, overwrite an existing skill directory even if it was not
118
+ * created by Crust (i.e. has no `crust.json`). Without this flag, a
119
+ * conflict throws a {@link SkillConflictError}.
120
+ * @default false
121
+ */
122
+ force?: boolean;
78
123
  }
79
124
  /** Status of an individual agent installation. */
80
125
  type InstallStatus = "installed" | "updated" | "up-to-date";
@@ -157,11 +202,12 @@ interface StatusResult {
157
202
  * new version is detected. Set `autoInstall: true` to also install skills that
158
203
  * are not yet present.
159
204
  *
160
- * **Interactive command**: set `command: true` to register a `skill` subcommand
161
- * that presents a single multiselect prompt for toggling agent installations.
205
+ * **Interactive command** (default): registers a `skill` subcommand that
206
+ * presents a single multiselect prompt for toggling agent installations.
207
+ * Set `command: false` to disable the command.
162
208
  */
163
209
  interface SkillPluginOptions {
164
- /** Skill version string — compared against the installed manifest */
210
+ /** Skill version string — compared against the installed crust.json */
165
211
  version: string;
166
212
  /**
167
213
  * Installation scope.
@@ -191,7 +237,7 @@ interface SkillPluginOptions {
191
237
  *
192
238
  * - `true`: register with default name `"skill"`
193
239
  * - `string`: register with a custom command name
194
- * @default false
240
+ * @default true
195
241
  */
196
242
  command?: boolean | string;
197
243
  }
@@ -220,10 +266,51 @@ interface SkillPluginOptions {
220
266
  * ```
221
267
  */
222
268
  declare function detectInstalledAgents(home?: string): Promise<AgentTarget[]>;
269
+ /** Details about the conflict between an existing skill and an incoming one. */
270
+ interface SkillConflictDetails {
271
+ /** The agent where the conflict was detected */
272
+ agent: AgentTarget;
273
+ /** Absolute path to the conflicting skill directory */
274
+ outputDir: string;
275
+ }
276
+ /**
277
+ * Thrown when `generateSkill()` detects that the target skill directory
278
+ * already exists but was not created by Crust (i.e. has no `crust.json`).
279
+ *
280
+ * This prevents Crust from silently overwriting a skill that was manually
281
+ * created or installed by another tool.
282
+ *
283
+ * @example
284
+ * ```ts
285
+ * import { generateSkill, SkillConflictError } from "@crustjs/skills";
286
+ *
287
+ * try {
288
+ * await generateSkill({ command, meta, agents });
289
+ * } catch (err) {
290
+ * if (err instanceof SkillConflictError) {
291
+ * console.error(
292
+ * `Conflict: "${err.details.outputDir}" already exists and was not created by Crust.`,
293
+ * );
294
+ * }
295
+ * }
296
+ * ```
297
+ */
298
+ declare class SkillConflictError extends Error {
299
+ readonly name = "SkillConflictError";
300
+ readonly details: SkillConflictDetails;
301
+ constructor(details: SkillConflictDetails);
302
+ }
303
+ /**
304
+ * Validates a resolved skill name against the Agent Skills specification.
305
+ *
306
+ * @param name - The resolved skill name to validate (already has `use-` prefix)
307
+ * @returns `true` if valid, `false` otherwise
308
+ */
309
+ declare function isValidSkillName(name: string): boolean;
223
310
  /**
224
311
  * Resolves the canonical skill name by applying the `use-` prefix.
225
312
  *
226
- * All generated output (directory names, manifest metadata, SKILL.md content)
313
+ * All generated output (directory names, crust.json metadata, SKILL.md content)
227
314
  * uses the resolved name. Consumers pass the raw CLI name (e.g. `"my-cli"`),
228
315
  * and this function returns the prefixed form (e.g. `"use-my-cli"`).
229
316
  *
@@ -241,13 +328,16 @@ declare function resolveSkillName(name: string): string;
241
328
  *
242
329
  * For each target agent:
243
330
  * 1. Resolves the output directory via {@link resolveAgentPath}
244
- * 2. Checks the installed version skips if up-to-date
245
- * 3. Builds a canonical manifest from the command tree
246
- * 4. Renders markdown files + `manifest.json`
247
- * 5. Writes files to the agent's skill directory
331
+ * 2. Checks for conflicts — if the directory exists but has no `crust.json`,
332
+ * it was not created by Crust and a {@link SkillConflictError} is thrown
333
+ * 3. Checks the installed version — skips if up-to-date
334
+ * 4. Builds a canonical manifest from the command tree
335
+ * 5. Renders markdown files + `crust.json`
336
+ * 6. Writes files to the agent's skill directory
248
337
  *
249
338
  * @param options - Generation options including command, metadata, agents, and scope
250
339
  * @returns Per-agent installation results
340
+ * @throws {SkillConflictError} If the output directory exists but was not created by Crust
251
341
  *
252
342
  * @example
253
343
  * ```ts
@@ -299,11 +389,12 @@ import { CrustPlugin } from "@crustjs/core";
299
389
  * new version is detected. Set `autoInstall: true` to also install skills that
300
390
  * are not yet present.
301
391
  *
302
- * **Interactive command**: set `command: true` to register a `skill` subcommand
303
- * that presents a single multiselect prompt for toggling agent installations.
392
+ * **Interactive command** (default): registers a `skill` subcommand that
393
+ * presents a single multiselect prompt for toggling agent installations.
304
394
  * Detected agents are shown with their current installation status pre-filled.
305
395
  * The system reconciles the desired state: newly selected agents are installed,
306
396
  * deselected agents are uninstalled, and already-correct agents are skipped.
397
+ * Set `command: false` to disable command injection.
307
398
  *
308
399
  * @param options - Plugin configuration with version and scope
309
400
  * @returns A `CrustPlugin` to register in a command's `plugins` array
@@ -327,4 +418,4 @@ import { CrustPlugin } from "@crustjs/core";
327
418
  * ```
328
419
  */
329
420
  declare function skillPlugin(options: SkillPluginOptions): CrustPlugin;
330
- export { uninstallSkill, skillStatus, skillPlugin, resolveSkillName, generateSkill, detectInstalledAgents, UninstallStatus, UninstallResult, UninstallOptions, StatusResult, StatusOptions, SkillPluginOptions, SkillMeta, Scope, InstallStatus, GenerateResult, GenerateOptions, AgentTarget, AgentResult };
421
+ export { uninstallSkill, skillStatus, skillPlugin, resolveSkillName, isValidSkillName, generateSkill, detectInstalledAgents, UninstallStatus, UninstallResult, UninstallOptions, StatusResult, StatusOptions, SkillPluginOptions, SkillMeta, SkillConflictError, SkillConflictDetails, Scope, InstallStatus, GenerateResult, GenerateOptions, AgentTarget, AgentResult };
package/dist/index.js CHANGED
@@ -40,6 +40,16 @@ function resolveAgentConfigDir(home, agent) {
40
40
  return join(home, ".config", "opencode");
41
41
  }
42
42
  }
43
+ // src/errors.ts
44
+ class SkillConflictError extends Error {
45
+ name = "SkillConflictError";
46
+ details;
47
+ constructor(details) {
48
+ const message = `Skill conflict for agent "${details.agent}": ` + `directory "${details.outputDir}" already exists but was not created by Crust ` + `(no crust.json found). Delete or rename the conflicting skill to resolve.`;
49
+ super(message);
50
+ this.details = details;
51
+ }
52
+ }
43
53
  // src/generate.ts
44
54
  import { access as access2, mkdir, rm, writeFile } from "fs/promises";
45
55
  import { dirname, join as join3 } from "path";
@@ -133,6 +143,15 @@ function serializeDefault(value) {
133
143
  }
134
144
 
135
145
  // src/render.ts
146
+ function escapeYaml(value) {
147
+ if (/[:#[\]{}&*!|>'"`,@?\\]|^\s|\s$|^---|[\n\r]/.test(value)) {
148
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\r/g, "\\r")}"`;
149
+ }
150
+ return value;
151
+ }
152
+ function escapeTableCell(value) {
153
+ return value.replace(/(?<!\\)\|/g, "\\|");
154
+ }
136
155
  function renderSkill(manifest, meta) {
137
156
  const files = [];
138
157
  const allNodes = collectNodes(manifest);
@@ -186,8 +205,20 @@ function relativePath(from, to) {
186
205
  function renderSkillMd(manifest, meta) {
187
206
  const lines = [];
188
207
  lines.push("---");
189
- lines.push(`name: ${meta.name}`);
190
- lines.push(`description: ${meta.description}`);
208
+ lines.push(`name: ${escapeYaml(meta.name)}`);
209
+ lines.push(`description: ${escapeYaml(meta.description)}`);
210
+ if (meta.license) {
211
+ lines.push(`license: ${escapeYaml(meta.license)}`);
212
+ }
213
+ if (meta.compatibility) {
214
+ lines.push(`compatibility: ${escapeYaml(meta.compatibility)}`);
215
+ }
216
+ if (meta.disableModelInvocation) {
217
+ lines.push("disable-model-invocation: true");
218
+ }
219
+ if (meta.allowedTools) {
220
+ lines.push(`allowed-tools: ${escapeYaml(meta.allowedTools)}`);
221
+ }
191
222
  lines.push("metadata:");
192
223
  lines.push(` version: "${meta.version}"`);
193
224
  lines.push("---");
@@ -198,13 +229,15 @@ function renderSkillMd(manifest, meta) {
198
229
  lines.push(manifest.description);
199
230
  lines.push("");
200
231
  }
201
- lines.push("## Command Reference");
232
+ const cliName = meta.name.startsWith("use-") ? meta.name.slice(4) : meta.name;
233
+ lines.push(`Use this skill when working with \`${cliName}\` commands, or when you need help with \`${cliName}\` syntax, flags, or subcommands.`);
202
234
  lines.push("");
203
- lines.push("This skill provides documentation for all available CLI commands.");
235
+ lines.push("## Command Reference");
204
236
  lines.push("");
205
- lines.push(`For a complete list of commands and their documentation paths, see [command-index.md](command-index.md).`);
237
+ lines.push(`For the full list of commands and their documentation paths, see [command-index.md](command-index.md). ` + "**Do not read all command files at once.** Instead:");
206
238
  lines.push("");
207
- lines.push("When you need details about a specific command, load the corresponding file from the `commands/` directory rather than reading all files at once.");
239
+ lines.push("1. Check [command-index.md](command-index.md) to find the relevant command");
240
+ lines.push("2. Read only the specific file from the `commands/` directory that you need");
208
241
  lines.push("");
209
242
  if (manifest.children.length > 0) {
210
243
  lines.push("## Available Commands");
@@ -358,7 +391,7 @@ function renderArgsTable(args) {
358
391
  for (const arg of args) {
359
392
  const name = arg.variadic ? `${arg.name}...` : arg.name;
360
393
  const required = arg.required ? "Yes" : "No";
361
- const desc = formatArgDescription(arg);
394
+ const desc = escapeTableCell(formatArgDescription(arg));
362
395
  lines.push(`| \`${name}\` | ${arg.type} | ${required} | ${desc} |`);
363
396
  }
364
397
  return lines;
@@ -380,7 +413,7 @@ function renderFlagsTable(flags) {
380
413
  for (const flag of flags) {
381
414
  const name = formatFlagName(flag);
382
415
  const required = flag.required ? "Yes" : "No";
383
- const desc = formatFlagDescription(flag);
416
+ const desc = escapeTableCell(formatFlagDescription(flag));
384
417
  lines.push(`| ${name} | ${flag.type} | ${required} | ${desc} |`);
385
418
  }
386
419
  return lines;
@@ -450,9 +483,10 @@ function arraysEqual(a, b) {
450
483
  // src/version.ts
451
484
  import { readFile } from "fs/promises";
452
485
  import { join as join2 } from "path";
486
+ var CRUST_MANIFEST = "crust.json";
453
487
  async function readInstalledVersion(dir) {
454
488
  try {
455
- const raw = await readFile(join2(dir, "manifest.json"), "utf-8");
489
+ const raw = await readFile(join2(dir, CRUST_MANIFEST), "utf-8");
456
490
  const parsed = JSON.parse(raw);
457
491
  if (typeof parsed === "object" && parsed !== null && "version" in parsed && typeof parsed.version === "string") {
458
492
  return parsed.version;
@@ -462,26 +496,31 @@ async function readInstalledVersion(dir) {
462
496
  return null;
463
497
  }
464
498
  }
465
- async function checkVersion(dir, newVersion) {
466
- const installed = await readInstalledVersion(dir);
467
- if (installed === null) {
468
- return { status: "installed", installedVersion: null };
469
- }
470
- if (installed !== newVersion) {
471
- return { status: "updated", installedVersion: installed };
472
- }
473
- return { status: "up-to-date", installedVersion: installed };
474
- }
475
499
 
476
500
  // src/generate.ts
501
+ var SKILL_NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
502
+ function isValidSkillName(name) {
503
+ return name.length >= 1 && name.length <= 64 && SKILL_NAME_PATTERN.test(name);
504
+ }
477
505
  function resolveSkillName(name) {
478
506
  return name.startsWith("use-") ? name : `use-${name}`;
479
507
  }
480
508
  async function generateSkill(options) {
481
- const { command, meta, agents, scope = "global", clean = true } = options;
509
+ const {
510
+ command,
511
+ meta,
512
+ agents,
513
+ scope = "global",
514
+ clean = true,
515
+ force = false
516
+ } = options;
517
+ const resolvedName = resolveSkillName(meta.name);
518
+ if (!isValidSkillName(resolvedName)) {
519
+ throw new Error(`Invalid skill name "${resolvedName}": must be 1\u201364 lowercase ` + `alphanumeric characters and hyphens, no leading/trailing/consecutive ` + `hyphens. Pattern: ${SKILL_NAME_PATTERN.source}`);
520
+ }
482
521
  const resolvedMeta = {
483
522
  ...meta,
484
- name: resolveSkillName(meta.name)
523
+ name: resolvedName
485
524
  };
486
525
  const manifest = buildManifest(command);
487
526
  const renderedFiles = renderSkill(manifest, resolvedMeta);
@@ -490,7 +529,14 @@ async function generateSkill(options) {
490
529
  const results = [];
491
530
  for (const agent of agents) {
492
531
  const outputDir = resolveAgentPath(agent, scope, resolvedMeta.name);
493
- const { status, installedVersion } = await checkVersion(outputDir, resolvedMeta.version);
532
+ const installedVersion = await readInstalledVersion(outputDir);
533
+ if (installedVersion === null) {
534
+ const dirExists = await access2(outputDir).then(() => true).catch(() => false);
535
+ if (dirExists && !force) {
536
+ throw new SkillConflictError({ agent, outputDir });
537
+ }
538
+ }
539
+ const status = installedVersion === null ? "installed" : installedVersion === resolvedMeta.version ? "up-to-date" : "updated";
494
540
  if (status === "up-to-date") {
495
541
  results.push({
496
542
  agent,
@@ -550,12 +596,12 @@ async function skillStatus(options) {
550
596
  function renderDistributionMetadata(manifest, meta) {
551
597
  return [
552
598
  {
553
- path: "manifest.json",
554
- content: renderManifestJson(manifest, meta)
599
+ path: CRUST_MANIFEST,
600
+ content: renderCrustJson(manifest, meta)
555
601
  }
556
602
  ];
557
603
  }
558
- function renderManifestJson(manifest, meta) {
604
+ function renderCrustJson(manifest, meta) {
559
605
  const commands = collectCommandPaths(manifest);
560
606
  const obj = {
561
607
  name: meta.name,
@@ -595,7 +641,7 @@ async function writeFiles(baseDir, files) {
595
641
  }
596
642
  // src/plugin.ts
597
643
  import { defineCommand } from "@crustjs/core";
598
- import { multiselect, spinner } from "@crustjs/prompts";
644
+ import { confirm, multiselect, spinner } from "@crustjs/prompts";
599
645
  function deriveSkillMeta(command, version) {
600
646
  return {
601
647
  name: command.meta.name,
@@ -610,7 +656,7 @@ function skillPlugin(options) {
610
656
  name: "skills",
611
657
  setup(context, actions) {
612
658
  rootCmd = context.rootCommand;
613
- if (options.command) {
659
+ if (options.command !== false) {
614
660
  const name = typeof options.command === "string" ? options.command : "skill";
615
661
  skillCmd = buildSkillCommand(rootCmd, options);
616
662
  actions.addSubCommand(rootCmd, name, skillCmd);
@@ -642,15 +688,32 @@ function skillPlugin(options) {
642
688
  return false;
643
689
  });
644
690
  if (needsUpdate.length > 0) {
645
- const result = await generateSkill({
646
- command: rootCmd,
647
- meta,
648
- agents: needsUpdate.map((a) => a.agent),
649
- scope: options.scope
650
- });
651
- const agentNames = result.agents.filter((a) => a.status !== "up-to-date").map((a) => AGENT_LABELS[a.agent]);
652
- if (agentNames.length > 0) {
653
- console.log(`Skill "${meta.name}" v${meta.version} installed for ${agentNames.join(", ")}`);
691
+ try {
692
+ const result = await generateSkill({
693
+ command: rootCmd,
694
+ meta,
695
+ agents: needsUpdate.map((a) => a.agent),
696
+ scope: options.scope
697
+ });
698
+ const installedAgents = result.agents.filter((a) => a.status === "installed").map((a) => AGENT_LABELS[a.agent]);
699
+ const updatedAgents = result.agents.filter((a) => a.status === "updated").map((a) => AGENT_LABELS[a.agent]);
700
+ if (installedAgents.length > 0) {
701
+ if (options.command !== false) {
702
+ const manageCommand = `${rootCmd.meta.name} skill`;
703
+ console.log(`Auto-installed skill "${meta.name}" v${meta.version} for ${installedAgents.join(", ")}. Manage with \`${manageCommand}\`.`);
704
+ } else {
705
+ console.log(`Auto-installed skill "${meta.name}" v${meta.version} for ${installedAgents.join(", ")}.`);
706
+ }
707
+ }
708
+ if (updatedAgents.length > 0) {
709
+ console.log(`Updated skill "${meta.name}" to v${meta.version} for ${updatedAgents.join(", ")}.`);
710
+ }
711
+ } catch (err) {
712
+ if (err instanceof SkillConflictError) {
713
+ console.warn(`Skill conflict: "${err.details.outputDir}" already exists ` + `but was not created by ${meta.name}. Skipping auto-update. ` + `Delete or rename the conflicting skill to resolve.`);
714
+ } else {
715
+ throw err;
716
+ }
654
717
  }
655
718
  }
656
719
  await next();
@@ -702,19 +765,50 @@ function buildSkillCommand(rootCmd, options) {
702
765
  const toUninstall = installedAgents.filter((agent) => !selected.includes(agent));
703
766
  const agentsToGenerate = [...toInstall, ...toUpdate];
704
767
  if (agentsToGenerate.length > 0) {
705
- const result = await spinner({
706
- message: "Installing skills...",
707
- task: async () => generateSkill({
708
- command: rootCmd,
709
- meta,
710
- agents: agentsToGenerate,
711
- scope
712
- })
713
- });
714
- console.log(`
768
+ try {
769
+ const result = await spinner({
770
+ message: "Installing skills...",
771
+ task: async () => generateSkill({
772
+ command: rootCmd,
773
+ meta,
774
+ agents: agentsToGenerate,
775
+ scope
776
+ })
777
+ });
778
+ console.log(`
779
+ Installed "${meta.name}" v${meta.version}`);
780
+ for (const r of result.agents) {
781
+ console.log(` ${AGENT_LABELS[r.agent]} \u2192 ${r.outputDir}`);
782
+ }
783
+ } catch (err) {
784
+ if (err instanceof SkillConflictError) {
785
+ const overwrite = await confirm({
786
+ message: `"${err.details.outputDir}" already exists but was not ` + `created by Crust. Overwrite?`,
787
+ default: false
788
+ });
789
+ if (overwrite) {
790
+ const result = await spinner({
791
+ message: "Overwriting skill...",
792
+ task: async () => generateSkill({
793
+ command: rootCmd,
794
+ meta,
795
+ agents: [err.details.agent],
796
+ scope,
797
+ force: true
798
+ })
799
+ });
800
+ console.log(`
715
801
  Installed "${meta.name}" v${meta.version}`);
716
- for (const r of result.agents) {
717
- console.log(` ${AGENT_LABELS[r.agent]} \u2192 ${r.outputDir}`);
802
+ for (const r of result.agents) {
803
+ console.log(` ${AGENT_LABELS[r.agent]} \u2192 ${r.outputDir}`);
804
+ }
805
+ } else {
806
+ console.log(`
807
+ Skipped ${AGENT_LABELS[err.details.agent]}`);
808
+ }
809
+ } else {
810
+ throw err;
811
+ }
718
812
  }
719
813
  }
720
814
  if (toUninstall.length > 0) {
@@ -743,6 +837,8 @@ export {
743
837
  skillStatus,
744
838
  skillPlugin,
745
839
  resolveSkillName,
840
+ isValidSkillName,
746
841
  generateSkill,
747
- detectInstalledAgents
842
+ detectInstalledAgents,
843
+ SkillConflictError
748
844
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crustjs/skills",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "description": "Agent skill generation from Crust command definitions",
5
5
  "type": "module",
6
6
  "license": "MIT",