fix(plugin): move the Claude Code plugin into plugins/codeman so an install no longer runs npm install

With the repo root as the plugin root, `claude plugin install codeman@codeman`
copied the whole checkout into its cache and, because that root carries a
package.json, ran an npm install there: 832 MB, 511 packages and this repo's
postinstall build on every installer's machine (measured from a clean worktree
of the previous commit). A plugin root must be a directory without one.

The plugin is now `plugins/codeman/`: its manifest, a README, and a MIRROR of
`skills/codeman/`. A mirror rather than a symlink because the install copies
the plugin directory and a link pointing outside it would dangle; a mirror
rather than the source because every install path, injector and doc already
names `skills/codeman/`. `scripts/sync-plugin.mjs` (replacing
sync-plugin-version.mjs) mirrors the skill and syncs both manifest versions
inside `version-packages`; `test/plugin-manifest.test.ts` pins byte-identity,
the versions, the absence of a package.json in the plugin root and that the
repo root `.claude-plugin/` holds only the marketplace manifest.

`claude plugin validate --strict` now passes for both the plugin and the repo
root. Install commands are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-09-14 14:28:57 +02:00
parent f6c08118dc
commit 49ab8bc2f1
14 changed files with 3845 additions and 81 deletions
-51
View File
@@ -1,51 +0,0 @@
#!/usr/bin/env node
/**
* @fileoverview Keep the Claude Code plugin manifests in step with package.json.
*
* The repo is its own plugin marketplace (`/plugin marketplace add Ark0N/Codeman`
* reads `.claude-plugin/marketplace.json` from the repo root, and the `codeman`
* plugin it lists is the repo itself, `source: "./"`, whose one skill is
* `skills/codeman/`). Claude Code's `plugin update` only sees a new release when
* the manifest version number changes, so both manifests must carry the version
* `package.json` carries. This runs inside `npm run version-packages`, right after
* `changeset version` bumps package.json, and `test/plugin-manifest.test.ts` pins
* the result so drift fails the gate.
*
* node scripts/sync-plugin-version.mjs rewrite both manifests
* node scripts/sync-plugin-version.mjs --check exit 1 on drift, change nothing
*/
import { readFileSync, writeFileSync } from 'node:fs';
const PLUGIN_NAME = 'codeman';
const MANIFESTS = ['.claude-plugin/plugin.json', '.claude-plugin/marketplace.json'];
const check = process.argv.includes('--check');
const { version } = JSON.parse(readFileSync('package.json', 'utf8'));
let drift = [];
for (const file of MANIFESTS) {
const json = JSON.parse(readFileSync(file, 'utf8'));
const targets = file.endsWith('marketplace.json')
? json.plugins.filter((p) => p.name === PLUGIN_NAME)
: [json];
if (targets.length === 0) {
console.error(`${file}: no plugin entry named "${PLUGIN_NAME}"`);
process.exit(1);
}
for (const target of targets) {
if (target.version !== version) {
drift.push(`${file}: ${target.version} -> ${version}`);
target.version = version;
}
}
if (!check) writeFileSync(file, JSON.stringify(json, null, 2) + '\n');
}
if (drift.length === 0) {
console.log(`plugin manifests already at ${version}`);
} else if (check) {
console.error(`plugin manifest version drift (run: node scripts/sync-plugin-version.mjs):\n ${drift.join('\n ')}`);
process.exit(1);
} else {
console.log(`plugin manifests synced to ${version}:\n ${drift.join('\n ')}`);
}
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env node
/**
* @fileoverview Keep the Claude Code plugin in `plugins/codeman/` in step with its sources.
*
* The repo is its own plugin marketplace: `/plugin marketplace add Ark0N/Codeman` reads
* `.claude-plugin/marketplace.json` from the repo root, and the one plugin it lists is
* `plugins/codeman/`, a small directory holding a plugin manifest, a README and a MIRROR of
* `skills/codeman/`. Two facts make it a mirror rather than the source or a symlink:
* `claude plugin install` copies the plugin directory into its cache, so a symlink pointing
* outside it would dangle; and a plugin root that carries a `package.json` gets an npm
* install at install time (measured: the repo root as plugin root cost every installer
* 832 MB, 511 packages and this repo's postinstall), so the plugin root must be a directory
* without one. `skills/codeman/` stays the single source; edit it, then run this.
*
* Claude Code's `plugin update` only sees a new release when the manifest version changes,
* so both manifests carry `package.json`'s version. This runs inside `npm run
* version-packages`, right after `changeset version` bumps it, and
* `test/plugin-manifest.test.ts` pins version equality and byte-identity of the mirror so
* drift fails the gate.
*
* node scripts/sync-plugin.mjs mirror the skill + rewrite both manifests
* node scripts/sync-plugin.mjs --check exit 1 on any drift, change nothing
*/
import { readFileSync, writeFileSync, readdirSync, statSync, rmSync, cpSync, existsSync } from 'node:fs';
import { join, relative } from 'node:path';
const PLUGIN_NAME = 'codeman';
const SOURCE = 'skills/codeman';
const PLUGIN_DIR = `plugins/${PLUGIN_NAME}`;
const MIRROR = `${PLUGIN_DIR}/skills/codeman`;
const MANIFESTS = [`${PLUGIN_DIR}/.claude-plugin/plugin.json`, '.claude-plugin/marketplace.json'];
const check = process.argv.includes('--check');
const { version } = JSON.parse(readFileSync('package.json', 'utf8'));
const drift = [];
/** Every file under `dir`, as repo-relative paths sorted for comparison. */
function walk(dir) {
const out = [];
for (const name of readdirSync(dir).sort()) {
const p = join(dir, name);
if (statSync(p).isDirectory()) out.push(...walk(p));
else out.push(p);
}
return out;
}
// 1. The mirror.
const src = walk(SOURCE).map((p) => relative(SOURCE, p));
const dst = existsSync(MIRROR) ? walk(MIRROR).map((p) => relative(MIRROR, p)) : [];
const same =
src.length === dst.length &&
src.every((rel, i) => rel === dst[i] && readFileSync(join(SOURCE, rel)).equals(readFileSync(join(MIRROR, rel))));
if (!same) {
drift.push(`${MIRROR} differs from ${SOURCE}`);
if (!check) {
rmSync(MIRROR, { recursive: true, force: true });
cpSync(SOURCE, MIRROR, { recursive: true });
}
}
// 2. The versions.
for (const file of MANIFESTS) {
const json = JSON.parse(readFileSync(file, 'utf8'));
const targets = file.endsWith('marketplace.json') ? json.plugins.filter((p) => p.name === PLUGIN_NAME) : [json];
if (targets.length === 0) {
console.error(`${file}: no plugin entry named "${PLUGIN_NAME}"`);
process.exit(1);
}
let changed = false;
for (const target of targets) {
if (target.version !== version) {
drift.push(`${file}: ${target.version} -> ${version}`);
target.version = version;
changed = true;
}
}
if (changed && !check) writeFileSync(file, JSON.stringify(json, null, 2) + '\n');
}
if (drift.length === 0) {
console.log(`plugin in step: mirror identical, manifests at ${version}`);
} else if (check) {
console.error(`plugin drift (run: node scripts/sync-plugin.mjs):\n ${drift.join('\n ')}`);
process.exit(1);
} else {
console.log(`plugin synced:\n ${drift.join('\n ')}`);
}