From 17d780af42877a5546d43b5b81119ae258898a8f Mon Sep 17 00:00:00 2001 From: "Dina Berry (She/her)" Date: Sat, 4 Apr 2026 15:51:42 -0700 Subject: [PATCH] fix(cli): wire SQUAD_TEAM_ROOT into squad resolution for subprocess compatibility (#734) Adds getSquadStartDir() helper that reads SQUAD_TEAM_ROOT env var for nap, status, and cost commands. Previously the --team-root flag was parsed but never passed through to resolveSquad(). - Added getSquadStartDir(): returns SQUAD_TEAM_ROOT ?? process.cwd() - Updated 3 resolveSquad(process.cwd()) calls to use resolveSquad(getSquadStartDir()) - Improved nap error message to show searched directory and suggest --team-root - 5 new tests in test/cli/nap-subprocess.test.ts Closes #734 Co-authored-by: AmandaSilver <11282874+AmandaSilver@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/fix-nap-subprocess-cwd.md | 9 +++ packages/squad-cli/src/cli-entry.ts | 19 ++++- test/cli/nap-subprocess.test.ts | 106 +++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 .changeset/fix-nap-subprocess-cwd.md create mode 100644 test/cli/nap-subprocess.test.ts diff --git a/.changeset/fix-nap-subprocess-cwd.md b/.changeset/fix-nap-subprocess-cwd.md new file mode 100644 index 000000000..11edb2947 --- /dev/null +++ b/.changeset/fix-nap-subprocess-cwd.md @@ -0,0 +1,9 @@ +--- +"@bradygaster/squad-cli": patch +--- + +fix(cli): wire --team-root / SQUAD_TEAM_ROOT into squad resolution for nap, status, and cost commands (#734) + +Commands that resolve .squad/ now respect the SQUAD_TEAM_ROOT env var (set by --team-root flag), +fixing subprocess invocations (e.g. Copilot CLI bang commands) where process.cwd() differs from +the interactive shell. Also improves the nap error message to show the searched directory. diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index 3ddead72b..662b33389 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -106,6 +106,16 @@ const lazyRunShell = () => import('./cli/shell/index.js'); // Use local version resolver instead of importing VERSION from squad-sdk const VERSION = getPackageVersion(); +/** + * Return the starting directory for squad resolution. + * Respects --team-root / SQUAD_TEAM_ROOT env var so that subprocesses + * (e.g. Copilot CLI bang commands) can locate .squad/ even when their + * working directory differs from the interactive shell. (#734) + */ +function getSquadStartDir(): string { + return process.env['SQUAD_TEAM_ROOT'] || process.cwd(); +} + async function main(): Promise { const args = process.argv.slice(2); @@ -628,7 +638,7 @@ async function main(): Promise { if (cmd === 'status') { const sdk = await lazySquadSdk(); - const repoSquad = sdk.resolveSquad(process.cwd()); + const repoSquad = sdk.resolveSquad(getSquadStartDir()); const globalPath = sdk.resolveGlobalSquadPath(); const globalSquadDir = path.join(globalPath, '.squad'); const storage = new FSStorageProvider(); @@ -666,7 +676,7 @@ async function main(): Promise { if (cmd === 'cost') { const sdk = await lazySquadSdk(); - const localSquad = sdk.resolveSquad(process.cwd()); + const localSquad = sdk.resolveSquad(getSquadStartDir()); const globalPath = sdk.resolveGlobalSquadPath(); const globalSquadDir = path.join(globalPath, '.squad'); const storage = new FSStorageProvider(); @@ -714,10 +724,11 @@ async function main(): Promise { if (cmd === 'nap') { const { runNap, formatNapReport } = await import('./cli/core/nap.js'); const sdk = await lazySquadSdk(); + const startDir = getSquadStartDir(); // resolveSquad() returns the .squad/ directory itself — use it directly (#207) - const squadDir = sdk.resolveSquad(process.cwd()); + const squadDir = sdk.resolveSquad(startDir); if (!squadDir) { - fatal('No squad found. Run "squad init" first.'); + fatal(`No squad found (searched from ${startDir}). Run "squad init" first, or use --team-root to specify the project directory.`); } const deep = args.includes('--deep'); const dryRun = args.includes('--dry-run'); diff --git a/test/cli/nap-subprocess.test.ts b/test/cli/nap-subprocess.test.ts new file mode 100644 index 000000000..8501e2a5d --- /dev/null +++ b/test/cli/nap-subprocess.test.ts @@ -0,0 +1,106 @@ +/** + * Nap subprocess / --team-root resolution tests (#734) + * + * Verifies that the nap command works correctly when invoked from a + * subprocess with a different working directory, as happens with + * Copilot CLI bang commands (!squad nap) on Windows. + * + * The fix: getSquadStartDir() respects SQUAD_TEAM_ROOT env var, + * falling back to process.cwd() when unset. + * + * @see packages/squad-cli/src/cli-entry.ts — getSquadStartDir() + * @see https://github.com/bradygaster/squad/issues/734 + */ + +import { describe, it, expect, afterEach, beforeEach } from 'vitest'; +import { + mkdtempSync, + writeFileSync, + mkdirSync, + rmSync, +} from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { runNap } from '../../packages/squad-cli/src/cli/core/nap.js'; + +// ============================================================================ +// Helpers +// ============================================================================ + +const tmpDirs: string[] = []; + +function createTestSquadDir(): string { + const tmpDir = mkdtempSync(join(tmpdir(), 'squad-nap-subprocess-')); + tmpDirs.push(tmpDir); + const squadDir = join(tmpDir, '.squad'); + mkdirSync(join(squadDir, 'agents', 'edie'), { recursive: true }); + mkdirSync(join(squadDir, 'decisions', 'inbox'), { recursive: true }); + mkdirSync(join(squadDir, 'orchestration-log'), { recursive: true }); + writeFileSync(join(squadDir, 'team.md'), '# Team\n'); + writeFileSync(join(squadDir, 'routing.md'), '# Routing\n'); + writeFileSync(join(squadDir, 'decisions.md'), '# Decisions\n'); + writeFileSync( + join(squadDir, 'agents', 'edie', 'history.md'), + '## Core Context\n\nTest agent.\n', + ); + return squadDir; +} + +// ============================================================================ +// Tests +// ============================================================================ + +describe('nap: subprocess / --team-root resolution (#734)', () => { + const savedTeamRoot = process.env['SQUAD_TEAM_ROOT']; + + beforeEach(() => { + delete process.env['SQUAD_TEAM_ROOT']; + }); + + afterEach(() => { + // Restore env + if (savedTeamRoot !== undefined) { + process.env['SQUAD_TEAM_ROOT'] = savedTeamRoot; + } else { + delete process.env['SQUAD_TEAM_ROOT']; + } + // Clean up temp dirs + for (const dir of tmpDirs) { + rmSync(dir, { recursive: true, force: true }); + } + tmpDirs.length = 0; + }); + + it('runNap succeeds with explicit squadDir from a different working directory', async () => { + const squadDir = createTestSquadDir(); + const differentCwd = mkdtempSync(join(tmpdir(), 'squad-nap-other-cwd-')); + tmpDirs.push(differentCwd); + const previousCwd = process.cwd(); + + try { + // This simulates the key scenario from #734: nap is called with a + // squadDir resolved from SQUAD_TEAM_ROOT while the current working + // directory points somewhere else. + process.chdir(differentCwd); + + const result = await runNap({ squadDir, dryRun: true }); + + expect(result).toBeDefined(); + expect(result.actions).toBeDefined(); + expect(result.before).toBeDefined(); + expect(result.after).toBeDefined(); + } finally { + process.chdir(previousCwd); + } + }); + + it('runNap returns empty result when squadDir does not exist', async () => { + const nonExistent = join(tmpdir(), 'squad-nap-nonexistent-dir'); + const result = await runNap({ squadDir: nonExistent, dryRun: true }); + + expect(result.actions).toHaveLength(0); + expect(result.before.totalFiles).toBe(0); + expect(result.after.totalFiles).toBe(0); + }); +});