From d6b5159aabe8a54fa82f029fc9eaba17a302dc23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20Can=20G=C3=BCm=C3=BC=C5=9F?= Date: Sat, 11 Jul 2026 03:07:10 +0300 Subject: [PATCH] fix(cli): follow externalized state in squad copilot and squad rc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After squad externalize, both commands kept operating on the local .squad/team.md. squad copilot edited (or scaffolded) a stale local roster the externalized project never reads, and squad rc loaded its agent list from the same dead file, so the remote UI showed an empty or stale team. squad copilot now resolves the roster through effectiveSquadDir() — the same resolution used by export, build, loop, plugin, watch, and doctor — and reads/writes team.md in the effective state dir. squad rc routes the team.md read through resolveStateDir(), extracted into an exported loadRosterAgents() so it's testable. The squadDir passed to RemoteBridge stays the local path on purpose: the bridge derives the copilot session cwd from it by stripping the /.squad suffix, so handing it the external state dir would point sessions at the wrong directory. Fixes #1397 Fixes #1398 Part of #1402 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../fix-1397-1398-external-state-reads.md | 5 ++ .../squad-cli/src/cli/commands/copilot.ts | 18 ++--- packages/squad-cli/src/cli/commands/rc.ts | 29 +++++--- test/cli/copilot.test.ts | 64 ++++++++++++++++- test/cli/rc.test.ts | 69 +++++++++++++++++++ 5 files changed, 167 insertions(+), 18 deletions(-) create mode 100644 .changeset/fix-1397-1398-external-state-reads.md diff --git a/.changeset/fix-1397-1398-external-state-reads.md b/.changeset/fix-1397-1398-external-state-reads.md new file mode 100644 index 000000000..ba881a51a --- /dev/null +++ b/.changeset/fix-1397-1398-external-state-reads.md @@ -0,0 +1,5 @@ +--- +"@bradygaster/squad-cli": patch +--- + +`squad copilot` and `squad rc` now follow externalized state: roster reads and writes go to the external state dir when `.squad/config.json` has the `stateLocation: external` marker, instead of always using the local `.squad/team.md`. diff --git a/packages/squad-cli/src/cli/commands/copilot.ts b/packages/squad-cli/src/cli/commands/copilot.ts index 72a7c637b..e974a0240 100644 --- a/packages/squad-cli/src/cli/commands/copilot.ts +++ b/packages/squad-cli/src/cli/commands/copilot.ts @@ -8,7 +8,7 @@ import { FSStorageProvider } from '@bradygaster/squad-sdk'; const storage = new FSStorageProvider(); import { success, dim, bold, info, BOLD, RESET, DIM } from '../core/output.js'; -import { detectSquadDir } from '../core/detect-squad-dir.js'; +import { effectiveSquadDir } from '../core/effective-squad-dir.js'; import { readTeamMd, writeTeamMd, @@ -27,15 +27,15 @@ export interface CopilotFlags { * Run copilot command */ export async function runCopilot(dest: string, flags: CopilotFlags): Promise { - const squadDirInfo = detectSquadDir(dest); - const squadDir = squadDirInfo.path; + // Roster lives in the effective state dir when state is externalized (#1397) + const { stateDir } = effectiveSquadDir(dest); - // Ensure squad directory exists - if (!storage.existsSync(squadDir)) { + // Ensure squad state exists + if (!storage.existsSync(stateDir)) { throw new Error('No squad found — run init first, then add the copilot agent.'); } - let content = readTeamMd(squadDir); + let content = readTeamMd(stateDir); const copilotExists = hasCopilot(content); // Remove copilot @@ -47,7 +47,7 @@ export async function runCopilot(dest: string, flags: CopilotFlags): Promise { + const agents: Array<{ name: string; role: string }> = []; + const stateDir = resolveStateDir(squadDir); + const teamMd = storage.readSync(path.join(stateDir, 'team.md')) ?? ''; + const memberLines = teamMd.split('\n').filter(l => l.startsWith('|') && l.includes('Active')); + for (const line of memberLines) { + const cols = line.split('|').map(c => c.trim()).filter(Boolean); + if (cols.length >= 2 && cols[0] !== 'Name') { + agents.push({ name: cols[0]!, role: cols[1]! }); + } + } + return agents; +} + export async function runRC(cwd: string, options: RCOptions): Promise { const { repo, branch } = getGitInfo(cwd); const machine = getMachineId(); @@ -55,14 +75,7 @@ export async function runRC(cwd: string, options: RCOptions): Promise { const agents: Array<{name: string; role: string}> = []; if (squadDir) { try { - const teamMd = storage.readSync(path.join(squadDir, 'team.md')) ?? ''; - const memberLines = teamMd.split('\n').filter(l => l.startsWith('|') && l.includes('Active')); - for (const line of memberLines) { - const cols = line.split('|').map(c => c.trim()).filter(Boolean); - if (cols.length >= 2 && cols[0] !== 'Name') { - agents.push({ name: cols[0]!, role: cols[1]! }); - } - } + agents.push(...loadRosterAgents(squadDir)); console.log(` ${GREEN}✓${RESET} Loaded ${agents.length} agents from team.md\n`); } catch { console.log(` ${YELLOW}⚠${RESET} Could not read team.md\n`); diff --git a/test/cli/copilot.test.ts b/test/cli/copilot.test.ts index dacf68809..6c9d9dd9e 100644 --- a/test/cli/copilot.test.ts +++ b/test/cli/copilot.test.ts @@ -4,7 +4,7 @@ * Tests module exports and error handling for missing squad directory. */ -import { describe, it, expect, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdirSync, rmSync, existsSync, writeFileSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { randomBytes } from 'node:crypto'; @@ -64,3 +64,65 @@ describe('CLI: copilot command', () => { await expect(runCopilot(TEST_ROOT, {})).resolves.toBeUndefined(); }); }); + +describe('CLI: copilot with externalized state (#1397)', () => { + const EXT_GLOBAL = join(tmpdir(), `.test-cli-copilot-ext-global-${randomBytes(4).toString('hex')}`); + const EXT_PROJECT_KEY = `test-copilot-ext-${randomBytes(4).toString('hex')}`; + const externalStateDir = join(EXT_GLOBAL, 'squad', 'projects', EXT_PROJECT_KEY); + const origAppData = process.env['APPDATA']; + const origXdgConfig = process.env['XDG_CONFIG_HOME']; + + beforeEach(() => { + if (existsSync(TEST_ROOT)) rmSync(TEST_ROOT, { recursive: true, force: true }); + if (existsSync(EXT_GLOBAL)) rmSync(EXT_GLOBAL, { recursive: true, force: true }); + + // Point resolveGlobalSquadPath() inside EXT_GLOBAL (not the real user dir) + if (process.platform === 'win32') { + process.env['APPDATA'] = EXT_GLOBAL; + } else { + process.env['XDG_CONFIG_HOME'] = EXT_GLOBAL; + } + + // Local repo: thin .squad/ holding only the marker `squad externalize` leaves behind + mkdirSync(join(TEST_ROOT, '.squad'), { recursive: true }); + writeFileSync( + join(TEST_ROOT, '.squad', 'config.json'), + JSON.stringify({ version: 1, teamRoot: '.', projectKey: EXT_PROJECT_KEY, stateLocation: 'external' }, null, 2) + ); + + // External state dir: the real roster + mkdirSync(externalStateDir, { recursive: true }); + writeFileSync(join(externalStateDir, 'team.md'), '# Team\n\n## Members\n\n## Project Context\n'); + }); + + afterEach(() => { + if (origAppData === undefined) delete process.env['APPDATA']; + else process.env['APPDATA'] = origAppData; + if (origXdgConfig === undefined) delete process.env['XDG_CONFIG_HOME']; + else process.env['XDG_CONFIG_HOME'] = origXdgConfig; + + if (existsSync(TEST_ROOT)) rmSync(TEST_ROOT, { recursive: true, force: true }); + if (existsSync(EXT_GLOBAL)) rmSync(EXT_GLOBAL, { recursive: true, force: true }); + }); + + it('adds copilot to the external team.md, not a local copy', async () => { + const { runCopilot } = await import('@bradygaster/squad-cli/commands/copilot'); + + await runCopilot(TEST_ROOT, {}); + + const external = readFileSync(join(externalStateDir, 'team.md'), 'utf-8'); + expect(external).toContain('Coding Agent'); + // The marker-only local .squad/ must not grow a team.md + expect(existsSync(join(TEST_ROOT, '.squad', 'team.md'))).toBe(false); + }); + + it('removes copilot from the external team.md with --off', async () => { + const { runCopilot } = await import('@bradygaster/squad-cli/commands/copilot'); + + await runCopilot(TEST_ROOT, {}); + await runCopilot(TEST_ROOT, { off: true }); + + const external = readFileSync(join(externalStateDir, 'team.md'), 'utf-8'); + expect(external).not.toContain('🤖 Coding Agent'); + }); +}); diff --git a/test/cli/rc.test.ts b/test/cli/rc.test.ts index dc2cb9ab1..1eaf17d15 100644 --- a/test/cli/rc.test.ts +++ b/test/cli/rc.test.ts @@ -459,3 +459,72 @@ describe('CLI: rc command', () => { }); }); }); + +describe('loadRosterAgents — externalized state (#1398)', () => { + const suffix = Math.random().toString(36).slice(2, 10); + const ROOT = path.join(os.tmpdir(), `.test-rc-roster-${suffix}`); + const EXT_GLOBAL = path.join(os.tmpdir(), `.test-rc-roster-global-${suffix}`); + const PROJECT_KEY = `test-rc-ext-${suffix}`; + const externalStateDir = path.join(EXT_GLOBAL, 'squad', 'projects', PROJECT_KEY); + const origAppData = process.env['APPDATA']; + const origXdgConfig = process.env['XDG_CONFIG_HOME']; + const ROSTER_MD = + '# Team\n\n| Name | Role | Status |\n|------|------|--------|\n| Kovash | Lead | Active |\n| Edie | Dev | Active |\n'; + + beforeEach(() => { + for (const d of [ROOT, EXT_GLOBAL]) { + if (fs.existsSync(d)) fs.rmSync(d, { recursive: true, force: true }); + } + fs.mkdirSync(path.join(ROOT, '.squad'), { recursive: true }); + }); + + afterEach(() => { + if (origAppData === undefined) delete process.env['APPDATA']; + else process.env['APPDATA'] = origAppData; + if (origXdgConfig === undefined) delete process.env['XDG_CONFIG_HOME']; + else process.env['XDG_CONFIG_HOME'] = origXdgConfig; + + for (const d of [ROOT, EXT_GLOBAL]) { + if (fs.existsSync(d)) fs.rmSync(d, { recursive: true, force: true }); + } + }); + + it('reads the roster from local .squad when state is not externalized', async () => { + const { loadRosterAgents } = await import('@bradygaster/squad-cli/commands/rc'); + fs.writeFileSync(path.join(ROOT, '.squad', 'team.md'), ROSTER_MD); + + const agents = loadRosterAgents(path.join(ROOT, '.squad')); + + expect(agents).toEqual([ + { name: 'Kovash', role: 'Lead' }, + { name: 'Edie', role: 'Dev' }, + ]); + }); + + it('follows the stateLocation marker to the external team.md', async () => { + const { loadRosterAgents } = await import('@bradygaster/squad-cli/commands/rc'); + + // Point resolveGlobalSquadPath() inside EXT_GLOBAL (not the real user dir) + if (process.platform === 'win32') { + process.env['APPDATA'] = EXT_GLOBAL; + } else { + process.env['XDG_CONFIG_HOME'] = EXT_GLOBAL; + } + + fs.writeFileSync( + path.join(ROOT, '.squad', 'config.json'), + JSON.stringify({ version: 1, teamRoot: '.', projectKey: PROJECT_KEY, stateLocation: 'external' }) + ); + // Stale local roster that must NOT win over the external one + fs.writeFileSync(path.join(ROOT, '.squad', 'team.md'), '# Team\n| Stale | Old | Active |\n'); + fs.mkdirSync(externalStateDir, { recursive: true }); + fs.writeFileSync(path.join(externalStateDir, 'team.md'), ROSTER_MD); + + const agents = loadRosterAgents(path.join(ROOT, '.squad')); + + expect(agents).toEqual([ + { name: 'Kovash', role: 'Lead' }, + { name: 'Edie', role: 'Dev' }, + ]); + }); +});