diff --git a/.changeset/mcp-launcher-subfolder.md b/.changeset/mcp-launcher-subfolder.md new file mode 100644 index 000000000..6fec61b67 --- /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 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 4d6ade011..53434240a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,7 @@ Claude Code is the first host after GitHub Copilot where Squad uses the host's o - `squad doctor` flags machine-specific launch commands and a missing launcher. Run `squad upgrade` once to fix an existing repository. - **Coordinators wait for a human's sign-off.** When `team.md` or `routing.md` says a human must sign off on work (a release, tag, publish, merge or deploy), the Claude, Kiro, OpenCode, Cursor, Codex and Antigravity coordinators no longer treat the request itself as the sign-off. They name the rule and the human, ask whether you are that human or have their sign-off, go ahead only on an explicit yes, and record it in the decisions inbox. In a non-interactive run they stop at the gate. Seen in a live test: asked to "cut the 1.0.0 release", the Claude coordinator used to bump, tag and run `npm publish` itself. - `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 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-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..992c5607f --- /dev/null +++ b/packages/squad-sdk/src/client/adapters/repo-launcher.ts @@ -0,0 +1,44 @@ +/** + * 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 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];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[] { + 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..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": "fc7033ca67bd82d6c4aab068c6afd7b2cad9533517417dea213dbd5641b986a7", + ".mcp.json": "f663bb87bb44da6d619c3919daf37c186fa7f1b97649f7f2055ece72d0dbb825", } `; 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..d05e9a75c 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,61 @@ 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('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']); @@ -262,7 +321,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' }] }] }, }));