Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion .claude/rules/prompt-skill-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Three audits ship twice, once as a skill directory and once as a single prompt f
| [`audit-pr.prompt.md`](../../.github/prompts/audit-pr.prompt.md) | [`audit-pr/`](../skills/audit-pr/SKILL.md) |
| [`audit-quality.prompt.md`](../../.github/prompts/audit-quality.prompt.md) | [`audit-quality/`](../skills/audit-quality/SKILL.md) |

Two audiences drive this. Someone whose employer allows a single file in the repository takes the prompt. Someone who can install a directory takes the skill, and gets the bundled `references/`, `agents/`, and `assets/` with it.
Two audiences drive this. Someone whose employer allows a single file in the repository takes the prompt. Someone who can install a directory takes the skill, and gets the bundled `references/`, `agents/`, and `assets/` with it, subject to the caveat in [the plugin manifest section](#the-plugin-manifest-and-what-it-does-not-change) about which of those the specification actually covers.

## The contract: same objective, not same bytes

Expand Down Expand Up @@ -52,6 +52,27 @@ An illustrative link, such as `[config.py](../src/config.py)` inside an example

`make -f .claude/Makefile check-skills` enforces every rule in this section, plus the specification itself: `name` matching the directory, `description` within its character limit, a body under 500 lines, a licence on every skill, and every bundled path resolving. It is deliberately **not** part of `npm run validate`, because the repository must build, test, and lint with no agent tooling present.

## The plugin manifest, and what it does not change

A skill directory containing `.claude-plugin/plugin.json` loads as a plugin named `<name>@skills-dir` on the next session, with no marketplace and no install step, and that is what turns the files in `agents/` into agents a run can delegate to. Without it they stay ordinary files, which is what each `SKILL.md` already treats as the default when it tells the run to open one and follow it: `agents/` is a host extension, not part of the Agent Skills specification, which defines `references/`, `assets/`, and `scripts/` and nothing else.

**The manifest is an optimization, never a dependency.** Every bundled procedure is written to be run by opening its file, and each `SKILL.md` says so before it mentions delegating, because a skill that tells an agent to delegate to something the host never registered has no documented fallback: the call fails and the run improvises. An improvised prompt carries none of the scope bound or evidence bar written inside the procedure, which is the whole reason the file exists.

Two consequences to know before editing either half:

- **The delegation identifier is namespaced**, as `<skill>@skills-dir:<agent>`. A bare agent name never resolves. Write neither form into a published skill: naming the file and letting the run resolve the identifier is what keeps the instruction true on a host that spells it differently.
- **Nothing documents whether `agents/` or `.claude-plugin/` survive `npx skills add` or `gh skill install`**, since neither is in the specification. Test an install rather than assuming, and treat opening the file as the path that has to work.

[`check-skill-publishability.mjs`](../scripts/check-skill-publishability.mjs) validates a manifest where one exists: that it parses, that its `name` matches the directory, that it carries a `version`, and that any path in an `agents` key resolves. It does not require one.

## A published skill stays reachable by name

**A published skill carries neither `user-invocable: false` nor `paths:`.** An adopter installs the directory and has the name on it and nothing else, so a key narrowing who may reach the skill, or when it activates, takes away the only handle they have. `user-invocable: false` hides it from the `/` menu outright. `paths:` is documented as limiting "when this skill is activated", and a skill carrying it did not answer to its own name here, though the documentation does not describe what happens when the command is typed while no matching file is open.

Where a project wants a skill to load automatically on certain files, the path glob belongs on a rules file, which is where [`code-style.md`](code-style.md) carries one. That mechanism stays inside the project and leaves the skill reachable everywhere.

An installable or internal skill may use both keys freely, and [`check-skill-publishability.mjs`](../scripts/check-skill-publishability.mjs) enforces this only against the `PUBLISHED` list. Nothing in the specification defines either key, so this is a policy of this repository rather than a rule of the format.

## The three states

Every skill is in exactly one, and [`check-skill-publishability.mjs`](../scripts/check-skill-publishability.mjs) prints which.
Expand Down
103 changes: 101 additions & 2 deletions .claude/scripts/check-skill-publishability.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,24 @@ const MAX_BODY_LINES = 500;
/** The spec caps `description` at 1024 characters, because it loads at startup. */
const MAX_DESCRIPTION = 1024;

/** Directories a skill may bundle, per the Agent Skills specification. */
/**
* Directories a skill may bundle. The specification defines `references/`, `assets/`, and
* `scripts/`; `agents/` is a host extension, read only where a plugin manifest turns the
* directory into a plugin, and inert everywhere else.
*/
const BUNDLE_DIRS = ['references', 'agents', 'assets', 'scripts'];

/**
* The manifest that makes a skill directory load as a plugin, so the files in `agents/` register
* as agents a run can delegate to instead of sitting there as unread text.
*
* It is optional, and a skill without one is not at fault: every bundled procedure is written to
* be followed by opening its file, which needs no manifest and no host support. What this path
* is checked for is the failure that hides, namely a manifest whose name disagrees with the
* directory, which registers the plugin under a name nothing refers to.
*/
const PLUGIN_MANIFEST = join('.claude-plugin', 'plugin.json');

const failures = [];

/** Records one failure against a file. */
Expand Down Expand Up @@ -180,6 +195,9 @@ function checkSkill(name) {
fail(label, 'an installer can offer any skill here, so it needs a LICENSE.txt beside it');
}

checkPluginManifest(name);
checkInvocable(name, parts.frontmatter);

// An internal skill names this repository's own prompt files on purpose, so the isolation
// rule below, which exists to keep a recipient from following a path they will not have,
// is the one thing it is exempt from.
Expand All @@ -196,7 +214,84 @@ function checkSkill(name) {
}
}

/** Every Markdown file inside a skill, one level of bundle directory deep. */
/**
* Checks that a published skill can still be invoked by name.
*
* This is a policy of this repository rather than a rule of the specification, which defines
* neither key. A published skill is one an adopter installs holding nothing but the directory
* and the name on it, so a key that narrows when it activates, or who may reach it, takes away
* the only handle that adopter has. An installable or internal skill is free to use both.
*
* @param {string} name Directory name of the skill under `.claude/skills/`.
* @param {string} frontmatter The skill's frontmatter block.
*/
function checkInvocable(name, frontmatter) {
if (!PUBLISHED.includes(name)) {
return;
}

const label = `.claude/skills/${name}/SKILL.md`;

if (/^user-invocable:[ \t]*(false|no|off|0)\s*$/im.test(frontmatter)) {
fail(label, 'a published skill may not set `user-invocable: false`, which hides it from the / menu');
}

if (/^paths:/m.test(frontmatter)) {
fail(
label,
'a published skill may not carry `paths:`, which limits when it activates; path-scope a rules file instead',
);
}
}

/**
* Checks a skill's plugin manifest, where it has one. A skill without one is skipped silently.
*
* @param {string} name Directory name of the skill under `.claude/skills/`.
*/
function checkPluginManifest(name) {
const manifestPath = join(SKILL_DIR, name, PLUGIN_MANIFEST);
const label = `.claude/skills/${name}/${PLUGIN_MANIFEST}`;

if (!existsSync(manifestPath)) {
return;
}

let manifest;

try {
manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
} catch (error) {
fail(label, `does not parse as JSON: ${error.message}`);

return;
}

if (manifest.name !== name) {
fail(label, `name "${manifest.name}" does not match the directory name "${name}"`);
}

if (!manifest.version) {
fail(label, 'no version, which a host uses to tell one loaded copy from another');
}

// A manifest may point `agents` at somewhere other than the default directory. Either way the
// paths it names travel with the skill, so a broken one breaks in the recipient's copy.
const declaredAgents = manifest.agents ? [manifest.agents].flat() : [];

for (const target of declaredAgents) {
if (!existsSync(join(SKILL_DIR, name, target))) {
fail(label, `declares agent "${target}", which does not exist in the skill directory`);
}
}
}

/**
* Every file inside a skill that travels with it and could name a path: top-level Markdown, every
* file one level deep in each bundle directory, plus the plugin manifest. The manifest carries a
* `description`, so it can name a prompt file exactly as a body can, and it ships in the copied
* directory either way.
*/
function skillFiles(name) {
const root = join(SKILL_DIR, name);
const files = readdirSync(root).filter((file) => file.endsWith('.md'));
Expand All @@ -209,6 +304,10 @@ function skillFiles(name) {
files.push(...readdirSync(join(root, dir)).map((file) => `${dir}/${file}`));
}

if (existsSync(join(root, PLUGIN_MANIFEST))) {
files.push(PLUGIN_MANIFEST);
}

return files;
}

Expand Down
5 changes: 5 additions & 0 deletions .claude/skills/audit-docs/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "audit-docs",
"version": "1.0.0",
"description": "Audit and update the project's documentation so it matches the current code, grounding every claim in a file opened this run."
}
Loading