From f6c08118dc2f914191d05c283c74ced2020cf8db Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 14 Sep 2026 14:24:35 +0200 Subject: [PATCH] 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 --- .claude-plugin/marketplace.json | 30 ++++++++++ .claude-plugin/plugin.json | 22 +++++++ CLAUDE.md | 4 +- README.md | 1 + README.zh-CN.md | 1 + docs/wiki/Driving-Codeman-From-An-Agent.md | 1 + package.json | 2 +- scripts/sync-plugin-version.mjs | 51 ++++++++++++++++ test/plugin-manifest.test.ts | 67 ++++++++++++++++++++++ 9 files changed, 176 insertions(+), 3 deletions(-) create mode 100644 .claude-plugin/marketplace.json create mode 100644 .claude-plugin/plugin.json create mode 100644 scripts/sync-plugin-version.mjs create mode 100644 test/plugin-manifest.test.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 00000000..5023527f --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,30 @@ +{ + "name": "codeman", + "owner": { + "name": "Ark0N", + "url": "https://github.com/Ark0N" + }, + "description": "Codeman, self-hosted mission control for AI coding agents. Ships the codeman agent skill: let one Claude Code session spawn, prompt, wait on and read other sessions.", + "plugins": [ + { + "name": "codeman", + "source": "./", + "description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.", + "version": "1.28.1", + "author": { + "name": "Ark0N", + "url": "https://github.com/Ark0N" + }, + "homepage": "https://getcodeman.com", + "category": "productivity", + "keywords": [ + "codeman", + "orchestration", + "multi-agent", + "session-manager", + "tmux", + "claude-code" + ] + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 00000000..9f90f061 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,22 @@ +{ + "name": "codeman", + "description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.", + "version": "1.28.1", + "author": { + "name": "Ark0N", + "url": "https://github.com/Ark0N" + }, + "homepage": "https://getcodeman.com", + "repository": "https://github.com/Ark0N/Codeman", + "license": "MIT", + "keywords": [ + "codeman", + "orchestration", + "multi-agent", + "session-manager", + "tmux", + "claude-code", + "codex", + "deepseek" + ] +} diff --git a/CLAUDE.md b/CLAUDE.md index 82bfb376..91803e14 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co > > **This file is in `.prettierignore` on purpose.** Prettier's markdown printer escapes underscores inside the glob-heavy paths used throughout (`agent-*.jsonl` became `agent-\_.jsonl`, collapsing backtick spans and corrupting a whole paragraph). Do not remove the ignore entry, and do not run `prettier --write` on it. > -> **Repo root is kept short on purpose** (the README sits below the file listing on GitHub). Config lives in `config/` (`eslint.config.js`, `knip.json`, the vitest configs), Prettier's config is the `"prettier"` key in `package.json`, and `SECURITY.md` is under `.github/`. Root-only files are the ones tools genuinely require there: `CLAUDE.md` + `AGENTS.md` (loaded from the root by Claude Code / Codex), `CHANGELOG.md` (changesets writes it next to `package.json`), `tsconfig.json`, `.editorconfig`, `.nvmrc`/`.npmrc`, `.prettierignore` (resolved relative to cwd), `LICENSE` (GitHub detection), `.dockerignore` (the build context is the repo root, so Docker resolves it there and nowhere else) and `install.sh` (its raw URL is the published install one-liner). Don't relocate those. +> **Repo root is kept short on purpose** (the README sits below the file listing on GitHub). Config lives in `config/` (`eslint.config.js`, `knip.json`, the vitest configs), Prettier's config is the `"prettier"` key in `package.json`, and `SECURITY.md` is under `.github/`. Root-only files are the ones tools genuinely require there: `CLAUDE.md` + `AGENTS.md` (loaded from the root by Claude Code / Codex), `CHANGELOG.md` (changesets writes it next to `package.json`), `tsconfig.json`, `.editorconfig`, `.nvmrc`/`.npmrc`, `.prettierignore` (resolved relative to cwd), `LICENSE` (GitHub detection), `.dockerignore` (the build context is the repo root, so Docker resolves it there and nowhere else) and `install.sh` (its raw URL is the published install one-liner). `.claude-plugin/` (`plugin.json` + `marketplace.json`) is root-only for the same reason: `/plugin marketplace add Ark0N/Codeman` reads it from the repo root and nowhere else, which makes the repo its own plugin marketplace with the repo itself (`source: "./"`) as the one plugin, whose one component is `skills/codeman/`. Both manifests carry `package.json`'s version, synced by `scripts/sync-plugin-version.mjs` inside `version-packages` and pinned by `test/plugin-manifest.test.ts`, which also refuses any other plugin component (`commands/`, `agents/`, `hooks/`, `.mcp.json`, `settings.json`) appearing at the root, since an install would silently ship it. Don't relocate those. ## Quick Reference @@ -193,7 +193,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Input**: `session.writeViaMux()` for programmatic/curl input via tmux `send-keys -l` + `send-keys Enter`, single-line only. Interactive **browser** input goes through a durable **exactly-once** layer: a stable `clientId` + monotonic per-session `seq` persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. `ws-connection-registry.ts` supersedes only same-TAB reconnects, so two tabs on one session coexist. → [architecture-invariants#input-delivery-and-ws-resilience](docs/architecture-invariants.md#input-delivery-and-ws-resilience) -**Agent wait primitives**: bounded long-polls so an agent driving Codeman from a shell can block instead of poll: `GET /api/sessions/:id/wait` (lifecycle signal), `GET /api/sessions/:id/wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST /api/sessions/:id/input`. Registry in `session-wait-registry.ts` (pure, no `Session` reference), bounds in `config/agent-wait.ts`. ⚠️ **A timeout is a 200** (`wait.timedOut`), never an error, so callers loop over short waits. ⚠️ `stop`/`blocked` are hook-driven and fire for **`claude` and `deepseek` ONLY** (`shell` installs none either); asking for one explicitly on any other mode is a 400, the default set silently drops them. `deepseek` qualifies because the DeepSeek Harness TUI REPORTS idle/working/blocked to its supervisor and Codeman is that supervisor (`deepseek-status-shim.ts`), so its signals are definitive rather than inferred — `hooksAvailableForMode()` in `session-wait-registry.ts` is the one place that rule lives. ⚠️ Send-and-wait registers the waiter BEFORE the write (a separate POST-then-wait races and reports the PREVIOUS turn), and both teardown paths must `notifySignal('exit')` BEFORE `cancelAll()`. ⚠️ Client-hangup abort listens on **`reply.raw`** guarded by `writableFinished`: on `req.raw`, `close` fires when the request BODY ends, which on a POST killed every send-and-wait instantly and no `app.inject()` test could see it. ⚠️ Worker liveness cannot come from `session.pid` — for a tmux session that is the local attach client, which outlives a worker dying inside its pane — so it is probed at the mux layer (`isPaneDead`, ~750 ms cache) on blocking waits only, never on the input hot path. ⚠️ Signals are edge-triggered with no history: one that fires with no waiter registered is unobservable afterwards, so gather fan-outs with send-and-wait or latched `wait-output` markers, never fire-and-forget-then-sequential-signal-waits. ⚠️ **`deepseek` is therefore the one non-claude mode the skill drives like claude** — `spawn_workers alpha beta:deepseek` is a mixed fleet in one call, and `sendwait`/`last_text` need no variant. Two traps are baked into the preamble rather than left to the agent: the harness's boot `idle` report lands ~300 ms BEFORE its composer paints (2.26 s vs 2.56 s, measured), so readiness must come from the composer and never from the signal, or a send-and-wait resolves on the boot edge and reports a turn that never ran; and `sendwait` asks for `wait:"stop,exit"` rather than the default set, because that set also carries `idle`, which for an external CLI is inferred from output stabilization — on a dsh worker whose TUI repaints rarely, a re-wait resolved in 0 ms with `signal:"idle"` on a turn with minutes left to run. The primitives are packaged as the **`skills/codeman` agent skill**: installable via `codeman skill install [--case ]` / `skill uninstall`, or auto-injected into a case's `.claude/skills/` on Claude session create behind `agentSkillEnabled` (SYNCED, default OFF). Injection is ADD-ONLY at create, marker-owned (`applyAgentSkill` in `hooks-config.ts` never touches an unmarked user copy) and refuses symlinks (this repo's own `.claude/skills/codeman` is a symlink to the source, which the injector must never write through). ⚠️ Claude Code loads a same-named USER-LEVEL skill (`~/.claude/skills/codeman`, written once by `codeman skill install` with no `--case`) over the per-case copy, and nothing used to refresh it: a stale Aug-9 user copy shadowed every fresh injection (2026-08-14: agents ran the old recipes, spawned workers serially and lost their lineage arcs), so session create now also refreshes a marker-owned user copy (`refreshUserAgentSkill`; refresh-only, never installs, foreign/symlink refused). Session create additionally pre-seeds the skill's §0 preamble cache (`seedAgentSessionPreamble` → `${XDG_CACHE_HOME:-~/.cache}/codeman-agent-.sh`, local claude sessions only), single-sourced from `skills/codeman/preamble.sh` and pinned byte-identical to SKILL.md's §0 heredoc by `test/agent-skill.test.ts`, so the skill's bootstrap is a two-line loader instead of a ~150-line paste the model types out (~47 s of generation, measured live). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md` +**Agent wait primitives**: bounded long-polls so an agent driving Codeman from a shell can block instead of poll: `GET /api/sessions/:id/wait` (lifecycle signal), `GET /api/sessions/:id/wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST /api/sessions/:id/input`. Registry in `session-wait-registry.ts` (pure, no `Session` reference), bounds in `config/agent-wait.ts`. ⚠️ **A timeout is a 200** (`wait.timedOut`), never an error, so callers loop over short waits. ⚠️ `stop`/`blocked` are hook-driven and fire for **`claude` and `deepseek` ONLY** (`shell` installs none either); asking for one explicitly on any other mode is a 400, the default set silently drops them. `deepseek` qualifies because the DeepSeek Harness TUI REPORTS idle/working/blocked to its supervisor and Codeman is that supervisor (`deepseek-status-shim.ts`), so its signals are definitive rather than inferred — `hooksAvailableForMode()` in `session-wait-registry.ts` is the one place that rule lives. ⚠️ Send-and-wait registers the waiter BEFORE the write (a separate POST-then-wait races and reports the PREVIOUS turn), and both teardown paths must `notifySignal('exit')` BEFORE `cancelAll()`. ⚠️ Client-hangup abort listens on **`reply.raw`** guarded by `writableFinished`: on `req.raw`, `close` fires when the request BODY ends, which on a POST killed every send-and-wait instantly and no `app.inject()` test could see it. ⚠️ Worker liveness cannot come from `session.pid` — for a tmux session that is the local attach client, which outlives a worker dying inside its pane — so it is probed at the mux layer (`isPaneDead`, ~750 ms cache) on blocking waits only, never on the input hot path. ⚠️ Signals are edge-triggered with no history: one that fires with no waiter registered is unobservable afterwards, so gather fan-outs with send-and-wait or latched `wait-output` markers, never fire-and-forget-then-sequential-signal-waits. ⚠️ **`deepseek` is therefore the one non-claude mode the skill drives like claude** — `spawn_workers alpha beta:deepseek` is a mixed fleet in one call, and `sendwait`/`last_text` need no variant. Two traps are baked into the preamble rather than left to the agent: the harness's boot `idle` report lands ~300 ms BEFORE its composer paints (2.26 s vs 2.56 s, measured), so readiness must come from the composer and never from the signal, or a send-and-wait resolves on the boot edge and reports a turn that never ran; and `sendwait` asks for `wait:"stop,exit"` rather than the default set, because that set also carries `idle`, which for an external CLI is inferred from output stabilization — on a dsh worker whose TUI repaints rarely, a re-wait resolved in 0 ms with `signal:"idle"` on a turn with minutes left to run. The primitives are packaged as the **`skills/codeman` agent skill**: installable via `codeman skill install [--case ]` / `skill uninstall`, as a Claude Code plugin from the repo's own marketplace (`/plugin marketplace add Ark0N/Codeman` + `/plugin install codeman@codeman`, see the root-files note above), or auto-injected into a case's `.claude/skills/` on Claude session create behind `agentSkillEnabled` (SYNCED, default OFF). Injection is ADD-ONLY at create, marker-owned (`applyAgentSkill` in `hooks-config.ts` never touches an unmarked user copy) and refuses symlinks (this repo's own `.claude/skills/codeman` is a symlink to the source, which the injector must never write through). ⚠️ Claude Code loads a same-named USER-LEVEL skill (`~/.claude/skills/codeman`, written once by `codeman skill install` with no `--case`) over the per-case copy, and nothing used to refresh it: a stale Aug-9 user copy shadowed every fresh injection (2026-08-14: agents ran the old recipes, spawned workers serially and lost their lineage arcs), so session create now also refreshes a marker-owned user copy (`refreshUserAgentSkill`; refresh-only, never installs, foreign/symlink refused). Session create additionally pre-seeds the skill's §0 preamble cache (`seedAgentSessionPreamble` → `${XDG_CACHE_HOME:-~/.cache}/codeman-agent-.sh`, local claude sessions only), single-sourced from `skills/codeman/preamble.sh` and pinned byte-identical to SKILL.md's §0 heredoc by `test/agent-skill.test.ts`, so the skill's bootstrap is a two-line loader instead of a ~150-line paste the model types out (~47 s of generation, measured live). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md` **Agent-created case marker** (`src/agent-case-marker.ts`): a case directory `POST /api/quick-start` **creates** for an agent-driven spawn gets a `.codeman-agent-case.json` marker, so the scratch workspaces a long orchestration leaves behind (one per worker, and deleting the session does not remove them) can still be told apart from the user's real projects months later. `GET /api/cases` publishes it as `agentCreated`; `GET /api/cases/agent-created` is the read-only cleanup listing, adding `inUse` (a live session's `workingDir` is that case) and `modifiedAt`; Add Case → Manage badges each one and offers a review-then-delete sweep. The signal is the skill preamble's `X-Codeman-Agent-Origin` header (or an `agentOrigin` body field), falling back to a RESOLVED `parentSessionId` — nothing in the browser UI sets lineage, so a create request naming its spawning session came from an agent by construction, and that fallback is what still labels workers spawned by a stale skill copy. ⚠️ **Only the branch that CREATES the directory may write it.** A linked case, a cloned repo or any pre-existing path must never be labelled: the label drives a recursive-delete affordance, and mislabelling someone's repo there is the one failure mode that costs real work. `POST /api/sessions` takes an existing `workingDir`, so it writes no marker at all, by construction. ⚠️ Reading is TOTAL: anything that is not a well-formed version-1 marker (truncated write, hand-edited junk) reads as *not* agent-created rather than as a half-trusted entry, and deleting the file is the supported way to adopt a scratch case as a real one — which is what the `note` written into it tells whoever finds it. ⚠️ Removal stays on the existing `DELETE /api/cases/:name`, one name at a time, so there is exactly ONE recursive-delete path; the UI's sweep names every directory in its confirm and EXCLUDES an `inUse` case outright rather than confirming it away. ⚠️ Marker in the case dir rather than a registry under `~/.codeman`: it survives a wiped data dir or a different instance, is removed by the same `rm -rf` that removes the case (so no stale-entry pruning), and a user who runs `ls -a` can see what labelled their directory. Adding the header changed the preamble, so `CODEMAN_PREAMBLE` was bumped (1.22.0) — a cached copy is version-checked, and forgetting the bump leaves every already-seeded agent sending the old headers. Tests: `test/agent-case-marker.test.ts`, `test/routes/agent-case-marker-routes.test.ts`. diff --git a/README.md b/README.md index 87e05257..49acf5d5 100644 --- a/README.md +++ b/README.md @@ -715,6 +715,7 @@ Everything in this section also ships as a **Claude Code skill** in [`skills/cod | How | Command | Scope | | -------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------ | | Skills CLI | `npx skills add Ark0N/Codeman --skill codeman -g` | Global, works for any skills-aware agent | +| Claude Code plugin | `/plugin marketplace add Ark0N/Codeman` then `/plugin install codeman@codeman` | Global, through Claude Code's plugin manager; `/plugin update codeman` follows releases | | Bundled CLI | `codeman skill install` | Global (`~/.claude/skills/codeman`), for npm installs that never cloned the repo | | Bundled CLI | `codeman skill install --case ` | One case only | | Web UI | App Settings → Agents & CLIs → Claude → **Agent Skill** | Auto-injects into each case on Claude session create (`agentSkillEnabled`, SYNCED, default off) | diff --git a/README.zh-CN.md b/README.zh-CN.md index 6d8d528b..37ea93d1 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -646,6 +646,7 @@ Codeman 默认用 `--dangerously-skip-permissions` 启动会话,因此 Web UI > **捷径:装上打包好的智能体技能。** 下面这一整套(外加多工作会话的实战配方)已经作为 Claude Code 技能随仓库发布在 [`skills/codeman`](skills/codeman/SKILL.md),会话内部的智能体不必等你把文档粘进提示词就能驱动 Codeman。三种获取方式: > > - `npx skills add Ark0N/Codeman --skill codeman -g`:全局安装,任何支持技能的智能体都能用 +> - Claude Code 插件:`/plugin marketplace add Ark0N/Codeman`,然后 `/plugin install codeman@codeman`:通过 Claude Code 自带的插件管理器全局安装,`/plugin update codeman` 跟随新版本 > - `codeman skill install`(全局)或 `codeman skill install --case `:给那些从 npm 安装、从未克隆过仓库的用户;`codeman skill uninstall` 可撤销 > - **App Settings → Agent Skill**(`agentSkillEnabled`,默认关闭):开启后,Codeman 会在每次于某个 case 中创建 Claude 会话时把技能注入该 case;case 里用户自己写的 `skills/codeman` 永远不会被覆盖 > diff --git a/docs/wiki/Driving-Codeman-From-An-Agent.md b/docs/wiki/Driving-Codeman-From-An-Agent.md index 0f595193..b84cd7ad 100644 --- a/docs/wiki/Driving-Codeman-From-An-Agent.md +++ b/docs/wiki/Driving-Codeman-From-An-Agent.md @@ -16,6 +16,7 @@ instead of pasting endpoint documentation into prompts. | How | Command | Scope | | ------------ | ----------------------------------------------------------- | ----------------------------------------------------------- | | Skills CLI | `npx skills add Ark0N/Codeman --skill codeman -g` | Global, any skills-aware agent. | +| Claude Code plugin | `/plugin marketplace add Ark0N/Codeman`, then `/plugin install codeman@codeman` | Global, through Claude Code's plugin manager. `/plugin update codeman` follows releases. | | Bundled CLI | `codeman skill install` | Global, at `~/.claude/skills/codeman`. | | Bundled CLI | `codeman skill install --case ` | One case. | | Web UI | **App Settings → Agents & CLIs → Claude → Agent Skill** | Injects into each case when a Claude session is created. Off by default. | diff --git a/package.json b/package.json index 68cb26c5..a01dc4a3 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "check:public-assets": "node scripts/check-public-assets.mjs", "capture:subagents": "node scripts/capture-subagent-screenshots.mjs", "changeset": "changeset", - "version-packages": "changeset version && npm install --package-lock-only && node scripts/check-lockfile-sync.mjs", + "version-packages": "changeset version && node scripts/sync-plugin-version.mjs && npm install --package-lock-only && node scripts/check-lockfile-sync.mjs", "check:lockfile": "node scripts/check-lockfile-sync.mjs", "knip": "npx --yes knip@latest --config config/knip.json", "release": "changeset publish", diff --git a/scripts/sync-plugin-version.mjs b/scripts/sync-plugin-version.mjs new file mode 100644 index 00000000..ea9a929c --- /dev/null +++ b/scripts/sync-plugin-version.mjs @@ -0,0 +1,51 @@ +#!/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 ')}`); +} diff --git a/test/plugin-manifest.test.ts b/test/plugin-manifest.test.ts new file mode 100644 index 00000000..5849fd39 --- /dev/null +++ b/test/plugin-manifest.test.ts @@ -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(); + } + }); +});