@crustjs/skills 0.0.2 → 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,12 +150,61 @@ 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
- Generated output goes to `<outDir>/skills/<name>/`:
204
+ Generated output goes to `<outDir>/skills/use-<name>/` (the `use-` prefix is applied automatically):
153
205
 
154
206
  ```
155
- skills/my-cli/
207
+ skills/use-my-cli/
156
208
  SKILL.md # Entrypoint — loaded by the agent
157
209
  command-index.md # Maps all commands to documentation file paths
158
210
  commands/ # Per-command documentation mirroring the CLI hierarchy
@@ -161,8 +213,7 @@ skills/my-cli/
161
213
  db/
162
214
  migrate.md # Nested subcommand
163
215
  seed.md
164
- manifest.json # Machine-readable bundle metadata
165
- README.md # Install instructions for consumers
216
+ crust.json # Machine-readable bundle metadata (Crust ownership marker)
166
217
  ```
167
218
 
168
219
  ### File Details
@@ -172,8 +223,26 @@ skills/my-cli/
172
223
  | `SKILL.md` | Agent entrypoint with YAML frontmatter. Directs agents to load specific command files on demand (lazy loading). |
173
224
  | `command-index.md` | Markdown table listing every command, its type (runnable/group), and documentation path. |
174
225
  | `commands/*.md` | Per-command reference files. Leaf commands include usage, arguments, flags, defaults, and aliases. Group commands list subcommands with links. |
175
- | `manifest.json` | JSON metadata: name, description, version, entrypoint, and list of all command paths. |
176
- | `README.md` | Human-readable install instructions for OpenCode and Claude Code. |
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
+ ```
177
246
 
178
247
  ## Installing Generated Skills
179
248
 
@@ -182,13 +251,13 @@ After generating a skill bundle, consumers can install it by copying the skill d
182
251
  ### OpenCode
183
252
 
184
253
  ```sh
185
- cp -r skills/my-cli/ .opencode/skills/my-cli/
254
+ cp -r skills/use-my-cli/ .opencode/skills/use-my-cli/
186
255
  ```
187
256
 
188
257
  ### Claude Code
189
258
 
190
259
  ```sh
191
- cp -r skills/my-cli/ .claude/skills/my-cli/
260
+ cp -r skills/use-my-cli/ .claude/skills/use-my-cli/
192
261
  ```
193
262
 
194
263
  The agent will discover the skill from `SKILL.md` and load command documentation on demand from the `commands/` directory.
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
@@ -12,15 +12,61 @@ import { AnyCommand } from "@crustjs/core";
12
12
  * description: "CLI tool for managing widgets",
13
13
  * version: "1.0.0",
14
14
  * };
15
+ * // generateSkill() will output to `use-my-cli/` with name "use-my-cli"
15
16
  * ```
16
17
  */
17
18
  interface SkillMeta {
18
- /** Skill name — used as the directory name and in frontmatter */
19
+ /**
20
+ * Skill name — the user-facing CLI name (e.g. `"my-cli"`).
21
+ *
22
+ * `generateSkill()`, `uninstallSkill()`, and `skillStatus()` automatically
23
+ * prefix `use-` to this name for output directory paths, SKILL.md frontmatter,
24
+ * and crust.json metadata. For example, `name: "my-cli"` produces output
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.
30
+ */
19
31
  name: string;
20
32
  /** Human-readable description of what the CLI does */
21
33
  description: string;
22
34
  /** Version string for the generated skill bundle */
23
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;
24
70
  }
25
71
  /** Supported agent targets for skill installation. */
26
72
  type AgentTarget = "claude-code" | "opencode";
@@ -29,6 +75,10 @@ type Scope = "global" | "project";
29
75
  /**
30
76
  * Top-level options for generating a skill bundle from a command tree.
31
77
  *
78
+ * The `meta.name` value is automatically prefixed with `use-` for all output
79
+ * paths and metadata. For example, `name: "my-cli"` produces skill directories
80
+ * named `use-my-cli/` and sets the manifest/frontmatter name to `"use-my-cli"`.
81
+ *
32
82
  * @example
33
83
  * ```ts
34
84
  * import { generateSkill } from "@crustjs/skills";
@@ -37,7 +87,7 @@ type Scope = "global" | "project";
37
87
  * await generateSkill({
38
88
  * command: rootCommand,
39
89
  * meta: {
40
- * name: "my-cli",
90
+ * name: "my-cli", // output: use-my-cli/
41
91
  * description: "CLI tool for managing widgets",
42
92
  * version: "1.0.0",
43
93
  * },
@@ -63,6 +113,13 @@ interface GenerateOptions {
63
113
  * @default true
64
114
  */
65
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;
66
123
  }
67
124
  /** Status of an individual agent installation. */
68
125
  type InstallStatus = "installed" | "updated" | "up-to-date";
@@ -145,11 +202,12 @@ interface StatusResult {
145
202
  * new version is detected. Set `autoInstall: true` to also install skills that
146
203
  * are not yet present.
147
204
  *
148
- * **Interactive command**: set `command: true` to register a `skill` subcommand
149
- * on the root command for manual install/uninstall/status management.
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.
150
208
  */
151
209
  interface SkillPluginOptions {
152
- /** Skill version string — compared against the installed manifest */
210
+ /** Skill version string — compared against the installed crust.json */
153
211
  version: string;
154
212
  /**
155
213
  * Installation scope.
@@ -170,9 +228,16 @@ interface SkillPluginOptions {
170
228
  autoUpdate?: boolean;
171
229
  /**
172
230
  * Register an interactive skill management subcommand on the root command.
231
+ *
232
+ * The command presents a single multiselect prompt listing all detected
233
+ * agents with their current installation status pre-filled. The user
234
+ * toggles agents on/off and the system reconciles the desired state:
235
+ * newly selected agents are installed, deselected agents are uninstalled,
236
+ * and already-correct agents are skipped.
237
+ *
173
238
  * - `true`: register with default name `"skill"`
174
239
  * - `string`: register with a custom command name
175
- * @default false
240
+ * @default true
176
241
  */
177
242
  command?: boolean | string;
178
243
  }
@@ -201,18 +266,78 @@ interface SkillPluginOptions {
201
266
  * ```
202
267
  */
203
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;
310
+ /**
311
+ * Resolves the canonical skill name by applying the `use-` prefix.
312
+ *
313
+ * All generated output (directory names, crust.json metadata, SKILL.md content)
314
+ * uses the resolved name. Consumers pass the raw CLI name (e.g. `"my-cli"`),
315
+ * and this function returns the prefixed form (e.g. `"use-my-cli"`).
316
+ *
317
+ * @param name - The raw CLI tool name
318
+ * @returns The prefixed skill name
319
+ *
320
+ * @example
321
+ * ```ts
322
+ * resolveSkillName("my-cli"); // "use-my-cli"
323
+ * ```
324
+ */
325
+ declare function resolveSkillName(name: string): string;
204
326
  /**
205
327
  * Generates and installs agent skill bundles from a Crust command tree.
206
328
  *
207
329
  * For each target agent:
208
330
  * 1. Resolves the output directory via {@link resolveAgentPath}
209
- * 2. Checks the installed version — skips if up-to-date
210
- * 3. Builds a canonical manifest from the command tree
211
- * 4. Renders markdown files + `manifest.json`
212
- * 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
213
337
  *
214
338
  * @param options - Generation options including command, metadata, agents, and scope
215
339
  * @returns Per-agent installation results
340
+ * @throws {SkillConflictError} If the output directory exists but was not created by Crust
216
341
  *
217
342
  * @example
218
343
  * ```ts
@@ -264,10 +389,12 @@ import { CrustPlugin } from "@crustjs/core";
264
389
  * new version is detected. Set `autoInstall: true` to also install skills that
265
390
  * are not yet present.
266
391
  *
267
- * **Interactive command**: set `command: true` to register a `skill` subcommand
268
- * on the root command for manual install/uninstall/status management. The
269
- * subcommand is injected via `addSubCommand` during `setup()` — if the user
270
- * already defines a subcommand with the same name, theirs takes priority.
392
+ * **Interactive command** (default): registers a `skill` subcommand that
393
+ * presents a single multiselect prompt for toggling agent installations.
394
+ * Detected agents are shown with their current installation status pre-filled.
395
+ * The system reconciles the desired state: newly selected agents are installed,
396
+ * deselected agents are uninstalled, and already-correct agents are skipped.
397
+ * Set `command: false` to disable command injection.
271
398
  *
272
399
  * @param options - Plugin configuration with version and scope
273
400
  * @returns A `CrustPlugin` to register in a command's `plugins` array
@@ -291,4 +418,4 @@ import { CrustPlugin } from "@crustjs/core";
291
418
  * ```
292
419
  */
293
420
  declare function skillPlugin(options: SkillPluginOptions): CrustPlugin;
294
- export { uninstallSkill, skillStatus, skillPlugin, 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,28 +496,47 @@ 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
+ }
505
+ function resolveSkillName(name) {
506
+ return name.startsWith("use-") ? name : `use-${name}`;
507
+ }
477
508
  async function generateSkill(options) {
478
- 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
+ }
521
+ const resolvedMeta = {
522
+ ...meta,
523
+ name: resolvedName
524
+ };
479
525
  const manifest = buildManifest(command);
480
- const renderedFiles = renderSkill(manifest, meta);
481
- const metadataFiles = renderDistributionMetadata(manifest, meta);
526
+ const renderedFiles = renderSkill(manifest, resolvedMeta);
527
+ const metadataFiles = renderDistributionMetadata(manifest, resolvedMeta);
482
528
  const allFiles = [...renderedFiles, ...metadataFiles].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
483
529
  const results = [];
484
530
  for (const agent of agents) {
485
- const outputDir = resolveAgentPath(agent, scope, meta.name);
486
- const { status, installedVersion } = await checkVersion(outputDir, meta.version);
531
+ const outputDir = resolveAgentPath(agent, scope, resolvedMeta.name);
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";
487
540
  if (status === "up-to-date") {
488
541
  results.push({
489
542
  agent,
@@ -510,9 +563,10 @@ async function generateSkill(options) {
510
563
  }
511
564
  async function uninstallSkill(options) {
512
565
  const { name, agents, scope = "global" } = options;
566
+ const resolvedName = resolveSkillName(name);
513
567
  const results = [];
514
568
  for (const agent of agents) {
515
- const outputDir = resolveAgentPath(agent, scope, name);
569
+ const outputDir = resolveAgentPath(agent, scope, resolvedName);
516
570
  const exists = await access2(outputDir).then(() => true).catch(() => false);
517
571
  if (exists) {
518
572
  await rm(outputDir, { recursive: true, force: true });
@@ -525,9 +579,10 @@ async function uninstallSkill(options) {
525
579
  }
526
580
  async function skillStatus(options) {
527
581
  const { name, agents, scope = "global" } = options;
582
+ const resolvedName = resolveSkillName(name);
528
583
  const results = [];
529
584
  for (const agent of agents) {
530
- const outputDir = resolveAgentPath(agent, scope, name);
585
+ const outputDir = resolveAgentPath(agent, scope, resolvedName);
531
586
  const version = await readInstalledVersion(outputDir);
532
587
  results.push({
533
588
  agent,
@@ -541,12 +596,12 @@ async function skillStatus(options) {
541
596
  function renderDistributionMetadata(manifest, meta) {
542
597
  return [
543
598
  {
544
- path: "manifest.json",
545
- content: renderManifestJson(manifest, meta)
599
+ path: CRUST_MANIFEST,
600
+ content: renderCrustJson(manifest, meta)
546
601
  }
547
602
  ];
548
603
  }
549
- function renderManifestJson(manifest, meta) {
604
+ function renderCrustJson(manifest, meta) {
550
605
  const commands = collectCommandPaths(manifest);
551
606
  const obj = {
552
607
  name: meta.name,
@@ -586,7 +641,7 @@ async function writeFiles(baseDir, files) {
586
641
  }
587
642
  // src/plugin.ts
588
643
  import { defineCommand } from "@crustjs/core";
589
- import { confirm, multiselect, select, spinner } from "@crustjs/prompts";
644
+ import { confirm, multiselect, spinner } from "@crustjs/prompts";
590
645
  function deriveSkillMeta(command, version) {
591
646
  return {
592
647
  name: command.meta.name,
@@ -601,7 +656,7 @@ function skillPlugin(options) {
601
656
  name: "skills",
602
657
  setup(context, actions) {
603
658
  rootCmd = context.rootCommand;
604
- if (options.command) {
659
+ if (options.command !== false) {
605
660
  const name = typeof options.command === "string" ? options.command : "skill";
606
661
  skillCmd = buildSkillCommand(rootCmd, options);
607
662
  actions.addSubCommand(rootCmd, name, skillCmd);
@@ -633,15 +688,32 @@ function skillPlugin(options) {
633
688
  return false;
634
689
  });
635
690
  if (needsUpdate.length > 0) {
636
- const result = await generateSkill({
637
- command: rootCmd,
638
- meta,
639
- agents: needsUpdate.map((a) => a.agent),
640
- scope: options.scope
641
- });
642
- const agentNames = result.agents.filter((a) => a.status !== "up-to-date").map((a) => AGENT_LABELS[a.agent]);
643
- if (agentNames.length > 0) {
644
- 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
+ }
645
717
  }
646
718
  }
647
719
  await next();
@@ -652,147 +724,121 @@ function buildSkillCommand(rootCmd, options) {
652
724
  return defineCommand({
653
725
  meta: {
654
726
  name: "skill",
655
- description: "Manage agent skills (install, uninstall, status)"
656
- },
657
- flags: {
658
- action: {
659
- type: "string",
660
- description: 'Action to perform: "install", "uninstall", or "status" (skips prompt)'
661
- },
662
- scope: {
663
- type: "string",
664
- description: 'Installation scope: "global" or "project" (skips prompt)'
665
- },
666
- force: {
667
- type: "boolean",
668
- description: "Skip confirmation prompts"
669
- }
727
+ description: "Manage agent skill installations"
670
728
  },
671
- async run({ flags }) {
729
+ async run() {
672
730
  const meta = deriveSkillMeta(rootCmd, options.version);
673
- const actionFlag = flags.action;
674
- const scopeFlag = flags.scope;
675
- const forceFlag = flags.force ?? false;
676
- const installedAgents = await detectInstalledAgents();
677
- if (installedAgents.length === 0) {
731
+ const scope = options.scope ?? "global";
732
+ const detectedAgents = await detectInstalledAgents();
733
+ if (detectedAgents.length === 0) {
678
734
  console.log("No supported agents detected. Install Claude Code or OpenCode first.");
679
735
  return;
680
736
  }
681
- const action = await select({
682
- message: "What would you like to do?",
683
- choices: [
684
- { label: "Install / update skills", value: "install" },
685
- { label: "Uninstall skills", value: "uninstall" },
686
- { label: "Check status", value: "status" }
687
- ],
688
- initial: actionFlag
737
+ const status = await skillStatus({
738
+ name: meta.name,
739
+ agents: detectedAgents,
740
+ scope
689
741
  });
690
- if (action === "install") {
691
- await handleInstall(rootCmd, meta, options, installedAgents, scopeFlag);
692
- } else if (action === "uninstall") {
693
- await handleUninstall(meta, installedAgents, scopeFlag, forceFlag);
694
- } else if (action === "status") {
695
- await handleStatus(meta, installedAgents, scopeFlag);
696
- }
697
- }
698
- });
699
- }
700
- async function handleInstall(rootCmd, meta, options, installedAgents, scopeFlag) {
701
- const agents = await multiselect({
702
- message: "Which agents?",
703
- choices: installedAgents.map((a) => ({
704
- label: AGENT_LABELS[a],
705
- value: a
706
- })),
707
- default: installedAgents,
708
- required: true
709
- });
710
- const scope = await select({
711
- message: "Scope?",
712
- choices: [
713
- { label: "Global", value: "global", hint: "recommended" },
714
- { label: "Project", value: "project" }
715
- ],
716
- default: options.scope ?? "global",
717
- initial: scopeFlag
718
- });
719
- const result = await spinner({
720
- message: "Installing skills...",
721
- task: async () => generateSkill({
722
- command: rootCmd,
723
- meta,
724
- agents,
725
- scope
726
- })
727
- });
728
- console.log(`
742
+ const installedAgents = [];
743
+ const choices = status.agents.map((entry) => {
744
+ const hint = entry.installed ? `v${entry.version} installed` : "not installed";
745
+ if (entry.installed) {
746
+ installedAgents.push(entry.agent);
747
+ }
748
+ return {
749
+ label: AGENT_LABELS[entry.agent],
750
+ value: entry.agent,
751
+ hint
752
+ };
753
+ });
754
+ const selected = await multiselect({
755
+ message: "Select agents to install skills for",
756
+ choices,
757
+ default: installedAgents,
758
+ required: false
759
+ });
760
+ const toInstall = selected.filter((agent) => !installedAgents.includes(agent));
761
+ const toUpdate = selected.filter((agent) => {
762
+ const entry = status.agents.find((a) => a.agent === agent);
763
+ return entry?.installed === true && entry.version !== meta.version;
764
+ });
765
+ const toUninstall = installedAgents.filter((agent) => !selected.includes(agent));
766
+ const agentsToGenerate = [...toInstall, ...toUpdate];
767
+ if (agentsToGenerate.length > 0) {
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(`
729
779
  Installed "${meta.name}" v${meta.version}`);
730
- for (const r of result.agents) {
731
- if (r.status !== "up-to-date") {
732
- console.log(` ${AGENT_LABELS[r.agent]} \u2192 ${r.outputDir}`);
733
- } else {
734
- console.log(` ${AGENT_LABELS[r.agent]} \u2014 already up-to-date`);
735
- }
736
- }
737
- }
738
- async function handleUninstall(meta, installedAgents, scopeFlag, forceFlag) {
739
- const agents = await multiselect({
740
- message: "Which agents to uninstall from?",
741
- choices: installedAgents.map((a) => ({
742
- label: AGENT_LABELS[a],
743
- value: a
744
- })),
745
- default: installedAgents,
746
- required: true
747
- });
748
- const agentNames = agents.map((a) => AGENT_LABELS[a]).join(", ");
749
- const confirmed = await confirm({
750
- message: `Remove "${meta.name}" skills from ${agentNames}?`,
751
- initial: forceFlag ? true : undefined
752
- });
753
- if (!confirmed) {
754
- console.log("Cancelled.");
755
- return;
756
- }
757
- const scope = scopeFlag ?? "global";
758
- const result = await spinner({
759
- message: "Removing skills...",
760
- task: async () => uninstallSkill({
761
- name: meta.name,
762
- agents,
763
- scope
764
- })
765
- });
766
- const removed = result.agents.filter((a) => a.status === "removed").map((a) => AGENT_LABELS[a.agent]);
767
- if (removed.length > 0) {
768
- console.log(`
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(`
801
+ Installed "${meta.name}" v${meta.version}`);
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
+ }
812
+ }
813
+ }
814
+ if (toUninstall.length > 0) {
815
+ const result = await spinner({
816
+ message: "Removing skills...",
817
+ task: async () => uninstallSkill({
818
+ name: meta.name,
819
+ agents: toUninstall,
820
+ scope
821
+ })
822
+ });
823
+ const removed = result.agents.filter((a) => a.status === "removed").map((a) => AGENT_LABELS[a.agent]);
824
+ if (removed.length > 0) {
825
+ console.log(`
769
826
  Removed from ${removed.join(", ")}`);
770
- } else {
771
- console.log(`
772
- No installed skills found.`);
773
- }
774
- }
775
- async function handleStatus(meta, installedAgents, scopeFlag) {
776
- const scope = scopeFlag ?? "global";
777
- const result = await skillStatus({
778
- name: meta.name,
779
- agents: installedAgents,
780
- scope
827
+ }
828
+ }
829
+ if (agentsToGenerate.length === 0 && toUninstall.length === 0) {
830
+ console.log("No changes.");
831
+ }
832
+ }
781
833
  });
782
- console.log("");
783
- console.log(`${"Agent".padEnd(14)}${"Scope".padEnd(10)}${"Version".padEnd(10)}Path`);
784
- for (const entry of result.agents) {
785
- const agentName = AGENT_LABELS[entry.agent];
786
- const version = entry.installed ? entry.version ?? "\u2014" : "(not installed)";
787
- const path = entry.installed ? entry.outputDir : "";
788
- console.log(`${agentName.padEnd(14)}${scope.padEnd(10)}${version.padEnd(10)}${path}`);
789
- }
790
- console.log("");
791
834
  }
792
835
  export {
793
836
  uninstallSkill,
794
837
  skillStatus,
795
838
  skillPlugin,
839
+ resolveSkillName,
840
+ isValidSkillName,
796
841
  generateSkill,
797
- detectInstalledAgents
842
+ detectInstalledAgents,
843
+ SkillConflictError
798
844
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crustjs/skills",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "Agent skill generation from Crust command definitions",
5
5
  "type": "module",
6
6
  "license": "MIT",