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
5 changes: 5 additions & 0 deletions .changeset/fix-1397-1398-external-state-reads.md
Original file line number Diff line number Diff line change
@@ -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`.
18 changes: 9 additions & 9 deletions packages/squad-cli/src/cli/commands/copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -27,15 +27,15 @@ export interface CopilotFlags {
* Run copilot command
*/
export async function runCopilot(dest: string, flags: CopilotFlags): Promise<void> {
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
Expand All @@ -47,7 +47,7 @@ export async function runCopilot(dest: string, flags: CopilotFlags): Promise<voi

// Remove the Coding Agent section
content = removeCopilotSection(content);
writeTeamMd(squadDir, content);
writeTeamMd(stateDir, content);
success('Removed @copilot from the team roster');

// Remove copilot-instructions.md
Expand All @@ -63,7 +63,7 @@ export async function runCopilot(dest: string, flags: CopilotFlags): Promise<voi
if (copilotExists) {
if (flags.autoAssign) {
content = setAutoAssign(content, true);
writeTeamMd(squadDir, content);
writeTeamMd(stateDir, content);
success('Enabled @copilot auto-assign');
} else {
console.log(`${DIM}@copilot is already on the team${RESET}`);
Expand All @@ -73,7 +73,7 @@ export async function runCopilot(dest: string, flags: CopilotFlags): Promise<voi

// Add copilot
content = insertCopilotSection(content, flags.autoAssign);
writeTeamMd(squadDir, content);
writeTeamMd(stateDir, content);
success('Added @copilot (Coding Agent) to team roster');

if (flags.autoAssign) {
Expand Down
29 changes: 21 additions & 8 deletions packages/squad-cli/src/cli/commands/rc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { FSStorageProvider, RemoteBridge } from '@bradygaster/squad-sdk';

const storage = new FSStorageProvider();
import type { RemoteBridgeConfig } from '@bradygaster/squad-sdk';
import { resolveStateDir } from '../core/effective-squad-dir.js';
import {
isDevtunnelAvailable,
createTunnel,
Expand All @@ -34,6 +35,25 @@ export interface RCOptions {
path?: string;
}

/**
* Load the agent roster from team.md, following the externalized state
* marker when present (#1398). The passed `squadDir` stays the local
* working-tree dir — only the team.md read is redirected.
*/
export function loadRosterAgents(squadDir: string): Array<{ name: string; role: string }> {
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<void> {
const { repo, branch } = getGitInfo(cwd);
const machine = getMachineId();
Expand All @@ -55,14 +75,7 @@ export async function runRC(cwd: string, options: RCOptions): Promise<void> {
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`);
Expand Down
64 changes: 63 additions & 1 deletion test/cli/copilot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
});
});
69 changes: 69 additions & 0 deletions test/cli/rc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
]);
});
});