Files
Codeman/test/plugin-manifest.test.ts
T
Codeman maintainer 49ab8bc2f1 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>
2026-09-14 14:28:57 +02:00

96 lines
4.5 KiB
TypeScript

/**
* @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; the one plugin it lists is `plugins/codeman/`, whose `skills/codeman`
* is a MIRROR of the real `skills/codeman/` (see `scripts/sync-plugin.mjs` for why it is a
* copy and not the source or a symlink). Pinned here:
*
* - the mirror is byte-identical to the source (edit the source, run the sync script);
* - both manifests carry package.json's version, or `plugin update` never sees a release;
* - the plugin root has NO `package.json`: a plugin root with one gets an npm install at
* install time, which for this repo meant 832 MB, 511 packages and the postinstall build
* on every installer's machine (measured 2026-09-14 with the repo root as plugin root);
* - the plugin ships exactly one component, the skill, and nothing else that would ride
* along silently (`commands/`, `agents/`, `hooks/`, `.mcp.json`, `settings.json`);
* - the skill's frontmatter names it, so the installed skill is `codeman:codeman` and not a
* versioned cache-directory name;
* - the repo root `.claude-plugin/` holds only the marketplace manifest, so the repo itself
* never reads as a plugin again.
*
* Pure filesystem reads against the real tree. Port: N/A.
*/
import { describe, it, expect } from 'vitest';
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
import { join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = fileURLToPath(new URL('..', import.meta.url));
const PLUGIN_DIR = join(ROOT, 'plugins/codeman');
const readJson = (rel: string) => JSON.parse(readFileSync(join(ROOT, rel), 'utf8'));
function walk(dir: string): string[] {
const out: string[] = [];
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;
}
const pkg = readJson('package.json');
const plugin = readJson('plugins/codeman/.claude-plugin/plugin.json');
const marketplace = readJson('.claude-plugin/marketplace.json');
describe('Claude Code plugin (plugins/codeman)', () => {
it('mirrors skills/codeman byte for byte', () => {
const source = join(ROOT, 'skills/codeman');
const mirror = join(PLUGIN_DIR, 'skills/codeman');
const srcFiles = walk(source).map((p) => relative(source, p));
const dstFiles = walk(mirror).map((p) => relative(mirror, p));
expect(dstFiles).toEqual(srcFiles);
for (const rel of srcFiles) {
expect(readFileSync(join(mirror, rel)).equals(readFileSync(join(source, rel))), `${rel} drifted`).toBe(true);
}
});
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 plugins/codeman, 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('./plugins/codeman');
expect(entry.version).toBe(pkg.version);
});
it('the plugin root carries no package.json (an npm-install trigger) and no component but the skill', () => {
expect(existsSync(join(PLUGIN_DIR, 'package.json')), 'plugins/codeman/package.json').toBe(false);
for (const rel of ['commands', 'agents', 'hooks', '.mcp.json', '.lsp.json', 'settings.json', 'monitors']) {
expect(existsSync(join(PLUGIN_DIR, rel)), `plugins/codeman/${rel} would ship with the plugin`).toBe(false);
}
for (const key of ['skills', 'commands', 'agents', 'hooks', 'mcpServers', 'lspServers']) {
expect(plugin[key], `plugin.json "${key}" override`).toBeUndefined();
}
expect(readdirSync(join(PLUGIN_DIR, 'skills'))).toEqual(['codeman']);
});
it('the skill declares its own name, so the installed skill is codeman:codeman', () => {
const skill = readFileSync(join(ROOT, 'skills/codeman/SKILL.md'), 'utf8');
expect(skill.split('---')[1] ?? '').toMatch(/^name: codeman$/m);
});
it('the repo root .claude-plugin holds only the marketplace manifest', () => {
expect(readdirSync(join(ROOT, '.claude-plugin'))).toEqual(['marketplace.json']);
});
});