mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 06:29:42 +02:00
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:
@@ -2,36 +2,60 @@
|
||||
* @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.
|
||||
* 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:
|
||||
*
|
||||
* `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.
|
||||
* - 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, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
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('.claude-plugin/plugin.json');
|
||||
const plugin = readJson('plugins/codeman/.claude-plugin/plugin.json');
|
||||
const marketplace = readJson('.claude-plugin/marketplace.json');
|
||||
|
||||
describe('Claude Code plugin manifests', () => {
|
||||
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);
|
||||
@@ -39,29 +63,33 @@ describe('Claude Code plugin manifests', () => {
|
||||
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', () => {
|
||||
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('./');
|
||||
expect(entry.source).toBe('./plugins/codeman');
|
||||
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', () => {
|
||||
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(ROOT, rel)), `${rel} at the repo root would become part of the plugin`).toBe(false);
|
||||
expect(existsSync(join(PLUGIN_DIR, rel)), `plugins/codeman/${rel} would ship with 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();
|
||||
}
|
||||
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']);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user