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
1 change: 1 addition & 0 deletions packages/squad-cli/src/cli/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi
version,
prompt: options.prompt,
extractionDisabled: options.extractionDisabled,
roles: options.roles,
};

// Handle SIGINT to cleanup orphan .init-prompt
Expand Down
95 changes: 94 additions & 1 deletion packages/squad-sdk/src/config/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import { execFileSync } from 'node:child_process';
import { MODELS } from '../runtime/constants.js';
import type { SquadConfig, ModelSelectionConfig, RoutingConfig } from '../runtime/config.js';
import type { SubSquadDefinition } from '../streams/types.js';
import { ENGINEERING_ROLE_IDS } from '../roles/catalog.js';
import { getRoleById } from '../roles/index.js';

// ============================================================================
// Template Resolution
Expand Down Expand Up @@ -113,6 +115,8 @@ export interface InitOptions {
extractionDisabled?: boolean;
/** Optional SubSquad definitions — generates .squad/workstreams.json when provided */
streams?: SubSquadDefinition[];
/** If true, use built-in base roles with useRole() in SDK config (default: false) */
roles?: boolean;
}

/**
Expand Down Expand Up @@ -375,6 +379,94 @@ function generateSDKBuilderConfig(options: InitOptions): string {
return code;
}

/** Default starter roles used when --sdk --roles is specified. */
const SDK_ROLES_STARTER_TEAM = ['lead', 'backend', 'frontend', 'tester'];

/**
* Generate SDK builder config using useRole() for base roles.
*
* Produces a squad.config.ts that imports useRole from the SDK and
* references built-in base role definitions instead of plain
* defineAgent() calls.
*/
function generateSDKBuilderConfigWithRoles(options: InitOptions): string {
const { projectName, projectDescription, agents } = options;

// Partition agents into base-role agents and non-role agents
const roleAgents = agents.filter(a => getRoleById(a.role));
const plainAgents = agents.filter(a => !getRoleById(a.role));

// If caller didn't provide any base-role agents, generate a
// starter team from the default set.
const effectiveRoleAgents = roleAgents.length > 0
? roleAgents
: SDK_ROLES_STARTER_TEAM.map(id => {
const role = getRoleById(id)!;
return { name: id, role: id, displayName: role.title };
});

const needsDefineAgent = plainAgents.length > 0;
const needsUseRole = effectiveRoleAgents.length > 0;

// Build import list
const imports = ['defineSquad', 'defineTeam'];
if (needsDefineAgent) imports.push('defineAgent');
if (needsUseRole) imports.push('useRole');

let code = `import {\n${imports.map(i => ` ${i},`).join('\n')}\n} from '@bradygaster/squad-sdk';\n\n`;

code += `/**\n * Squad Configuration — ${projectName}\n`;
if (projectDescription) {
code += ` *\n * ${projectDescription}\n`;
}
code += ` *\n * Uses built-in base roles from the role catalog.\n`;
code += ` * Customize names and overrides for your project.\n`;
code += ` */\n\n`;

// Generate useRole() definitions
for (const agent of effectiveRoleAgents) {
const varName = agent.name.replace(/-/g, '_');
code += `const ${varName} = useRole('${agent.role}', {\n`;
code += ` name: '${agent.name}',\n`;
code += `});\n\n`;
}

// Generate plain defineAgent() definitions (for system agents like scribe/ralph)
for (const agent of plainAgents) {
const displayName = agent.displayName || titleCase(agent.name);
code += `const ${agent.name} = defineAgent({\n`;
code += ` name: '${agent.name}',\n`;
code += ` role: '${agent.role}',\n`;
code += ` description: '${displayName}',\n`;
code += ` status: 'active',\n`;
code += `});\n\n`;
}

// All agent variable names in order
const allVarNames = [
...effectiveRoleAgents.map(a => a.name.replace(/-/g, '_')),
...plainAgents.map(a => a.name),
];
const allNames = [
...effectiveRoleAgents.map(a => `'${a.name}'`),
...plainAgents.map(a => `'${a.name}'`),
];

code += `export default defineSquad({\n`;
code += ` version: '1.0.0',\n\n`;
code += ` team: defineTeam({\n`;
code += ` name: '${projectName}',\n`;
if (projectDescription) {
code += ` description: '${projectDescription.replace(/'/g, "\\'")}',\n`;
}
code += ` members: [${allNames.join(', ')}],\n`;
code += ` }),\n\n`;
code += ` agents: [${allVarNames.join(', ')}],\n`;
code += `});\n`;

return code;
}

// ============================================================================
// Agent Template Generation
// ============================================================================
Expand Down Expand Up @@ -658,7 +750,8 @@ export async function initSquad(options: InitOptions): Promise<InitResult> {
const configFileName = configFormat === 'sdk' ? 'squad.config.ts' :
configFormat === 'typescript' ? 'squad.config.ts' : 'squad.config.json';
configPath = join(teamRoot, configFileName);
const configContent = configFormat === 'sdk' ? generateSDKBuilderConfig(options) :
const configContent = (configFormat === 'sdk' && options.roles) ? generateSDKBuilderConfigWithRoles(options) :
configFormat === 'sdk' ? generateSDKBuilderConfig(options) :
configFormat === 'typescript' ? generateTypeScriptConfig(options) :
generateJsonConfig(options);

Expand Down
152 changes: 152 additions & 0 deletions test/init-sdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,4 +203,156 @@ describe('squad init --sdk flag', () => {
// Should contain the team name
expect(configContent).toContain('Test Squad');
});

// ── --sdk --roles integration (#378) ────────────────────────────────

it('init --sdk --roles uses useRole() instead of defineAgent()', async () => {
const options: InitOptions = {
teamRoot: tempDir,
projectName: 'test-squad',
agents: [{ name: 'scribe', role: 'scribe' }],
configFormat: 'sdk',
roles: true,
};

await initSquad(options);

const configPath = join(tempDir, 'squad.config.ts');
expect(existsSync(configPath)).toBe(true);

const configContent = await readFile(configPath, 'utf-8');

// Should import useRole
expect(configContent).toContain('useRole');
expect(configContent).toContain('@bradygaster/squad-sdk');

// Should have useRole() calls for starter team
expect(configContent).toMatch(/useRole\s*\(\s*'lead'/);
expect(configContent).toMatch(/useRole\s*\(\s*'backend'/);
expect(configContent).toMatch(/useRole\s*\(\s*'frontend'/);
expect(configContent).toMatch(/useRole\s*\(\s*'tester'/);
});

it('init --sdk --roles keeps defineAgent() for non-role agents', async () => {
const options: InitOptions = {
teamRoot: tempDir,
projectName: 'test-squad',
agents: [
{ name: 'scribe', role: 'scribe' },
{ name: 'ralph', role: 'ralph' },
],
configFormat: 'sdk',
roles: true,
};

await initSquad(options);

const configPath = join(tempDir, 'squad.config.ts');
const configContent = await readFile(configPath, 'utf-8');

// System agents use defineAgent, not useRole
expect(configContent).toContain('defineAgent');
expect(configContent).toMatch(/defineAgent\([\s\S]*?name:\s*'scribe'/);
expect(configContent).toMatch(/defineAgent\([\s\S]*?name:\s*'ralph'/);
});

it('init --sdk --roles includes role catalog comment', async () => {
const options: InitOptions = {
teamRoot: tempDir,
projectName: 'test-squad',
agents: [{ name: 'scribe', role: 'scribe' }],
configFormat: 'sdk',
roles: true,
};

await initSquad(options);

const configPath = join(tempDir, 'squad.config.ts');
const configContent = await readFile(configPath, 'utf-8');

// Should have helpful comment about base roles
expect(configContent).toContain('built-in base roles');
});

it('init --sdk --roles generates valid export default', async () => {
const options: InitOptions = {
teamRoot: tempDir,
projectName: 'test-squad',
agents: [{ name: 'scribe', role: 'scribe' }],
configFormat: 'sdk',
roles: true,
};

await initSquad(options);

const configPath = join(tempDir, 'squad.config.ts');
const configContent = await readFile(configPath, 'utf-8');

expect(configContent).toMatch(/export\s+default/);
expect(configContent).toMatch(/defineSquad\s*\(/);
expect(configContent).toMatch(/defineTeam\s*\(/);
});

it('init --sdk --roles uses base role agent when passed', async () => {
const options: InitOptions = {
teamRoot: tempDir,
projectName: 'test-squad',
agents: [
{ name: 'kane', role: 'backend' },
{ name: 'ripley', role: 'lead' },
],
configFormat: 'sdk',
roles: true,
};

await initSquad(options);

const configPath = join(tempDir, 'squad.config.ts');
const configContent = await readFile(configPath, 'utf-8');

// Should use useRole for recognized base roles
expect(configContent).toMatch(/useRole\s*\(\s*'backend'.*name:\s*'kane'/s);
expect(configContent).toMatch(/useRole\s*\(\s*'lead'.*name:\s*'ripley'/s);

// Should NOT generate default starter team since caller provided roles
expect(configContent).not.toContain("useRole('frontend'");
});

it('init --sdk without --roles still uses defineAgent()', async () => {
const options: InitOptions = {
teamRoot: tempDir,
projectName: 'test-squad',
agents: [{ name: 'edie', role: 'Engineer' }],
configFormat: 'sdk',
roles: false,
};

await initSquad(options);

const configPath = join(tempDir, 'squad.config.ts');
const configContent = await readFile(configPath, 'utf-8');

// Should NOT contain useRole
expect(configContent).not.toContain('useRole');
// Should contain defineAgent
expect(configContent).toContain('defineAgent');
});

it('init --roles without --sdk still creates markdown-only', async () => {
const options: InitOptions = {
teamRoot: tempDir,
projectName: 'test-squad',
agents: [{ name: 'edie', role: 'Engineer' }],
configFormat: 'markdown',
roles: true,
};

await initSquad(options);

// Should NOT generate squad.config.ts
expect(existsSync(join(tempDir, 'squad.config.ts'))).toBe(false);

// .squad/ directory should still be created
expect(existsSync(join(tempDir, '.squad'))).toBe(true);
});
});
Loading