From 1cf4e6aae4ee92909fc41c8e265c29cfd90bdad2 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Thu, 12 Nov 2026 22:24:16 -0800 Subject: [PATCH 1/3] fix(claude): squad_state starts from a repository subfolder Claude Code finds the root .mcp.json from a subfolder (a monorepo package) but starts MCP servers in that subfolder, and ${CLAUDE_PROJECT_DIR} is not expanded in .mcp.json, so `node .squad/squad.mjs state-mcp` failed with "Cannot find module" (found in the #2487 scenario pass; regression from #2470). The committed command is now `node -e .squad/squad.mjs state-mcp`: the bootstrap walks up from the working directory, stops at the repository root, and runs the launcher with the remaining arguments. Hooks, doctor and upgrade read the launcher path from both forms. The Copilot snapshot changes only in .mcp.json, which Copilot shares. Co-Authored-By: Claude Opus 5.5 --- .changeset/mcp-launcher-subfolder.md | 6 +++ CHANGELOG.md | 1 + packages/squad-cli/src/cli/commands/doctor.ts | 3 +- packages/squad-cli/src/cli/core/mcp-spec.ts | 8 ++-- packages/squad-cli/src/cli/core/upgrade.ts | 3 +- .../src/client/adapters/claude-hooks.ts | 5 ++- .../src/client/adapters/claude-plugin.ts | 6 +-- .../src/client/adapters/repo-launcher.ts | 40 +++++++++++++++++++ ...-projection-regression-guards.test.ts.snap | 2 +- test/cli/upgrade-portability.test.ts | 3 +- test/portable-launch.test.ts | 31 +++++++++++++- 11 files changed, 92 insertions(+), 16 deletions(-) create mode 100644 .changeset/mcp-launcher-subfolder.md create mode 100644 packages/squad-sdk/src/client/adapters/repo-launcher.ts diff --git a/.changeset/mcp-launcher-subfolder.md b/.changeset/mcp-launcher-subfolder.md new file mode 100644 index 000000000..4ee212652 --- /dev/null +++ b/.changeset/mcp-launcher-subfolder.md @@ -0,0 +1,6 @@ +--- +"@bradygaster/squad-sdk": patch +"@bradygaster/squad-cli": patch +--- + +`squad_state` starts when Claude Code is started in a subfolder of the repository, such as a package in a monorepo. Claude Code finds the root `.mcp.json` from there but starts the server in the subfolder, so `node .squad/squad.mjs state-mcp` failed with "Cannot find module". The committed command now runs a short `node -e` script that looks for `.squad/squad.mjs` in the subfolder and each parent, up to the repository root. diff --git a/CHANGELOG.md b/CHANGELOG.md index 043e8930e..c23b08840 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,7 @@ Claude Code is the first host after GitHub Copilot where Squad uses the host's o - Claude hooks reach the launcher through `$CLAUDE_PROJECT_DIR`. Standalone bundle installs write `squad state-mcp`. A CLI run through `npx` keeps the exact-version `npx` command. - `squad doctor` flags machine-specific launch commands and a missing launcher. Run `squad upgrade` once to fix an existing repository. - `squad init --client claude` tells you to run `claude --agent squad --mcp-config .mcp.json`, not Copilot. With several hosts it gives one line per host (#2453). +- `squad_state` starts when Claude Code is started in a subfolder of the repository, such as a package in a monorepo. Claude Code finds the root `.mcp.json` from there but starts the server in the subfolder, so `node .squad/squad.mjs state-mcp` failed with "Cannot find module". The committed command now runs a short `node -e` script that looks for `.squad/squad.mjs` in the subfolder and each parent, up to the repository root. Run `squad upgrade` to rewrite an existing `.mcp.json`. - `.squad/squad.mjs` finds a `squad` on PATH. It used to skip every PATH entry, so a repository with only a global Squad CLI failed with "no Squad CLI found". Arguments reach the CLI unchanged on Windows (through npm's `.cmd` shims, never cmd.exe) and Linux, and a new CI job runs the launcher tests on both. ## [2.0.0-preview.4] - 2026-11-09 diff --git a/packages/squad-cli/src/cli/commands/doctor.ts b/packages/squad-cli/src/cli/commands/doctor.ts index ba05db2de..2c74e029a 100644 --- a/packages/squad-cli/src/cli/commands/doctor.ts +++ b/packages/squad-cli/src/cli/commands/doctor.ts @@ -19,6 +19,7 @@ import { resolveSquad, resolveSquadPaths, clearResolveSquadCache } from '@bradyg import { fatalNoSquad } from '../core/errors.js'; import { isMutableStateKey, MUTABLE_STATE_PATHS } from '@bradygaster/squad-sdk/tools'; import { resolveUpstreams } from '@bradygaster/squad-sdk/upstream'; +import { squadRepoLauncherScript } from '@bradygaster/squad-sdk/client'; import { effectiveSquadDir } from '../core/effective-squad-dir.js'; import { getPackageVersion } from '../core/version.js'; import { @@ -775,7 +776,7 @@ export function checkPortableSquadLaunch(cwd: string): DoctorCheck | undefined { }; } for (const l of launches) { - const script = l.command === 'node' ? l.args[0] : /^node "\$CLAUDE_PROJECT_DIR\/([^"]+)"/.exec(l.command)?.[1]; + const script = l.command === 'node' ? squadRepoLauncherScript(l.command, l.args) : /^node "\$CLAUDE_PROJECT_DIR\/([^"]+)"/.exec(l.command)?.[1]; if (script && /(?:^|\/)squad\.mjs$/.test(script) && !isAbs(script) && !lstatExists(path.join(cwd, script))) { return { name, status: 'fail', message: `${l.file} runs ${script}, which is missing. ${fix}` }; } diff --git a/packages/squad-cli/src/cli/core/mcp-spec.ts b/packages/squad-cli/src/cli/core/mcp-spec.ts index 25aa6ca0b..d05c85062 100644 --- a/packages/squad-cli/src/cli/core/mcp-spec.ts +++ b/packages/squad-cli/src/cli/core/mcp-spec.ts @@ -27,6 +27,7 @@ import { SQUAD_REPO_LAUNCHER_MARKER, SQUAD_REPO_LAUNCHER_PATH, squadRepoLauncher, + squadRepoLauncherArgs, } from '@bradygaster/squad-sdk/client'; export interface SquadStateMcpSpec { @@ -159,8 +160,9 @@ export function isMachineSpecificMcpSpec(spec: SquadStateMcpSpec): boolean { /** * The portable form of `direct` for files a repository commits (#2462). * - * - `local-entry` becomes `node /squad.mjs state-mcp`, the repository launcher - * ({@link writeSquadRepoLauncher}), which finds a Squad CLI on whatever machine runs it. A CLI + * - `local-entry` becomes `node -e /squad.mjs state-mcp`, the repository + * launcher ({@link writeSquadRepoLauncher}), which finds a Squad CLI on whatever machine runs it. + * The bootstrap finds the launcher from any subfolder of the repository (`squadRepoLauncherArgs`). A CLI * running from a package runner's temporary cache (`npx`, `pnpm dlx`, `yarn dlx`) is gone once * that command exits, so it keeps the exact-version `npx` form instead. * - `standalone` becomes a bare `squad state-mcp`: standalone installs put `squad` on PATH and may @@ -177,7 +179,7 @@ export function committedSquadStateMcpSpec(direct: SquadStateMcpSpec, squadDir: if (direct.source === 'local-entry' && squadDir) { return { command: 'node', - args: [`${squadDir}/${SQUAD_REPO_LAUNCHER_PATH}`, 'state-mcp'], + args: squadRepoLauncherArgs(squadDir, 'state-mcp'), source: 'repo-launcher', ...(direct.fallback ? { fallback: direct.fallback } : {}), }; diff --git a/packages/squad-cli/src/cli/core/upgrade.ts b/packages/squad-cli/src/cli/core/upgrade.ts index f5216c1b0..239fcb77a 100644 --- a/packages/squad-cli/src/cli/core/upgrade.ts +++ b/packages/squad-cli/src/cli/core/upgrade.ts @@ -9,6 +9,7 @@ import { execFileSync } from 'node:child_process'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { FSStorageProvider, stripTeamCapabilitiesBlock, syncTeamCapabilities } from '@bradygaster/squad-sdk'; import { clearResolveSquadCache, isRetiredProfileTeamRoot } from '@bradygaster/squad-sdk/resolution'; +import { squadRepoLauncherScript } from '@bradygaster/squad-sdk/client'; import { backfillCastingRegistryFromTeam, detectHistoricalCastingSchema, @@ -1276,7 +1277,7 @@ export function describeMcpSpec(spec: SquadStateMcpSpec): string { return `${spec.command} ${spec.args[0]} (machine-specific installed CLI; exact-version fallback recorded)`; } if (spec.source === 'repo-launcher') { - return `node ${spec.args[0]} (repository launcher: finds the Squad CLI on each machine)`; + return `node ${squadRepoLauncherScript(spec.command, spec.args) ?? spec.args[0]} (repository launcher: finds the Squad CLI on each machine)`; } if (spec.source === 'path') { return 'squad from PATH'; diff --git a/packages/squad-sdk/src/client/adapters/claude-hooks.ts b/packages/squad-sdk/src/client/adapters/claude-hooks.ts index f04b9a915..33043469b 100644 --- a/packages/squad-sdk/src/client/adapters/claude-hooks.ts +++ b/packages/squad-sdk/src/client/adapters/claude-hooks.ts @@ -17,6 +17,7 @@ import { CLAUDE_AGENTS_DIR, readRouting } from './claude-members.js'; import { effectiveLocked, lockoutsFor, readLockouts, type LockoutRecord } from './lockout.js'; import type { ClientContext, McpLaunchSpec, ProjectionFile } from './types.js'; import { readContained } from './safe-path.js'; +import { squadRepoLauncherScript } from './repo-launcher.js'; export const CLAUDE_SETTINGS_PATH = '.claude/settings.json'; const PLUGIN_SETTINGS_STATE = '.claude/.squad-plugin-settings.json'; @@ -112,8 +113,8 @@ export function claudeHookLaunch(mcp: McpLaunchSpec, event: ClaudeHookEvent): Mc /** `/squad.mjs` when `mcp` is the repository launcher, else null. */ function repoLauncherPath(mcp: McpLaunchSpec): string | null { - const [script, sub] = mcp.args; - if (mcp.command !== 'node' || mcp.args.length !== 2 || sub !== 'state-mcp' || !script) return null; + const script = squadRepoLauncherScript(mcp.command, mcp.args); + if (!script || mcp.args.indexOf(script) !== mcp.args.length - 2) return null; return /^(?:[\w.-]+\/)+squad\.mjs$/.test(script) && !script.split('/').includes('..') ? script : null; } diff --git a/packages/squad-sdk/src/client/adapters/claude-plugin.ts b/packages/squad-sdk/src/client/adapters/claude-plugin.ts index a5822423c..a2bfd79f7 100644 --- a/packages/squad-sdk/src/client/adapters/claude-plugin.ts +++ b/packages/squad-sdk/src/client/adapters/claude-plugin.ts @@ -17,6 +17,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { buildCoordinatorInstructions, claudeCoordinatorSkills } from './coordinator.js'; import { CLAUDE_HOOK_EVENTS, claudeHookMatcher, type ClaudeHookEvent } from './claude-hooks.js'; +export { SQUAD_REPO_LAUNCHER_BOOTSTRAP, SQUAD_REPO_LAUNCHER_PATH, squadRepoLauncherArgs, squadRepoLauncherScript } from './repo-launcher.js'; /** Plugin name: the namespace of its agents, skills, commands and MCP server. */ export const CLAUDE_PLUGIN_NAME = 'squad'; @@ -102,11 +103,6 @@ function walk(dir: string, rel = ''): string[] { const NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; -/** - * Where {@link squadRepoLauncher} lives, relative to the squad directory (#2462). Not under a - * `bin/` directory: many repositories ignore `bin/`, which would leave the launcher uncommitted. - */ -export const SQUAD_REPO_LAUNCHER_PATH = 'squad.mjs'; /** First line after the shebang of a repository launcher Squad wrote: marks the file as Squad's. */ export const SQUAD_REPO_LAUNCHER_MARKER = '// Generated by Squad. Managed file: rewritten by `squad init` and `squad upgrade`.'; diff --git a/packages/squad-sdk/src/client/adapters/repo-launcher.ts b/packages/squad-sdk/src/client/adapters/repo-launcher.ts new file mode 100644 index 000000000..936a18ac7 --- /dev/null +++ b/packages/squad-sdk/src/client/adapters/repo-launcher.ts @@ -0,0 +1,40 @@ +/** + * The repository launcher's committed location and the command that starts it (#2462). + */ + +/** + * Where `squadRepoLauncher` lives, relative to the squad directory (#2462). Not under a + * `bin/` directory: many repositories ignore `bin/`, which would leave the launcher uncommitted. + */ +export const SQUAD_REPO_LAUNCHER_PATH = 'squad.mjs'; + +/** + * Inline script committed MCP configs run as `node -e /squad.mjs state-mcp`. + * + * Hosts start MCP servers in the folder the session started in. Claude Code finds the root + * `.mcp.json` from a subfolder (a package in a monorepo) and starts the server there, and + * `${CLAUDE_PROJECT_DIR}` is not expanded in `.mcp.json`, so a relative script path fails with + * "Cannot find module". This walks up from that folder, stopping at the repository root (the first + * folder with `.git`), and runs the launcher it finds with the arguments after the script path. + * Single quotes only, so Windows argument quoting never has to escape it. + */ +export const SQUAD_REPO_LAUNCHER_BOOTSTRAP = + "const p=require('path'),f=require('fs'),s=process.argv[1];" + + 'for(let d=process.cwd();;d=p.dirname(d)){const c=p.join(d,s);' + + "if(f.existsSync(c)){process.argv[1]=c;import(require('url').pathToFileURL(c).href).catch(e=>{console.error(e);process.exit(1)});break}" + + "if(p.dirname(d)===d||f.existsSync(p.join(d,'.git'))){console.error('squad: '+s+' was not found in '+process.cwd()+' or a parent folder up to the repository root. Run squad upgrade at the repository root.');process.exit(1)}}"; + +/** The committed MCP arguments that run the repository launcher with `rest`, from any subfolder. */ +export function squadRepoLauncherArgs(squadDir: string, ...rest: string[]): string[] { + return ['-e', SQUAD_REPO_LAUNCHER_BOOTSTRAP, `${squadDir}/${SQUAD_REPO_LAUNCHER_PATH}`, ...rest]; +} + +/** + * The launcher path (`/squad.mjs`, relative) a `node` MCP command runs, in the committed + * form {@link squadRepoLauncherArgs} writes or as a plain script argument; else null. + */ +export function squadRepoLauncherScript(command: string, args: readonly string[]): string | null { + if (command !== 'node') return null; + const script = args[0] === '-e' && args[1] === SQUAD_REPO_LAUNCHER_BOOTSTRAP ? args[2] : args[0]; + return script && /(?:^|\/)squad\.mjs$/.test(script) ? script : null; +} diff --git a/test/__snapshots__/host-projection-regression-guards.test.ts.snap b/test/__snapshots__/host-projection-regression-guards.test.ts.snap index 596988f71..e4945d680 100644 --- a/test/__snapshots__/host-projection-regression-guards.test.ts.snap +++ b/test/__snapshots__/host-projection-regression-guards.test.ts.snap @@ -36,6 +36,6 @@ exports[`host projection regression guards (#2438) > guard 1: the Copilot projec ".github/workflows/squad-triage.yml": "54f8a35713f9cd88bef9ee54b03da8f7d9cbd0bbdcbb03c73cb0dd37fd9bd1f2", ".github/workflows/sync-squad-labels.yml": "105f9f0b1e004833154c6511c84b7f70c465e69e1e8f3da8676ec1255396c34d", ".gitignore": "37aa6c4e49c873d2603b79e173ba46e216b18c596057964aac1cd6e62ff4e89f", - ".mcp.json": "fc7033ca67bd82d6c4aab068c6afd7b2cad9533517417dea213dbd5641b986a7", + ".mcp.json": "dbfed1ee3fd281daf1bedfb98244640d6c4f531c6b54e266e569a4ccaad0a06f", } `; diff --git a/test/cli/upgrade-portability.test.ts b/test/cli/upgrade-portability.test.ts index d7c561524..730511db6 100644 --- a/test/cli/upgrade-portability.test.ts +++ b/test/cli/upgrade-portability.test.ts @@ -6,6 +6,7 @@ import { runInit } from '../../packages/squad-cli/src/cli/core/init.js'; import { runUpgrade } from '../../packages/squad-cli/src/cli/core/upgrade.js'; import { getPackageVersion } from '../../packages/squad-cli/src/cli/core/version.js'; import { STANDALONE_HOME_ENV } from '../../packages/squad-cli/src/cli/core/mcp-spec.js'; +import { squadRepoLauncherArgs } from '@bradygaster/squad-sdk/client'; import { getTemplatesDir } from '../../packages/squad-cli/src/cli/core/templates.js'; import { copyManagedFile, writeManagedText } from '../../packages/squad-cli/src/cli/core/managed-file.js'; import { TEAM_CAPABILITIES_BEGIN } from '@bradygaster/squad-sdk/config'; @@ -65,7 +66,7 @@ describe('upgrade portability and destination newlines', () => { // becomes `squad` from PATH, an installed CLI becomes the repository launcher. expect(config.mcpServers.squad_state.command).toBe(mode === 'packaged' ? 'squad' : 'node'); expect(config.mcpServers.squad_state.args).toEqual(mode === 'packaged' - ? ['state-mcp'] : ['.squad/squad.mjs', 'state-mcp']); + ? ['state-mcp'] : squadRepoLauncherArgs('.squad', 'state-mcp')); expect(config.mcpServers.squad_state['x-squad']).toEqual({ managed: true, version, diff --git a/test/portable-launch.test.ts b/test/portable-launch.test.ts index 311107d34..7056f6f10 100644 --- a/test/portable-launch.test.ts +++ b/test/portable-launch.test.ts @@ -14,6 +14,7 @@ import { mergeClaudeHooks, stripClaudeHooks, squadRepoLauncher, + squadRepoLauncherArgs, SQUAD_REPO_LAUNCHER_MARKER, } from '@bradygaster/squad-sdk/client'; import { @@ -33,7 +34,7 @@ const localEntry: SquadStateMcpSpec = { source: 'local-entry', fallback, }; -const repoLauncher = { command: 'node', args: ['.squad/squad.mjs', 'state-mcp'] }; +const repoLauncher = { command: 'node', args: squadRepoLauncherArgs('.squad', 'state-mcp') }; let root: string; beforeEach(() => { @@ -140,6 +141,9 @@ describe('Claude hooks through the repository launcher (#2462)', () => { command: 'node "$CLAUDE_PROJECT_DIR/.squad/squad.mjs" hook claude Stop', args: [], }); + // The plain script form, written before the bootstrap, maps to the same hook. + expect(claudeHookLaunch({ command: 'node', args: ['.squad/squad.mjs', 'state-mcp'] }, 'Stop')) + .toEqual(claudeHookLaunch(repoLauncher, 'Stop')); // Anything else keeps the argv form. expect(claudeHookLaunch({ command: 'node', args: ['/abs/.squad/squad.mjs', 'state-mcp'] }, 'Stop')!.args) .toEqual(['/abs/.squad/squad.mjs', 'hook', 'claude', 'Stop']); @@ -193,6 +197,29 @@ describe('the repository launcher script (#2462)', () => { write('.squad/squad.mjs', squadRepoLauncher('2.0.0', '.squad')); }); + it('starts from a subfolder of the repository, as Claude Code does in a monorepo package', () => { + // Claude Code finds the root .mcp.json from packages/api and starts the server there. + fakeCli(root, '2.0.1'); + fs.mkdirSync(path.join(root, '.git')); + const api = path.join(root, 'packages', 'api'); + fs.mkdirSync(api, { recursive: true }); + const spec = committedSquadStateMcpSpec(localEntry, '.squad'); + for (const cwd of [root, api]) { + const out = spawnSync(spec.command, [...spec.args.slice(0, -1), 'state-mcp', 'a b', '"q"'], { + cwd, encoding: 'utf8', env: { PATH: path.dirname(process.execPath) }, + }); + expect(out.stderr, cwd).toBe(''); + expect(out.stdout.trim()).toBe('fake 2.0.1 ["state-mcp","a b","\\"q\\""]'); + } + // It stops at the repository root: a nested repository never runs the launcher above it. + const nested = path.join(root, 'packages', 'inner'); + fs.mkdirSync(path.join(nested, '.git'), { recursive: true }); + const out = spawnSync(spec.command, spec.args, { cwd: nested, encoding: 'utf8', env: { PATH: path.dirname(process.execPath) } }); + expect(out.status).toBe(1); + expect(out.stdout).toBe(''); + expect(out.stderr).toContain('.squad/squad.mjs was not found'); + }); + it('runs the repository\'s own install from any working directory', () => { fakeCli(root, '2.0.1'); const out = run(['state-mcp']); @@ -262,7 +289,7 @@ describe('squad doctor: portable launch commands (#2462)', () => { }); it('checks Claude hooks too, and that the launcher exists', () => { - write('.mcp.json', mcp('node', ['.squad/squad.mjs', 'state-mcp'])); + write('.mcp.json', mcp('node', squadRepoLauncherArgs('.squad', 'state-mcp'))); write('.claude/settings.json', JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'node "$CLAUDE_PROJECT_DIR/.squad/squad.mjs" hook claude Stop' }] }] }, })); From 0d40472d708dcdbbe77c883082fd9d85bc4439d5 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Thu, 12 Nov 2026 23:59:59 -0800 Subject: [PATCH 2/3] fix(launcher): run only the launcher at the repository root Copilot review: the first-match search let a package's own .squad/squad.mjs shadow the root launcher, and without .git it could search above the checkout. The bootstrap now finds the repository root first and runs only //squad.mjs; with no .git it uses the working directory alone, and it fails closed on a missing launcher or an escaping path. Co-Authored-By: Claude Opus 5.5 --- .changeset/mcp-launcher-subfolder.md | 2 +- CHANGELOG.md | 2 +- .../src/client/adapters/repo-launcher.ts | 16 ++++++---- ...-projection-regression-guards.test.ts.snap | 2 +- test/portable-launch.test.ts | 32 +++++++++++++++++++ 5 files changed, 45 insertions(+), 9 deletions(-) diff --git a/.changeset/mcp-launcher-subfolder.md b/.changeset/mcp-launcher-subfolder.md index 4ee212652..6fec61b67 100644 --- a/.changeset/mcp-launcher-subfolder.md +++ b/.changeset/mcp-launcher-subfolder.md @@ -3,4 +3,4 @@ "@bradygaster/squad-cli": patch --- -`squad_state` starts when Claude Code is started in a subfolder of the repository, such as a package in a monorepo. Claude Code finds the root `.mcp.json` from there but starts the server in the subfolder, so `node .squad/squad.mjs state-mcp` failed with "Cannot find module". The committed command now runs a short `node -e` script that looks for `.squad/squad.mjs` in the subfolder and each parent, up to the repository root. +`squad_state` starts when Claude Code is started in a subfolder of the repository, such as a package in a monorepo. Claude Code finds the root `.mcp.json` from there but starts the server in the subfolder, so `node .squad/squad.mjs state-mcp` failed with "Cannot find module". The committed command now runs a short `node -e` script that finds the repository root (the nearest folder with `.git`) and runs only the `.squad/squad.mjs` there, so a launcher in a package's own `.squad/` or above the repository is never run. diff --git a/CHANGELOG.md b/CHANGELOG.md index c23b08840..2b6b0ce70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,7 +78,7 @@ Claude Code is the first host after GitHub Copilot where Squad uses the host's o - Claude hooks reach the launcher through `$CLAUDE_PROJECT_DIR`. Standalone bundle installs write `squad state-mcp`. A CLI run through `npx` keeps the exact-version `npx` command. - `squad doctor` flags machine-specific launch commands and a missing launcher. Run `squad upgrade` once to fix an existing repository. - `squad init --client claude` tells you to run `claude --agent squad --mcp-config .mcp.json`, not Copilot. With several hosts it gives one line per host (#2453). -- `squad_state` starts when Claude Code is started in a subfolder of the repository, such as a package in a monorepo. Claude Code finds the root `.mcp.json` from there but starts the server in the subfolder, so `node .squad/squad.mjs state-mcp` failed with "Cannot find module". The committed command now runs a short `node -e` script that looks for `.squad/squad.mjs` in the subfolder and each parent, up to the repository root. Run `squad upgrade` to rewrite an existing `.mcp.json`. +- `squad_state` starts when Claude Code is started in a subfolder of the repository, such as a package in a monorepo. Claude Code finds the root `.mcp.json` from there but starts the server in the subfolder, so `node .squad/squad.mjs state-mcp` failed with "Cannot find module". The committed command now runs a short `node -e` script that finds the repository root (the nearest folder with `.git`) and runs only the `.squad/squad.mjs` there, so a launcher in a package's own `.squad/` or above the repository is never run. Run `squad upgrade` to rewrite an existing `.mcp.json`. - `.squad/squad.mjs` finds a `squad` on PATH. It used to skip every PATH entry, so a repository with only a global Squad CLI failed with "no Squad CLI found". Arguments reach the CLI unchanged on Windows (through npm's `.cmd` shims, never cmd.exe) and Linux, and a new CI job runs the launcher tests on both. ## [2.0.0-preview.4] - 2026-11-09 diff --git a/packages/squad-sdk/src/client/adapters/repo-launcher.ts b/packages/squad-sdk/src/client/adapters/repo-launcher.ts index 936a18ac7..992c5607f 100644 --- a/packages/squad-sdk/src/client/adapters/repo-launcher.ts +++ b/packages/squad-sdk/src/client/adapters/repo-launcher.ts @@ -14,15 +14,19 @@ export const SQUAD_REPO_LAUNCHER_PATH = 'squad.mjs'; * Hosts start MCP servers in the folder the session started in. Claude Code finds the root * `.mcp.json` from a subfolder (a package in a monorepo) and starts the server there, and * `${CLAUDE_PROJECT_DIR}` is not expanded in `.mcp.json`, so a relative script path fails with - * "Cannot find module". This walks up from that folder, stopping at the repository root (the first - * folder with `.git`), and runs the launcher it finds with the arguments after the script path. + * "Cannot find module". This finds the repository root first (the nearest folder, from the + * working directory up, that has `.git`) and runs only the launcher at `//squad.mjs`, + * with the arguments after the script path. A launcher in a subfolder (a package's own `.squad/`) + * or above the root is never run. With no `.git` anywhere, the root is the working directory itself: + * nothing is searched. It fails closed when the launcher is missing or the path leaves the root. * Single quotes only, so Windows argument quoting never has to escape it. */ export const SQUAD_REPO_LAUNCHER_BOOTSTRAP = - "const p=require('path'),f=require('fs'),s=process.argv[1];" + - 'for(let d=process.cwd();;d=p.dirname(d)){const c=p.join(d,s);' + - "if(f.existsSync(c)){process.argv[1]=c;import(require('url').pathToFileURL(c).href).catch(e=>{console.error(e);process.exit(1)});break}" + - "if(p.dirname(d)===d||f.existsSync(p.join(d,'.git'))){console.error('squad: '+s+' was not found in '+process.cwd()+' or a parent folder up to the repository root. Run squad upgrade at the repository root.');process.exit(1)}}"; + "const p=require('path'),f=require('fs'),s=process.argv[1];let r=process.cwd();" + + "for(let d=r;;d=p.dirname(d)){if(f.existsSync(p.join(d,'.git'))){r=d;break}if(p.dirname(d)===d)break}" + + "const c=p.join(r,s);" + + "if(p.isAbsolute(s)||s.split(/[\\\\/]/).includes('..')||!f.existsSync(c)){console.error('squad: '+c+' was not found. Run squad upgrade at the repository root.');process.exit(1)}" + + "process.argv[1]=c;import(require('url').pathToFileURL(c).href).catch(e=>{console.error(e);process.exit(1)});"; /** The committed MCP arguments that run the repository launcher with `rest`, from any subfolder. */ export function squadRepoLauncherArgs(squadDir: string, ...rest: string[]): string[] { diff --git a/test/__snapshots__/host-projection-regression-guards.test.ts.snap b/test/__snapshots__/host-projection-regression-guards.test.ts.snap index e4945d680..0c9565206 100644 --- a/test/__snapshots__/host-projection-regression-guards.test.ts.snap +++ b/test/__snapshots__/host-projection-regression-guards.test.ts.snap @@ -36,6 +36,6 @@ exports[`host projection regression guards (#2438) > guard 1: the Copilot projec ".github/workflows/squad-triage.yml": "54f8a35713f9cd88bef9ee54b03da8f7d9cbd0bbdcbb03c73cb0dd37fd9bd1f2", ".github/workflows/sync-squad-labels.yml": "105f9f0b1e004833154c6511c84b7f70c465e69e1e8f3da8676ec1255396c34d", ".gitignore": "37aa6c4e49c873d2603b79e173ba46e216b18c596057964aac1cd6e62ff4e89f", - ".mcp.json": "dbfed1ee3fd281daf1bedfb98244640d6c4f531c6b54e266e569a4ccaad0a06f", + ".mcp.json": "f663bb87bb44da6d619c3919daf37c186fa7f1b97649f7f2055ece72d0dbb825", } `; diff --git a/test/portable-launch.test.ts b/test/portable-launch.test.ts index 7056f6f10..56e9cf525 100644 --- a/test/portable-launch.test.ts +++ b/test/portable-launch.test.ts @@ -220,6 +220,38 @@ describe('the repository launcher script (#2462)', () => { expect(out.stderr).toContain('.squad/squad.mjs was not found'); }); + it('runs only the launcher at the repository root, never one in a subfolder or above the root (Copilot review)', () => { + fakeCli(root, '2.0.1'); + fs.mkdirSync(path.join(root, '.git')); + const env = { PATH: path.dirname(process.execPath) }; + const spec = committedSquadStateMcpSpec(localEntry, '.squad'); + // A package's own .squad/squad.mjs does not shadow the root launcher the root .mcp.json names. + const api = path.join(root, 'packages', 'api'); + fs.mkdirSync(path.join(api, '.squad'), { recursive: true }); + fs.writeFileSync(path.join(api, '.squad', 'squad.mjs'), "console.log('PLANTED')\n"); + const fromApi = spawnSync(spec.command, spec.args, { cwd: api, encoding: 'utf8', env }); + expect(fromApi.stdout).not.toContain('PLANTED'); + expect(fromApi.stdout.trim()).toBe('fake 2.0.1 ["state-mcp"]'); + // With no .git anywhere, nothing is searched: a launcher above the working directory is never run. + const loose = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-no-git-')); + try { + fs.mkdirSync(path.join(loose, '.squad')); + fs.writeFileSync(path.join(loose, '.squad', 'squad.mjs'), "console.log('PLANTED')\n"); + const sub = path.join(loose, 'sub'); + fs.mkdirSync(sub); + const out = spawnSync(spec.command, spec.args, { cwd: sub, encoding: 'utf8', env }); + expect(out.status).toBe(1); + expect(out.stdout).not.toContain('PLANTED'); + expect(out.stderr).toContain('was not found'); + } finally { + fs.rmSync(loose, { recursive: true, force: true }); + } + // A script path that leaves the root is refused. + const escape = spawnSync(spec.command, [spec.args[0]!, spec.args[1]!, '../outside/squad.mjs', 'state-mcp'], { cwd: root, encoding: 'utf8', env }); + expect(escape.status).toBe(1); + expect(escape.stderr).toContain('was not found'); + }); + it('runs the repository\'s own install from any working directory', () => { fakeCli(root, '2.0.1'); const out = run(['state-mcp']); From 1965dbff35f0a616166d864188f41d57b58a329b Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Fri, 13 Nov 2026 00:01:54 -0800 Subject: [PATCH 3/3] test(launcher): match the not-found message on Windows paths Co-Authored-By: Claude Opus 5.5 --- test/portable-launch.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/portable-launch.test.ts b/test/portable-launch.test.ts index 56e9cf525..d05e9a75c 100644 --- a/test/portable-launch.test.ts +++ b/test/portable-launch.test.ts @@ -217,7 +217,7 @@ describe('the repository launcher script (#2462)', () => { const out = spawnSync(spec.command, spec.args, { cwd: nested, encoding: 'utf8', env: { PATH: path.dirname(process.execPath) } }); expect(out.status).toBe(1); expect(out.stdout).toBe(''); - expect(out.stderr).toContain('.squad/squad.mjs was not found'); + expect(out.stderr).toContain('was not found'); }); it('runs only the launcher at the repository root, never one in a subfolder or above the root (Copilot review)', () => {