From d1dbd26fb3eb0797e6d7443a79983d3451f5c8a4 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Thu, 12 Nov 2026 07:00:20 -0800 Subject: [PATCH 1/4] fix(claude): repository launcher finds squad on PATH; Windows argv test The repo launcher skipped every PATH entry because realpath of its (nonexistent) .squad/bin threw inside the comparison. New test/launcher-argv.test.ts checks hostile argv round-trips through a global squad, npx, SQUAD_CLI and a project install, through .cmd shims on Windows without cmd.exe. A new workflow runs the launcher tests on Windows and Linux. Co-Authored-By: Claude Opus 5.5 --- .changeset/launcher-path-lookup.md | 6 + .../workflows/squad-launcher-platforms.yml | 59 ++++++++ CHANGELOG.md | 1 + .../src/client/adapters/claude-plugin.ts | 9 +- plugins/squad/scripts/squad.mjs | 9 +- test/launcher-argv.test.ts | 135 ++++++++++++++++++ 6 files changed, 213 insertions(+), 6 deletions(-) create mode 100644 .changeset/launcher-path-lookup.md create mode 100644 .github/workflows/squad-launcher-platforms.yml create mode 100644 test/launcher-argv.test.ts diff --git a/.changeset/launcher-path-lookup.md b/.changeset/launcher-path-lookup.md new file mode 100644 index 000000000..5e7f4a08e --- /dev/null +++ b/.changeset/launcher-path-lookup.md @@ -0,0 +1,6 @@ +--- +"@bradygaster/squad-sdk": patch +"@bradygaster/squad-cli": patch +--- + +The repository launcher `.squad/squad.mjs` now finds a `squad` on PATH. It skipped every PATH entry because it has no `bin` directory of its own, so a repository whose only Squad CLI was a global install failed with "no Squad CLI found". A new test checks that the launcher passes arguments through unchanged, including `&`, `|`, `%VAR%`, quotes and empty strings, on Windows (through npm's `.cmd` shims, never cmd.exe) and Linux. diff --git a/.github/workflows/squad-launcher-platforms.yml b/.github/workflows/squad-launcher-platforms.yml new file mode 100644 index 000000000..10b3d559b --- /dev/null +++ b/.github/workflows/squad-launcher-platforms.yml @@ -0,0 +1,59 @@ +# Squad launcher on Windows and Linux. +# +# `.squad/squad.mjs` and the Claude plugin's `scripts/squad.mjs` take a different path on Windows: +# npm's `squad.cmd` and `npx.cmd` shims are run through their JavaScript entry, never cmd.exe, so +# arguments arrive unchanged. The main CI runs on Linux only, so this job runs the launcher tests on +# both platforms whenever the launcher template or its tests change. +name: Squad launcher platforms + +on: + pull_request: + paths: + - 'packages/squad-sdk/src/client/adapters/claude-plugin.ts' + - 'packages/squad-sdk/src/client/adapters/claude-hooks.ts' + - 'packages/squad-cli/src/cli/core/mcp-spec.ts' + - 'plugins/squad/scripts/**' + - 'test/launcher-argv.test.ts' + - 'test/portable-launch.test.ts' + - '.github/workflows/squad-launcher-platforms.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + launcher: + name: launcher (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 #v6 + with: + node-version: 22 + cache: 'npm' + - name: Install dependencies + shell: bash + run: | + success=false + for i in 1 2 3; do + if npm install; then success=true; break; fi + echo "Retry $i/3 — npm install failed, retrying in 5s..."; sleep 5 + done + [ "$success" = true ] || { echo "::error::npm install failed after 3 attempts"; exit 1; } + - name: Build (no version bump) + shell: bash + run: npm run build + env: + SKIP_BUILD_BUMP: '1' + - name: Launcher tests + shell: bash + run: npx vitest run test/launcher-argv.test.ts test/portable-launch.test.ts --reporter=default diff --git a/CHANGELOG.md b/CHANGELOG.md index 105935dfa..475afd788 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,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/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/claude-plugin.ts b/packages/squad-sdk/src/client/adapters/claude-plugin.ts index ecd4a8f47..a5822423c 100644 --- a/packages/squad-sdk/src/client/adapters/claude-plugin.ts +++ b/packages/squad-sdk/src/client/adapters/claude-plugin.ts @@ -238,11 +238,14 @@ function recentEnough(entry, version) { function onPath(name) { const exts = win ? (process.env.PATHEXT || '.EXE;.CMD;.BAT').split(';') : ['']; + // The repository launcher has no bin directory of its own; only skip one that exists. + let self; + try { self = canonical(ownBin); } catch { self = undefined; } for (const dir of (process.env.PATH || '').split(path.delimiter)) { if (!dir) continue; - try { - if (canonical(dir) === canonical(ownBin)) continue; - } catch { continue; } + let here; + try { here = canonical(dir); } catch { continue; } + if (here === self) continue; for (const ext of exts) { const file = path.join(dir, name + ext); try { diff --git a/plugins/squad/scripts/squad.mjs b/plugins/squad/scripts/squad.mjs index 2a005b99b..8f2e9ceca 100755 --- a/plugins/squad/scripts/squad.mjs +++ b/plugins/squad/scripts/squad.mjs @@ -131,11 +131,14 @@ function recentEnough(entry, version) { function onPath(name) { const exts = win ? (process.env.PATHEXT || '.EXE;.CMD;.BAT').split(';') : ['']; + // The repository launcher has no bin directory of its own; only skip one that exists. + let self; + try { self = canonical(ownBin); } catch { self = undefined; } for (const dir of (process.env.PATH || '').split(path.delimiter)) { if (!dir) continue; - try { - if (canonical(dir) === canonical(ownBin)) continue; - } catch { continue; } + let here; + try { here = canonical(dir); } catch { continue; } + if (here === self) continue; for (const ext of exts) { const file = path.join(dir, name + ext); try { diff --git a/test/launcher-argv.test.ts b/test/launcher-argv.test.ts new file mode 100644 index 000000000..05f536574 --- /dev/null +++ b/test/launcher-argv.test.ts @@ -0,0 +1,135 @@ +/** + * The Squad launcher (`.squad/squad.mjs`, and the plugin's `scripts/squad.mjs`, built from the + * same template) passes argv to the CLI byte for byte, on every platform (#2469). + * + * On Windows, npm installs `squad` and `npx` as `.cmd` shims. Running a shim means going through + * cmd.exe, which rewrites `&`, `|`, `%VAR%`, `^` and quotes. The launcher must instead run the + * shim's JavaScript entry with Node, with no shell. These tests put fake shims on PATH that would + * print `SHELL-USED` if cmd.exe ever ran them, and check every argument arrives unchanged. + * + * CI runs this file on Windows and Linux (`.github/workflows/squad-launcher-platforms.yml`). + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { squadRepoLauncher } from '@bradygaster/squad-sdk/client'; + +const win = process.platform === 'win32'; +const PKG = ['@bradygaster', 'squad-cli']; + +/** Arguments cmd.exe or a shell would rewrite, split or expand. */ +const HOSTILE = [ + 'state-mcp', + 'two words', + 'quote"inside', + "single'quote", + '&echo pwned', + 'pipe|more', + '%PATH%', + '^caret', + '!bang!', + 'out', + '$HOME', + '`tick`', + '', + 'trailing\\', + 'back\\"slash', + 'ünïcødé ✓', +]; + +/** A script that prints its argv as JSON. */ +const ECHO = "process.stdout.write(JSON.stringify(process.argv.slice(2)));\n"; + +let root: string; +let bin: string; +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-launcher-argv-')); + bin = path.join(root, 'fake bin'); // a space in the directory, as in "Program Files" + fs.mkdirSync(bin, { recursive: true }); + fs.mkdirSync(path.join(root, '.squad'), { recursive: true }); + fs.writeFileSync(path.join(root, '.squad', 'squad.mjs'), squadRepoLauncher('2.0.0', '.squad')); +}); +afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); +}); + +function write(file: string, body: string, mode?: number): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, body); + if (mode !== undefined) fs.chmodSync(file, mode); +} + +/** A shim that must never run: under cmd.exe it prints SHELL-USED. */ +function cmdShim(name: string): void { + write(path.join(bin, `${name}.cmd`), '@echo SHELL-USED %*\r\n'); +} + +function run(args: string[], env: Record = {}) { + const keep = ['SystemRoot', 'SYSTEMROOT', 'PATHEXT', 'TEMP', 'TMP', 'windir']; + const base = Object.fromEntries(keep.filter((k) => process.env[k]).map((k) => [k, process.env[k] as string])); + return spawnSync(process.execPath, [path.join(root, '.squad', 'squad.mjs'), ...args], { + cwd: os.tmpdir(), + encoding: 'utf8', + env: { + ...base, + // The fake bin first, then Node's own directory; no real squad or npx. + PATH: [bin, path.dirname(process.execPath)].join(path.delimiter), + SQUAD_HOME: path.join(root, 'squad-home'), + ...env, + }, + }); +} + +function parsed(out: ReturnType): string[] { + expect(out.stderr).toBe(''); + expect(out.stdout).not.toContain('SHELL-USED'); + expect(out.status).toBe(0); + return JSON.parse(out.stdout); +} + +describe('the launcher passes argv through unchanged', () => { + it('to a global squad on PATH', () => { + if (win) { + cmdShim('squad'); + const pkg = path.join(bin, 'node_modules', ...PKG); + write(path.join(pkg, 'package.json'), JSON.stringify({ name: PKG.join('/'), version: '2.0.0' })); + write(path.join(pkg, 'dist', 'cli-entry.js'), ECHO); + } else { + write(path.join(bin, 'squad'), `#!/usr/bin/env node\n${ECHO}`, 0o755); + } + expect(parsed(run(HOSTILE))).toEqual(HOSTILE); + }); + + it('to npx when SQUAD_ALLOW_NPX=1', () => { + if (win) { + cmdShim('npx'); + write(path.join(bin, 'node_modules', 'npm', 'bin', 'npx-cli.js'), ECHO); + } else { + write(path.join(bin, 'npx'), `#!/usr/bin/env node\n${ECHO}`, 0o755); + } + expect(parsed(run(HOSTILE, { SQUAD_ALLOW_NPX: '1' }))).toEqual(['-y', `${PKG.join('/')}@2.0.0`, ...HOSTILE]); + }); + + it('to SQUAD_CLI given as a script', () => { + const entry = path.join(root, 'cli dir', 'cli-entry.js'); + write(entry, ECHO); + expect(parsed(run(HOSTILE, { SQUAD_CLI: entry }))).toEqual(HOSTILE); + }); + + it('to the project\'s own install', () => { + const pkg = path.join(root, 'node_modules', ...PKG); + write(path.join(pkg, 'package.json'), JSON.stringify({ name: PKG.join('/'), version: '2.0.0' })); + write(path.join(pkg, 'dist', 'cli-entry.js'), ECHO); + expect(parsed(run(HOSTILE))).toEqual(HOSTILE); + }); + + it.runIf(win)('fails closed when a .cmd shim has no JavaScript entry beside it', () => { + cmdShim('squad'); + const out = run(['state-mcp']); + expect(out.status).toBe(1); + expect(out.stdout).not.toContain('SHELL-USED'); + expect(out.stderr).toContain('set SQUAD_CLI to cli-entry.js'); + }); +}); From 355131344d5ca5ceee77e53d736c56e31f6b6410 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Thu, 12 Nov 2026 07:08:08 -0800 Subject: [PATCH 2/4] test(claude): launcher tests ignore the developer's recorded CLI ~/.squad/cli.json (since #2470) is found by the launcher, so tests that expect the npx fallback or a missing CLI failed on any machine that had run squad init from a source build. Point SQUAD_HOME at an empty dir. Co-Authored-By: Claude Opus 5.5 --- test/claude-plugin.test.ts | 4 +++- test/claude-projection-v3-plugin.test.ts | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/test/claude-plugin.test.ts b/test/claude-plugin.test.ts index 307a2cf39..e3ff4bc14 100644 --- a/test/claude-plugin.test.ts +++ b/test/claude-plugin.test.ts @@ -178,11 +178,13 @@ describe('Claude plugin: contents', () => { describe('Claude plugin: launcher', () => { const launcher = path.join(PLUGIN, 'scripts', 'squad.mjs'); + const NO_SQUAD_HOME = path.join(os.tmpdir(), 'squad-plugin-test-no-home'); function run(env: Record, cwd: string) { return spawnSync(process.execPath, [launcher, 'one', 'two words'], { cwd, - env: { PATH: process.env.PATH ?? '', ...env }, + // An empty Squad home: the CLI recorded on the developer's machine must not be found. + env: { PATH: process.env.PATH ?? '', SQUAD_HOME: NO_SQUAD_HOME, ...env }, encoding: 'utf8', }); } diff --git a/test/claude-projection-v3-plugin.test.ts b/test/claude-projection-v3-plugin.test.ts index 2636aae3d..a44d5c9a5 100644 --- a/test/claude-projection-v3-plugin.test.ts +++ b/test/claude-projection-v3-plugin.test.ts @@ -328,7 +328,7 @@ describe('Claude projection v3: plugin mode install', () => { fs.chmodSync(abs('fallback/npx'), 0o755); const result = spawnSync(process.execPath, [launcher, 'status'], { cwd: root, encoding: 'utf8', timeout: 3000, - env: { PATH: `${abs('linked-bin')}:${abs('fallback')}:/usr/bin:/bin`, CLAUDE_PROJECT_DIR: root, SQUAD_ALLOW_NPX: '1' }, + env: { PATH: `${abs('linked-bin')}:${abs('fallback')}:/usr/bin:/bin`, CLAUDE_PROJECT_DIR: root, SQUAD_ALLOW_NPX: '1', SQUAD_HOME: abs('no-squad-home') }, }); expect(result.status, result.stderr).toBe(0); expect(result.stdout).toBe('fallback'); @@ -345,7 +345,7 @@ describe('Claude projection v3: plugin mode install', () => { write('global/node_modules/@bradygaster/squad-cli/dist/cli-entry.js', 'console.log(JSON.stringify(process.argv.slice(2)))'); const args = ['two words', 'literal&token', 'literal"quote']; const run = () => spawnSync(process.execPath, [abs('scripts/squad.mjs'), ...args], { - cwd: root, encoding: 'utf8', env: { PATH: abs('global'), PATHEXT: '.CMD', CLAUDE_PROJECT_DIR: abs('project'), SQUAD_ALLOW_NPX: '1' }, + cwd: root, encoding: 'utf8', env: { PATH: abs('global'), PATHEXT: '.CMD', CLAUDE_PROJECT_DIR: abs('project'), SQUAD_ALLOW_NPX: '1', SQUAD_HOME: abs('no-squad-home') }, }); const global = run(); expect(global.status, global.stderr).toBe(0); From dae92d484795d6245d342a772740c5d658871463 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Thu, 12 Nov 2026 07:19:55 -0800 Subject: [PATCH 3/4] test: avoid a retired term in a test name Co-Authored-By: Claude Opus 5.5 --- test/launcher-argv.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/launcher-argv.test.ts b/test/launcher-argv.test.ts index 05f536574..b18adb1c4 100644 --- a/test/launcher-argv.test.ts +++ b/test/launcher-argv.test.ts @@ -90,7 +90,7 @@ function parsed(out: ReturnType): string[] { } describe('the launcher passes argv through unchanged', () => { - it('to a global squad on PATH', () => { + it('to a squad CLI installed on PATH', () => { if (win) { cmdShim('squad'); const pkg = path.join(bin, 'node_modules', ...PKG); From cad519bb576351343e9dad2dbe4c84f3d069ca97 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Thu, 12 Nov 2026 07:41:34 -0800 Subject: [PATCH 4/4] test(release): explain the Windows-only launcher-argv skip Co-Authored-By: Claude Opus 5.5 --- scripts/release/test-reconciliation.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release/test-reconciliation.mjs b/scripts/release/test-reconciliation.mjs index e20833568..610818db2 100644 --- a/scripts/release/test-reconciliation.mjs +++ b/scripts/release/test-reconciliation.mjs @@ -44,6 +44,7 @@ export const CATEGORY_RULES = Object.freeze([ { category: 'env-gated', file: /(claude-agent-sdk|copilot-sdk)-harness\.test\.ts$/, title: /live \(real (Agent|Copilot) SDK\)/, reason: 'SQUAD_REAL_CLI_TESTS=1 not set / execution-harness credentials absent (Anthropic API key or Bedrock/Vertex for Claude; a signed-in Copilot user for Copilot)' }, { category: 'env-gated', file: /squad-20-host-feature-matrix\.test\.ts$/, reason: 'live host evidence requires the real CLI binaries' }, { category: 'env-gated', file: /mcp-spec-init\.test\.ts$/, title: /squad\.exe/, reason: 'it.runIf(win32): Windows-only PATH/.exe resolution' }, + { category: 'env-gated', file: /launcher-argv\.test\.ts$/, title: /\.cmd shim has no JavaScript entry/, reason: 'it.runIf(win32): Windows-only npm .cmd shim handling' }, { category: 'env-gated', file: 'test/mcp-root-write.test.ts',