Skip to content

Commit a2827ac

Browse files
authored
Nix-free plugin launcher — consumers launch via bun on PATH (#2)
## What The Claude Code plugin required **Nix on the consumer's PATH**: `.mcp.json` launched the MCP server via `nix run path:${CLAUDE_PLUGIN_ROOT}#default -- … server.ts`, and the PreToolUse hook resolved bun via `nix build`. The plugin's flake only ever provided `pkgs.bun`, so every external installer of `commy@commy` needed Nix for no reason beyond pinning bun. Nix stays fully supported for those who use it (the flake and dev shell are untouched) — it just no longer sits in the **consumer** launch path. Vanilla `bun`-on-PATH is the baseline; nix is an opt-in addition. ## How - **Launcher → `clients/claude-code/launch.sh`**, a POSIX-`sh` bun-on-PATH bootstrap (no bashisms, so it imposes no shell of our choosing — dash/ash/busybox/bash all run it). Claude Code installs a plugin by cloning the marketplace repo and installs **no** JS deps, so a fresh checkout has no `node_modules`; the launcher stages the workspace deps **once** — `bun install --frozen-lockfile` at the workspace root, guarded by a portable `mkdir` mutex + a re-check so concurrently-booting sessions can't race to EEXIST — then `exec`s bun against the server entrypoint. - `exec` keeps the server Claude Code's **direct child**, so a session disconnect reaches it (no orphaned server). - The stage runs **only** when `node_modules` is absent; every launch after the first is a plain `exec`, no install at connect. - **PreToolUse hook → `bun` on PATH directly**; the now-vestigial `hooks/bun-wrap.sh` (the nix-build wrapper) and its test are removed. The hook entrypoint imports no workspace packages, so it needs only bun, nothing staged. - **Bun pin** moves from the flake to `packageManager: "bun@1.3.13"` + a documented minimum (`bun ≥ 1.3.13`) in the plugin README. ## Prereq for consumers **`bun ≥ 1.3.13` on PATH** — that's the whole prerequisite. No Nix, no global installs. ## Verification - Clean clone + `launch.sh` **cold-boots** (stages deps, answers MCP `initialize` with `serverInfo`) and **warm-boots** (no reinstall, pure `exec`) — both under POSIX `sh`. - `shellcheck -s sh` clean; `sh -n` clean. - Full `bun run check` green; new tests pin the contracts (bun-not-nix, `exec` direct-child, no run/start indirection, guarded one-time stage, POSIX-sh shebath/no-bashisms).
1 parent 331eeb3 commit a2827ac

9 files changed

Lines changed: 121 additions & 275 deletions

File tree

‎clients/claude-code/.mcp.json‎

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,7 @@
11
{
22
"mcpServers": {
33
"commy": {
4-
"command": "nix",
5-
"args": [
6-
"run",
7-
"path:${CLAUDE_PLUGIN_ROOT}#default",
8-
"--",
9-
"--cwd=${CLAUDE_PLUGIN_ROOT}",
10-
"node_modules/@commy/mcp/server.ts"
11-
],
4+
"command": "${CLAUDE_PLUGIN_ROOT}/launch.sh",
125
"env": {
136
"ZULIP_SITE": "${user_config.ZULIP_SITE}",
147
"ZULIP_MINTER_EMAIL": "${user_config.ZULIP_MINTER_EMAIL}",

‎clients/claude-code/README.md‎

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,24 @@ exists.
99

1010
## Requirements
1111

12-
[Nix][nix] (with flakes enabled) on the host PATH. The plugin's `.mcp.json`
13-
invokes `nix run path:${CLAUDE_PLUGIN_ROOT}#default` to launch its own pinned
14-
Bun via the plugin's flake — no global `bun` install is needed, and the Bun
15-
version is reproducible across hosts because it tracks the plugin's
16-
`flake.lock`. The PreToolUse hook resolves the same Bun via a thin wrapper
17-
(`hooks/bun-wrap.sh`) that lazy-builds a GC-root symlink at
18-
`${CLAUDE_PLUGIN_ROOT}/.bun-result` on first call after install — direct
19-
exec thereafter (comms-f9n). Installing the plugin on a host without Nix
20-
will leave its MCP server unable to start and the PreToolUse hook unable
21-
to inject `session_id`.
22-
23-
[nix]: https://nixos.org/download
12+
[Bun][bun] **≥ 1.3.13** on the host PATH — that's the whole prerequisite.
13+
(The pinned version is recorded in the repo-root `package.json` `packageManager`
14+
field; 1.3.13 is the floor the plugin is tested against.) Nix is not required:
15+
it's how this repo is *developed*, not how the plugin *runs*.
16+
17+
Claude Code installs the plugin by cloning the marketplace repo; it does not
18+
install JS dependencies, so a fresh checkout has no `node_modules`. The plugin's
19+
`.mcp.json` launches the server through `launch.sh`, which stages the workspace
20+
dependencies once — `bun install --frozen-lockfile` at the workspace root,
21+
guarded by a portable `mkdir` mutex so concurrently-booting sessions can't race
22+
it — and then `exec`s `bun` against the server entrypoint. The `exec` keeps the
23+
server Claude Code's direct child (so a session disconnect actually reaches it);
24+
the stage runs only when `node_modules` is genuinely absent, so every launch
25+
after the first is a plain `exec` with no install. The PreToolUse hook
26+
(`inject-session-id.ts`) likewise runs under `bun` on PATH; it imports no
27+
workspace packages, so it needs nothing staged.
28+
29+
[bun]: https://bun.sh
2430

2531
## Configuration
2632

‎clients/claude-code/hooks/bun-wrap.sh‎

Lines changed: 0 additions & 21 deletions
This file was deleted.

‎clients/claude-code/hooks/bun-wrap.test.ts‎

Lines changed: 0 additions & 204 deletions
This file was deleted.

‎clients/claude-code/hooks/hooks.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"hooks": [
77
{
88
"type": "command",
9-
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/bun-wrap.sh",
9+
"command": "bun",
1010
"args": ["${CLAUDE_PLUGIN_ROOT}/hooks/inject-session-id.ts"]
1111
}
1212
]

‎clients/claude-code/hooks/hooks.test.ts‎

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -59,15 +59,14 @@ test('matcher does NOT match arbitrary other MCP tools (no over-broad capture)',
5959
expect(re.test(`${EXPECTED_PREFIX}list_channels`)).toBe(false)
6060
})
6161

62-
test('command points at the plugin-local bun wrapper, not bare `bun` on host PATH (comms-f9n)', () => {
63-
// Bare `bun` would fail in CC sessions where bun is not on the host PATH —
64-
// CC reports the hook error as non-blocking, the tool call proceeds with
65-
// unmodified tool_input, and no session_id injection happens. The wrapper
66-
// resolves bun via the plugin's own Nix flake (same provenance as the MCP
67-
// server's `nix run path:.#default`), so the hook works regardless of host PATH.
62+
test('command invokes bun on PATH against the hook entrypoint — no Nix wrapper (comms-ip4q)', () => {
63+
// The plugin's prereq is `bun` on PATH (the same contract the MCP launcher
64+
// now relies on — comms-ip4q). The hook entrypoint imports no workspace
65+
// packages, so it needs only bun, nothing staged. The former bun-wrap.sh
66+
// resolved bun via the plugin's Nix flake; dropping Nix from the consumer
67+
// path (comms-ip4q) makes that wrapper vestigial.
6868
const hook = preToolUse[0]?.hooks[0]
69-
// biome-ignore lint/suspicious/noTemplateCurlyInString: intentional — testing placeholder rejection
70-
expect(hook?.command).toBe('${CLAUDE_PLUGIN_ROOT}/hooks/bun-wrap.sh')
69+
expect(hook?.command).toBe('bun')
7170
// biome-ignore lint/suspicious/noTemplateCurlyInString: intentional — testing placeholder rejection
7271
expect(hook?.args).toEqual(['${CLAUDE_PLUGIN_ROOT}/hooks/inject-session-id.ts'])
7372
})

‎clients/claude-code/launch.sh‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
#!/bin/sh
2+
# Launch the commy MCP server with bun on PATH — no Nix required (comms-ip4q).
3+
#
4+
# A consumer installs the plugin from the git marketplace; Claude Code clones
5+
# the marketplace repo but installs no JS deps, so a fresh checkout has no
6+
# node_modules and the workspace symlink `@commy/mcp` does not exist yet. This
7+
# launcher stages the workspace deps once, idempotently, then execs the server.
8+
#
9+
# Two invariants this preserves:
10+
# - The server must be claude's DIRECT child so its stdin is claude's pipe
11+
# (comms-hfhm): the final `exec` replaces this shell, so bun inherits the
12+
# pid and the stdio — no grandchild, no orphaned ~200MB server on disconnect.
13+
# - The one-time stage must not race across concurrently-booting sessions
14+
# (comms-ae3: an unguarded connect-time `bun install` raced to EEXIST). A
15+
# portable mkdir mutex + a re-check inside the lock makes the stage safe
16+
# even if two sessions cold-start at once. `mkdir` (not `flock`) so the
17+
# guard works on macOS too, where util-linux `flock` is absent.
18+
#
19+
# Plain POSIX sh — no bashisms, integer `sleep` — so the launcher imposes no
20+
# shell of our choosing on the consumer (dash/ash/busybox/bash all run it).
21+
# The frozen marketplace copy ships node_modules pre-staged (publish-marketplace
22+
# stages and installs the workspace), so fleet seats skip the install branch
23+
# entirely and fall straight through to the exec.
24+
25+
set -eu
26+
27+
: "${CLAUDE_PLUGIN_ROOT:?CLAUDE_PLUGIN_ROOT must be set by Claude Code}"
28+
29+
ROOT="${CLAUDE_PLUGIN_ROOT}"
30+
WORKSPACE="$(cd "${ROOT}/../.." && pwd)"
31+
ENTRYPOINT="${ROOT}/node_modules/@commy/mcp/server.ts"
32+
33+
if [ ! -e "${ENTRYPOINT}" ]; then
34+
LOCK="${WORKSPACE}/.commy-install.lock"
35+
until mkdir "${LOCK}" 2>/dev/null; do sleep 1; done
36+
trap 'rmdir "${LOCK}" 2>/dev/null || true' EXIT
37+
if [ ! -e "${ENTRYPOINT}" ]; then
38+
(cd "${WORKSPACE}" && bun install --frozen-lockfile --ignore-scripts) >&2
39+
fi
40+
rmdir "${LOCK}"
41+
trap - EXIT
42+
fi
43+
44+
exec bun --cwd="${ROOT}" node_modules/@commy/mcp/server.ts

0 commit comments

Comments
 (0)