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
30 changes: 30 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"name": "alexjsully-skills",
"owner": {
"name": "Alexander Sullivan",
"url": "https://github.com/AlexJSully"
},
"description": "Audit and coding-standards skills for documentation, pull requests, code quality, and TypeScript.",
"plugins": [
{
"name": "audit-docs",
"source": "./.claude/skills/audit-docs",
Comment thread
AlexJSully marked this conversation as resolved.
"description": "Audit and update the project's documentation so it matches the current code, grounding every claim in a file opened this run."
},
{
"name": "audit-pr",
"source": "./.claude/skills/audit-pr",
"description": "Review a pull request or working-branch diff across eighteen triaged categories and produce findings evidenced by the changed line."
},
{
"name": "audit-quality",
"source": "./.claude/skills/audit-quality",
"description": "Audit code for architecture, security, privacy, testing, dependency, supply chain, and cost issues, reporting findings with file and symbol evidence."
},
{
"name": "typescript-code-and-test-standards",
"source": "./.claude/skills/typescript-code-and-test-standards",
"description": "TypeScript and JavaScript standards that formatters and linters cannot catch, covering comment discipline, JSDoc, the test mandate, and the mocking policy."
}
]
}
8 changes: 6 additions & 2 deletions .claude/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,21 @@
# Run from the repository root:
#
# make -f .claude/Makefile check-skills
# make -f .claude/Makefile test-scripts
#
# This lives here rather than at the repository root because it contains nothing but
# agent tooling, and the root is the product. Nothing in `npm run validate` or in a
# workflow may invoke any of it. See `rules/repo-independence.md`.

CHECK := .claude/scripts/check-skill-publishability.mjs

.PHONY: help check-skills
.PHONY: help check-skills test-scripts

help: ## List the available targets
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " %-24s %s\n", $$1, $$2}'

check-skills: ## Validate every skill against the Agent Skills spec, and each published half against the isolation rules
check-skills: ## Validate every skill against the Agent Skills spec, each published half against the isolation rules, and the plugin marketplace
@node $(CHECK)

test-scripts: ## Run the tests for the scripts in .claude/scripts/
@node --test '.claude/scripts/*.test.mjs'
17 changes: 11 additions & 6 deletions .claude/rules/prompt-skill-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ paths:
- '.claude/skills/*/references/*.md'
- '.claude/skills/*/agents/*.md'
- '.claude/skills/*/assets/*.md'
- '.claude/skills/*/.claude-plugin/plugin.json'
- '.claude-plugin/marketplace.json'
---

# Published skills and their prompt halves
Expand Down Expand Up @@ -56,16 +58,19 @@ An illustrative link, such as `[config.py](../src/config.py)` inside an example

## 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.
A skill directory is a plugin root in two ways. In the repository holding it, a `.claude-plugin/plugin.json` inside it makes Claude Code load it as `<name>@skills-dir` on the next session, with no marketplace and no install step, and that manifest is what turns the files in `agents/` into agents a run can delegate to. Everywhere else, the marketplace at [`.claude-plugin/marketplace.json`](../../.claude-plugin/marketplace.json) lists it with a `source` of `./.claude/skills/<name>`, and VS Code and Claude Code install the directory as a plugin whose root `SKILL.md` is its one skill and whose `agents/` they find by default, manifest or not. Without either route the files in `agents/` 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:
Consequences to know before editing a skill, its manifest, or the marketplace:

- **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.
- **The delegation identifier differs by route.** It is `<skill>@skills-dir:<agent>` in place and `<skill>:<agent>` after a Claude Code marketplace install. Write neither into a published skill: naming the file and letting the run resolve the identifier is what keeps the instruction true on every host.
- **The skill's manifest and `agents/` travel with every install.** `npx skills` copies every file except `metadata.json` and the `.git`, `__pycache__`, and `__pypackages__` directories, and `gh skill` copies every file in the tree, so a recipient's copy carries `.claude-plugin/` and loads as `<name>@skills-dir` in their repository too.
- **No manifest carries a `version`.** Claude Code keys a marketplace install on it, so a fixed value freezes every recipient on the copy they first installed. Left out, the version is the commit the plugin came from, and a push to `main` reaches marketplace installs the way it reaches `npx skills`. VS Code ignores the field and pulls the repository instead.
- **A marketplace entry carries only `name`, `source`, and `description`.** VS Code reads `name`, `description`, `version`, and `source` from an entry and drops the rest, so a component declared there would exist in Claude Code alone, and `npx skills` skips any path without the leading `./`. The entry repeats the manifest's `description` because that is the copy VS Code shows. For the same reason the marketplace sets no `metadata.pluginRoot`, which VS Code applies to `./` sources and Claude Code does not.
- **One manifest per skill, one marketplace per repository.** VS Code reads `.plugin/plugin.json`, or a root `plugin.json` declaring the Agent Plugins `$schema`, ahead of `.claude-plugin/plugin.json`, and that format finds skills only under `skills/`, which would leave the directory's own `SKILL.md` unloaded. The Copilot CLI reads `.plugin/plugin.json`, any root `plugin.json`, and `.github/plugin/plugin.json` first. For marketplaces, VS Code and the Copilot CLI try `marketplace.json`, `.plugin/marketplace.json`, and `.github/plugin/marketplace.json` before `.claude-plugin/marketplace.json`, and the first one found is the whole catalogue.

[`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.
[`plugin-manifests.mjs`](../scripts/plugin-manifests.mjs), which `make -f .claude/Makefile check-skills` runs, holds every manifest to these rules: it parses, its `name` matches the directory, it sets no `version`, no competing manifest sits beside it, and every path in an `agents` key starts with `./`, stays inside the skill directory, and resolves. It requires the marketplace to list every skill that is not internal and nothing else, each entry carrying exactly the three keys above, with a `description` equal to its manifest's. That comparison is why a listed skill needs a manifest even though the marketplace route does not. [`plugin-manifests.test.mjs`](../scripts/plugin-manifests.test.mjs) covers each of these rules, and `make -f .claude/Makefile test-scripts` runs it.

## A published skill stays reachable by name

Expand All @@ -81,7 +86,7 @@ Every skill is in exactly one, and [`check-skill-publishability.mjs`](../scripts

- **Published**, listed in that script's `PUBLISHED` array: used outside this repository, so **codebase-agnostic** (no path, script name, framework, or convention from here) and **language-agnostic**, except `typescript-code-and-test-standards`, whose subject is the language. Where an example needs a language, vary it across examples so no single one reads as required.
- **Installable**: an installer can offer it, but it is not held to the agnosticism bar.
- **Internal**: carries `metadata: internal: true`, which hides it from `npx skills` discovery and from installation unless `INSTALL_INTERNAL_SKILLS=1` is set.
- **Internal**: carries `metadata: internal: true`, which hides it from `npx skills` discovery and from installation unless `INSTALL_INTERNAL_SKILLS=1` is set, and keeps it out of the plugin marketplace, which offers every published and installable skill.

**Every skill carries a licence**, meaning both a `license` frontmatter key and a `LICENSE.txt` in the directory, because a copied directory is the whole of what the recipient gets. Nothing is exempt, internal skills included.

Expand Down
11 changes: 7 additions & 4 deletions .claude/rules/repo-independence.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,16 @@ paths:
- '**/.markdownlint-cli2.jsonc'
- '.github/workflows/**'
- 'docs/**'
- '.claude-plugin/marketplace.json'
---

# The repository never depends on agentic files

**If `.claude/` and `.github/prompts/` were deleted tomorrow, every command, build, test, and lint must still work.** The portfolio is the product; the agent tooling is scaffolding around it. Scaffolding may lean on the building, never the reverse.
**If `.claude/`, `.claude-plugin/`, and `.github/prompts/` were deleted tomorrow, every command, build, test, and lint must still work.** The portfolio is the product; the agent tooling is scaffolding around it. Scaffolding may lean on the building, never the reverse.

## The rule

No `package.json` script, configuration file, workflow, build step, test, or page under [`docs/`](../../docs/index.md) may reference, invoke, import, or require anything under `.claude/` or `.github/prompts/`.
No `package.json` script, configuration file, workflow, build step, test, or page under [`docs/`](../../docs/index.md) may reference, invoke, import, or require anything under `.claude/`, `.claude-plugin/`, or `.github/prompts/`.

That includes indirect reliance: a script that shells out to a file there, a config that imports one, a test that reads one, and a documented procedure that tells a reader to run one.

Expand All @@ -42,16 +43,18 @@ That includes indirect reliance: a script that shells out to a file there, a con

Rule of thumb for a new rule file, hook, or script: it lives under `.claude/`, nothing outside `.claude/` learns its name, and if a human needs to run it, it gets a target in `.claude/Makefile`.

The one piece of agent tooling outside `.claude/` and `.github/prompts/` is the plugin marketplace, [`.claude-plugin/marketplace.json`](../../.claude-plugin/marketplace.json), which sits there because VS Code looks for a marketplace only at the repository root. It names the skill directories under `.claude/skills/`, which is agent tooling naming agent tooling, and nothing in the build reads it. Prettier formats it like any other JSON file, and a formatter finding nothing to format is a no-op.

## How to check

```bash
grep -rn '\.claude/\|\.github/prompts/' package.json docs/ README.md CONTRIBUTING.md \
grep -rn '\.claude/\|\.claude-plugin/\|\.github/prompts/' package.json docs/ README.md CONTRIBUTING.md \
*.config.* tsconfig.json .github/workflows/
```

Every hit must be an ignore glob. Anything else is a violation. Note that `.claude/Makefile` is not a hit, because the search covers only non-agentic files.

The real proof is the delete simulation: move `.claude/` and `.github/prompts/` aside, run `npm run validate` end to end, and confirm exit 0. Restore afterwards.
The real proof is the delete simulation: move `.claude/`, `.claude-plugin/`, and `.github/prompts/` aside, run `npm run validate` end to end, and confirm exit 0. Restore afterwards.

## Why this is written down

Expand Down
95 changes: 27 additions & 68 deletions .claude/scripts/check-skill-publishability.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
#!/usr/bin/env node
// Checks that every skill under `.claude/skills/` is valid against the Agent Skills
// specification, and that each half of a published pair still works when it is the only
// thing someone has.
// specification, that each half of a published pair still works when it is the only
// thing someone has, and, through `plugin-manifests.mjs`, that every skill an installer may
// offer is also installable as an agent plugin.
//
// The two halves of a pair carry the same objective, not the same bytes: a skill may bundle
// `references/`, `agents/`, and `assets/` that a single prompt file cannot. Whether they still
Expand All @@ -12,6 +13,7 @@
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
import { dirname, join, resolve } from 'path';
import { fileURLToPath } from 'url';
import { MARKETPLACE_MANIFEST, PLUGIN_MANIFEST, SHADOWING_MARKETPLACES, checkPlugins } from './plugin-manifests.mjs';

const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
const PROMPT_DIR = join(REPO_ROOT, '.github', 'prompts');
Expand Down Expand Up @@ -59,24 +61,16 @@ const MAX_PROMPT_CHARS_BY_FILE = { 'audit-docs.prompt.md': 36_000 };

/**
* 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.
* `scripts/`; `agents/` is a host extension, read only where a host loads the directory as 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 = [];

/** Each skill's state, worked out once, because the checks and the report each ask for it. */
const states = new Map();

/** Records one failure against a file. */
function fail(file, message) {
failures.push({ file, message });
Expand Down Expand Up @@ -215,7 +209,6 @@ 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
Expand Down Expand Up @@ -264,48 +257,6 @@ function checkInvocable(name, frontmatter) {
}
}

/**
* 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
Expand Down Expand Up @@ -389,16 +340,23 @@ if (skills.length === 0 && prompts.length === 0) {
skills.forEach(checkSkill);
prompts.forEach(checkPrompt);

/** Which of the three states a skill is in, for the report. */
// Only an internal skill is withheld from the marketplace. Every other one is already offered by
// `npx skills`, so leaving it out there would make the two catalogues disagree without anyone
// deciding they should.
const offered = skills.filter((name) => existsSync(join(SKILL_DIR, name, 'SKILL.md')) && state(name) !== 'internal');

failures.push(...checkPlugins(skills, offered));

/** Which of the three states a skill is in. */
function state(name) {
const text = readFileSync(join(SKILL_DIR, name, 'SKILL.md'), 'utf8');
const parts = split(text);
if (!states.has(name)) {
const parts = split(readFileSync(join(SKILL_DIR, name, 'SKILL.md'), 'utf8'));
const internal = parts && isInternal(parts.frontmatter);

if (parts && isInternal(parts.frontmatter)) {
return 'internal';
states.set(name, internal ? 'internal' : PUBLISHED.includes(name) ? 'published' : 'installable');
}

return PUBLISHED.includes(name) ? 'published' : 'installable';
return states.get(name);
}

for (const name of skills) {
Expand All @@ -407,17 +365,18 @@ for (const name of skills) {
}
}

if (!failures.some((entry) => [MARKETPLACE_MANIFEST, ...SHADOWING_MARKETPLACES].includes(entry.file))) {
console.log(`ok ${MARKETPLACE_MANIFEST.padEnd(36)} marketplace`);
}

if (failures.length > 0) {
console.error('');

for (const { file, message } of failures) {
console.error(`FAIL ${file}: ${message}`);
}

console.error(
`\n${failures.length} problem(s). Each half is downloaded on its own, so a prompt may name ` +
'nothing beside it and a skill may name nothing outside itself.',
);
console.error(`\n${failures.length} problem(s).`);
process.exit(1);
}

Expand Down
Loading