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/fix-1399-cast-external-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@bradygaster/squad-sdk": minor
"@bradygaster/squad-cli": patch
---

`squad cast` now discovers project agents from the external state dir when state is externalized. `LocalAgentSource` accepts an optional explicit agents directory that overrides the `.squad/agents` probing, since externalized state keeps agents at `<externalStateDir>/agents` with no `.squad` nesting.
14 changes: 12 additions & 2 deletions packages/squad-cli/src/cli/commands/cast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import * as path from 'node:path';
import { LocalAgentSource } from '@bradygaster/squad-sdk/config/agent-source';
import { resolvePersonalAgents, mergeSessionCast } from '@bradygaster/squad-sdk/agents/personal';
import { resolveSquadPaths } from '@bradygaster/squad-sdk/resolution';
import { resolveSquadPaths, resolveExternalStateDir } from '@bradygaster/squad-sdk/resolution';
import { BOLD, RESET, DIM, GREEN, YELLOW } from '../core/output.js';
import { fatal } from '../core/errors.js';

Expand All @@ -32,7 +32,17 @@ export async function runCast(cwd: string): Promise<void> {
paths.mode === 'remote'
? paths.teamDir
: path.resolve(paths.projectDir, '..');
const projectSource = new LocalAgentSource(agentBase);
// #1399: when state is externalized, agents live at <externalStateDir>/agents
// (no .squad nesting), so the base-path probing above can't reach them —
// externalize sets teamRoot '.' which lands here as remote mode with a
// repo-local teamDir. Hand LocalAgentSource the explicit directory instead.
// (resolveExternalStateDir comes from the /resolution subpath on purpose:
// importing the sdk root barrel here adds seconds to this module's load.)
const externalAgentsDir =
paths.config?.stateLocation === 'external' && paths.config.projectKey
? path.join(resolveExternalStateDir(paths.config.projectKey, false), 'agents')
: undefined;
const projectSource = new LocalAgentSource(agentBase, undefined, undefined, externalAgentsDir);
const projectAgents = await projectSource.listAgents();

// Discover personal agents
Expand Down
10 changes: 10 additions & 0 deletions packages/squad-sdk/src/config/agent-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,12 +103,22 @@ export class LocalAgentSource implements AgentSource {
private basePath: string,
private storage: StorageProvider = new FSStorageProvider(),
private state?: SquadState,
/**
* Explicit agents directory — skips the `<basePath>/.squad/agents`
* probing. Needed for externalized state, where agents live at
* `<externalStateDir>/agents` with no `.squad` nesting (#1399).
*/
private agentsDir?: string,
) {}

/**
* Resolve the agents directory, preferring .squad/agents over .ai-team/agents.
* An explicit `agentsDir` from the constructor wins over probing.
*/
private async resolveAgentsDir(): Promise<string | null> {
if (this.agentsDir) {
return (await this.storage.isDirectory(this.agentsDir)) ? this.agentsDir : null;
}
for (const dir of AGENT_DIRS) {
const fullPath = path.join(this.basePath, dir);
if (await this.storage.isDirectory(fullPath)) return fullPath;
Expand Down
47 changes: 47 additions & 0 deletions test/cli/cast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,4 +150,51 @@ describe('squad cast', () => {
expect(output).toContain('RemoteAgent');
expect(output).toContain('Session Cast');
});

it('discovers agents from the external state dir when state is externalized (#1399)', async () => {
const extGlobal = join(TEST_ROOT, 'global');
const projectKey = `test-cast-ext-${randomBytes(4).toString('hex')}`;
const origAppData = process.env['APPDATA'];
const origXdg = process.env['XDG_CONFIG_HOME'];
// Point resolveGlobalSquadPath() inside extGlobal (not the real user dir)
if (process.platform === 'win32') process.env['APPDATA'] = extGlobal;
else process.env['XDG_CONFIG_HOME'] = extGlobal;

try {
// Local repo: marker .squad as `squad externalize` leaves it, plus a
// stale local agent that must NOT be discovered
const sq = join(TEST_ROOT, '.squad');
await mkdir(join(sq, 'agents', 'stale-agent'), { recursive: true });
await writeFile(
join(sq, 'config.json'),
JSON.stringify({ version: 1, teamRoot: '.', projectKey, stateLocation: 'external' }),
);
await writeFile(
join(sq, 'agents', 'stale-agent', 'charter.md'),
`## Identity\n\n**Name:** StaleAgent\n**Role:** Leftover\n`,
);

// External state dir: agents live directly under it, no .squad nesting
const extAgent = join(extGlobal, 'squad', 'projects', projectKey, 'agents', 'ext-agent');
await mkdir(extAgent, { recursive: true });
await writeFile(
join(extAgent, 'charter.md'),
`## Identity\n\n**Name:** ExternalAgent\n**Role:** External Dev\n`,
);

const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});

const { runCast } = await import('@bradygaster/squad-cli/commands/cast');
await runCast(TEST_ROOT);

const output = logSpy.mock.calls.map(c => c.join(' ')).join('\n');
expect(output).toContain('ExternalAgent');
expect(output).not.toContain('StaleAgent');
} finally {
if (origAppData === undefined) delete process.env['APPDATA'];
else process.env['APPDATA'] = origAppData;
if (origXdg === undefined) delete process.env['XDG_CONFIG_HOME'];
else process.env['XDG_CONFIG_HOME'] = origXdg;
}
});
});
31 changes: 31 additions & 0 deletions test/config-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,37 @@ describe('Integration: LocalAgentSource discovery', () => {
expect(manifests).toEqual([]);
});

it('uses an explicit agentsDir override instead of probing .squad/agents (#1399)', async () => {
const base = fixture('project-external');
// Decoy in the probed location — must lose when the override is set
const probedDir = join(base, '.squad', 'agents');
mkdirSync(probedDir, { recursive: true });
setupAgentDir(probedDir, 'decoy', MINIMAL_CHARTER);
// External-state layout: agents directly under the state dir, no .squad nesting
const externalAgents = join(base, 'external-state', 'agents');
mkdirSync(externalAgents, { recursive: true });
setupAgentDir(externalAgents, 'fenster', SAMPLE_CHARTER);

const source = new LocalAgentSource(base, undefined, undefined, externalAgents);
const manifests = await source.listAgents();

expect(manifests.length).toBe(1);
expect(manifests[0].name).toBe('Fenster');
});

it('returns empty array when the agentsDir override does not exist', async () => {
const base = fixture('project-external-missing');
const probedDir = join(base, '.squad', 'agents');
mkdirSync(probedDir, { recursive: true });
setupAgentDir(probedDir, 'decoy', MINIMAL_CHARTER);

// Override points at a missing dir — must not fall back to probing
const source = new LocalAgentSource(base, undefined, undefined, join(base, 'nope', 'agents'));
const manifests = await source.listAgents();

expect(manifests).toEqual([]);
});

it('skips agents with missing charter.md', async () => {
const base = fixture('project-missing-charter');
const agentsDir = join(base, '.squad', 'agents');
Expand Down