From a95065bb4734c69e02de9767076ee12427b27fc1 Mon Sep 17 00:00:00 2001 From: bradygaster Date: Sun, 15 Mar 2026 14:21:42 -0700 Subject: [PATCH] feat: support --sdk switch with built-in base roles (#378) When `squad init --roles --sdk` is used, generates TypeScript source files under squad/ using defineAgent()/defineSquad() API with base role definitions via useRole(). - Added `roles` option to SDK InitOptions interface - Created generateSDKBuilderConfigWithRoles() that emits useRole() calls for base role agents and defineAgent() for non-role system agents (scribe, ralph) - Default starter team (lead, backend, frontend, tester) generated when no base-role agents are explicitly provided - CLI passes --roles flag through to SDK init - 8 new tests covering sdk+roles, sdk-only, and roles-only paths Closes #378 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/squad-cli/src/cli/core/init.ts | 1 + packages/squad-sdk/src/config/init.ts | 95 ++++++++++++++- test/init-sdk.test.ts | 152 ++++++++++++++++++++++++ 3 files changed, 247 insertions(+), 1 deletion(-) diff --git a/packages/squad-cli/src/cli/core/init.ts b/packages/squad-cli/src/cli/core/init.ts index 8286a8f48..f99602bf3 100644 --- a/packages/squad-cli/src/cli/core/init.ts +++ b/packages/squad-cli/src/cli/core/init.ts @@ -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 diff --git a/packages/squad-sdk/src/config/init.ts b/packages/squad-sdk/src/config/init.ts index 74d750656..9a768e710 100644 --- a/packages/squad-sdk/src/config/init.ts +++ b/packages/squad-sdk/src/config/init.ts @@ -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 @@ -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; } /** @@ -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 // ============================================================================ @@ -658,7 +750,8 @@ export async function initSquad(options: InitOptions): Promise { 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); diff --git a/test/init-sdk.test.ts b/test/init-sdk.test.ts index 895e08aae..e3d14a23a 100644 --- a/test/init-sdk.test.ts +++ b/test/init-sdk.test.ts @@ -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); + }); });