@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 +73 -2
- package/dist/index.d.ts +105 -14
- package/dist/index.js +145 -49
- package/package.json +1 -1
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
|
-
|
|
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
|
-
| `
|
|
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 (`
|
|
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
|
|
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
|
|
161
|
-
*
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
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
|
|
303
|
-
*
|
|
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
|
-
|
|
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("
|
|
235
|
+
lines.push("## Command Reference");
|
|
204
236
|
lines.push("");
|
|
205
|
-
lines.push(`For
|
|
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("
|
|
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,
|
|
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 {
|
|
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:
|
|
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
|
|
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:
|
|
554
|
-
content:
|
|
599
|
+
path: CRUST_MANIFEST,
|
|
600
|
+
content: renderCrustJson(manifest, meta)
|
|
555
601
|
}
|
|
556
602
|
];
|
|
557
603
|
}
|
|
558
|
-
function
|
|
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
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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
|
-
|
|
717
|
-
|
|
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
|
};
|