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/launcher-path-lookup.md
Original file line number Diff line number Diff line change
@@ -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.
59 changes: 59 additions & 0 deletions .github/workflows/squad-launcher-platforms.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/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
9 changes: 6 additions & 3 deletions packages/squad-sdk/src/client/adapters/claude-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
9 changes: 6 additions & 3 deletions plugins/squad/scripts/squad.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions scripts/release/test-reconciliation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 3 additions & 1 deletion test/claude-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>, 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',
});
}
Expand Down
4 changes: 2 additions & 2 deletions test/claude-projection-v3-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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);
Expand Down
135 changes: 135 additions & 0 deletions test/launcher-argv.test.ts
Original file line number Diff line number Diff line change
@@ -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!',
'<in>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<string, string> = {}) {
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),
Comment on lines +72 to +78
SQUAD_HOME: path.join(root, 'squad-home'),
...env,
},
});
}

function parsed(out: ReturnType<typeof run>): 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 squad CLI installed 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');
});
});
Loading