Appearance
Are you an LLM? You can read better optimized documentation at /skills/latest/authoring.md for this page in Markdown format
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
- Copy the starter files from
template/. - Create
skills/<skill-name>/. - Rename placeholders so the skill name, folder name, and plugin manifest name match.
- Fill in
SKILL.mdwith activation criteria, workflow steps, and fallback behavior. Keep the frontmatterdescriptiona short trigger ("Use when ..."), and lead with worked examples, scenarios, or concrete commands rather than abstract prose. - Create
agents/openai.yamlwith aninterfaceblock mirroring the codex manifest (required; addpolicy.allow_implicit_invocation: falsefor explicit-invocation-only skills). - Add a human-facing
README.md. - Update the root
README.mdcatalog: the Available Skills entry under the matching invocation heading, linking to the skill'sSKILL.md, and a node in the mermaid skill-reference graph with aclickline to that page and edges only for hand-offs the skill's text actually makes. - List the new skill in
.claude-plugin/plugin.json. - Add the skill to the
skilldropdown in.github/ISSUE_TEMPLATE/bug_report.yml. - Ship a byte-identical copy of the root
LICENSE.mdin the skill folder, set frontmattermetadata.authorto the plugin author's first name, and never setmetadata.internalor add ametadata.json(the install CLI drops both silently). - Explicit-invocation-only skills set
disable-model-invocation: truein the frontmatter ANDpolicy.allow_implicit_invocation: falseinagents/openai.yaml; the README entry then goes under "Invoked by you", otherwise under "Automatic". - Commit; the pre-commit hook's
bun run check:stagedruns the validator and smoke test, which enforce every item above plus frontmatter limits (name<= 64 characters,description<= 1024), the codex manifest'shomepageandkeywords, andinterface.short_descriptionat 25 to 64 characters. It also runs the docs probe: paragraphs and list items in first-partySKILL.mdandreferences/*.mdfiles 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:
- The entry above, in
.claude-plugin/marketplace.json; the one-command installer,bin/install.mjs, andbun run addread 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. - A bullet under the root README's "Upstream skills", linking to the upstream folder at
tree/HEAD, and onenpx skills add <owner/repo>/<path> -gline there; a flagged skill's name joins the "Installed by ..., load only when you invoke them" sentence. - 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 gitignoredThe 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 --helpCompatibility Rules
npx skills add Vivswan/skillsis the primary install path.- A skill must remain useful even if no plugin host is available.
SKILL.mdshould 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.examplefirst 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.mdhas valid frontmatter withnameanddescription- folder name matches the public skill name
README.mdexists for each public skill.codex-plugin/plugin.jsonexists and parses as JSONagents/openai.yamlexists and its interface mirrors the codex manifest.claude-plugin/plugin.jsonlists the skill directory, and.claude-plugin/marketplace.jsonstays consistent with it- the catalog version lives only in
.claude-plugin/marketplace.json(metadata.version); noversionin anySKILL.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 ornode_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.