Skip to content

Commit 09fb87f

Browse files
committed
feat(plugin): drop npx — install pinned devcoach from npm once, run via node
The plugin previously fetched devcoach via `npx -y` on every MCP launch AND every Stop hook (which fire after each turn). Now it ships only config + a tiny bootstrap (plugin/scripts/launch.mjs) — no committed binary: - launch.mjs installs the pinned version (plugin/package.json) once into the plugin's persistent data dir (${CLAUDE_PLUGIN_DATA}), then runs dist/bin.js in-process; it re-installs only when a plugin update bumps the pin - .mcp.json + both Stop hooks call `node ${CLAUDE_PLUGIN_ROOT}/scripts/launch.mjs <cmd>` — zero per-invocation npx (warm hook run measured ~0.15s vs npx resolution each time) - npm stays the single source of truth (no build artifact in git); sync-plugin.mjs keeps the pin in lockstep with package.json and CI commits plugin/package.json on release Verified: launcher installs devcoach@0.3.65 from npm, --version dispatches, and the MCP server answers initialize through the launcher. Docs updated; Docusaurus build clean.
1 parent f5baf3b commit 09fb87f

8 files changed

Lines changed: 76 additions & 18 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ jobs:
130130
git config user.name "github-actions[bot]"
131131
git config user.email "github-actions[bot]@users.noreply.github.com"
132132
git add package.json package-lock.json mcpb/manifest.json \
133-
plugin/.claude-plugin/plugin.json plugin/skills/devcoach/SKILL.md
133+
plugin/.claude-plugin/plugin.json plugin/skills/devcoach/SKILL.md plugin/package.json
134134
git commit -m "chore: release v${NEW}"
135135
git tag "v${NEW}"
136136
git push origin HEAD:main "v${NEW}"

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ dist-plugin/
88
mcpb/build/
99
*.tsbuildinfo
1010

11+
# Plugin launcher's runtime fallback data dir (real one is ${CLAUDE_PLUGIN_DATA})
12+
plugin/.data/
13+
1114
# MCPB signing keys (generated by `npm run mcpb:sign --self-signed`)
1215
mcpb/cert.pem
1316
mcpb/key.pem

docs/install/claude-code-plugin.md

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -45,21 +45,25 @@ unzip it, then point Claude Code at the unzipped folder:
4545

4646
## How it works
4747

48-
When you enable the plugin, Claude Code wires in its three component files:
49-
50-
1. **`.mcp.json`** → Claude Code launches the MCP server locally as a stdio process
51-
(`npx -y devcoach mcp`). It opens `~/.devcoach/coaching.db` (SQLite), derived from your home
52-
directory.
53-
2. **`hooks/hooks.json`** → after every turn, Claude Code runs the two `Stop` hooks (also local
54-
`npx -y devcoach` processes). They read the same database and either stay silent or nudge the agent
55-
to run onboarding / deliver a lesson. This is what makes coaching automatic.
48+
When you enable the plugin, Claude Code wires in its component files. The plugin ships only config plus a
49+
small bootstrap (`scripts/launch.mjs`) — **no bundled binary and no per-call `npx`**. On first use the
50+
launcher installs the *pinned* `devcoach` version (from `plugin/package.json`) **once** into the plugin's
51+
persistent data dir, then runs it directly with `node`; later calls (every hook fire, every server start)
52+
skip straight to `node` — and it only re-installs when a plugin update bumps the pinned version.
53+
54+
1. **`.mcp.json`** → Claude Code launches the MCP server as a local stdio process
55+
(`node ${CLAUDE_PLUGIN_ROOT}/scripts/launch.mjs mcp`). It opens `~/.devcoach/coaching.db` (SQLite),
56+
derived from your home directory.
57+
2. **`hooks/hooks.json`** → after every turn, Claude Code runs the two `Stop` hooks through the same
58+
launcher. They read the same database and either stay silent or nudge the agent to run onboarding /
59+
deliver a lesson. This is what makes coaching automatic — and with no `npx` per fire, it's snappy.
5660
3. **`skills/devcoach/SKILL.md`** → the coaching playbook, auto-loaded so the agent knows *how* to
5761
teach when a hook fires.
5862

5963
## Running the CLI & web dashboard
6064

61-
The plugin gives Claude Code everything it needs to coach you, but it does **not** put the `devcoach`
62-
**CLI** on your `PATH` — it runs the MCP server internally via `npx`. So to open the
65+
The plugin gives Claude Code everything it needs to coach you, but it keeps devcoach inside its own data
66+
dir — it does **not** put the `devcoach` **CLI** on your `PATH`. So to open the
6367
[web dashboard](../usage/web-ui.md) or use any [CLI command](../usage/cli.md), prefix it with `npx -y`:
6468

6569
```bash
@@ -84,7 +88,7 @@ machine's home directory:
8488
persisted across runs. Use [`devcoach backup`](../usage/cli.md#backup-export--import) / `restore` to
8589
carry your profile between machines.
8690

87-
It needs **Node.js ≥ 24** (for Node's embedded `node:sqlite`), since the plugin runs the published
88-
`devcoach` npm package via `npx`.
91+
It needs **Node.js ≥ 24** (for Node's embedded `node:sqlite`) and a one-time network connection on first
92+
use (to install the pinned `devcoach` package into the plugin's data dir); after that it runs offline.
8993

9094
→ Next: **[Coaching in your agent](../usage/coaching.md)**.

plugin/.mcp.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
"mcpServers": {
33
"devcoach": {
44
"type": "stdio",
5-
"command": "npx",
6-
"args": ["-y", "devcoach", "mcp"]
5+
"command": "node",
6+
"args": ["${CLAUDE_PLUGIN_ROOT}/scripts/launch.mjs", "mcp"]
77
}
88
}
99
}

plugin/hooks/hooks.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
{
22
"hooks": {
33
"Stop": [
4-
{ "hooks": [{ "type": "command", "command": "npx -y devcoach onboard-hook" }] },
5-
{ "hooks": [{ "type": "command", "command": "npx -y devcoach lesson-ready" }] }
4+
{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/launch.mjs\" onboard-hook" }] },
5+
{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/launch.mjs\" lesson-ready" }] }
66
]
77
}
88
}

plugin/package.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"name": "devcoach-plugin",
3+
"private": true,
4+
"description": "Pins the published devcoach version the plugin installs once into its data dir (see scripts/launch.mjs).",
5+
"dependencies": {
6+
"devcoach": "0.3.65"
7+
}
8+
}

plugin/scripts/launch.mjs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
#!/usr/bin/env node
2+
// devcoach Claude Code plugin bootstrap.
3+
//
4+
// The plugin ships only config + this launcher — the actual devcoach binary comes from npm, so there's
5+
// no committed build artifact and no per-invocation `npx`. On first use we install the *pinned* version
6+
// (plugin/package.json) once into the plugin's persistent data dir (${CLAUDE_PLUGIN_DATA}), then run it
7+
// in-process. Subsequent calls (the MCP server start + every Stop hook) just run `node` against the
8+
// installed binary; we only re-install when the pinned version changes.
9+
import { spawnSync } from "node:child_process";
10+
import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
11+
import { join } from "node:path";
12+
import { pathToFileURL } from "node:url";
13+
14+
const root = process.env.CLAUDE_PLUGIN_ROOT ?? join(import.meta.dirname, "..");
15+
const data = process.env.CLAUDE_PLUGIN_DATA ?? join(root, ".data");
16+
const srcManifest = join(root, "package.json");
17+
const dstManifest = join(data, "package.json");
18+
const binJs = join(data, "node_modules", "devcoach", "dist", "bin.js");
19+
20+
mkdirSync(data, { recursive: true });
21+
const want = readFileSync(srcManifest, "utf8");
22+
const have = existsSync(dstManifest) ? readFileSync(dstManifest, "utf8") : "";
23+
if (!existsSync(binJs) || have !== want) {
24+
copyFileSync(srcManifest, dstManifest);
25+
// stdio "ignore": npm output must not touch the MCP server's stdout (protocol) or a hook's stderr (cue).
26+
const r = spawnSync("npm", ["install", "--omit=dev", "--no-audit", "--no-fund", "--no-progress"], {
27+
cwd: data,
28+
stdio: "ignore",
29+
});
30+
if (r.status !== 0 || !existsSync(binJs)) {
31+
console.error("devcoach plugin: could not install the devcoach package (needs Node ≥24 + network on first run).");
32+
process.exit(0); // stay silent rather than break the agent's turn
33+
}
34+
}
35+
36+
// Hand off in-process to the real binary — it reads the subcommand from argv ("mcp" / "onboard-hook" / …).
37+
await import(pathToFileURL(binJs).href);

scripts/sync-plugin.mjs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,10 @@ const skillDest = join(root, "plugin", "skills", "devcoach", "SKILL.md");
2121
mkdirSync(dirname(skillDest), { recursive: true });
2222
cpSync(join(root, "assets", "SKILL.md"), skillDest);
2323

24-
console.log(`synced plugin → version ${version}, SKILL.md copied`);
24+
// 3. Pin the published devcoach version the plugin installs at runtime (scripts/launch.mjs) to this
25+
// release — same in-place regex approach, so a version bump re-triggers the launcher's npm install.
26+
const pluginPkgPath = join(root, "plugin", "package.json");
27+
const pluginPkg = readFileSync(pluginPkgPath, "utf8");
28+
writeFileSync(pluginPkgPath, pluginPkg.replace(/"devcoach": "[^"]+"/, `"devcoach": "${version}"`));
29+
30+
console.log(`synced plugin → version ${version}, SKILL.md copied, devcoach pinned`);

0 commit comments

Comments
 (0)