diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 58c9000f..1a1701b0 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "commitlore", "displayName": "CommitLore", - "version": "1.2.1", + "version": "1.2.2", "description": "Recorded decisions from git history, delivered to the agent before it edits. Constraints, alternatives already ruled out, and warnings left by whoever was here last.", "author": { "name": "MongLong0214", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 7470e9e7..1a79f028 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "commitlore", - "version": "1.2.1", + "version": "1.2.2", "description": "Decision memory from Git history, with verified capture for coding sessions.", "author": { "name": "MongLong0214", diff --git a/.mcp.json b/.mcp.json index e759f6cb..75ffbcae 100644 --- a/.mcp.json +++ b/.mcp.json @@ -3,10 +3,9 @@ "commitlore": { "command": "node", "args": [ - "./dist/commitlore.mjs", + "${CLAUDE_PLUGIN_ROOT:-.}/dist/commitlore.mjs", "mcp" - ], - "cwd": "." + ] } } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a816821..51d244d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,56 @@ Release notes for 1.0.0, 1.0.1 and 1.0.2 are on the [GitHub releases page](https://github.com/MongLong0214/commitlore/releases); they were not written here. +## 1.2.2 + +One line of configuration, and the plugin's MCP server had never started for +anybody. + +**The plugin's MCP server died at launch everywhere but a commitlore checkout +(#870).** `.mcp.json` named the entry point as `./dist/commitlore.mjs` and set +`"cwd": "."`, and both of those resolve against the *session's* working +directory rather than the plugin's install directory. So node was asked for +`/dist/commitlore.mjs`, which does not exist, and the server exited +in under 60ms with `MODULE_NOT_FOUND`. Capture is MCP-only, so a session with +the plugin installed made commits carrying no records at all, and the only +visible symptom was one line saying a connection failure had been cached. It was +identical in the `0.8.0` and `1.2.0` plugin caches, so no release ever shipped a +working one. The entry point is now +`${CLAUDE_PLUGIN_ROOT:-.}/dist/commitlore.mjs` with no `cwd` — the form +[ADR-0026](docs/adr/ADR-0026-node-only-distribution.md) and the F14 ticket had +both documented while the file said otherwise. + +**The defect was invisible to every check because they all ran in the one place +it cannot appear.** A checkout is the one cwd where a session-relative path is +also the plugin's path, and that is where the suite, the release gate and the +maintainers all work. The manifest checks now launch from a directory that is +not a checkout, and one of them drives the server to an MCP `initialize` rather +than stopping at "something resolved" — the same distinction #483 forced on the +plugin entry point two releases ago. + +**A registration is read the way a host reads it.** `.mcp.json` is a launch +instruction for a host, and hosts expand `${VAR}` and `${VAR:-default}` before +they spawn anything; the readers here returned the raw text. Doctor's +unattended-initiator probe therefore launched a literal `${...}` as a path and +would have called this repository's own registration unhealthy — sending an +operator to repair the one thing that is not broken. An unset placeholder with +no default is left as written on purpose: a host refuses that registration +outright, and expanding it to nothing would turn the refusal into a +plausible-looking path whose failure names a file nobody wrote. + +**The default is what keeps this repository working on itself.** `:-.` is not +decoration. This repository's `.mcp.json` is also an ordinary project file here, +loaded by a host that sets no plugin root, and without the fallback the +dogfooding install would break in exchange for fixing the plugin. + +**Not verified.** Codex reads the same `.mcp.json`, declared by +`.codex-plugin/plugin.json`, and whether Codex performs the same placeholder +expansion was not measured — no Codex install was available to this change. What +is measured is that the Claude Code plugin path now launches from a foreign cwd +and answers `initialize`. If Codex does not expand, its launch is no worse than +the relative path it had before, and that is a claim about the shape of the +change, not a test result. + ## 1.2.1 A security release, and one line that had been asserting something nobody checked. diff --git a/README.ja.md b/README.ja.md index 54a57d98..b443698a 100644 --- a/README.ja.md +++ b/README.ja.md @@ -47,18 +47,18 @@

```bash -curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.1/install.sh | sh -s v1.2.1 +curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.2/install.sh | sh -s v1.2.2 ```
先にインストーラーを読みたいですか? ```bash -curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.1/install.sh -sh install.sh v1.2.1 +curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.2/install.sh +sh install.sh v1.2.2 # あるいはスクリプトを使わずに。スクリプトが作るチェックアウトは自分でも作れます。 -git clone --depth 1 --branch v1.2.1 https://github.com/MongLong0214/commitlore +git clone --depth 1 --branch v1.2.2 https://github.com/MongLong0214/commitlore node commitlore/dist/commitlore.mjs --version ``` @@ -107,13 +107,13 @@ CommitLore はその判断をコードのそばに残します。 macOS と Linux: ```bash -curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.1/install.sh | sh -s v1.2.1 +curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.2/install.sh | sh -s v1.2.2 ``` Windows: ```powershell -& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.1/install.ps1))) v1.2.1 +& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.2/install.ps1))) v1.2.2 ``` Node.js 22.23.2+ と Git が必要です。スクリプトは何かを書き込む前に両方を確認します。 diff --git a/README.ko.md b/README.ko.md index 4a31cdaa..396becd9 100644 --- a/README.ko.md +++ b/README.ko.md @@ -47,18 +47,18 @@

```bash -curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.1/install.sh | sh -s v1.2.1 +curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.2/install.sh | sh -s v1.2.2 ```
먼저 설치기를 읽어 보고 싶나요? ```bash -curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.1/install.sh -sh install.sh v1.2.1 +curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.2/install.sh +sh install.sh v1.2.2 # 또는 스크립트를 건너뜁니다. 스크립트가 만드는 체크아웃은 직접 만들 수 있습니다. -git clone --depth 1 --branch v1.2.1 https://github.com/MongLong0214/commitlore +git clone --depth 1 --branch v1.2.2 https://github.com/MongLong0214/commitlore node commitlore/dist/commitlore.mjs --version ``` @@ -107,13 +107,13 @@ CommitLore는 그 판단을 코드 곁에 보관합니다. macOS와 Linux: ```bash -curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.1/install.sh | sh -s v1.2.1 +curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.2/install.sh | sh -s v1.2.2 ``` Windows: ```powershell -& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.1/install.ps1))) v1.2.1 +& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.2/install.ps1))) v1.2.2 ``` Node.js 22.23.2+와 Git이 필요합니다. 스크립트는 무엇이든 쓰기 전에 둘을 확인합니다. diff --git a/README.md b/README.md index e3ad22b1..ce9331e3 100644 --- a/README.md +++ b/README.md @@ -48,18 +48,18 @@

```bash -curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.1/install.sh | sh -s v1.2.1 +curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.2/install.sh | sh -s v1.2.2 ```
Prefer to read the installer first? ```bash -curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.1/install.sh -sh install.sh v1.2.1 +curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.2/install.sh +sh install.sh v1.2.2 # Or skip the script: the checkout it makes is one you can make yourself. -git clone --depth 1 --branch v1.2.1 https://github.com/MongLong0214/commitlore +git clone --depth 1 --branch v1.2.2 https://github.com/MongLong0214/commitlore node commitlore/dist/commitlore.mjs --version ``` @@ -109,13 +109,13 @@ preserve, not for narrating every change. macOS and Linux: ```bash -curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.1/install.sh | sh -s v1.2.1 +curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.2/install.sh | sh -s v1.2.2 ``` Windows: ```powershell -& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.1/install.ps1))) v1.2.1 +& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.2/install.ps1))) v1.2.2 ``` Requires Node.js 22.23.2+ and Git. The script checks both before it writes anything. diff --git a/README.zh-CN.md b/README.zh-CN.md index fb2f22b6..8448a86b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -47,18 +47,18 @@

```bash -curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.1/install.sh | sh -s v1.2.1 +curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.2/install.sh | sh -s v1.2.2 ```
想先阅读安装器吗? ```bash -curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.1/install.sh -sh install.sh v1.2.1 +curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.2/install.sh +sh install.sh v1.2.2 # 或者跳过脚本:它创建的检出,你自己也能创建。 -git clone --depth 1 --branch v1.2.1 https://github.com/MongLong0214/commitlore +git clone --depth 1 --branch v1.2.2 https://github.com/MongLong0214/commitlore node commitlore/dist/commitlore.mjs --version ``` @@ -105,13 +105,13 @@ CommitLore 把那份判断留在代码旁边。 macOS 和 Linux: ```bash -curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.1/install.sh | sh -s v1.2.1 +curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.2/install.sh | sh -s v1.2.2 ``` Windows: ```powershell -& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.1/install.ps1))) v1.2.1 +& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.2/install.ps1))) v1.2.2 ``` 需要 Node.js 22.23.2+ 和 Git。脚本会在写入任何内容前检查两者。 diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index eda083e0..786475f0 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -200,7 +200,7 @@ skill and MCP server. | Capability | Provided by | Value | |---|---|---| -| MCP server | `.mcp.json` | `node ./dist/commitlore.mjs mcp` with plugin-root `cwd` | +| MCP server | `.mcp.json` | `node ${CLAUDE_PLUGIN_ROOT:-.}/dist/commitlore.mjs mcp` | | capture skill | `skills/commitlore-codex/SKILL.md` | transcript-backed capture; claims lacking support are dropped, never cited by invention | | plugin identity | `.codex-plugin/plugin.json` | `commitlore` at the `package.json` version | @@ -211,7 +211,7 @@ named in the middle column rather than one it assumed. | Capability | Provided by | Value | |---|---|---| -| MCP server | `.mcp.json` | `node ./dist/commitlore.mjs mcp` | +| MCP server | `.mcp.json` | `node ${CLAUDE_PLUGIN_ROOT:-.}/dist/commitlore.mjs mcp` | | pre-edit context hook | `hooks/hooks.json` | `PreToolUse` on `Read\|Edit\|Write\|MultiEdit\|NotebookEdit` | | skills | `skills/` | `commitlore-commits`, `commitlore-codex`, `commitlore-query`, `commitlore-setup` | | plugin identity | `.claude-plugin/plugin.json` | `commitlore` at the `package.json` version | diff --git a/install.ps1 b/install.ps1 index 30b85c06..0e7bc0fd 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1,8 +1,8 @@ <# Installs commitlore from source on Windows, for any agent that is not Claude Code. - irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.1/install.ps1 | iex - & ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.1/install.ps1))) v1.2.1 + irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.2/install.ps1 | iex + & ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.2/install.ps1))) v1.2.2 Claude Code users do not need this script. The repository is itself a plugin marketplace (ADR-0011), so two /plugin commands register the MCP server, the diff --git a/install.sh b/install.sh index d279dd53..c05ff382 100755 --- a/install.sh +++ b/install.sh @@ -1,8 +1,8 @@ #!/bin/sh # Installs commitlore from source, for any agent that is not Claude Code. # -# curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.1/install.sh | sh -# curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.1/install.sh | sh -s v1.2.1 +# curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.2/install.sh | sh +# curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.2/install.sh | sh -s v1.2.2 # # **Claude Code users do not need this script.** The repository is itself a # plugin marketplace (ADR-0011), so two `/plugin` commands register the MCP diff --git a/package-lock.json b/package-lock.json index 99a685e8..24b1d386 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "commitlore", - "version": "1.2.1", + "version": "1.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "commitlore", - "version": "1.2.1", + "version": "1.2.2", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", diff --git a/package.json b/package.json index f5f49075..b2e96388 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "commitlore", - "version": "1.2.1", + "version": "1.2.2", "description": "Git-native, lifecycle-aware decision memory for coding agents", "license": "MIT", "private": true, diff --git a/server.json b/server.json index a3bdc2cf..f597fef4 100644 --- a/server.json +++ b/server.json @@ -8,7 +8,7 @@ "source": "github" }, "websiteUrl": "https://github.com/MongLong0214/commitlore#readme", - "version": "1.2.1", + "version": "1.2.2", "_meta": { "io.modelcontextprotocol.registry/publisher-provided": { "registryFit": "Distribution is a tagged git checkout plus a Claude Code plugin marketplace (ADR-0011 registry-free git distribution, ADR-0026 no compiled executables and no uploaded release asset), so no official package type applies and this record relies on websiteUrl plus publisher metadata.", @@ -18,8 +18,8 @@ "/plugin marketplace add MongLong0214/commitlore", "/plugin install commitlore@commitlore" ], - "installer": "curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.1/install.sh | sh -s v1.2.1", - "release": "https://github.com/MongLong0214/commitlore/releases/tag/v1.2.1" + "installer": "curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.2/install.sh | sh -s v1.2.2", + "release": "https://github.com/MongLong0214/commitlore/releases/tag/v1.2.2" }, "runtime": { "transport": "stdio", diff --git a/src/core/mcp-registration.ts b/src/core/mcp-registration.ts index 40210d58..0b5e40f1 100644 --- a/src/core/mcp-registration.ts +++ b/src/core/mcp-registration.ts @@ -61,6 +61,34 @@ const messageOf = (error: unknown): string => (error instanceof Error ? error.me const isLaunchableEntry = (value: unknown): boolean => isJsonObject(value) && typeof value['command'] === 'string' && value['command'].trim() !== ''; +/** + * `${VAR}` and `${VAR:-default}`, expanded the way a host expands them before + * it launches the server. + * + * A registration is a launch instruction for a host, and the hosts that read + * this file substitute environment placeholders in `command` and `args` first — + * which is what lets one committed file name a path that only the host knows, + * `${CLAUDE_PLUGIN_ROOT}` being the one this repository's own registration uses + * (#870). Every reader here answers questions about that launch: what command a + * host will run, whether it is ours, and — in doctor's unattended-initiator + * check — whether it actually answers an MCP initialize. Reading the raw text + * answered those questions about a command no host ever runs, and the probe + * spawned the literal `${...}` as a path. + * + * An unset placeholder with no default is left as written rather than expanded + * to nothing. A host refuses that registration outright, and `""/dist/x.mjs` + * would turn the refusal into a plausible-looking path whose failure names a + * file nobody wrote. + */ +const expandHostPlaceholders = (value: string): string => + value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g, (whole, name: string, fallback: string | undefined) => { + const set = process.env[name]; + // `:-` is shell semantics, which the syntax is borrowed from: an empty + // value takes the default, because an empty path is not a path. + if (set !== undefined && set !== '') return set; + return fallback ?? whole; + }); + /** * The command a registration under our key names, or null when there is none a * host could launch. @@ -74,7 +102,10 @@ export const registeredMcpCommand = (cwd: string): string | null => { return launch?.command ?? null; }; -/** The complete launch command a host will use, when its argv is parseable. */ +/** + * The complete launch command a host will use, when its argv is parseable, with + * `${VAR}` placeholders expanded as the host would expand them. + */ export const registeredMcpLaunch = (cwd: string): { command: string; args: string[] } | null => { const path = mcpRegistrationPath(cwd); if (path === null) return null; @@ -91,7 +122,10 @@ export const registeredMcpLaunch = (cwd: string): { command: string; args: strin if (!isLaunchableEntry(entry)) return null; const args = (entry as JsonObject)['args']; if (args !== undefined && (!Array.isArray(args) || !args.every((arg) => typeof arg === 'string'))) return null; - return { command: String((entry as JsonObject)['command']), args: (args ?? []) as string[] }; + return { + command: expandHostPlaceholders(String((entry as JsonObject)['command'])), + args: ((args ?? []) as string[]).map(expandHostPlaceholders), + }; }; /** diff --git a/test/codex-plugin.test.ts b/test/codex-plugin.test.ts index d585499d..77fa7592 100644 --- a/test/codex-plugin.test.ts +++ b/test/codex-plugin.test.ts @@ -253,7 +253,7 @@ describe('Codex plugin package', () => { mcpServers: string; }; const mcp = JSON.parse(readFileSync(join(ROOT, '.mcp.json'), 'utf8')) as { - mcpServers: { commitlore: { command: string; args: string[]; cwd: string } }; + mcpServers: { commitlore: { command: string; args: string[] } }; }; expect(manifest).toMatchObject({ @@ -262,10 +262,12 @@ describe('Codex plugin package', () => { skills: './skills/', mcpServers: './.mcp.json', }); + // #870: the entry point is bound to the plugin root. A relative path, or a + // `cwd`, resolves against whatever directory the session was started in — + // so the server died at launch everywhere but a built checkout. expect(mcp.mcpServers.commitlore).toEqual({ command: 'node', - args: ['./dist/commitlore.mjs', 'mcp'], - cwd: '.', + args: ['${CLAUDE_PLUGIN_ROOT:-.}/dist/commitlore.mjs', 'mcp'], }); }); diff --git a/test/doctor.test.ts b/test/doctor.test.ts index 4ef0630a..ad80e0e2 100644 --- a/test/doctor.test.ts +++ b/test/doctor.test.ts @@ -1376,6 +1376,45 @@ describe('#527 unattended capture initiator', () => { }); } + /** + * #870: this repository's own registration names its entry point as + * `${CLAUDE_PLUGIN_ROOT:-.}/dist/commitlore.mjs`, because the hosts that read + * `.mcp.json` expand placeholders before they spawn anything. The reader + * returned the raw text, so the probe launched a literal `${...}` as a path + * and reported a working registration unhealthy — a report that sends an + * operator to repair the one thing that is not broken. + */ + it('probes the launch a host would run, with ${VAR} placeholders expanded', () => { + const repo = initRepo('unattended-placeholder'); + enableUnattended(repo); + const command = mcpWrapper(repo, 'commitlore'); + // A default rather than a set variable: the expansion is what is under + // test, and a test that exports the answer first proves less. + registerMcp(repo, `\${COMMITLORE_TEST_UNSET:-${command}}`); + + const check = runDoctor({ cwd: repo }).checks.find((entry) => entry.id === 'unattended-initiator'); + + expect(check?.status).toBe('ok'); + expect(check?.evidence?.['initiator']).toBe('capture-tools-advertised'); + }); + + /** + * The other half of the same rule. An unset placeholder with no default is + * left as written, because a host refuses that registration outright and + * expanding it to nothing would turn the refusal into a plausible-looking + * path whose failure names a file nobody wrote. + */ + it('leaves an unset placeholder with no default as written', () => { + const repo = initRepo('unattended-placeholder-unset'); + enableUnattended(repo); + registerMcp(repo, '${COMMITLORE_TEST_UNSET}/dist/commitlore.mjs'); + + const check = runDoctor({ cwd: repo }).checks.find((entry) => entry.id === 'unattended-initiator'); + + expect(check?.status).toBe('warn'); + expect(check?.detail).toContain('${COMMITLORE_TEST_UNSET}'); + }); + it.each([ ['a dead command', (repo: string) => join(repo, 'missing'), 'command-not-found'], ['a command that is a directory', (repo: string) => repo, 'command-is-directory'], diff --git a/test/manifest.test.ts b/test/manifest.test.ts index 2b2b4ee0..c77fd8a5 100644 --- a/test/manifest.test.ts +++ b/test/manifest.test.ts @@ -50,6 +50,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { execGit } from '../src/core/git.js'; import { createTestRepo } from './git-fixtures.js'; +import { startStub } from './mcp-client.js'; const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url)); @@ -171,20 +172,72 @@ describe('plugin manifest: entry points are runnable from a clean clone', () => expect(run.stdout.trim()).toBe(manifest.version); }); - it('the Codex MCP declaration resolves the plugin-local bundle', () => { - const manifest = readJson(clonePath('.codex-plugin/plugin.json')) as PluginManifest; + /** + * #870: the declaration named `./dist/commitlore.mjs` and set `"cwd": "."`, + * and both of those resolve against the *session's* working directory, never + * the plugin's. So the plugin's MCP server died at launch with + * `MODULE_NOT_FOUND` in every session whose cwd was not a built commitlore + * checkout — which is every real use, and the one cwd where the defect cannot + * appear is this repository, where the old check ran. + * + * Everything below therefore launches from a directory that is not a + * checkout, with the placeholder expanded the way a host expands it before + * spawning. Reverting `.mcp.json` fails these and passes the old one. + */ + const pluginLaunch = (): { entry: string; args: string[] } => { const mcp = readJson(clonePath('.mcp.json')) as { - mcpServers: { commitlore: { command: string; args: string[]; cwd: string } }; + mcpServers: { commitlore: { command: string; args: string[]; cwd?: string } }; }; const server = mcp.mcpServers.commitlore; - const run = spawnSync(server.command, [server.args[0]!, '--version'], { - cwd: clonePath(server.cwd), + expect( + server.cwd, + 'a "cwd" here is resolved against the session, not the plugin (#870)', + ).toBeUndefined(); + expect( + server.args[0], + 'the entry point must be bound to the plugin root, not the session cwd (#870)', + ).toContain('${CLAUDE_PLUGIN_ROOT'); + const expand = (arg: string): string => + arg.replace(/\$\{CLAUDE_PLUGIN_ROOT(?::-[^}]*)?\}/g, cloneDir); + return { entry: expand(server.args[0]!), args: server.args.slice(1).map(expand) }; + }; + + it('the MCP declaration resolves the bundle against the plugin root, not the session cwd', () => { + const manifest = readJson(clonePath('.claude-plugin/plugin.json')) as PluginManifest; + const codex = readJson(clonePath('.codex-plugin/plugin.json')) as PluginManifest; + const { entry } = pluginLaunch(); + const run = spawnSync(process.execPath, [entry, '--version'], { + // Deliberately not the clone: this is the cwd a real session has. + cwd: tempDir('foreign-cwd'), encoding: 'utf8', }); - expect(run.status).toBe(0); + expect(run.status, run.stderr).toBe(0); expect(run.stdout.trim()).toBe(manifest.version); - expect(manifest.mcpServers).toBe('./.mcp.json'); + expect(codex.mcpServers).toBe('./.mcp.json'); + }); + + it('that launch reaches an MCP initialize from a cwd that is not a checkout', async () => { + // `--version` proves the module resolves; this proves the server the host + // actually asked for comes up and answers. The old configuration exited + // before a byte was exchanged, and the only visible symptom was one line + // about a cached connection failure. + const { entry, args } = pluginLaunch(); + const stub = startStub(tempDir('foreign-session'), entry, args); + try { + const initialized = await stub.request('initialize', { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'manifest', version: '1' }, + }); + expect( + initialized.error, + `initialize failed: ${JSON.stringify(initialized.error)}; stderr: ${stub.stderr()}`, + ).toBeUndefined(); + expect(initialized.result?.['serverInfo']).toMatchObject({ name: 'commitlore' }); + } finally { + await stub.close(); + } }); it('scripts/commitlore-run.sh resolves the plugin entry point via CLAUDE_PLUGIN_ROOT', () => {