Crust logoCrust

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/skills

Quick 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 skill presents a multiselect prompt for toggling installations. It includes a single Universal option plus additional agents. A skill update subcommand 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:

  1. If --scope flag is passed, that scope is used.
  2. If defaultScope is set, that scope is used (no prompt).
  3. Otherwise, an interactive prompt asks the user to choose project or global.

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:

GroupGlobal PathProject Path
Universal~/.agents/skills/<name>/.agents/skills/<name>/
AdditionalAgent-specific convention per CLIAgent-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 skill

Hand-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:

  1. No crust.json — the directory exists but was not created by Crust (e.g. manually authored or installed by another tool).
  2. Kind mismatchcrust.json records a different kind than the install attempt (e.g. an existing generated skill collides with an incoming bundle).
  3. Malformed manifestcrust.json is present but cannot be interpreted (invalid JSON, top-level non-object, missing version, or an unrecognized kind value such as a hand-edit typo).

All three throw SkillConflictError. Pass force: true to overwrite, or uninstall the existing skill first.

Behavior by context

ContextConflict behavior
Auto-update middlewareLogs a warning and skips — the CLI continues running normally
Interactive skill commandPrompts the user to confirm overwriting the conflicting skill
skill update commandLogs a warning and skips the conflicting agent
generateSkill() APIThrows SkillConflictError. Pass force: true to overwrite
installSkillBundle() APIThrows 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 malformed crust.json. reason is one of "parse-error", "not-an-object", "missing-version", or "unknown-kind". rawKind is populated only when reason === "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,
};
FieldFrontmatter KeyDescription
allowedToolsallowed-toolsSpace-delimited list of pre-approved tools (e.g. Bash(my-cli *) Read Grep)
licenselicenseLicense name or file reference
compatibilitycompatibilityEnvironment requirements or compatibility notes
disableModelInvocationdisable-model-invocationWhen 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: string renders as a raw markdown block in SKILL.md.
  • instructions: string[] renders as bullet list items in SKILL.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"); // false

Low-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:

EntrypointDefault when agents is omittedPATH I/O?
generateSkillUniversal agents + additional agents detected on PATHYes
uninstallSkill, skillStatusEvery 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

OptionTypeDefaultDescription
sourceDirstring | URL— requiredBundle directory. Absolute path, file: URL, or relative path resolved from the nearest package.json.
agentsAgentTarget[]— requiredAgents to install for. [] validates the bundle without installing (no auto-detection — unlike generateSkill()).
versionstring— requiredRecorded 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.
cleanbooleantrueRemove the existing skill directory before writing.
forcebooleanfalseRewrite 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:

  1. Include the bundle directory in the published tarball. Add the path to your package.json files array; verify with npm pack --dry-run before publishing. Local installs work even when the directory would be excluded from the tarball, but consumers will hit a missing-SKILL.md error.

    {
      "name": "acme-skills",
      "version": "1.0.0",
      "files": ["dist", "skills"]
    }
  2. Consumers point at the published path with import.meta.resolve. Relative sourceDir resolution walks up from the consumer's process.argv[1], so a relative path lands in the consumer's package — not yours. Consumers should use a file: 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.

On this page