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
9 changes: 9 additions & 0 deletions .changeset/fix-nap-subprocess-cwd.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 15 additions & 4 deletions packages/squad-cli/src/cli-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

}

async function main(): Promise<void> {
const args = process.argv.slice(2);

Expand Down Expand Up @@ -628,7 +638,7 @@ async function main(): Promise<void> {

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();
Expand Down Expand Up @@ -666,7 +676,7 @@ async function main(): Promise<void> {

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();
Expand Down Expand Up @@ -714,10 +724,11 @@ async function main(): Promise<void> {
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');
Expand Down
106 changes: 106 additions & 0 deletions test/cli/nap-subprocess.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading