Skills
Agent skill generation for AI coding assistants.
The @crustjs/skills package generates and installs agent skills from your CLI's command definitions. Skills provide structured documentation that AI coding assistants use to understand your CLI.
Install
bun add @crustjs/skillsQuick Start
Add skillPlugin() to your CLI. It reads name and description from your root command's meta — you only need to supply version:
import { Crust } from "@crustjs/core";
import { skillPlugin } from "@crustjs/skills";
const app = new Crust("my-cli").meta({ description: "My CLI tool" }).use(
skillPlugin({
version: "1.0.0",
instructions: `
Read command docs before suggesting exact flags.
## Answer Style
- Prefer exact syntax copied from the relevant command file.
`,
// command defaults to "skill" and registers "my-cli skill"
}),
);
await app.execute();This gives you:
- Auto-detection: additional (non-universal) agents are detected by probing their CLI commands (for example
<agent> --version). - Auto-update: when a user runs any command, the plugin silently updates installed skills if the version has changed.
- Interactive command:
my-cli skillpresents a multiselect prompt for toggling installations. It includes a single Universal option plus additional agents. Askill updatesubcommand is also available for update-only flows.
Plugin Options
interface SkillPluginOptions {
version: string; // required — compared against installed crust.json
defaultScope?: "global" | "project"; // skip the interactive scope prompt
autoUpdate?: boolean; // default: true
command?: string; // default: "skill"
instructions?: string | string[]; // extra top-level SKILL.md content
customSkills?: CustomSkillConfig[]; // hand-authored bundles, default: []
}Scope resolution for the interactive command and skill update:
- If
--scopeflag is passed, that scope is used. - If
defaultScopeis set, that scope is used (no prompt). - Otherwise, an interactive prompt asks the user to choose
projectorglobal.
When the current working directory is the home directory, project scope is normalized to global. In that case, installs, updates, and status checks all use the global skill locations and update output reports (global).
Supported Agents
@crustjs/skills supports the same broad ecosystem as Vercel Skills.
Universal group (single prompt option, shared path):
amp,cline,codex,cursor,gemini-cli,github-copilot,kimi-cli,opencode,replit
Additional agents: 30+ agents detected on PATH via <agent> --version
(claude-code, windsurf, pi, roo, goose, continue, etc.). See
the AgentTarget union exported from @crustjs/skills for the full list.
Path model:
| Group | Global Path | Project Path |
|---|---|---|
| Universal | ~/.agents/skills/<name>/ | .agents/skills/<name>/ |
| Additional | Agent-specific convention per CLI | Agent-specific path |
If process.cwd() is the home directory, project paths collapse to the same global paths shown above.
Interactive Command
By default, the plugin registers a skill subcommand that presents a single multiselect prompt. A skill update subcommand is also registered for update-only flows (updates installed skills without prompting for agent selection).
The prompt behavior is:
- A single Universal option is shown (for all universal agents).
- Additional agents are shown when detected, or when the skill is already installed for that agent.
- Choice subtext shows the target installation directory.
- Already-installed targets are pre-selected.
The user toggles options on or off and the system reconciles the desired state:
- Newly selected agents are installed
- Deselected agents are uninstalled
- Already-correct agents are skipped
my-cli skillHand-authored bundles via customSkills
The plugin can manage hand-authored skill bundles alongside the auto-
generated command-reference skill. Each entry follows the same plugin
lifecycle as the main skill — auto-update, interactive multiselect, and
skill update — and gets its own multiselect prompt after the main one,
in array order. See the @crustjs/skills README for
the full reference.
import { Crust } from "@crustjs/core";
import { skillPlugin } from "@crustjs/skills";
import pkg from "./package.json" with { type: "json" };
const app = new Crust("my-cli")
.meta({ description: "My CLI" })
.use(
skillPlugin({
version: pkg.version,
customSkills: [
// Inherits `version: pkg.version` from the plugin.
{ name: "funnel-builder", sourceDir: "skills/funnel-builder" },
// Explicit override for an independently-versioned bundle.
{
name: "vendored-toolkit",
sourceDir: "skills/vendored-toolkit",
version: "0.3.0",
},
],
}),
)
.run(() => {});The CustomSkillConfig shape:
interface CustomSkillConfig {
name: string; // unique; must match SKILL.md frontmatter
sourceDir: string | URL; // file:// URL, absolute path, or bare relative
version?: string; // inherits plugin `version` when omitted
scope?: "global" | "project"; // overrides plugin defaultScope
installMode?: "auto" | "symlink" | "copy";
}version is optional: omit it to inherit the plugin's top-level
version (the typical case when the bundle ships in the same package as
the CLI), or pass an explicit value when a bundle's release cadence is
independent of the consuming CLI. The bundle's SKILL.md frontmatter
version: / metadata.version, if any, is intentionally not read — see
the installSkillBundle() callout
for the rationale.
Resolution rules for sourceDir mirror
installSkillBundle(): URL with
file: protocol, absolute path, or a bare relative string resolved from
the nearest package.json walking up from process.argv[1]. Resolution
errors surface at install time, not at plugin setup.
Bundles share the canonical .crust/skills store with the main skill
and inherit defaultScope / installMode resolution unless overridden
per-entry. When customSkills is omitted or empty, only the generated
main skill is managed.
Conflict Detection
Each Crust-managed skill directory contains a crust.json file that serves
as an ownership marker. The install entrypoints (generateSkill() and
installSkillBundle()) refuse to overwrite a target directory in three
cases:
- No
crust.json— the directory exists but was not created by Crust (e.g. manually authored or installed by another tool). - Kind mismatch —
crust.jsonrecords a differentkindthan the install attempt (e.g. an existinggeneratedskill collides with an incomingbundle). - Malformed manifest —
crust.jsonis present but cannot be interpreted (invalid JSON, top-level non-object, missingversion, or an unrecognizedkindvalue such as a hand-edit typo).
All three throw SkillConflictError. Pass force: true to overwrite, or
uninstall the existing skill first.
Behavior by context
| Context | Conflict behavior |
|---|---|
| Auto-update middleware | Logs a warning and skips — the CLI continues running normally |
Interactive skill command | Prompts the user to confirm overwriting the conflicting skill |
skill update command | Logs a warning and skips the conflicting agent |
generateSkill() API | Throws SkillConflictError. Pass force: true to overwrite |
installSkillBundle() API | Throws SkillConflictError. Pass force: true to overwrite |
Handling conflicts programmatically
SkillConflictError.details carries the agent and output directory plus
optional discriminators that describe which flavor fired:
details.kindMismatch?: { existing, attempted }— set on kind mismatch.details.manifestMalformed?: { reason, rawKind? }— set on malformedcrust.json.reasonis one of"parse-error","not-an-object","missing-version", or"unknown-kind".rawKindis populated only whenreason === "unknown-kind"(the offending value found in the file).- Neither field set — the original "directory exists with no
crust.json" case.
import { generateSkill, SkillConflictError } from "@crustjs/skills";
try {
await generateSkill({ command, meta, agents });
} catch (err) {
if (!(err instanceof SkillConflictError)) throw err;
const { outputDir, kindMismatch, manifestMalformed } = err.details;
if (kindMismatch) {
console.error(
`${outputDir}: existing ${kindMismatch.existing} blocks ${kindMismatch.attempted} install`,
);
} else if (manifestMalformed) {
console.error(`${outputDir}: malformed crust.json (${manifestMalformed.reason})`);
} else {
console.error(`${outputDir}: not created by Crust`);
}
await generateSkill({ command, meta, agents, force: true });
}Skill Metadata
The SkillMeta object controls the generated SKILL.md frontmatter. Beyond the required name, description, and version fields, several optional fields are supported:
const meta: SkillMeta = {
name: "my-cli",
description: "CLI tool for managing widgets",
version: "1.0.0",
allowedTools: "Bash(my-cli *) Read Grep",
license: "MIT",
compatibility: "Requires my-cli on PATH",
disableModelInvocation: false,
};| Field | Frontmatter Key | Description |
|---|---|---|
allowedTools | allowed-tools | Space-delimited list of pre-approved tools (e.g. Bash(my-cli *) Read Grep) |
license | license | License name or file reference |
compatibility | compatibility | Environment requirements or compatibility notes |
disableModelInvocation | disable-model-invocation | When true, prevents agents from auto-loading the skill |
Command-Level Prompt Guidance
Use annotate() to attach agent-facing instructions to a specific
command without extending the public @crustjs/core builder API.
instructions: stringrenders as a raw markdown block inSKILL.md.instructions: string[]renders as bullet list items inSKILL.md.- Empty or whitespace-only instruction input is ignored.
annotate()renders command guidance as bullets in the command doc.
import { Crust } from "@crustjs/core";
import { annotate } from "@crustjs/skills";
const deploy = annotate(
new Crust("deploy").meta({ description: "Deploy the application" }).run(() => {
// ...
}),
[
"Prefer preview modes before running destructive operations.",
"Ask for confirmation before production changes.",
],
);Name Validation
Skill names must conform to the Agent Skills spec: 1–64 lowercase alphanumeric characters and hyphens, no leading/trailing/consecutive hyphens. generateSkill() validates the resolved name (with use- prefix) and throws a descriptive error if invalid.
Use isValidSkillName() to check names before calling generateSkill():
import { isValidSkillName } from "@crustjs/skills";
isValidSkillName("my-cli"); // true
isValidSkillName("My_CLI"); // falseLow-Level Primitives
For custom workflows, the package also exports lower-level functions:
Default targets — omit agents for the common case
generateSkill, uninstallSkill, and skillStatus accept an optional
agents field. The default differs by entrypoint so install behavior tracks
what is actually on the current machine, while uninstall and status sweep
every known path:
| Entrypoint | Default when agents is omitted | PATH I/O? |
|---|---|---|
generateSkill | Universal agents + additional agents detected on PATH | Yes |
uninstallSkill, skillStatus | Every supported agent (exhaustive sweep of all known paths) | No |
In all three, passing agents: [] is treated as a no-op (no install,
uninstall, or status entries). An explicit array always overrides the
default.
import { generateSkill, skillStatus, uninstallSkill } from "@crustjs/skills";
// Install — universal + agents whose CLI is on PATH.
const result = await generateSkill({
command: rootCommand,
meta: { name: "my-cli", description: "My CLI", version: "1.0.0" },
scope: "global", // or "project"
});
// Status — reports an entry for every supported agent. Most read
// `installed: false` on a typical machine; that is expected.
const status = await skillStatus({ name: "my-cli" });
// Uninstall — sweeps every known agent path and removes any Crust-managed
// install regardless of whether its CLI is currently on PATH.
const removed = await uninstallSkill({ name: "my-cli" });Explicit targets — override the default
For full control, pass agents explicitly. getUniversalAgents(),
getAdditionalAgents(), and detectInstalledAgents() remain exported so
callers can compose any subset:
import {
detectInstalledAgents,
generateSkill,
getUniversalAgents,
isValidSkillName,
} from "@crustjs/skills";
// Validate a skill name.
isValidSkillName("my-cli"); // true
// Compose the same list `generateSkill` would build by default.
const universal = getUniversalAgents();
const additional = await detectInstalledAgents();
await generateSkill({
command: rootCommand,
meta: { name: "my-cli", description: "My CLI", version: "1.0.0" },
agents: [...universal, ...additional], // explicit form
scope: "global",
});Installing Hand-Authored Bundles
generateSkill() produces a skill bundle from a Crust command tree.
installSkillBundle() is the dual entrypoint for hand-authored bundles —
use it when you have a directory containing SKILL.md and supporting files
that should flow through the same canonical-store + agent fan-out plumbing.
import { installSkillBundle } from "@crustjs/skills";
import pkg from "./package.json" with { type: "json" };
await installSkillBundle({
// Resolved relative to the nearest package.json walking up from
// process.argv[1]. You may also pass an absolute path or a file: URL.
// Resolution is performed by `resolveSourceDir` from
// [`@crustjs/utils`](/docs/modules/utils#resolvesourcedir).
sourceDir: "skills/funnel-builder",
agents: ["claude-code", "opencode"],
version: pkg.version,
});The bundle's SKILL.md frontmatter is the source of truth for name and
description; Crust reads them but never rewrites the file. Both fields
are required:
---
name: funnel-builder
description: Build a sales funnel
---version is required and recorded in crust.json; identical-version
reinstalls report up-to-date and skip the canonical-store rewrite
(unless force: true is passed), so pass a fresh value whenever bundle
contents change. Wiring it to the consuming package's package.json
version is the typical pattern.
`metadata.version` in SKILL.md is not read
The Agent Skills spec lets bundle authors record a version under metadata.version in their
SKILL.md frontmatter. Crust intentionally does not read that field — the version option you
pass to installSkillBundle() is the sole source of truth for crust.json and update detection.
If you keep a metadata.version in your SKILL.md for spec compliance, keep it in sync with the
value you pass here.
Options
| Option | Type | Default | Description |
|---|---|---|---|
sourceDir | string | URL | — required | Bundle directory. Absolute path, file: URL, or relative path resolved from the nearest package.json. |
agents | AgentTarget[] | — required | Agents to install for. [] validates the bundle without installing (no auto-detection — unlike generateSkill()). |
version | string | — required | Recorded in crust.json and compared on subsequent installs. Typically wired to the consuming package's package.json version. |
scope | "global" | "project" | "global" | Install scope. When process.cwd() is the home directory, "project" normalizes to "global". |
installMode | "auto" | "symlink" | "copy" | "auto" | Same semantics as generateSkill(). "auto" symlinks from the canonical store, falling back to copy. |
clean | boolean | true | Remove the existing skill directory before writing. |
force | boolean | false | Rewrite even when the recorded version is unchanged, and overwrite a conflicting directory instead of throwing. |
Crust copies bundle files as raw bytes, so supporting assets such as images,
fonts, or scripts round-trip unchanged. SKILL.md is also parsed as UTF-8 to
read its required frontmatter.
crust.json at the bundle root is reserved: Crust regenerates it
during installation. If a crust.json is found in the source bundle,
installSkillBundle() throws so the conflict surfaces immediately
instead of being silently overwritten.
Publishing a bundle to npm
Two gotchas trip up bundle authors who publish to npm:
-
Include the bundle directory in the published tarball. Add the path to your
package.jsonfilesarray; verify withnpm pack --dry-runbefore publishing. Local installs work even when the directory would be excluded from the tarball, but consumers will hit a missing-SKILL.mderror.{ "name": "acme-skills", "version": "1.0.0", "files": ["dist", "skills"] } -
Consumers point at the published path with
import.meta.resolve. RelativesourceDirresolution walks up from the consumer'sprocess.argv[1], so a relative path lands in the consumer's package — not yours. Consumers should use afile:URL:import skillsPkg from "acme-skills/package.json" with { type: "json" }; await installSkillBundle({ sourceDir: new URL(import.meta.resolve("acme-skills/skills/funnel-builder")), agents: ["claude-code"], version: skillsPkg.version, });For this to work, the bundle directory must be reachable from your package's
exports(or accessible as a subpath of the package root).
kind field on crust.json
Every installed bundle records its origin in crust.json as a kind
field — "generated" for generateSkill() output, "bundle" for
installSkillBundle(). Crust uses this to detect cross-kind collisions:
attempting to install a bundle on top of a generated skill (or vice versa)
at the same name throws a SkillConflictError whose details.kindMismatch
carries { existing, attempted }. Uninstall the existing skill first or
pass force: true to overwrite.
Legacy crust.json files written before this field existed are read as
kind: "generated" so existing generated installs continue to update
cleanly without a migration step.