Skip to content
On this page

Authoring Guide ​

This repository is built around a simple rule:

  • every skill must work as a plain SKILL.md
  • plugin manifests are optional enhancements
  • MCP is optional and must never be the only path

Repository Model ​

Use a shared skill core with agent-specific plugin layers:

text
skills/<skill-name>/
  SKILL.md
  README.md
  .codex-plugin/plugin.json
  agents/openai.yaml     # required Codex sidecar: interface; policy when explicit-invocation-only
  .mcp.json              # optional
  references/            # optional
  scripts/               # optional
  package.json           # optional: the scripts' dependencies, exact versions (Dependencies, below)
  vale/                  # optional: Vale rules the skill ships (docs/vale.md)

This keeps the repo compatible with:

  • Claude Code through npx skills add ... and optional marketplace/plugin metadata
  • Codex through npx skills add ... and optional .codex-plugin/plugin.json
  • GitHub Copilot through the installed skill content itself

Adding a New Skill ​

  1. Copy the starter files from template/.
  2. Create skills/<skill-name>/.
  3. Rename placeholders so the skill name, folder name, and plugin manifest name match.
  4. Fill in SKILL.md with activation criteria, workflow steps, and fallback behavior. Keep the frontmatter description a short trigger ("Use when ..."), and lead with worked examples, scenarios, or concrete commands rather than abstract prose.
  5. Create agents/openai.yaml with an interface block mirroring the codex manifest (required; add policy.allow_implicit_invocation: false for explicit-invocation-only skills).
  6. Add a human-facing README.md.
  7. Update the root README.md catalog: the Available Skills entry under the matching invocation heading, linking to the skill's SKILL.md, and a node in the mermaid skill-reference graph with a click line to that page and edges only for hand-offs the skill's text actually makes.
  8. List the new skill in .claude-plugin/plugin.json.
  9. Add the skill to the skill dropdown in .github/ISSUE_TEMPLATE/bug_report.yml.
  10. Ship a byte-identical copy of the root LICENSE.md in the skill folder, set frontmatter metadata.author to the plugin author's first name, and never set metadata.internal or add a metadata.json (the install CLI drops both silently).
  11. Explicit-invocation-only skills set disable-model-invocation: true in the frontmatter AND policy.allow_implicit_invocation: false in agents/openai.yaml; the README entry then goes under "Invoked by you", otherwise under "Automatic".
  12. Commit; the pre-commit hook's bun run check:staged runs the validator and smoke test, which enforce every item above plus frontmatter limits (name <= 64 characters, description <= 1024), the codex manifest's homepage and keywords, and interface.short_description at 25 to 64 characters. It also runs the docs probe: paragraphs and list items in first-party SKILL.md and references/*.md files and the template are capped at 70 words.

Upstream Skills ​

A skill written elsewhere is published by its own entry in .claude-plugin/marketplace.json, whose git-subdir source names the upstream repository (owner/repo) and the skill folder inside it. Nothing of the upstream is copied here, and the entry carries no sha and no ref: the Claude Code marketplace fetches the folder from the upstream's default branch, so an install is upstream's latest, frontmatter included.

json
{
  "name": "unslop",
  "description": "Cuts AI tells from any writing",
  "source": { "source": "git-subdir", "url": "cursor/plugins", "path": "pstack/skills/unslop" },
  "skills": ["./"]
}

To add one:

  1. The entry above, in .claude-plugin/marketplace.json; the one-command installer, bin/install.mjs, and bun run add read the entries, so they need no change. Add "metadata": { "disable-model-invocation": true } when the installed copy should load only on invocation: both commands then mark it so.
  2. A bullet under the root README's "Upstream skills", linking to the upstream folder at tree/HEAD, and one npx skills add <owner/repo>/<path> -g line there; a flagged skill's name joins the "Installed by ..., load only when you invoke them" sentence.
  3. A node in the mermaid graph, clicking to the same page.

The smoke test holds the entry to that shape and the README to the entries. The layout above does not apply: upstream's folder is the skill as it is.

Contributing Criteria to Reviews ​

A skill contributes to /rubber-duck-review second-opinion passes by declaring a ## Review Criteria section in its SKILL.md. That alone joins it to every review of the user's own change; there is no registry to update. On someone else's PR, the /reply-and-review-discipline skill's lens choice selects which sections are expanded.

Keep the section short: a few bullets the reviewer can act on, plus a pointer to the skill's own workflow for triaging findings. The smoke test rejects a section without list items.

Criteria that only apply in a specific context use a differently named heading (e.g. ## Orchestration Review Criteria) and are folded into reviews by the skill that owns that context instead of being auto-discovered.

Dependencies ​

A skill whose scripts import a package ships package.json at the skill root: exact versions under dependencies, nothing else. No lockfile, no node_modules, nothing generated is committed. A skill with no dependencies ships no package.json.

The root package.json pins the same version as a devDependency, so tsc checks the code that runs.

text
skills/my-skill/
  package.json           { "name": "my-skill-dependencies", "private": true, "dependencies": { "yaml": "2.9.1" } }
  scripts/x.mts          the block, then await import() of its packages, then the program
  .build/x.js            the bundle, written on first run; .build/ is gitignored

The script bundles itself on first run. Run as source (bun scripts/x.mts), the block copies scripts/ and the package.json into a scratch directory under the .build folder, installs the pins and bundles the script there with bun build, moves the bundle into the .build folder, removes the scratch, and runs the bundle with the same arguments.

Later runs go straight to the bundle until a file in scripts/ or the package.json is newer. Two first runs at once each build in their own scratch and never share an install.

An entry below scripts/ bundles to the same relative path under the .build folder; the block finds the skill root by walking up to the scripts/ directory with the package.json beside it.

bun build resolves installed packages only, which is why the install step exists. A failed install or build prints bun's error and exits 2. A user may install any one skill alone, so no script imports from another skill.

The block needs spawn and spawnSync from node:child_process, copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, renameSync, rmSync, statSync from node:fs, and basename, dirname, join, relative from node:path among the script's imports. Every script that loads a package, directly or through a relative import, carries it byte for byte; the smoke test holds the copies to this text, and a dependency-free script carries none.

Below the guard, the program loads its packages and sibling scripts with await import("yaml"), never a static import: ESM hoists a static import above the guard, where the source path would resolve the package through bun's unpinned auto-install. Type-only imports stay static; they are erased. The smoke test refuses a hoisted import, and a load placed above the block.

ts
// --- Self-build: run as source, bundle into <skill>/.build/ and run the bundle; run as the bundle, do nothing.
// docs/authoring.md > Dependencies owns this block; the smoke test keeps every copy identical.

function buildPaths(source: string) {
  // An entry may sit below scripts/: the skill root is the parent of the scripts/ with the manifest beside it.
  let scripts = dirname(source);
  while (basename(scripts) !== "scripts" || !existsSync(join(dirname(scripts), "package.json"))) {
    if (dirname(scripts) === scripts) {
      console.error(
        `${source}: no scripts/ directory with a package.json beside it above the script`,
      );
      process.exit(2);
    }
    scripts = dirname(scripts);
  }
  const skill = dirname(scripts);
  const built = join(skill, ".build", relative(scripts, source).replace(/\.mts$/, ".js"));
  return { scripts, skill, built };
}

function buildIsStale(scripts: string, skill: string, built: string): boolean {
  if (!existsSync(built)) return true;
  // An entry imports its siblings, and a pin bump changes the bundle, so every script and the manifest count.
  const inputs = readdirSync(scripts, { recursive: true, encoding: "utf8" })
    .filter((name) => name.endsWith(".mts"))
    .map((name) => join(scripts, name));
  inputs.push(join(skill, "package.json"));
  const newest = Math.max(...inputs.map((path) => statSync(path).mtimeMs));
  return statSync(built).mtimeMs < newest;
}

function runBun(args: string[]): boolean {
  const step = spawnSync("bun", args, { stdio: ["ignore", "ignore", "pipe"] });
  if (step.status !== 0) process.stderr.write(step.stderr);
  return step.status === 0;
}

function build(source: string, scripts: string, skill: string, built: string): boolean {
  // bun build resolves installed packages only, from a node_modules beside the entry, and two first runs at
  // once must never share one: each build gets its own scratch under .build/ and publishes by one rename.
  mkdirSync(join(skill, ".build"), { recursive: true });
  const scratch = mkdtempSync(join(skill, ".build", "scratch-"));
  try {
    cpSync(scripts, join(scratch, "scripts"), { recursive: true });
    copyFileSync(join(skill, "package.json"), join(scratch, "package.json"));
    const entry = join(scratch, "scripts", relative(scripts, source));
    const bundle = join(scratch, "bundle.js");
    const ok =
      runBun(["install", "--production", "--no-save", "--cwd", scratch]) &&
      runBun(["build", entry, "--target", "bun", "--outfile", bundle]);
    if (!ok) return false;
    mkdirSync(dirname(built), { recursive: true });
    renameSync(bundle, built);
    return true;
  } finally {
    rmSync(scratch, { recursive: true, force: true });
  }
}

function ensureBuilt(source: string): Promise<boolean> {
  if (!source.endsWith(".mts")) return Promise.resolve(false);
  const { scripts, skill, built } = buildPaths(source);
  if (buildIsStale(scripts, skill, built) && !build(source, scripts, skill, built)) process.exit(2);
  // The bundle runs on this process's stdio, and a signal to this process goes on to it; this process then ends as
  // the bundle did, by its status, or by the same signal once the forwarding is off and the signal's default is back.
  const run = spawn("bun", [built, ...process.argv.slice(2)], { stdio: "inherit" });
  const signals = ["SIGTERM", "SIGINT", "SIGHUP"] as const;
  const forward = (signal: NodeJS.Signals) => {
    run.kill(signal);
  };
  for (const signal of signals) process.on(signal, forward);
  return new Promise((resolve) => {
    const settle = (status: number | null, signal: NodeJS.Signals | null) => {
      for (const name of signals) process.off(name, forward);
      if (signal !== null) {
        process.kill(process.pid, signal);
        return;
      }
      process.exitCode = status ?? 1;
      resolve(true);
    };
    run.on("exit", settle);
    // A bundle that never started (no bun on PATH) emits error and no exit: that is the run's failure.
    run.on("error", (error) => {
      console.error(String(error));
      settle(1, null);
    });
  });
}

// Only the command builds; a test or a sibling importing this file must not.
if (import.meta.main && (await ensureBuilt(import.meta.path))) process.exit();

Why a bundle: bun's auto-install ignores a package.json pin, an inline pkg@1.2.3 specifier fails once a second version of the package was ever fetched on the machine, and a node_modules in a user's project is theirs, not the skill's.

Commands and Their Usage Blocks ​

A command is a skill script that starts with #!/usr/bin/env bun. Its usage block is the contract: the command line, the flags, what it prints, the exit codes. The script prints it on --help or -h as the first argument, to stdout, exit 0, and one page quotes it in a fence whose info string names the script:

markdown
```text usage=skills/working-text/scripts/architecture/lint.mts
usage: lint.mts [--config <architecture.yml>] [--root <dir>] [--mermaid]
  --config   the layering declaration (default: <root>/architecture.yml)
  --root     the repository root the paths are relative to (default: cwd)
  --mermaid  print the module map instead of linting
exits: 0 the tree matches the declaration; 1 forbidden or stale edges; 2 usage or an unreadable graph
```

tests/repo/usage-blocks.test.ts runs bun <script> --help for every command and holds the fence equal to it, trailing whitespace aside. A command with no fence, or with two, fails the same test. Other pages link to the one that carries the fence.

The test's first line, // reaches: **/*.md skills/*/scripts/**/*.mts, declares what it reads, so the pre-commit hook runs it when a staged file matches. Any test that discovers its inputs by pattern declares them the same way.

A fence that drifted is never edited by hand. Regenerate it from the repository root and paste the output:

bash
bun skills/working-text/scripts/architecture/lint.mts --help

Compatibility Rules ​

  • npx skills add Vivswan/skills is the primary install path.
  • A skill must remain useful even if no plugin host is available.
  • SKILL.md should contain the real workflow, with pointers to MCP tools only as a supplement.
  • Plugin manifests should improve discovery and UX, not hold the only business logic.
  • GitHub Copilot should be treated as skill-first, not plugin-first.

MCP Conventions ​

When a skill needs MCP:

  • Keep the MCP config in the skill folder as .mcp.json.
  • Add a .mcp.json.example first when the real config needs secrets or environment-specific commands.
  • Describe required environment variables in the skill README.md.
  • Add a "Fallback Without MCP" section in SKILL.md.
  • Never require MCP to understand the skill's primary task.

Recommended pattern:

text
skills/my-skill/
  SKILL.md
  README.md
  .codex-plugin/plugin.json
  .mcp.json.example
  references/

Validation Checklist ​

  • SKILL.md has valid frontmatter with name and description
  • folder name matches the public skill name
  • README.md exists for each public skill
  • .codex-plugin/plugin.json exists and parses as JSON
  • agents/openai.yaml exists and its interface mirrors the codex manifest
  • .claude-plugin/plugin.json lists the skill directory, and .claude-plugin/marketplace.json stays consistent with it
  • the catalog version lives only in .claude-plugin/marketplace.json (metadata.version); no version in any SKILL.md, .codex-plugin/plugin.json, or .claude-plugin/plugin.json
  • optional MCP files parse as JSON when present
  • a skill with dependencies pins exact versions in package.json, mirrored by the root devDependency, commits no lockfile or node_modules, and every script loading a package carries the ensureBuilt block

Template Notes ​

The files in template/ are intentionally marked internal or placeholder-only. They are for authors, not for public installation.

Built from main at 0066e1dSource: docs/authoring.md