Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 1 addition & 8 deletions clients/claude-code/.mcp.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,7 @@
{
"mcpServers": {
"commy": {
"command": "nix",
"args": [
"run",
"path:${CLAUDE_PLUGIN_ROOT}#default",
"--",
"--cwd=${CLAUDE_PLUGIN_ROOT}",
"node_modules/@commy/mcp/server.ts"
],
"command": "${CLAUDE_PLUGIN_ROOT}/launch.sh",
"env": {
"ZULIP_SITE": "${user_config.ZULIP_SITE}",
"ZULIP_MINTER_EMAIL": "${user_config.ZULIP_MINTER_EMAIL}",
Expand Down
30 changes: 18 additions & 12 deletions clients/claude-code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,24 @@ exists.

## Requirements

[Nix][nix] (with flakes enabled) on the host PATH. The plugin's `.mcp.json`
invokes `nix run path:${CLAUDE_PLUGIN_ROOT}#default` to launch its own pinned
Bun via the plugin's flake — no global `bun` install is needed, and the Bun
version is reproducible across hosts because it tracks the plugin's
`flake.lock`. The PreToolUse hook resolves the same Bun via a thin wrapper
(`hooks/bun-wrap.sh`) that lazy-builds a GC-root symlink at
`${CLAUDE_PLUGIN_ROOT}/.bun-result` on first call after install — direct
exec thereafter (comms-f9n). Installing the plugin on a host without Nix
will leave its MCP server unable to start and the PreToolUse hook unable
to inject `session_id`.

[nix]: https://nixos.org/download
[Bun][bun] **≥ 1.3.13** on the host PATH — that's the whole prerequisite.
(The pinned version is recorded in the repo-root `package.json` `packageManager`
field; 1.3.13 is the floor the plugin is tested against.) Nix is not required:
it's how this repo is *developed*, not how the plugin *runs*.

Claude Code installs the plugin by cloning the marketplace repo; it does not
install JS dependencies, so a fresh checkout has no `node_modules`. The plugin's
`.mcp.json` launches the server through `launch.sh`, which stages the workspace
dependencies once — `bun install --frozen-lockfile` at the workspace root,
guarded by a portable `mkdir` mutex so concurrently-booting sessions can't race
it — and then `exec`s `bun` against the server entrypoint. The `exec` keeps the
server Claude Code's direct child (so a session disconnect actually reaches it);
the stage runs only when `node_modules` is genuinely absent, so every launch
after the first is a plain `exec` with no install. The PreToolUse hook
(`inject-session-id.ts`) likewise runs under `bun` on PATH; it imports no
workspace packages, so it needs nothing staged.

[bun]: https://bun.sh

## Configuration

Expand Down
21 changes: 0 additions & 21 deletions clients/claude-code/hooks/bun-wrap.sh

This file was deleted.

204 changes: 0 additions & 204 deletions clients/claude-code/hooks/bun-wrap.test.ts

This file was deleted.

2 changes: 1 addition & 1 deletion clients/claude-code/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/bun-wrap.sh",
"command": "bun",
"args": ["${CLAUDE_PLUGIN_ROOT}/hooks/inject-session-id.ts"]
}
]
Expand Down
15 changes: 7 additions & 8 deletions clients/claude-code/hooks/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,15 +59,14 @@ test('matcher does NOT match arbitrary other MCP tools (no over-broad capture)',
expect(re.test(`${EXPECTED_PREFIX}list_channels`)).toBe(false)
})

test('command points at the plugin-local bun wrapper, not bare `bun` on host PATH (comms-f9n)', () => {
// Bare `bun` would fail in CC sessions where bun is not on the host PATH —
// CC reports the hook error as non-blocking, the tool call proceeds with
// unmodified tool_input, and no session_id injection happens. The wrapper
// resolves bun via the plugin's own Nix flake (same provenance as the MCP
// server's `nix run path:.#default`), so the hook works regardless of host PATH.
test('command invokes bun on PATH against the hook entrypoint — no Nix wrapper (comms-ip4q)', () => {
// The plugin's prereq is `bun` on PATH (the same contract the MCP launcher
// now relies on — comms-ip4q). The hook entrypoint imports no workspace
// packages, so it needs only bun, nothing staged. The former bun-wrap.sh
// resolved bun via the plugin's Nix flake; dropping Nix from the consumer
// path (comms-ip4q) makes that wrapper vestigial.
const hook = preToolUse[0]?.hooks[0]
// biome-ignore lint/suspicious/noTemplateCurlyInString: intentional — testing placeholder rejection
expect(hook?.command).toBe('${CLAUDE_PLUGIN_ROOT}/hooks/bun-wrap.sh')
expect(hook?.command).toBe('bun')
// biome-ignore lint/suspicious/noTemplateCurlyInString: intentional — testing placeholder rejection
expect(hook?.args).toEqual(['${CLAUDE_PLUGIN_ROOT}/hooks/inject-session-id.ts'])
})
44 changes: 44 additions & 0 deletions clients/claude-code/launch.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#!/bin/sh
# Launch the commy MCP server with bun on PATH — no Nix required (comms-ip4q).
#
# A consumer installs the plugin from the git marketplace; Claude Code clones
# the marketplace repo but installs no JS deps, so a fresh checkout has no
# node_modules and the workspace symlink `@commy/mcp` does not exist yet. This
# launcher stages the workspace deps once, idempotently, then execs the server.
#
# Two invariants this preserves:
# - The server must be claude's DIRECT child so its stdin is claude's pipe
# (comms-hfhm): the final `exec` replaces this shell, so bun inherits the
# pid and the stdio — no grandchild, no orphaned ~200MB server on disconnect.
# - The one-time stage must not race across concurrently-booting sessions
# (comms-ae3: an unguarded connect-time `bun install` raced to EEXIST). A
# portable mkdir mutex + a re-check inside the lock makes the stage safe
# even if two sessions cold-start at once. `mkdir` (not `flock`) so the
# guard works on macOS too, where util-linux `flock` is absent.
#
# Plain POSIX sh — no bashisms, integer `sleep` — so the launcher imposes no
# shell of our choosing on the consumer (dash/ash/busybox/bash all run it).
# The frozen marketplace copy ships node_modules pre-staged (publish-marketplace
# stages and installs the workspace), so fleet seats skip the install branch
# entirely and fall straight through to the exec.

set -eu

: "${CLAUDE_PLUGIN_ROOT:?CLAUDE_PLUGIN_ROOT must be set by Claude Code}"

ROOT="${CLAUDE_PLUGIN_ROOT}"
WORKSPACE="$(cd "${ROOT}/../.." && pwd)"
ENTRYPOINT="${ROOT}/node_modules/@commy/mcp/server.ts"

if [ ! -e "${ENTRYPOINT}" ]; then
LOCK="${WORKSPACE}/.commy-install.lock"
until mkdir "${LOCK}" 2>/dev/null; do sleep 1; done
trap 'rmdir "${LOCK}" 2>/dev/null || true' EXIT
if [ ! -e "${ENTRYPOINT}" ]; then
(cd "${WORKSPACE}" && bun install --frozen-lockfile --ignore-scripts) >&2
fi
rmdir "${LOCK}"
trap - EXIT
fi

exec bun --cwd="${ROOT}" node_modules/@commy/mcp/server.ts
Loading