feat(skill): ship the codeman agent skill as a Claude Code plugin from the repo's own marketplace

`.claude-plugin/marketplace.json` at the repo root makes
`/plugin marketplace add Ark0N/Codeman` work, and the one plugin it lists is
the repo itself (`source: "./"`), whose one component is `skills/codeman/`.
So `/plugin install codeman@codeman` is a third install route next to
`npx skills add` and `codeman skill install`, and the skill shows up in the
plugin directories that index Claude Code marketplaces.

Both manifests carry package.json's version: `scripts/sync-plugin-version.mjs`
rewrites them inside `version-packages`, right after `changeset version`, and
`test/plugin-manifest.test.ts` pins the equality, the skill's frontmatter name
(without it the installed skill would be named after a versioned cache dir),
and that no other plugin component (`commands/`, `agents/`, `hooks/`,
`.mcp.json`, `settings.json`) appears at the repo root, since an install would
silently ship it.

Verified with `claude plugin validate` (one expected warning: CLAUDE.md at a
plugin root is not plugin context) and a local marketplace add, install,
details, uninstall cycle against a clean checkout of this commit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-09-14 14:24:35 +02:00
parent 7df2dc5955
commit f6c08118dc
9 changed files with 176 additions and 3 deletions
+67
View File
@@ -0,0 +1,67 @@
/**
* @fileoverview Static guard for the Claude Code plugin the repo publishes about itself.
*
* `.claude-plugin/marketplace.json` at the repo root makes `/plugin marketplace add
* Ark0N/Codeman` work, and the one plugin it lists is the repo itself (`source: "./"`),
* so the plugin's component roots ARE the repo root. Two things follow and both are
* pinned here: the manifests must carry package.json's version (Claude Code's
* `plugin update` only sees a release when that number changes; `version-packages`
* runs `scripts/sync-plugin-version.mjs` to keep them in step), and the repo root
* must not grow any other plugin component (`commands/`, `agents/`, `hooks/`,
* `.mcp.json`, `.lsp.json`, `settings.json`), or every plugin install would silently
* ship it. The skill's frontmatter `name` is pinned too: without it the installed
* skill would be named after the cache directory, which is a version string.
*
* `claude plugin validate .claude-plugin/plugin.json` passes with one warning, that CLAUDE.md at
* the plugin root is not loaded as plugin context. That is what a repo-root plugin looks like,
* not a defect; `--strict` is therefore not the right mode for this repo.
*
* Pure filesystem reads against the real tree. Port: N/A.
*/
import { describe, it, expect } from 'vitest';
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = fileURLToPath(new URL('..', import.meta.url));
const readJson = (rel: string) => JSON.parse(readFileSync(join(ROOT, rel), 'utf8'));
const pkg = readJson('package.json');
const plugin = readJson('.claude-plugin/plugin.json');
const marketplace = readJson('.claude-plugin/marketplace.json');
describe('Claude Code plugin manifests', () => {
it('plugin.json names the codeman plugin at the package version', () => {
expect(plugin.name).toBe('codeman');
expect(plugin.version).toBe(pkg.version);
expect(plugin.license).toBe('MIT');
expect(plugin.repository).toBe('https://github.com/Ark0N/Codeman');
});
it('marketplace.json lists exactly that plugin, sourced from the repo root, at the same version', () => {
expect(marketplace.name).toBe('codeman');
expect(marketplace.owner?.name).toBeTruthy();
expect(marketplace.plugins).toHaveLength(1);
const [entry] = marketplace.plugins;
expect(entry.name).toBe(plugin.name);
expect(entry.source).toBe('./');
expect(entry.version).toBe(pkg.version);
});
it('the skill declares its own name, so the installed skill is codeman:codeman and not a cache-dir version string', () => {
const skill = readFileSync(join(ROOT, 'skills/codeman/SKILL.md'), 'utf8');
const frontmatter = skill.split('---')[1] ?? '';
expect(frontmatter).toMatch(/^name: codeman$/m);
});
it('the repo root carries no other plugin component the install would ship', () => {
for (const rel of ['commands', 'agents', 'hooks', '.mcp.json', '.lsp.json', 'settings.json', 'monitors']) {
expect(existsSync(join(ROOT, rel)), `${rel} at the repo root would become part of the plugin`).toBe(false);
}
// The manifest must not redirect component discovery either; the defaults are the contract.
for (const key of ['skills', 'commands', 'agents', 'hooks', 'mcpServers', 'lspServers']) {
expect(plugin[key], `plugin.json "${key}" override`).toBeUndefined();
}
});
});