@crustjs/skills 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,202 @@
1
+ # @crustjs/skills
2
+
3
+ Generate distributable AI agent skills from [Crust](https://crustjs.com) command definitions.
4
+
5
+ Instead of hand-maintaining skill files for AI coding agents, generate them from your `defineCommand` metadata. The output is a portable skill bundle that developers can download and install into their own agent environments (OpenCode, Claude Code, etc.).
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ bun add @crustjs/skills
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ### CLI (via `@crustjs/crust`)
16
+
17
+ ```sh
18
+ crust skills generate ./src/cli.ts --name my-cli --description "My CLI tool"
19
+ ```
20
+
21
+ ### Programmatic API
22
+
23
+ ```ts
24
+ import { generateSkill } from "@crustjs/skills";
25
+ import { rootCommand } from "./commands.ts";
26
+
27
+ const result = await generateSkill({
28
+ command: rootCommand,
29
+ meta: {
30
+ name: "my-cli",
31
+ description: "CLI tool for managing widgets",
32
+ version: "1.0.0",
33
+ },
34
+ });
35
+
36
+ console.log(`Generated ${result.files.length} files to ${result.outputDir}`);
37
+ ```
38
+
39
+ ## Recommended Export Pattern
40
+
41
+ To avoid side effects when your command module is imported for generation, guard runtime code with `import.meta.main`:
42
+
43
+ ```ts
44
+ import { defineCommand, runMain } from "@crustjs/core";
45
+
46
+ // Export the command object — used by skill generation
47
+ export const rootCommand = defineCommand({
48
+ meta: { name: "my-cli", description: "My CLI tool" },
49
+ run({ args }) {
50
+ console.log("Hello from my-cli!");
51
+ },
52
+ });
53
+
54
+ // Only run when executed directly — not when imported for generation
55
+ if (import.meta.main) {
56
+ runMain(rootCommand);
57
+ }
58
+ ```
59
+
60
+ This pattern lets `crust skills generate` import the command definition without triggering `runMain`.
61
+
62
+ ## CLI Usage
63
+
64
+ The `crust skills generate` command is provided by `@crustjs/crust`:
65
+
66
+ ```sh
67
+ crust skills generate <module> [options]
68
+ ```
69
+
70
+ ### Arguments
71
+
72
+ | Argument | Description |
73
+ | -------- | ----------- |
74
+ | `module` | Path to the command module (e.g. `./src/cli.ts`) |
75
+
76
+ ### Flags
77
+
78
+ | Flag | Alias | Required | Default | Description |
79
+ | ---- | ----- | -------- | ------- | ----------- |
80
+ | `--name` | `-n` | Yes | - | Skill name (used as directory name) |
81
+ | `--description` | `-d` | Yes | - | Human-readable description |
82
+ | `--version` | `-V` | No | - | Version string |
83
+ | `--out-dir` | `-o` | No | `.` | Output directory |
84
+ | `--clean` | - | No | `true` | Remove existing skill directory before writing |
85
+ | `--export` | `-e` | No | `default` | Named export to use from the module |
86
+
87
+ ### Examples
88
+
89
+ ```sh
90
+ # Basic generation
91
+ crust skills generate ./src/cli.ts --name my-cli --description "My CLI"
92
+
93
+ # With version and custom output directory
94
+ crust skills generate ./src/cli.ts -n my-cli -d "My CLI" --version 1.0.0 -o ./dist
95
+
96
+ # Using a named export instead of the default export
97
+ crust skills generate ./src/cli.ts -n my-cli -d "My CLI" --export rootCommand
98
+
99
+ # Keep existing files (no clean)
100
+ crust skills generate ./src/cli.ts -n my-cli -d "My CLI" --no-clean
101
+ ```
102
+
103
+ ## Programmatic API
104
+
105
+ ### `generateSkill(options)`
106
+
107
+ High-level API that runs the full pipeline: introspection, rendering, and writing to disk.
108
+
109
+ ```ts
110
+ import { generateSkill } from "@crustjs/skills";
111
+
112
+ const result = await generateSkill({
113
+ command: rootCommand,
114
+ meta: { name: "my-cli", description: "My CLI tool", version: "1.0.0" },
115
+ outDir: "./dist", // default: "."
116
+ clean: true, // default: true — removes existing skill dir first
117
+ });
118
+
119
+ // result.outputDir — absolute path to the generated skill directory
120
+ // result.files — sorted list of written file paths (relative to outputDir)
121
+ ```
122
+
123
+ ### `buildManifest(command)`
124
+
125
+ Introspects a command tree and produces a canonical, serializable manifest.
126
+
127
+ ```ts
128
+ import { buildManifest } from "@crustjs/skills";
129
+
130
+ const manifest = buildManifest(rootCommand);
131
+ // manifest.name, manifest.path, manifest.args, manifest.flags, manifest.children
132
+ ```
133
+
134
+ ### `renderSkill(manifest, meta)`
135
+
136
+ Renders markdown files from a manifest tree without writing to disk.
137
+
138
+ ```ts
139
+ import { buildManifest, renderSkill } from "@crustjs/skills";
140
+
141
+ const manifest = buildManifest(rootCommand);
142
+ const files = renderSkill(manifest, { name: "my-cli", description: "My CLI" });
143
+
144
+ for (const file of files) {
145
+ console.log(file.path); // e.g. "SKILL.md", "commands/serve.md"
146
+ console.log(file.content); // markdown content
147
+ }
148
+ ```
149
+
150
+ ## Output Structure
151
+
152
+ Generated output goes to `<outDir>/skills/<name>/`:
153
+
154
+ ```
155
+ skills/my-cli/
156
+ SKILL.md # Entrypoint — loaded by the agent
157
+ command-index.md # Maps all commands to documentation file paths
158
+ commands/ # Per-command documentation mirroring the CLI hierarchy
159
+ my-cli.md # Root command
160
+ serve.md # Subcommand
161
+ db/
162
+ migrate.md # Nested subcommand
163
+ seed.md
164
+ manifest.json # Machine-readable bundle metadata
165
+ README.md # Install instructions for consumers
166
+ ```
167
+
168
+ ### File Details
169
+
170
+ | File | Purpose |
171
+ | ---- | ------- |
172
+ | `SKILL.md` | Agent entrypoint with YAML frontmatter. Directs agents to load specific command files on demand (lazy loading). |
173
+ | `command-index.md` | Markdown table listing every command, its type (runnable/group), and documentation path. |
174
+ | `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. |
177
+
178
+ ## Installing Generated Skills
179
+
180
+ After generating a skill bundle, consumers can install it by copying the skill directory.
181
+
182
+ ### OpenCode
183
+
184
+ ```sh
185
+ cp -r skills/my-cli/ .opencode/skills/my-cli/
186
+ ```
187
+
188
+ ### Claude Code
189
+
190
+ ```sh
191
+ cp -r skills/my-cli/ .claude/skills/my-cli/
192
+ ```
193
+
194
+ The agent will discover the skill from `SKILL.md` and load command documentation on demand from the `commands/` directory.
195
+
196
+ ## Documentation
197
+
198
+ See the full docs at [crustjs.com](https://crustjs.com).
199
+
200
+ ## License
201
+
202
+ MIT
@@ -0,0 +1,294 @@
1
+ import { AnyCommand } from "@crustjs/core";
2
+ /**
3
+ * Metadata for the generated skill bundle.
4
+ *
5
+ * This information populates the `SKILL.md` frontmatter and distribution
6
+ * metadata files (`manifest.json`).
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * const meta: SkillMeta = {
11
+ * name: "my-cli",
12
+ * description: "CLI tool for managing widgets",
13
+ * version: "1.0.0",
14
+ * };
15
+ * ```
16
+ */
17
+ interface SkillMeta {
18
+ /** Skill name — used as the directory name and in frontmatter */
19
+ name: string;
20
+ /** Human-readable description of what the CLI does */
21
+ description: string;
22
+ /** Version string for the generated skill bundle */
23
+ version: string;
24
+ }
25
+ /** Supported agent targets for skill installation. */
26
+ type AgentTarget = "claude-code" | "opencode";
27
+ /** Installation scope — global (home directory) or project (cwd). */
28
+ type Scope = "global" | "project";
29
+ /**
30
+ * Top-level options for generating a skill bundle from a command tree.
31
+ *
32
+ * @example
33
+ * ```ts
34
+ * import { generateSkill } from "@crustjs/skills";
35
+ * import { rootCommand } from "./commands.ts";
36
+ *
37
+ * await generateSkill({
38
+ * command: rootCommand,
39
+ * meta: {
40
+ * name: "my-cli",
41
+ * description: "CLI tool for managing widgets",
42
+ * version: "1.0.0",
43
+ * },
44
+ * agents: ["claude-code", "opencode"],
45
+ * });
46
+ * ```
47
+ */
48
+ interface GenerateOptions {
49
+ /** Root command to generate the skill from */
50
+ command: AnyCommand;
51
+ /** Skill metadata for the generated bundle */
52
+ meta: SkillMeta;
53
+ /** Agent targets to install skills for */
54
+ agents: AgentTarget[];
55
+ /**
56
+ * Installation scope — global (home directory) or project (cwd).
57
+ * @default "global"
58
+ */
59
+ scope?: Scope;
60
+ /**
61
+ * When `true`, removes the existing skill directory before writing.
62
+ * Prevents stale files from previous generations.
63
+ * @default true
64
+ */
65
+ clean?: boolean;
66
+ }
67
+ /** Status of an individual agent installation. */
68
+ type InstallStatus = "installed" | "updated" | "up-to-date";
69
+ /** Status of an individual agent uninstallation. */
70
+ type UninstallStatus = "removed" | "not-found";
71
+ /** Per-agent result from a generateSkill call. */
72
+ interface AgentResult {
73
+ /** Which agent this result is for */
74
+ agent: AgentTarget;
75
+ /** Absolute path to the skill output directory for this agent */
76
+ outputDir: string;
77
+ /** List of files that were written (relative paths) */
78
+ files: string[];
79
+ /** What happened during this installation */
80
+ status: InstallStatus;
81
+ /** Previous version string when status is "updated" */
82
+ previousVersion?: string;
83
+ }
84
+ /**
85
+ * Result returned by `generateSkill` after writing files to disk.
86
+ */
87
+ interface GenerateResult {
88
+ /** Per-agent installation results */
89
+ agents: AgentResult[];
90
+ }
91
+ /** Options for removing installed skills. */
92
+ interface UninstallOptions {
93
+ /** Skill name to uninstall */
94
+ name: string;
95
+ /** Agent targets to uninstall from */
96
+ agents: AgentTarget[];
97
+ /**
98
+ * Installation scope to uninstall from.
99
+ * @default "global"
100
+ */
101
+ scope?: Scope;
102
+ }
103
+ /** Result returned by `uninstallSkill`. */
104
+ interface UninstallResult {
105
+ /** Per-agent uninstall results */
106
+ agents: Array<{
107
+ agent: AgentTarget;
108
+ outputDir: string;
109
+ status: UninstallStatus;
110
+ }>;
111
+ }
112
+ /** Options for checking installed skill status. */
113
+ interface StatusOptions {
114
+ /** Skill name to check */
115
+ name: string;
116
+ /** Agent targets to check */
117
+ agents: AgentTarget[];
118
+ /**
119
+ * Installation scope to check.
120
+ * @default "global"
121
+ */
122
+ scope?: Scope;
123
+ }
124
+ /** Result returned by `skillStatus`. */
125
+ interface StatusResult {
126
+ /** Per-agent status results */
127
+ agents: Array<{
128
+ agent: AgentTarget;
129
+ outputDir: string;
130
+ installed: boolean;
131
+ version?: string;
132
+ }>;
133
+ }
134
+ /**
135
+ * Options for the skill plugin.
136
+ *
137
+ * The plugin reads `name` and `description` from the root command's `meta`
138
+ * at setup time, so only `version` is required here.
139
+ *
140
+ * Installed agents are detected automatically by checking for global
141
+ * configuration directories (`~/.claude/` for Claude Code,
142
+ * `~/.config/opencode/` for OpenCode). Only detected agents are managed.
143
+ *
144
+ * **Auto-update** (default): silently updates already-installed skills when a
145
+ * new version is detected. Set `autoInstall: true` to also install skills that
146
+ * are not yet present.
147
+ *
148
+ * **Interactive command**: set `command: true` to register a `skill` subcommand
149
+ * on the root command for manual install/uninstall/status management.
150
+ */
151
+ interface SkillPluginOptions {
152
+ /** Skill version string — compared against the installed manifest */
153
+ version: string;
154
+ /**
155
+ * Installation scope.
156
+ * @default "global"
157
+ */
158
+ scope?: Scope;
159
+ /**
160
+ * Automatically install skills when not yet present.
161
+ * Set to `true` to install on first CLI invocation without requiring the
162
+ * interactive skill command.
163
+ * @default false
164
+ */
165
+ autoInstall?: boolean;
166
+ /**
167
+ * Automatically update skills when the installed version is outdated.
168
+ * @default true
169
+ */
170
+ autoUpdate?: boolean;
171
+ /**
172
+ * Register an interactive skill management subcommand on the root command.
173
+ * - `true`: register with default name `"skill"`
174
+ * - `string`: register with a custom command name
175
+ * @default false
176
+ */
177
+ command?: boolean | string;
178
+ }
179
+ /**
180
+ * Detects which supported agents are installed by checking for the
181
+ * existence of their global configuration directories.
182
+ *
183
+ * Detection always checks global paths regardless of the intended
184
+ * installation scope — if the agent's global config directory exists,
185
+ * the agent is considered installed.
186
+ *
187
+ * Detection table:
188
+ * | Agent | Config directory |
189
+ * | ------------ | ----------------------------- |
190
+ * | `claude-code`| `<homedir>/.claude/` |
191
+ * | `opencode` | `<homedir>/.config/opencode/` |
192
+ *
193
+ * @param home - Override the home directory for detection (defaults to `os.homedir()`).
194
+ * Primarily useful for testing.
195
+ * @returns Array of detected agent targets (may be empty)
196
+ *
197
+ * @example
198
+ * ```ts
199
+ * const agents = await detectInstalledAgents();
200
+ * // ["claude-code"] — only Claude Code config found
201
+ * ```
202
+ */
203
+ declare function detectInstalledAgents(home?: string): Promise<AgentTarget[]>;
204
+ /**
205
+ * Generates and installs agent skill bundles from a Crust command tree.
206
+ *
207
+ * For each target agent:
208
+ * 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
213
+ *
214
+ * @param options - Generation options including command, metadata, agents, and scope
215
+ * @returns Per-agent installation results
216
+ *
217
+ * @example
218
+ * ```ts
219
+ * import { generateSkill } from "@crustjs/skills";
220
+ * import { rootCommand } from "./commands.ts";
221
+ *
222
+ * const result = await generateSkill({
223
+ * command: rootCommand,
224
+ * meta: {
225
+ * name: "my-cli",
226
+ * description: "CLI tool for managing widgets",
227
+ * version: "1.0.0",
228
+ * },
229
+ * agents: ["claude-code", "opencode"],
230
+ * });
231
+ *
232
+ * for (const r of result.agents) {
233
+ * console.log(`${r.agent}: ${r.status} → ${r.outputDir}`);
234
+ * }
235
+ * ```
236
+ */
237
+ declare function generateSkill(options: GenerateOptions): Promise<GenerateResult>;
238
+ /**
239
+ * Removes installed skills from agent directories.
240
+ *
241
+ * @param options - Uninstall options specifying name, agents, and scope
242
+ * @returns Per-agent uninstall results
243
+ */
244
+ declare function uninstallSkill(options: UninstallOptions): Promise<UninstallResult>;
245
+ /**
246
+ * Checks the installation status of skills across agent directories.
247
+ *
248
+ * @param options - Status options specifying name, agents, and scope
249
+ * @returns Per-agent status results
250
+ */
251
+ declare function skillStatus(options: StatusOptions): Promise<StatusResult>;
252
+ import { CrustPlugin } from "@crustjs/core";
253
+ /**
254
+ * Plugin that manages agent skills for a Crust CLI application.
255
+ *
256
+ * `name` and `description` are read from the root command's `meta` at setup
257
+ * time — only `version` needs to be supplied in the options.
258
+ *
259
+ * Installed agents are detected automatically by checking for global
260
+ * configuration directories. Only detected agents are managed by the
261
+ * middleware and the interactive command.
262
+ *
263
+ * **Auto-update** (default): silently updates already-installed skills when a
264
+ * new version is detected. Set `autoInstall: true` to also install skills that
265
+ * are not yet present.
266
+ *
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.
271
+ *
272
+ * @param options - Plugin configuration with version and scope
273
+ * @returns A `CrustPlugin` to register in a command's `plugins` array
274
+ *
275
+ * @example
276
+ * ```ts
277
+ * import { defineCommand, runMain } from "@crustjs/core";
278
+ * import { skillPlugin } from "@crustjs/skills";
279
+ *
280
+ * const app = defineCommand({
281
+ * meta: { name: "my-cli", description: "My CLI" },
282
+ * plugins: [
283
+ * skillPlugin({
284
+ * version: "1.0.0",
285
+ * command: true, // registers "my-cli skill" subcommand
286
+ * }),
287
+ * ],
288
+ * });
289
+ *
290
+ * runMain(app);
291
+ * ```
292
+ */
293
+ 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 };
package/dist/index.js ADDED
@@ -0,0 +1,798 @@
1
+ // @bun
2
+ // src/agents.ts
3
+ import { access } from "fs/promises";
4
+ import { homedir } from "os";
5
+ import { join } from "path";
6
+ var ALL_AGENTS = ["claude-code", "opencode"];
7
+ var AGENT_LABELS = {
8
+ "claude-code": "Claude Code",
9
+ opencode: "OpenCode"
10
+ };
11
+ function resolveAgentPath(agent, scope, name) {
12
+ const base = scope === "global" ? homedir() : process.cwd();
13
+ switch (agent) {
14
+ case "claude-code":
15
+ return join(base, ".claude", "skills", name);
16
+ case "opencode":
17
+ if (scope === "global") {
18
+ return join(base, ".config", "opencode", "skills", name);
19
+ }
20
+ return join(base, ".opencode", "skills", name);
21
+ }
22
+ }
23
+ async function detectInstalledAgents(home) {
24
+ const resolvedHome = home ?? homedir();
25
+ const detected = [];
26
+ for (const agent of ALL_AGENTS) {
27
+ const configDir = resolveAgentConfigDir(resolvedHome, agent);
28
+ const exists = await access(configDir).then(() => true).catch(() => false);
29
+ if (exists) {
30
+ detected.push(agent);
31
+ }
32
+ }
33
+ return detected;
34
+ }
35
+ function resolveAgentConfigDir(home, agent) {
36
+ switch (agent) {
37
+ case "claude-code":
38
+ return join(home, ".claude");
39
+ case "opencode":
40
+ return join(home, ".config", "opencode");
41
+ }
42
+ }
43
+ // src/generate.ts
44
+ import { access as access2, mkdir, rm, writeFile } from "fs/promises";
45
+ import { dirname, join as join3 } from "path";
46
+
47
+ // src/manifest.ts
48
+ function buildManifest(command) {
49
+ return buildNode(command, []);
50
+ }
51
+ function buildNode(command, parentPath) {
52
+ const name = normalizeName(command.meta.name);
53
+ const path = [...parentPath, name];
54
+ const args = normalizeArgs(command.args);
55
+ const flags = normalizeFlags(command.flags);
56
+ const children = normalizeChildren(command.subCommands, path);
57
+ return {
58
+ name,
59
+ path,
60
+ description: command.meta.description,
61
+ usage: command.meta.usage,
62
+ runnable: typeof command.run === "function",
63
+ args,
64
+ flags,
65
+ children
66
+ };
67
+ }
68
+ function normalizeName(raw) {
69
+ return raw.trim().toLowerCase();
70
+ }
71
+ function normalizeArgs(argsDef) {
72
+ if (!argsDef || argsDef.length === 0)
73
+ return [];
74
+ return argsDef.map(normalizeArg);
75
+ }
76
+ function normalizeArg(arg) {
77
+ const result = {
78
+ name: arg.name,
79
+ type: arg.type,
80
+ required: arg.required === true,
81
+ variadic: arg.variadic === true
82
+ };
83
+ if (arg.description !== undefined) {
84
+ result.description = arg.description;
85
+ }
86
+ if (arg.default !== undefined) {
87
+ result.default = serializeDefault(arg.default);
88
+ }
89
+ return result;
90
+ }
91
+ function normalizeFlags(flagsDef) {
92
+ if (!flagsDef)
93
+ return [];
94
+ const keys = Object.keys(flagsDef).sort();
95
+ return keys.map((key) => {
96
+ return normalizeFlag(key, flagsDef[key]);
97
+ });
98
+ }
99
+ function normalizeFlag(name, flag) {
100
+ const result = {
101
+ name,
102
+ type: flag.type,
103
+ required: flag.required === true,
104
+ multiple: flag.multiple === true,
105
+ aliases: normalizeAliases(flag.alias)
106
+ };
107
+ if (flag.description !== undefined) {
108
+ result.description = flag.description;
109
+ }
110
+ if (flag.default !== undefined) {
111
+ result.default = serializeDefault(flag.default);
112
+ }
113
+ return result;
114
+ }
115
+ function normalizeAliases(alias) {
116
+ if (alias === undefined)
117
+ return [];
118
+ if (typeof alias === "string")
119
+ return [alias];
120
+ return [...alias].sort();
121
+ }
122
+ function normalizeChildren(subCommands, parentPath) {
123
+ const keys = Object.keys(subCommands).sort();
124
+ return keys.map((key) => {
125
+ return buildNode(subCommands[key], parentPath);
126
+ });
127
+ }
128
+ function serializeDefault(value) {
129
+ if (Array.isArray(value)) {
130
+ return JSON.stringify(value);
131
+ }
132
+ return String(value);
133
+ }
134
+
135
+ // src/render.ts
136
+ function renderSkill(manifest, meta) {
137
+ const files = [];
138
+ const allNodes = collectNodes(manifest);
139
+ files.push({
140
+ path: "SKILL.md",
141
+ content: renderSkillMd(manifest, meta)
142
+ });
143
+ files.push({
144
+ path: "command-index.md",
145
+ content: renderCommandIndex(manifest, allNodes)
146
+ });
147
+ for (const node of allNodes) {
148
+ const filePath = commandFilePath(node);
149
+ const content = node.children.length > 0 ? renderGroupCommand(node, manifest) : renderLeafCommand(node, manifest);
150
+ files.push({ path: filePath, content });
151
+ }
152
+ return files;
153
+ }
154
+ function collectNodes(root) {
155
+ const nodes = [root];
156
+ for (const child of root.children) {
157
+ nodes.push(...collectNodes(child));
158
+ }
159
+ return nodes;
160
+ }
161
+ function commandFilePath(node) {
162
+ if (node.path.length <= 1) {
163
+ return `commands/${node.name}.md`;
164
+ }
165
+ const segments = node.path.slice(1);
166
+ return `commands/${segments.join("/")}.md`;
167
+ }
168
+ function commandInvocation(node) {
169
+ return node.path.join(" ");
170
+ }
171
+ function relativePath(from, to) {
172
+ const fromParts = from.split("/").slice(0, -1);
173
+ const toParts = to.split("/");
174
+ let common = 0;
175
+ while (common < fromParts.length && common < toParts.length && fromParts[common] === toParts[common]) {
176
+ common++;
177
+ }
178
+ const ups = fromParts.length - common;
179
+ const remaining = toParts.slice(common);
180
+ if (ups === 0) {
181
+ return remaining.join("/");
182
+ }
183
+ const upSegments = Array.from({ length: ups }, () => "..");
184
+ return [...upSegments, ...remaining].join("/");
185
+ }
186
+ function renderSkillMd(manifest, meta) {
187
+ const lines = [];
188
+ lines.push("---");
189
+ lines.push(`name: ${meta.name}`);
190
+ lines.push(`description: ${meta.description}`);
191
+ lines.push("metadata:");
192
+ lines.push(` version: "${meta.version}"`);
193
+ lines.push("---");
194
+ lines.push("");
195
+ lines.push(`# ${meta.name}`);
196
+ lines.push("");
197
+ if (manifest.description) {
198
+ lines.push(manifest.description);
199
+ lines.push("");
200
+ }
201
+ lines.push("## Command Reference");
202
+ lines.push("");
203
+ lines.push("This skill provides documentation for all available CLI commands.");
204
+ lines.push("");
205
+ lines.push(`For a complete list of commands and their documentation paths, see [command-index.md](command-index.md).`);
206
+ 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.");
208
+ lines.push("");
209
+ if (manifest.children.length > 0) {
210
+ lines.push("## Available Commands");
211
+ lines.push("");
212
+ for (const child of manifest.children) {
213
+ const filePath = commandFilePath(child);
214
+ const desc = child.description ? ` - ${child.description}` : "";
215
+ lines.push(`- [\`${child.name}\`](${filePath})${desc}`);
216
+ }
217
+ lines.push("");
218
+ }
219
+ if (manifest.runnable) {
220
+ lines.push("## Usage");
221
+ lines.push("");
222
+ const rootFile = commandFilePath(manifest);
223
+ lines.push(`The root command is directly executable. See [${manifest.name}](${rootFile}) for usage details.`);
224
+ lines.push("");
225
+ }
226
+ return lines.join(`
227
+ `);
228
+ }
229
+ function renderCommandIndex(_manifest, allNodes) {
230
+ const lines = [];
231
+ lines.push("# Command Index");
232
+ lines.push("");
233
+ lines.push("| Command | Type | Documentation |");
234
+ lines.push("| ------- | ---- | ------------- |");
235
+ for (const node of allNodes) {
236
+ const invocation = commandInvocation(node);
237
+ const filePath = commandFilePath(node);
238
+ const type = commandType(node);
239
+ lines.push(`| \`${invocation}\` | ${type} | [${filePath}](${filePath}) |`);
240
+ }
241
+ lines.push("");
242
+ return lines.join(`
243
+ `);
244
+ }
245
+ function commandType(node) {
246
+ if (node.runnable && node.children.length > 0) {
247
+ return "runnable, group";
248
+ }
249
+ if (node.runnable) {
250
+ return "runnable";
251
+ }
252
+ return "group";
253
+ }
254
+ function renderLeafCommand(node, root) {
255
+ const lines = [];
256
+ const invocation = commandInvocation(node);
257
+ lines.push(`# \`${invocation}\``);
258
+ lines.push("");
259
+ if (node.description) {
260
+ lines.push(node.description);
261
+ lines.push("");
262
+ }
263
+ lines.push("## Usage");
264
+ lines.push("");
265
+ if (node.usage) {
266
+ lines.push("```");
267
+ lines.push(node.usage);
268
+ lines.push("```");
269
+ } else {
270
+ lines.push("```");
271
+ lines.push(buildUsageLine(node));
272
+ lines.push("```");
273
+ }
274
+ lines.push("");
275
+ if (node.args.length > 0) {
276
+ lines.push("## Arguments");
277
+ lines.push("");
278
+ lines.push(...renderArgsTable(node.args));
279
+ lines.push("");
280
+ }
281
+ if (node.flags.length > 0) {
282
+ lines.push("## Flags");
283
+ lines.push("");
284
+ lines.push(...renderFlagsTable(node.flags));
285
+ lines.push("");
286
+ }
287
+ lines.push(...renderNavigation(node, root));
288
+ return lines.join(`
289
+ `);
290
+ }
291
+ function renderGroupCommand(node, root) {
292
+ const lines = [];
293
+ const invocation = commandInvocation(node);
294
+ const filePath = commandFilePath(node);
295
+ lines.push(`# \`${invocation}\``);
296
+ lines.push("");
297
+ if (node.description) {
298
+ lines.push(node.description);
299
+ lines.push("");
300
+ }
301
+ if (node.runnable) {
302
+ lines.push("## Usage");
303
+ lines.push("");
304
+ if (node.usage) {
305
+ lines.push("```");
306
+ lines.push(node.usage);
307
+ lines.push("```");
308
+ } else {
309
+ lines.push("```");
310
+ lines.push(buildUsageLine(node));
311
+ lines.push("```");
312
+ }
313
+ lines.push("");
314
+ if (node.args.length > 0) {
315
+ lines.push("## Arguments");
316
+ lines.push("");
317
+ lines.push(...renderArgsTable(node.args));
318
+ lines.push("");
319
+ }
320
+ if (node.flags.length > 0) {
321
+ lines.push("## Flags");
322
+ lines.push("");
323
+ lines.push(...renderFlagsTable(node.flags));
324
+ lines.push("");
325
+ }
326
+ }
327
+ lines.push("## Subcommands");
328
+ lines.push("");
329
+ for (const child of node.children) {
330
+ const childPath = commandFilePath(child);
331
+ const childRelative = relativePath(filePath, childPath);
332
+ const desc = child.description ? ` - ${child.description}` : "";
333
+ lines.push(`- [\`${child.name}\`](${childRelative})${desc}`);
334
+ }
335
+ lines.push("");
336
+ lines.push(...renderNavigation(node, root));
337
+ return lines.join(`
338
+ `);
339
+ }
340
+ function buildUsageLine(node) {
341
+ const parts = [...node.path];
342
+ for (const arg of node.args) {
343
+ if (arg.variadic) {
344
+ parts.push(arg.required ? `<${arg.name}...>` : `[${arg.name}...]`);
345
+ } else {
346
+ parts.push(arg.required ? `<${arg.name}>` : `[${arg.name}]`);
347
+ }
348
+ }
349
+ if (node.flags.length > 0) {
350
+ parts.push("[options]");
351
+ }
352
+ return parts.join(" ");
353
+ }
354
+ function renderArgsTable(args) {
355
+ const lines = [];
356
+ lines.push("| Argument | Type | Required | Description |");
357
+ lines.push("| -------- | ---- | -------- | ----------- |");
358
+ for (const arg of args) {
359
+ const name = arg.variadic ? `${arg.name}...` : arg.name;
360
+ const required = arg.required ? "Yes" : "No";
361
+ const desc = formatArgDescription(arg);
362
+ lines.push(`| \`${name}\` | ${arg.type} | ${required} | ${desc} |`);
363
+ }
364
+ return lines;
365
+ }
366
+ function formatArgDescription(arg) {
367
+ const parts = [];
368
+ if (arg.description) {
369
+ parts.push(arg.description);
370
+ }
371
+ if (arg.default !== undefined) {
372
+ parts.push(`Default: \`${arg.default}\``);
373
+ }
374
+ return parts.join(". ") || "-";
375
+ }
376
+ function renderFlagsTable(flags) {
377
+ const lines = [];
378
+ lines.push("| Flag | Type | Required | Description |");
379
+ lines.push("| ---- | ---- | -------- | ----------- |");
380
+ for (const flag of flags) {
381
+ const name = formatFlagName(flag);
382
+ const required = flag.required ? "Yes" : "No";
383
+ const desc = formatFlagDescription(flag);
384
+ lines.push(`| ${name} | ${flag.type} | ${required} | ${desc} |`);
385
+ }
386
+ return lines;
387
+ }
388
+ function formatFlagName(flag) {
389
+ const parts = [`\`--${flag.name}\``];
390
+ for (const alias of flag.aliases) {
391
+ parts.push(`\`-${alias}\``);
392
+ }
393
+ return parts.join(", ");
394
+ }
395
+ function formatFlagDescription(flag) {
396
+ const parts = [];
397
+ if (flag.description) {
398
+ parts.push(flag.description);
399
+ }
400
+ if (flag.multiple) {
401
+ parts.push("Can be specified multiple times");
402
+ }
403
+ if (flag.default !== undefined) {
404
+ parts.push(`Default: \`${flag.default}\``);
405
+ }
406
+ return parts.join(". ") || "-";
407
+ }
408
+ function renderNavigation(node, root) {
409
+ const lines = [];
410
+ const filePath = commandFilePath(node);
411
+ lines.push("---");
412
+ lines.push("");
413
+ if (node.path.length > 1) {
414
+ const parentPath = node.path.slice(0, -1);
415
+ const parentNode = findNode(root, parentPath);
416
+ if (parentNode) {
417
+ const parentFile = commandFilePath(parentNode);
418
+ const parentRelative = relativePath(filePath, parentFile);
419
+ const parentInvocation = commandInvocation(parentNode);
420
+ lines.push(`Parent: [\`${parentInvocation}\`](${parentRelative})`);
421
+ lines.push("");
422
+ }
423
+ }
424
+ const indexRelative = relativePath(filePath, "command-index.md");
425
+ lines.push(`[Command Index](${indexRelative})`);
426
+ lines.push("");
427
+ return lines;
428
+ }
429
+ function findNode(root, path) {
430
+ if (arraysEqual(root.path, path)) {
431
+ return root;
432
+ }
433
+ for (const child of root.children) {
434
+ const found = findNode(child, path);
435
+ if (found)
436
+ return found;
437
+ }
438
+ return;
439
+ }
440
+ function arraysEqual(a, b) {
441
+ if (a.length !== b.length)
442
+ return false;
443
+ for (let i = 0;i < a.length; i++) {
444
+ if (a[i] !== b[i])
445
+ return false;
446
+ }
447
+ return true;
448
+ }
449
+
450
+ // src/version.ts
451
+ import { readFile } from "fs/promises";
452
+ import { join as join2 } from "path";
453
+ async function readInstalledVersion(dir) {
454
+ try {
455
+ const raw = await readFile(join2(dir, "manifest.json"), "utf-8");
456
+ const parsed = JSON.parse(raw);
457
+ if (typeof parsed === "object" && parsed !== null && "version" in parsed && typeof parsed.version === "string") {
458
+ return parsed.version;
459
+ }
460
+ return null;
461
+ } catch {
462
+ return null;
463
+ }
464
+ }
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
+
476
+ // src/generate.ts
477
+ async function generateSkill(options) {
478
+ const { command, meta, agents, scope = "global", clean = true } = options;
479
+ const manifest = buildManifest(command);
480
+ const renderedFiles = renderSkill(manifest, meta);
481
+ const metadataFiles = renderDistributionMetadata(manifest, meta);
482
+ const allFiles = [...renderedFiles, ...metadataFiles].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
483
+ const results = [];
484
+ for (const agent of agents) {
485
+ const outputDir = resolveAgentPath(agent, scope, meta.name);
486
+ const { status, installedVersion } = await checkVersion(outputDir, meta.version);
487
+ if (status === "up-to-date") {
488
+ results.push({
489
+ agent,
490
+ outputDir,
491
+ files: [],
492
+ status: "up-to-date"
493
+ });
494
+ continue;
495
+ }
496
+ const previousVersion = status === "updated" ? installedVersion ?? undefined : undefined;
497
+ if (clean) {
498
+ await cleanDirectory(outputDir);
499
+ }
500
+ await writeFiles(outputDir, allFiles);
501
+ results.push({
502
+ agent,
503
+ outputDir,
504
+ files: allFiles.map((f) => f.path),
505
+ status,
506
+ previousVersion
507
+ });
508
+ }
509
+ return { agents: results };
510
+ }
511
+ async function uninstallSkill(options) {
512
+ const { name, agents, scope = "global" } = options;
513
+ const results = [];
514
+ for (const agent of agents) {
515
+ const outputDir = resolveAgentPath(agent, scope, name);
516
+ const exists = await access2(outputDir).then(() => true).catch(() => false);
517
+ if (exists) {
518
+ await rm(outputDir, { recursive: true, force: true });
519
+ results.push({ agent, outputDir, status: "removed" });
520
+ } else {
521
+ results.push({ agent, outputDir, status: "not-found" });
522
+ }
523
+ }
524
+ return { agents: results };
525
+ }
526
+ async function skillStatus(options) {
527
+ const { name, agents, scope = "global" } = options;
528
+ const results = [];
529
+ for (const agent of agents) {
530
+ const outputDir = resolveAgentPath(agent, scope, name);
531
+ const version = await readInstalledVersion(outputDir);
532
+ results.push({
533
+ agent,
534
+ outputDir,
535
+ installed: version !== null,
536
+ version: version ?? undefined
537
+ });
538
+ }
539
+ return { agents: results };
540
+ }
541
+ function renderDistributionMetadata(manifest, meta) {
542
+ return [
543
+ {
544
+ path: "manifest.json",
545
+ content: renderManifestJson(manifest, meta)
546
+ }
547
+ ];
548
+ }
549
+ function renderManifestJson(manifest, meta) {
550
+ const commands = collectCommandPaths(manifest);
551
+ const obj = {
552
+ name: meta.name,
553
+ description: meta.description,
554
+ version: meta.version,
555
+ entrypoint: "SKILL.md",
556
+ commands
557
+ };
558
+ return `${JSON.stringify(obj, null, "\t")}
559
+ `;
560
+ }
561
+ function collectCommandPaths(node) {
562
+ const paths = [node.path.join(" ")];
563
+ for (const child of node.children) {
564
+ paths.push(...collectCommandPaths(child));
565
+ }
566
+ return paths;
567
+ }
568
+ async function cleanDirectory(dir) {
569
+ await rm(dir, { recursive: true, force: true });
570
+ }
571
+ async function writeFiles(baseDir, files) {
572
+ const dirs = new Set;
573
+ for (const file of files) {
574
+ const filePath = join3(baseDir, file.path);
575
+ const dir = dirname(filePath);
576
+ dirs.add(dir);
577
+ }
578
+ const sortedDirs = [...dirs].sort();
579
+ for (const dir of sortedDirs) {
580
+ await mkdir(dir, { recursive: true });
581
+ }
582
+ for (const file of files) {
583
+ const filePath = join3(baseDir, file.path);
584
+ await writeFile(filePath, file.content, "utf-8");
585
+ }
586
+ }
587
+ // src/plugin.ts
588
+ import { defineCommand } from "@crustjs/core";
589
+ import { confirm, multiselect, select, spinner } from "@crustjs/prompts";
590
+ function deriveSkillMeta(command, version) {
591
+ return {
592
+ name: command.meta.name,
593
+ description: command.meta.description ?? "",
594
+ version
595
+ };
596
+ }
597
+ function skillPlugin(options) {
598
+ let rootCmd;
599
+ let skillCmd = null;
600
+ return {
601
+ name: "skills",
602
+ setup(context, actions) {
603
+ rootCmd = context.rootCommand;
604
+ if (options.command) {
605
+ const name = typeof options.command === "string" ? options.command : "skill";
606
+ skillCmd = buildSkillCommand(rootCmd, options);
607
+ actions.addSubCommand(rootCmd, name, skillCmd);
608
+ }
609
+ },
610
+ async middleware(_context, next) {
611
+ if (skillCmd && _context.route?.command === skillCmd) {
612
+ await next();
613
+ return;
614
+ }
615
+ const agents = await detectInstalledAgents();
616
+ if (agents.length === 0) {
617
+ await next();
618
+ return;
619
+ }
620
+ const autoInstall = options.autoInstall ?? false;
621
+ const autoUpdate = options.autoUpdate ?? true;
622
+ const meta = deriveSkillMeta(rootCmd, options.version);
623
+ const status = await skillStatus({
624
+ name: meta.name,
625
+ agents,
626
+ scope: options.scope ?? "global"
627
+ });
628
+ const needsUpdate = status.agents.filter((a) => {
629
+ if (!a.installed)
630
+ return autoInstall;
631
+ if (a.version !== meta.version)
632
+ return autoUpdate;
633
+ return false;
634
+ });
635
+ 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(", ")}`);
645
+ }
646
+ }
647
+ await next();
648
+ }
649
+ };
650
+ }
651
+ function buildSkillCommand(rootCmd, options) {
652
+ return defineCommand({
653
+ meta: {
654
+ 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
+ }
670
+ },
671
+ async run({ flags }) {
672
+ 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) {
678
+ console.log("No supported agents detected. Install Claude Code or OpenCode first.");
679
+ return;
680
+ }
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
689
+ });
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(`
729
+ 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(`
769
+ 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
781
+ });
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
+ }
792
+ export {
793
+ uninstallSkill,
794
+ skillStatus,
795
+ skillPlugin,
796
+ generateSkill,
797
+ detectInstalledAgents
798
+ };
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@crustjs/skills",
3
+ "version": "0.0.2",
4
+ "description": "Agent skill generation from Crust command definitions",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "chenxin-yan",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/chenxin-yan/crust.git",
11
+ "directory": "packages/skills"
12
+ },
13
+ "homepage": "https://crustjs.com",
14
+ "bugs": {
15
+ "url": "https://github.com/chenxin-yan/crust/issues"
16
+ },
17
+ "keywords": [
18
+ "cli",
19
+ "skills",
20
+ "agent",
21
+ "generation",
22
+ "documentation",
23
+ "bun",
24
+ "typescript"
25
+ ],
26
+ "files": [
27
+ "dist"
28
+ ],
29
+ "exports": {
30
+ ".": {
31
+ "import": "./dist/index.js",
32
+ "types": "./dist/index.d.ts"
33
+ }
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "scripts": {
39
+ "build": "bunup",
40
+ "dev": "bunup --watch",
41
+ "check:types": "tsc --noEmit",
42
+ "test": "bun test"
43
+ },
44
+ "devDependencies": {
45
+ "@crustjs/config": "0.0.0",
46
+ "@crustjs/core": "0.0.8",
47
+ "@crustjs/prompts": "0.0.4",
48
+ "bunup": "^0.16.29"
49
+ },
50
+ "peerDependencies": {
51
+ "@crustjs/core": "0.0.8",
52
+ "@crustjs/prompts": "0.0.4",
53
+ "typescript": "^5"
54
+ }
55
+ }