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
11 changes: 11 additions & 0 deletions .changeset/fix-init-noargs-createteam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@bradygaster/squad-cli": patch
---

fix: `/init` with no args now accepts follow-up message as cast prompt, and `createTeam` correctly creates `team.md`/`routing.md` in fresh projects

Two related bugs in the TUI init flow:

1. After `/init` (no args) showed guidance text, the user's follow-up message hit the "No Squad team found" guard instead of starting team casting. Fixed by tracking `awaitingInitPrompt` state in `App.tsx` and bypassing the team-file guard in `handleDispatch` when `skipCastConfirmation` is explicitly set.

2. After confirming a team proposal, `createTeam` silently skipped creating `team.md` and `routing.md` in a fresh project (no `.squad/` directory), causing the coordinator to immediately say "no team yet" after showing "Team hired!". Fixed with else-branches that create both files from scratch when they don't exist.
54 changes: 50 additions & 4 deletions packages/squad-cli/src/cli/core/cast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,9 +410,10 @@ export async function createTeam(teamRoot: string, proposal: CastProposal): Prom
membersCreated.push(member.name);
}

// Update team.md — preserve content before and after ## Members
// Create or update team.md
const teamPath = join(squadDir, 'team.md');
if (existsSync(teamPath)) {
// Update existing — preserve content before and after ## Members
const content = await readFile(teamPath, 'utf8');
const membersIdx = content.indexOf('## Members');
if (membersIdx !== -1) {
Expand All @@ -428,14 +429,59 @@ export async function createTeam(teamRoot: string, proposal: CastProposal): Prom
await writeFile(teamPath, newContent);
filesCreated.push(teamPath);
}
} else {
// Create from scratch — fresh project with no prior .squad/ directory
const projectName = proposal.projectDescription
? proposal.projectDescription.slice(0, 80).replace(/\n/g, ' ')
: 'Squad Project';
const freshContent = [
'# Squad Team',
'',
`> ${projectName}`,
'',
'## Coordinator',
'',
'| Name | Role | Notes |',
'|------|------|-------|',
'| Squad | Coordinator | Routes work, enforces handoffs and reviewer gates. |',
'',
buildMembersTable(allMembers),
'## Project Context',
'',
`- **Project:** ${projectName}`,
`- **Created:** ${new Date().toISOString().split('T')[0]}`,
'',
].join('\n');
await writeFile(teamPath, freshContent);
filesCreated.push(teamPath);
}

// Update routing.md — append routing table
// Create or update routing.md
const routingPath = join(squadDir, 'routing.md');
const routingTable = buildRoutingTable(allMembers);
if (existsSync(routingPath)) {
// Update existing — append routing table
const content = await readFile(routingPath, 'utf8');
const table = buildRoutingTable(allMembers);
await writeFile(routingPath, content.trimEnd() + '\n\n' + table + '\n');
await writeFile(routingPath, content.trimEnd() + '\n\n' + routingTable + '\n');
filesCreated.push(routingPath);
} else {
// Create from scratch
const freshRouting = [
'# Squad Routing',
'',
'## Work Type Rules',
'',
'| Work Type | Primary Agent | Fallback |',
'|-----------|---------------|----------|',
'',
'## Governance',
'',
'- Route based on work type and agent expertise',
'- Update this file as team capabilities evolve',
'',
routingTable,
].join('\n');
await writeFile(routingPath, freshRouting);
filesCreated.push(routingPath);
}

Expand Down
9 changes: 8 additions & 1 deletion packages/squad-cli/src/cli/shell/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ export interface CommandResult {
clear?: boolean;
/** When true, the shell should trigger init casting with the provided prompt. */
triggerInitCast?: { prompt: string };
/**
* When true, the shell should enter "awaiting init prompt" mode:
* the next user message will be treated as a team-cast request.
* Set when `/init` is run with no inline prompt.
*/
awaitInitPrompt?: boolean;
}

/**
Expand Down Expand Up @@ -223,9 +229,10 @@ function handleInit(args: string[], context: CommandContext): CommandResult {
};
}

// No prompt: guide the user
// No prompt: guide the user and enter "awaiting init prompt" mode
return {
handled: true,
awaitInitPrompt: true,
output: [
'To cast your Squad team, just type what you want to build.',
'',
Expand Down
38 changes: 37 additions & 1 deletion packages/squad-cli/src/cli/shell/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ export const App: React.FC<AppProps> = ({ registry, renderer, teamRoot, version,
const [activityHint, setActivityHint] = useState<string | undefined>(undefined);
const [agentActivities, setAgentActivities] = useState<Map<string, string>>(new Map());
const [welcome, setWelcome] = useState<WelcomeData | null>(() => loadWelcomeData(teamRoot));
/**
* True after a no-args `/init` so the next user message is treated as a
* team-cast request (equivalent to `/init <message>`).
*/
const [awaitingInitPrompt, setAwaitingInitPrompt] = useState(false);
const messagesRef = useRef<ShellMessage[]>([]);
const ctrlCRef = useRef(0);
const ctrlCTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
Expand Down Expand Up @@ -177,6 +182,32 @@ export const App: React.FC<AppProps> = ({ registry, renderer, teamRoot, version,
const knownAgents = registry.getAll().map(a => a.name);
const parsed = parseInput(input, knownAgents);

// If we're awaiting an init prompt and the user sent a non-slash message,
// treat it as an inline /init <prompt> cast request.
if (awaitingInitPrompt && parsed.type !== 'slash_command') {
setAwaitingInitPrompt(false);
if (!onDispatch) {
appendMessages(prev => [...prev, {
role: 'system' as const,
content: 'SDK not connected. Try: (1) squad doctor to check setup, (2) check your internet connection, (3) restart the shell to reconnect.',
timestamp: new Date(),
}]);
return;
}
const castParsed: ParsedInput = {
type: 'coordinator',
raw: input,
content: input,
skipCastConfirmation: false, // show confirmation, same as freeform cast
};
setProcessing(true);
onDispatch(castParsed).finally(() => {
setProcessing(false);
setAgents([...registry.getAll()]);
});
return;
}

if (parsed.type === 'slash_command') {
const result = executeCommand(parsed.command!, parsed.args ?? [], {
registry,
Expand Down Expand Up @@ -214,6 +245,11 @@ export const App: React.FC<AppProps> = ({ registry, renderer, teamRoot, version,
return;
}

if (result.awaitInitPrompt) {
// No-args /init: show the guidance and wait for the user's next message
setAwaitingInitPrompt(true);
}

if (result.output) {
appendMessages(prev => [...prev, {
role: 'system' as const,
Expand All @@ -238,7 +274,7 @@ export const App: React.FC<AppProps> = ({ registry, renderer, teamRoot, version,
}

setAgents([...registry.getAll()]);
}, [registry, renderer, teamRoot, exit, onDispatch, appendMessages]);
}, [registry, renderer, teamRoot, exit, onDispatch, appendMessages, awaitingInitPrompt]);

const rosterAgents = welcome?.agents ?? [];

Expand Down
7 changes: 7 additions & 0 deletions packages/squad-cli/src/cli/shell/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1021,6 +1021,13 @@ export async function runShell(): Promise<void> {
// Guard: require a Squad team before processing work requests
const teamFile = join(teamRoot, '.squad', 'team.md');
if (!existsSync(teamFile)) {
// When skipCastConfirmation is explicitly set (true or false), the message
// was routed from an /init flow (inline or follow-up), so bypass the guard
// and go straight to Init Mode casting even without a team.md.
if (parsed.skipCastConfirmation !== undefined) {
await handleInitCast(parsed, parsed.skipCastConfirmation);
return;
}
shellApi?.addMessage({
role: 'system',
content: '\u26A0 No Squad team found. Run /init to create your team first.',
Expand Down
132 changes: 128 additions & 4 deletions test/cast-parser.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
/**
* Tests for parseCastResponse — the REPL casting parser.
* Ensures robust parsing of various model response formats.
* Tests for parseCastResponse and createTeam — the REPL casting engine.
* Ensures robust parsing of various model response formats and correct
* file scaffolding for both fresh and pre-initialised projects.
*/

import { describe, it, expect } from 'vitest';
import { parseCastResponse } from '../packages/squad-cli/src/cli/core/cast.js';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, rm, readFile, writeFile, mkdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { parseCastResponse, createTeam, type CastProposal } from '../packages/squad-cli/src/cli/core/cast.js';

describe('parseCastResponse', () => {
it('parses strict INIT_TEAM format', () => {
Expand Down Expand Up @@ -160,3 +165,122 @@ PROJECT: Something`;
}
});
});

// ── createTeam ─────────────────────────────────────────────────────

const minimalProposal: CastProposal = {
universe: 'Alien',
projectDescription: 'A React and Node.js web application',
members: [
{ name: 'Ripley', role: 'Lead', scope: 'Architecture, code review', emoji: '🏗️' },
{ name: 'Dallas', role: 'Frontend Dev', scope: 'React, UI, components', emoji: '⚛️' },
{ name: 'Kane', role: 'Backend Dev', scope: 'Node.js, APIs, database', emoji: '🔧' },
],
};

describe('createTeam', () => {
let tempDir: string;

beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'squad-test-'));
});

afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});

describe('fresh project — no .squad/ directory', () => {
it('creates team.md with ## Members section and data rows', async () => {
await createTeam(tempDir, minimalProposal);

const teamPath = join(tempDir, '.squad', 'team.md');
expect(existsSync(teamPath)).toBe(true);

const content = await readFile(teamPath, 'utf-8');
expect(content).toContain('## Members');
expect(content).toContain('| Ripley |');
expect(content).toContain('| Dallas |');
expect(content).toContain('| Kane |');
});

it('creates routing.md from scratch', async () => {
await createTeam(tempDir, minimalProposal);

const routingPath = join(tempDir, '.squad', 'routing.md');
expect(existsSync(routingPath)).toBe(true);

const content = await readFile(routingPath, 'utf-8');
expect(content).toContain('# Squad Routing');
});

it('includes project description in team.md header', async () => {
await createTeam(tempDir, minimalProposal);

const content = await readFile(join(tempDir, '.squad', 'team.md'), 'utf-8');
expect(content).toContain('A React and Node.js web application');
});

it('team.md passes hasRosterEntries check (coordinator can read it)', async () => {
// Import hasRosterEntries to verify the coordinator will recognise the team
const { hasRosterEntries } = await import('../packages/squad-cli/src/cli/shell/coordinator.js');

await createTeam(tempDir, minimalProposal);
const content = await readFile(join(tempDir, '.squad', 'team.md'), 'utf-8');
expect(hasRosterEntries(content)).toBe(true);
});

it('adds built-in Scribe and Ralph when not in proposal', async () => {
const result = await createTeam(tempDir, minimalProposal);
expect(result.membersCreated).toContain('Scribe');
expect(result.membersCreated).toContain('Ralph');
});

it('creates agent charter and history files for each member', async () => {
const result = await createTeam(tempDir, minimalProposal);
for (const name of result.membersCreated) {
const base = join(tempDir, '.squad', 'agents', name.toLowerCase());
expect(existsSync(join(base, 'charter.md'))).toBe(true);
expect(existsSync(join(base, 'history.md'))).toBe(true);
}
});
});

describe('existing project — .squad/ with empty team.md', () => {
beforeEach(async () => {
const squadDir = join(tempDir, '.squad');
await mkdir(squadDir, { recursive: true });
await writeFile(join(squadDir, 'team.md'), [
'# Squad Team',
'',
'> Pre-existing project',
'',
'## Members',
'',
'| Name | Role | Charter | Status |',
'|------|------|---------|--------|',
'',
'## Project Context',
'',
'- **Project:** Pre-existing',
'',
].join('\n'));
});

it('updates the Members section without clobbering surrounding content', async () => {
await createTeam(tempDir, minimalProposal);

const content = await readFile(join(tempDir, '.squad', 'team.md'), 'utf-8');
expect(content).toContain('Pre-existing project');
expect(content).toContain('## Project Context');
expect(content).toContain('| Ripley |');
});

it('team.md passes hasRosterEntries after update', async () => {
const { hasRosterEntries } = await import('../packages/squad-cli/src/cli/shell/coordinator.js');

await createTeam(tempDir, minimalProposal);
const content = await readFile(join(tempDir, '.squad', 'team.md'), 'utf-8');
expect(hasRosterEntries(content)).toBe(true);
});
});
});
Loading
Loading