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
6 changes: 6 additions & 0 deletions .changeset/mcp-launcher-subfolder.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion packages/squad-cli/src/cli/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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}` };
}
Expand Down
8 changes: 5 additions & 3 deletions packages/squad-cli/src/cli/core/mcp-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
SQUAD_REPO_LAUNCHER_MARKER,
SQUAD_REPO_LAUNCHER_PATH,
squadRepoLauncher,
squadRepoLauncherArgs,
} from '@bradygaster/squad-sdk/client';

export interface SquadStateMcpSpec {
Expand Down Expand Up @@ -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 <squadDir>/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 <bootstrap> <squadDir>/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
Expand All @@ -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 } : {}),
};
Expand Down
3 changes: 2 additions & 1 deletion packages/squad-cli/src/cli/core/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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';
Expand Down
5 changes: 3 additions & 2 deletions packages/squad-sdk/src/client/adapters/claude-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -112,8 +113,8 @@ export function claudeHookLaunch(mcp: McpLaunchSpec, event: ClaudeHookEvent): Mc

/** `<squad dir>/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;
}

Expand Down
6 changes: 1 addition & 5 deletions packages/squad-sdk/src/client/adapters/claude-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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`.';

Expand Down
44 changes: 44 additions & 0 deletions packages/squad-sdk/src/client/adapters/repo-launcher.ts
Original file line number Diff line number Diff line change
@@ -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 <this> <squad dir>/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 `<root>/<squad dir>/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 dir>/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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
`;
3 changes: 2 additions & 1 deletion test/cli/upgrade-portability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
63 changes: 61 additions & 2 deletions test/portable-launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
mergeClaudeHooks,
stripClaudeHooks,
squadRepoLauncher,
squadRepoLauncherArgs,
SQUAD_REPO_LAUNCHER_MARKER,
} from '@bradygaster/squad-sdk/client';
import {
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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']);
Expand Down Expand Up @@ -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']);
Expand Down Expand Up @@ -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' }] }] },
}));
Expand Down
Loading