diff --git a/.changeset/squad-home-presets.md b/.changeset/squad-home-presets.md new file mode 100644 index 000000000..5ee44fea8 --- /dev/null +++ b/.changeset/squad-home-presets.md @@ -0,0 +1,12 @@ +--- +"@bradygaster/squad-sdk": minor +"@bradygaster/squad-cli": minor +--- + +feat: add SQUAD_HOME env var and preset system + +- Add `SQUAD_HOME` environment variable support for a roaming squad root directory +- Add preset system for reusable agent configurations (list, show, apply, init) +- Ship a default built-in preset with 5 agents (lead, reviewer, devrel, security, docs) +- Add `squad preset` CLI command with list, show, apply, and init subcommands +- Resolves #1038 diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index c30760f8a..af2f6ce31 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -157,6 +157,7 @@ async function main(): Promise { console.log(` --roles (use base roles)`); console.log(` --global (personal squad dir)`); console.log(` --no-workflows (skip CI setup)`); + console.log(` --preset (apply a preset after init)`); console.log(` Usage: init --mode remote `); console.log(` Creates .squad/config.json pointing to an external team root`); console.log(` ${BOLD}upgrade${RESET} Update Squad-owned files to latest version`); @@ -238,6 +239,10 @@ async function main(): Promise { console.log(` ${BOLD}personal${RESET} Manage your personal squad (ambient agents)`); console.log(` Usage: personal init | list | add `); console.log(` --role | remove `); + console.log(` ${BOLD}preset${RESET} Manage squad presets (curated agent collections)`); + console.log(` Usage: preset list | show `); + console.log(` apply [--force] | save `); + console.log(` init [--remote]`); console.log(` ${BOLD}cast${RESET} Show current session cast (project + personal agents)`); console.log(` ${BOLD}rc${RESET} Start Remote Control bridge (phone/browser → Copilot)`); console.log(` Usage: rc [--tunnel] [--port ] [--path ]`); @@ -309,8 +314,42 @@ async function main(): Promise { const noWorkflows = args.includes('--no-workflows'); const sdk = args.includes('--sdk'); const roles = args.includes('--roles'); + const presetIdx = args.indexOf('--preset'); + const presetName = (presetIdx !== -1 && args[presetIdx + 1]) ? args[presetIdx + 1] : undefined; // Global init: suppress workflows (no GitHub CI in ~/.config/squad/) and bootstrap personal squad - runInit(dest, { includeWorkflows: !noWorkflows && !hasGlobal, sdk, roles, isGlobal: hasGlobal }).catch(err => { + runInit(dest, { includeWorkflows: !noWorkflows && !hasGlobal, sdk, roles, isGlobal: hasGlobal }).then(async () => { + if (presetName) { + const { seedBuiltinPresets, applyPreset } = await import('@bradygaster/squad-sdk/presets'); + const { resolvePresetsDir, ensureSquadHome } = await import('@bradygaster/squad-sdk/resolution'); + const nodePath = await import('node:path'); + + // Auto-initialize squad home + presets if they don't exist yet + if (!resolvePresetsDir()) { + console.log(`\n⚙️ No presets found — setting up squad home...`); + ensureSquadHome(); + seedBuiltinPresets(); + console.log(`✅ Squad home initialized at ${ensureSquadHome()}`); + console.log(` Built-in presets ready. Run 'squad preset init --remote' to back with a GitHub repo.\n`); + } else { + seedBuiltinPresets(); + } + + const targetAgentsDir = nodePath.join(dest, '.squad', 'agents'); + const results = applyPreset(presetName, targetAgentsDir); + const installed = results.filter(r => r.status === 'installed'); + const skipped = results.filter(r => r.status === 'skipped'); + const errors = results.filter(r => r.status === 'error'); + if (installed.length > 0) { + console.log(`✅ Applied preset '${presetName}': ${installed.length} agents installed`); + } + if (skipped.length > 0) { + console.log(` ${skipped.length} agents skipped (already exist)`); + } + if (errors.length > 0 && installed.length === 0) { + console.error(`❌ Preset '${presetName}' not found. Run 'squad preset list' to see available presets.`); + } + } + }).catch(err => { fatal(err.message); }); return; @@ -895,6 +934,13 @@ async function main(): Promise { return; } + if (cmd === 'preset') { + const { runPreset } = await import('./cli/commands/preset.js'); + const subcommand = args[1] || 'list'; + await runPreset(getSquadStartDir(), subcommand, args.slice(2)); + return; + } + if (cmd === 'cast') { const { runCast } = await import('./cli/commands/cast.js'); await runCast(getSquadStartDir()); diff --git a/packages/squad-cli/src/cli/commands/preset.ts b/packages/squad-cli/src/cli/commands/preset.ts new file mode 100644 index 000000000..a498ad2f6 --- /dev/null +++ b/packages/squad-cli/src/cli/commands/preset.ts @@ -0,0 +1,389 @@ +/** + * squad preset — manage squad presets (curated agent collections) + * + * Presets are saved to SQUAD_HOME/presets/ (default: ~/.squad/presets/). + * Each preset is a directory with a preset.json manifest + agents/ charters. + * + * Subcommands: + * squad preset list — list available presets + * squad preset show — show preset details + * squad preset apply — install preset agents into current squad + * squad preset save — save current project agents as a preset + * squad preset init — initialize presets directory in squad home + * + * Note: Presets capture agents only (charters). For full squad snapshots + * including casting state, skills, and routing rules — e.g. to share a + * configured squad or publish to an agent toolbox — use `squad export`. + * + * @module cli/commands/preset + */ + +import path from 'node:path'; +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import { resolveSquadHome, ensureSquadHome, resolvePresetsDir } from '@bradygaster/squad-sdk/resolution'; +import { listPresets, loadPreset, applyPreset, savePreset, seedBuiltinPresets } from '@bradygaster/squad-sdk/presets'; +import { resolveSquad } from '@bradygaster/squad-sdk/resolution'; +import { success, warn, info, BOLD, RESET, DIM } from '../core/output.js'; +import { fatal } from '../core/errors.js'; + +/** + * Entry point for `squad preset` subcommands. + */ +export async function runPreset(cwd: string, subcommand: string, args: string[]): Promise { + switch (subcommand) { + case 'list': + await presetList(); + break; + case 'show': { + const name = args[0]; + if (!name) { + fatal('Usage: squad preset show '); + } + await presetShow(name!); + break; + } + case 'apply': { + const name = args[0]; + if (!name) { + fatal('Usage: squad preset apply [--force]'); + } + const force = args.includes('--force'); + await presetApply(cwd, name!, force); + break; + } + case 'init': { + const remote = args.includes('--remote'); + await presetInit(remote); + break; + } + case 'save': { + const name = args[0]; + if (!name) { + fatal('Usage: squad preset save [--force] [--description "..."]'); + } + const force = args.includes('--force'); + const descIdx = args.indexOf('--description'); + const description = descIdx >= 0 ? args[descIdx + 1] : undefined; + await presetSave(cwd, name!, force, description); + break; + } + default: + fatal( + `Unknown preset subcommand: ${subcommand}\n` + + `Usage:\n` + + ` squad preset list\n` + + ` squad preset show \n` + + ` squad preset apply [--force]\n` + + ` squad preset save \n` + + ` squad preset init [--remote]`, + ); + } +} + +// ============================================================================ +// Subcommand: init +// ============================================================================ + +async function presetInit(remote: boolean): Promise { + if (remote) { + await presetInitRemote(); + return; + } + + const homeDir = ensureSquadHome(); + const presetsDir = path.join(homeDir, 'presets'); + + const seeded = seedBuiltinPresets(); + + success('Presets directory initialized'); + info(` Path: ${presetsDir}`); + if (seeded.length > 0) { + info(` Built-in presets installed: ${seeded.join(', ')}`); + } + info(` Run 'squad preset list' to see available presets.`); + console.log(); + info(`${DIM}Tip: Run 'squad preset init --remote' to back your squad home`); + info(`with a private GitHub repo so presets roam across machines.${RESET}`); +} + +async function presetInitRemote(): Promise { + // Check gh CLI is available + try { + execSync('gh --version', { stdio: 'pipe' }); + } catch { + fatal('GitHub CLI (gh) is required for --remote. Install it: https://cli.github.com'); + } + + // Check gh auth + try { + execSync('gh auth status', { stdio: 'pipe' }); + } catch { + fatal('Not logged in to GitHub CLI. Run: gh auth login'); + } + + const os = await import('node:os'); + const envHome = process.env['SQUAD_HOME']; + const homeDir = envHome ? path.resolve(envHome) : path.join(os.homedir(), '.squad'); + const repoName = 'squad-home'; + + // Get GitHub username + let ghUser: string; + try { + ghUser = execSync('gh api user --jq .login', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); + } catch { + fatal('Could not determine GitHub username. Run: gh auth login'); + return; + } + + const repoFullName = `${ghUser}/${repoName}`; + + // Check if SQUAD_HOME is already a git repo + if (fs.existsSync(path.join(homeDir, '.git'))) { + info(`Squad home is already a git repo: ${homeDir}`); + const seeded = seedBuiltinPresets(); + if (seeded.length > 0) { + info(` Built-in presets installed: ${seeded.join(', ')}`); + } + success('Squad home ready — presets roam via git push/pull.'); + return; + } + + // If ~/.squad/ doesn't exist yet, try to clone existing remote repo + if (!fs.existsSync(homeDir)) { + let repoExists = false; + try { + execSync(`gh repo view ${repoFullName} --json name`, { stdio: 'pipe' }); + repoExists = true; + } catch { + repoExists = false; + } + + if (repoExists) { + // Second machine — clone existing squad home + info(`Found existing repo ${repoFullName} — cloning...`); + try { + execSync(`gh repo clone ${repoFullName} "${homeDir}"`, { stdio: 'inherit' }); + const seeded = seedBuiltinPresets(); + if (seeded.length > 0) { + info(` New built-in presets added: ${seeded.join(', ')}`); + try { + execSync(`git -C "${homeDir}" add -A && git -C "${homeDir}" commit -m "seed built-in presets" --allow-empty`, { stdio: 'pipe' }); + } catch { /* nothing new to commit */ } + } + success(`Squad home cloned from ${repoFullName}`); + info(` Path: ${homeDir}`); + success('Presets synced — you\'re ready to go.'); + return; + } catch { + fatal(`Failed to clone ${repoFullName}. Check permissions.`); + } + } + + // Repo doesn't exist — create fresh + info(`Creating private repo ${repoFullName}...`); + try { + fs.mkdirSync(homeDir, { recursive: true }); + execSync(`git init`, { cwd: homeDir, stdio: 'pipe' }); + execSync(`gh repo create ${repoName} --private --source "${homeDir}" --push --description "Squad home — presets and config"`, { + stdio: 'inherit', + }); + } catch { + fatal(`Failed to create repo. Try manually: gh repo create ${repoName} --private`); + } + } else { + // ~/.squad/ exists but isn't a git repo — init and connect + info(`Initializing git in existing squad home: ${homeDir}`); + execSync(`git init`, { cwd: homeDir, stdio: 'pipe' }); + + let repoExists = false; + try { + execSync(`gh repo view ${repoFullName} --json name`, { stdio: 'pipe' }); + repoExists = true; + } catch { + repoExists = false; + } + + if (!repoExists) { + info(`Creating private repo ${repoFullName}...`); + try { + execSync(`gh repo create ${repoName} --private --source "${homeDir}" --push --description "Squad home — presets and config"`, { + stdio: 'inherit', + }); + } catch { + fatal(`Failed to create repo ${repoFullName}.`); + } + } else { + info(`Connecting to existing repo ${repoFullName}...`); + try { + execSync(`git remote add origin https://github.com/${repoFullName}.git`, { cwd: homeDir, stdio: 'pipe' }); + execSync(`git pull origin main --allow-unrelated-histories`, { cwd: homeDir, stdio: 'pipe' }); + } catch { + warn('Could not pull from remote. You may need to resolve manually.'); + } + } + } + + // Seed built-in presets and push + const seeded = seedBuiltinPresets(); + + try { + execSync(`git -C "${homeDir}" add -A`, { stdio: 'pipe' }); + execSync(`git -C "${homeDir}" commit -m "Initialize squad home with presets"`, { stdio: 'pipe' }); + execSync(`git -C "${homeDir}" push -u origin main`, { stdio: 'pipe' }); + } catch { + // May fail if nothing to commit or push + } + + success(`Squad home initialized with private repo: ${repoFullName}`); + info(` Path: ${homeDir}`); + if (seeded.length > 0) { + info(` Built-in presets installed: ${seeded.join(', ')}`); + } + console.log(); + info(`On another machine, run the same command to sync your presets:`); + info(` ${BOLD}squad preset init --remote${RESET}`); +} + +// ============================================================================ +// Subcommand: list +// ============================================================================ + +async function presetList(): Promise { + const presetsDir = resolvePresetsDir(); + + if (!presetsDir) { + info('No presets directory found.'); + info(' Run `squad preset init --remote` to set up with a GitHub repo (recommended).'); + info(' Or `squad preset init` for local-only setup.'); + return; + } + + const presets = listPresets(); + + if (presets.length === 0) { + info(`Presets directory exists at ${presetsDir} but contains no presets.`); + info(' Create a preset directory with a preset.json manifest.'); + return; + } + + console.log(`\n${BOLD}Available Presets${RESET} (${presets.length}):\n`); + + const maxNameLen = Math.max(...presets.map(p => p.name.length), 4); + + console.log( + ` ${'Name'.padEnd(maxNameLen)} ` + + `${'Agents'} ` + + `Description` + ); + console.log( + ` ${'─'.repeat(maxNameLen)} ` + + `${'─'.repeat(6)} ` + + `${'─'.repeat(40)}` + ); + + for (const preset of presets) { + console.log( + ` ${preset.name.padEnd(maxNameLen)} ` + + `${String(preset.agents.length).padEnd(6)} ` + + `${DIM}${preset.description}${RESET}` + ); + } + + console.log(); +} + +// ============================================================================ +// Subcommand: show +// ============================================================================ + +async function presetShow(name: string): Promise { + const preset = loadPreset(name); + + if (!preset) { + fatal(`Preset '${name}' not found. Run 'squad preset list' to see available presets.`); + } + + console.log(`\n${BOLD}${preset.name}${RESET} v${preset.version}`); + console.log(` ${preset.description}`); + if (preset.author) console.log(` Author: ${preset.author}`); + if (preset.tags?.length) console.log(` Tags: ${preset.tags.join(', ')}`); + + console.log(`\n ${BOLD}Agents${RESET} (${preset.agents.length}):`); + + for (const agent of preset.agents) { + console.log(` • ${BOLD}${agent.name}${RESET} (${agent.role})${agent.description ? ` — ${DIM}${agent.description}${RESET}` : ''}`); + } + + console.log(); +} + +// ============================================================================ +// Subcommand: apply +// ============================================================================ + +async function presetApply(cwd: string, name: string, force: boolean): Promise { + // Find target squad directory + const squadDir = resolveSquad(cwd); + if (!squadDir) { + fatal('No .squad/ directory found. Run `squad init` first, or use from a repo with a squad.'); + } + + const targetAgentsDir = path.join(squadDir, 'agents'); + + const results = applyPreset(name, targetAgentsDir, { force }); + + if (results.length === 1 && results[0]!.status === 'error' && results[0]!.agent === name) { + fatal(results[0]!.reason ?? `Failed to apply preset '${name}'`); + } + + let installed = 0; + let skipped = 0; + let errors = 0; + + for (const result of results) { + switch (result.status) { + case 'installed': + success(` ${result.agent}`); + installed++; + break; + case 'skipped': + warn(` ${result.agent} — ${result.reason}`); + skipped++; + break; + case 'error': + console.error(` ✗ ${result.agent} — ${result.reason}`); + errors++; + break; + } + } + + console.log(); + if (installed > 0) success(`Applied preset '${name}': ${installed} agents installed`); + if (skipped > 0) info(` ${skipped} agents skipped (already exist)`); + if (errors > 0) warn(` ${errors} agents had errors`); +} + +// ============================================================================ +// Subcommand: save +// ============================================================================ + +async function presetSave(cwd: string, name: string, force: boolean, description?: string): Promise { + const squadDir = resolveSquad(cwd); + if (!squadDir) { + fatal('No .squad/ directory found. Initialize a squad first with `squad init`.'); + } + + try { + const destDir = savePreset(name, squadDir, { force, description }); + success(`Preset '${name}' saved`); + info(` Location: ${destDir}`); + info(` Use it in any project: squad preset apply ${name}`); + console.log(); + info(`${DIM}Tip: Presets save agents only (charters). For a full squad snapshot`); + info(`including casting state, skills, and routing rules — e.g. to share`); + info(`a configured squad or publish to an agent toolbox — use 'squad export'.${RESET}`); + } catch (err) { + fatal(String(err)); + } +} diff --git a/packages/squad-sdk/package.json b/packages/squad-sdk/package.json index 3e9e3e184..cb7757b11 100644 --- a/packages/squad-sdk/package.json +++ b/packages/squad-sdk/package.json @@ -102,6 +102,10 @@ "types": "./dist/resolution.d.ts", "import": "./dist/resolution.js" }, + "./presets": { + "types": "./dist/presets/index.d.ts", + "import": "./dist/presets/index.js" + }, "./adapter/errors": { "types": "./dist/adapter/errors.d.ts", "import": "./dist/adapter/errors.js" @@ -222,7 +226,7 @@ ], "scripts": { "prepublishOnly": "npm run build", - "build": "tsc -p tsconfig.json" + "build": "tsc -p tsconfig.json && node -e \"const{rmSync,cpSync}=require('fs');rmSync('dist/presets/builtin',{recursive:true,force:true});cpSync('src/presets/builtin','dist/presets/builtin',{recursive:true,filter:(s)=>!s.endsWith('.ts')})\"" }, "engines": { "node": ">=22.5.0" diff --git a/packages/squad-sdk/src/index.ts b/packages/squad-sdk/src/index.ts index 945b99dea..72e5b88b8 100644 --- a/packages/squad-sdk/src/index.ts +++ b/packages/squad-sdk/src/index.ts @@ -10,7 +10,7 @@ const pkg = require('../package.json'); export const VERSION: string = pkg.version; // Export public API -export { resolveSquad, resolveGlobalSquadPath, resolvePersonalSquadDir, ensurePersonalSquadDir, ensureSquadPath, ensureSquadPathTriple, loadDirConfig, isConsultMode, scratchDir, scratchFile, deriveProjectKey, resolveExternalStateDir } from './resolution.js'; +export { resolveSquad, resolveGlobalSquadPath, resolvePersonalSquadDir, ensurePersonalSquadDir, ensureSquadPath, ensureSquadPathTriple, loadDirConfig, isConsultMode, scratchDir, scratchFile, deriveProjectKey, resolveExternalStateDir, resolveSquadHome, ensureSquadHome, resolvePresetsDir } from './resolution.js'; export type { SquadDirConfig, ResolvedSquadPaths } from './resolution.js'; export * from './config/index.js'; export * from './agents/onboarding.js'; diff --git a/packages/squad-sdk/src/presets/builtin/default/agents/devrel/charter.md b/packages/squad-sdk/src/presets/builtin/default/agents/devrel/charter.md new file mode 100644 index 000000000..3fdad38a9 --- /dev/null +++ b/packages/squad-sdk/src/presets/builtin/default/agents/devrel/charter.md @@ -0,0 +1,30 @@ +# devrel — Developer Relations + +> I make your project approachable — if a developer can't get started in 5 minutes, that's on me. + +## Identity + +- **Name:** devrel +- **Role:** devrel +- **Expertise:** Developer experience, onboarding flows, README/quickstart writing +- **Style:** Friendly and practical — I write for the developer who just wants it to work + +## What I Own + +- README and quickstart guides +- Developer onboarding experience +- Sample code and tutorials +- Changelog and release notes + +## How I Work + +- Test every quickstart by following my own instructions +- Lead with "what can you build?" not "how does it work?" +- Keep examples copy-pasteable and self-contained +- Write for the 80% use case first, document edge cases separately + +## Boundaries + +**I handle:** READMEs, quickstarts, tutorials, developer-facing content, release notes + +**I don't handle:** Internal architecture docs (docs agent), code reviews, security diff --git a/packages/squad-sdk/src/presets/builtin/default/agents/docs/charter.md b/packages/squad-sdk/src/presets/builtin/default/agents/docs/charter.md new file mode 100644 index 000000000..33682fdd9 --- /dev/null +++ b/packages/squad-sdk/src/presets/builtin/default/agents/docs/charter.md @@ -0,0 +1,30 @@ +# docs — Documentation Specialist + +> Good docs aren't written — they're maintained. I keep technical documentation accurate and discoverable. + +## Identity + +- **Name:** docs +- **Role:** docs +- **Expertise:** Technical writing, API documentation, information architecture +- **Style:** Clear and structured — every doc has a purpose and an audience + +## What I Own + +- Technical documentation and API references +- Architecture documentation and diagrams +- Internal knowledge base and runbooks +- Documentation site structure and navigation + +## How I Work + +- Every doc answers: who is this for? what will they learn? what should they do next? +- Keep docs close to code — update docs in the same PR as code changes +- Use consistent terminology — maintain a glossary if needed +- Prefer examples over explanations + +## Boundaries + +**I handle:** Technical docs, API references, architecture docs, runbooks, doc site structure + +**I don't handle:** Developer-facing quickstarts (devrel agent), code reviews, security assessments diff --git a/packages/squad-sdk/src/presets/builtin/default/agents/lead/charter.md b/packages/squad-sdk/src/presets/builtin/default/agents/lead/charter.md new file mode 100644 index 000000000..a9749f109 --- /dev/null +++ b/packages/squad-sdk/src/presets/builtin/default/agents/lead/charter.md @@ -0,0 +1,29 @@ +# lead — Technical Lead + +> The calm hand on the tiller — I turn ambiguity into architecture and keep the team moving. + +## Identity + +- **Name:** lead +- **Role:** lead +- **Expertise:** System design, architectural trade-offs, cross-cutting concerns +- **Style:** Decisive but collaborative — I explain the "why" behind every decision + +## What I Own + +- Architecture decisions and technical direction +- Cross-agent coordination and conflict resolution +- Sprint planning and scope management + +## How I Work + +- Start with constraints: what are the hard requirements? +- Prefer simple solutions over clever ones +- Document decisions as ADRs (Architecture Decision Records) +- Break big problems into parallelizable work + +## Boundaries + +**I handle:** Architecture, design reviews, technical planning, blocker resolution + +**I don't handle:** Writing production code (that's the team's job), security audits (security agent), documentation (docs agent) diff --git a/packages/squad-sdk/src/presets/builtin/default/agents/reviewer/charter.md b/packages/squad-sdk/src/presets/builtin/default/agents/reviewer/charter.md new file mode 100644 index 000000000..2c8a69d62 --- /dev/null +++ b/packages/squad-sdk/src/presets/builtin/default/agents/reviewer/charter.md @@ -0,0 +1,29 @@ +# reviewer — Code Reviewer + +> I catch what you missed — bugs, edge cases, and code that future-you will regret. + +## Identity + +- **Name:** reviewer +- **Role:** reviewer +- **Expertise:** Code quality, testing patterns, performance pitfalls +- **Style:** Direct and constructive — I flag real issues, not style nits + +## What I Own + +- Pull request reviews and code quality standards +- Test coverage assessment +- Performance and maintainability review + +## How I Work + +- Focus on correctness first, then clarity, then performance +- Always explain *why* something is a problem, not just *what* +- Suggest fixes, don't just point out problems +- Skip style/formatting — that's what linters are for + +## Boundaries + +**I handle:** Code reviews, test assessment, refactoring suggestions, bug detection + +**I don't handle:** Writing the initial implementation, security-specific audits, documentation diff --git a/packages/squad-sdk/src/presets/builtin/default/agents/security/charter.md b/packages/squad-sdk/src/presets/builtin/default/agents/security/charter.md new file mode 100644 index 000000000..383f9a9f9 --- /dev/null +++ b/packages/squad-sdk/src/presets/builtin/default/agents/security/charter.md @@ -0,0 +1,30 @@ +# security — Security Engineer + +> I assume everything is vulnerable until proven otherwise — and I verify the proof. + +## Identity + +- **Name:** security +- **Role:** security +- **Expertise:** Application security, dependency auditing, threat modeling +- **Style:** Thorough and skeptical — I ask "what could go wrong?" before "does it work?" + +## What I Own + +- Security review of code changes +- Dependency vulnerability scanning +- Authentication and authorization patterns +- Secrets management practices + +## How I Work + +- Review every PR for common vulnerability patterns (injection, auth bypass, data exposure) +- Flag hardcoded secrets, missing input validation, unsafe deserialization +- Recommend least-privilege access patterns +- Keep a running threat model for the project + +## Boundaries + +**I handle:** Security reviews, vulnerability assessment, auth patterns, secrets hygiene + +**I don't handle:** General code quality (reviewer agent), performance optimization, feature implementation diff --git a/packages/squad-sdk/src/presets/builtin/default/preset.json b/packages/squad-sdk/src/presets/builtin/default/preset.json new file mode 100644 index 000000000..9c0ce65d7 --- /dev/null +++ b/packages/squad-sdk/src/presets/builtin/default/preset.json @@ -0,0 +1,34 @@ +{ + "name": "default", + "version": "1.0.0", + "description": "Starter squad — a well-rounded team for any software project", + "author": "Squad Contributors", + "tags": ["starter", "general", "software"], + "agents": [ + { + "name": "lead", + "role": "lead", + "description": "Technical lead — owns architecture decisions and coordinates the team" + }, + { + "name": "reviewer", + "role": "reviewer", + "description": "Code reviewer — catches bugs, enforces standards, and improves quality" + }, + { + "name": "devrel", + "role": "devrel", + "description": "Developer relations — writes docs, READMEs, and developer-facing content" + }, + { + "name": "security", + "role": "security", + "description": "Security engineer — reviews for vulnerabilities and enforces security practices" + }, + { + "name": "docs", + "role": "docs", + "description": "Documentation specialist — maintains technical docs and API references" + } + ] +} diff --git a/packages/squad-sdk/src/presets/index.ts b/packages/squad-sdk/src/presets/index.ts new file mode 100644 index 000000000..ed4be59d2 --- /dev/null +++ b/packages/squad-sdk/src/presets/index.ts @@ -0,0 +1,323 @@ +/** + * Preset loading and application logic. + * + * Presets are curated agent collections stored in `/presets//`. + * Each preset directory contains: + * - `preset.json` — manifest with metadata and agent list + * - `agents//charter.md` — agent charter files + * + * @module presets + */ + +import path from 'node:path'; +import { readdirSync, statSync, lstatSync, rmSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { FSStorageProvider } from '../storage/fs-storage-provider.js'; +import { resolvePresetsDir, ensureSquadHome } from '../resolution.js'; +import type { PresetManifest, PresetApplyResult } from './types.js'; + +export type { PresetManifest, PresetAgent, PresetApplyResult } from './types.js'; + +const storage = new FSStorageProvider(); + +function isDirSync(p: string): boolean { + try { return lstatSync(p).isDirectory(); } catch { return false; } +} + +/** Validate a preset or agent name — must be a safe basename (no path separators, no ..) */ +function validateName(name: string, label: string): void { + if (!name || name !== path.basename(name) || name === '..' || name === '.') { + throw new Error(`Invalid ${label} name: '${name}'. Must be a simple directory name.`); + } +} + + +/** + * List all available presets from the squad home presets directory. + * + * @returns Array of preset manifests, or empty array if no presets found. + */ +export function listPresets(): PresetManifest[] { + const presetsDir = resolvePresetsDir(); + if (!presetsDir) return []; + + const entries = readdirSync(presetsDir, { encoding: 'utf-8' }); + const presets: PresetManifest[] = []; + + for (const entry of entries) { + const presetDir = path.join(presetsDir, entry); + if (!isDirSync(presetDir)) continue; + + const manifest = loadPresetManifest(presetDir); + if (manifest) presets.push(manifest); + } + + return presets; +} + +/** + * Load a specific preset by name. + * + * @param name - Preset name (directory name under presets/). + * @returns The preset manifest, or null if not found. + */ +export function loadPreset(name: string): PresetManifest | null { + const presetsDir = resolvePresetsDir(); + if (!presetsDir) return null; + + const presetDir = path.join(presetsDir, name); + if (!storage.existsSync(presetDir) || !isDirSync(presetDir)) { + return null; + } + + return loadPresetManifest(presetDir); +} + +/** + * Apply a preset — copy its agents into a target squad directory. + * + * By default, existing agents are skipped (not overwritten). + * Pass `force: true` to overwrite existing agents. + * + * @param presetName - Name of the preset to apply. + * @param targetDir - Target directory to install agents into (e.g. `.squad/agents/`). + * @param options - Options for applying the preset. + * @returns Array of results for each agent in the preset. + */ +export function applyPreset( + presetName: string, + targetDir: string, + options: { force?: boolean } = {}, +): PresetApplyResult[] { + try { validateName(presetName, 'preset'); } catch (err) { + return [{ agent: presetName, status: 'error', reason: String(err) }]; + } + + const presetsDir = resolvePresetsDir(); + if (!presetsDir) { + return [{ agent: presetName, status: 'error', reason: 'No presets directory found. Run `squad preset init --remote` to set up, or `squad preset init` for local-only.' }]; + } + + const presetDir = path.join(presetsDir, presetName); + const manifest = loadPresetManifest(presetDir); + if (!manifest) { + return [{ agent: presetName, status: 'error', reason: `Preset '${presetName}' not found` }]; + } + + const presetAgentsDir = path.join(presetDir, 'agents'); + const results: PresetApplyResult[] = []; + + for (const agent of manifest.agents) { + try { validateName(agent.name, 'agent'); } catch { + results.push({ agent: agent.name, status: 'error', reason: `Invalid agent name: '${agent.name}'` }); + continue; + } + + const sourceDir = path.join(presetAgentsDir, agent.name); + const destDir = path.join(targetDir, agent.name); + + if (!storage.existsSync(sourceDir)) { + results.push({ agent: agent.name, status: 'error', reason: 'Source agent directory missing in preset' }); + continue; + } + + if (storage.existsSync(destDir) && !options.force) { + results.push({ agent: agent.name, status: 'skipped', reason: 'Already exists (use --force to overwrite)' }); + continue; + } + + try { + // When forcing, remove dest first so renamed/deleted files don't linger + if (options.force && storage.existsSync(destDir)) { + rmSync(destDir, { recursive: true, force: true }); + } + copyDirRecursive(sourceDir, destDir); + results.push({ agent: agent.name, status: 'installed' }); + } catch (err) { + results.push({ agent: agent.name, status: 'error', reason: String(err) }); + } + } + + return results; +} + +/** + * Install a preset into squad home from an external source directory. + * Copies the preset directory into `/presets//`. + * + * @param sourceDir - Source directory containing preset.json and agents/. + * @param name - Preset name (used as destination directory name). + * @returns Path to the installed preset. + */ +export function installPreset(sourceDir: string, name: string): string { + const homeDir = ensureSquadHome(); + const destDir = path.join(homeDir, 'presets', name); + + copyDirRecursive(sourceDir, destDir); + return destDir; +} + +/** + * Save the current project's squad agents as a reusable preset. + * + * Reads agents from the given squad directory (e.g. `.squad/agents/`), + * generates a `preset.json` manifest, and copies everything into + * `/presets//`. + * + * @param name - Name for the new preset. + * @param squadDir - Path to the project's .squad/ directory containing agents/. + * @param options - force: overwrite existing preset; description: preset description. + * @returns Path to the saved preset directory. + */ +export function savePreset( + name: string, + squadDir: string, + options: { force?: boolean; description?: string } = {}, +): string { + validateName(name, 'preset'); + + const agentsDir = path.join(squadDir, 'agents'); + if (!storage.existsSync(agentsDir) || !isDirSync(agentsDir)) { + throw new Error(`No agents/ directory found in ${squadDir}`); + } + + const homeDir = ensureSquadHome(); + const destDir = path.join(homeDir, 'presets', name); + + if (storage.existsSync(destDir) && !options.force) { + throw new Error(`Preset '${name}' already exists. Use --force to overwrite.`); + } + + // Discover agents + const agentEntries = readdirSync(agentsDir, { encoding: 'utf-8' }); + const agents: { name: string; role: string; description: string }[] = []; + + for (const entry of agentEntries) { + const agentDir = path.join(agentsDir, entry); + if (!isDirSync(agentDir)) continue; + + // Try to extract role from charter.md front matter + let role = entry; + let description = ''; + const charterPath = path.join(agentDir, 'charter.md'); + if (storage.existsSync(charterPath)) { + const content = storage.readSync(charterPath); + if (content) { + const roleMatch = content.match(/^##?\s+.*?[-–—]\s*(.+)/m); + if (roleMatch) role = roleMatch[1]!.trim(); + // First non-empty, non-heading line as description + const lines = content.split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed && !trimmed.startsWith('#') && !trimmed.startsWith('---')) { + description = trimmed.slice(0, 120); + break; + } + } + } + } + + agents.push({ name: entry, role, description }); + } + + if (agents.length === 0) { + throw new Error(`No agents found in ${agentsDir}`); + } + + // Build manifest + const manifest: PresetManifest = { + name, + version: '1.0.0', + description: options.description ?? `Custom preset '${name}'`, + agents: agents.map(a => ({ name: a.name, role: a.role, description: a.description })), + }; + + // Write preset directory + storage.mkdirSync(destDir, { recursive: true }); + storage.writeSync(path.join(destDir, 'preset.json'), JSON.stringify(manifest, null, 2)); + copyDirRecursive(agentsDir, path.join(destDir, 'agents')); + + return destDir; +} + +// ============================================================================ +// Internal helpers +// ============================================================================ + +function loadPresetManifest(presetDir: string): PresetManifest | null { + const manifestPath = path.join(presetDir, 'preset.json'); + if (!storage.existsSync(manifestPath)) return null; + + try { + const content = storage.readSync(manifestPath); + if (!content) return null; + const manifest = JSON.parse(content) as PresetManifest; + if (!manifest.name || !manifest.agents || !Array.isArray(manifest.agents)) { + return null; + } + return manifest; + } catch { + return null; + } +} + +function copyDirRecursive(src: string, dest: string): void { + storage.mkdirSync(dest, { recursive: true }); + const entries = readdirSync(src, { encoding: 'utf-8' }); + + for (const entry of entries) { + const srcPath = path.join(src, entry); + const destPath = path.join(dest, entry); + const stat = lstatSync(srcPath); + + // Skip symlinks — don't follow them into unintended locations + if (stat.isSymbolicLink()) continue; + + if (stat.isDirectory()) { + copyDirRecursive(srcPath, destPath); + } else { + const content = storage.readSync(srcPath); + if (content !== undefined) { + storage.writeSync(destPath, content); + } + } + } +} + +/** + * Get the path to the built-in presets that ship with the SDK. + * These are bundled in the package under `presets/builtin/`. + */ +export function getBuiltinPresetsDir(): string { + const thisFile = fileURLToPath(import.meta.url); + return path.join(path.dirname(thisFile), 'builtin'); +} + +/** + * Seed squad home with built-in presets if they don't already exist. + * Only copies presets that are missing — never overwrites user presets. + * + * @returns Names of presets that were seeded. + */ +export function seedBuiltinPresets(): string[] { + const homeDir = ensureSquadHome(); + const builtinDir = getBuiltinPresetsDir(); + const targetPresetsDir = path.join(homeDir, 'presets'); + const seeded: string[] = []; + + if (!storage.existsSync(builtinDir)) return seeded; + + const entries = readdirSync(builtinDir, { encoding: 'utf-8' }); + for (const entry of entries) { + const srcDir = path.join(builtinDir, entry); + const destDir = path.join(targetPresetsDir, entry); + + if (!isDirSync(srcDir)) continue; + if (storage.existsSync(destDir)) continue; + + copyDirRecursive(srcDir, destDir); + seeded.push(entry); + } + + return seeded; +} diff --git a/packages/squad-sdk/src/presets/types.ts b/packages/squad-sdk/src/presets/types.ts new file mode 100644 index 000000000..0b1eb0539 --- /dev/null +++ b/packages/squad-sdk/src/presets/types.ts @@ -0,0 +1,48 @@ +/** + * Preset type definitions for Squad presets system. + * + * A preset is a curated collection of agents that can be applied to any + * squad project. Presets live in `/presets//`. + * + * @module presets/types + */ + +/** + * Agent definition within a preset manifest. + */ +export interface PresetAgent { + /** Agent name (used as directory name) */ + name: string; + /** Agent role (e.g. 'lead', 'reviewer', 'devrel') */ + role: string; + /** Short description of this agent's purpose */ + description?: string; +} + +/** + * Preset manifest — describes a preset and its agents. + * Stored as `preset.json` in the preset directory. + */ +export interface PresetManifest { + /** Preset display name */ + name: string; + /** Version identifier */ + version: string; + /** Short description of what this preset is for */ + description: string; + /** Agents included in this preset */ + agents: PresetAgent[]; + /** Optional author attribution */ + author?: string; + /** Optional tags for discovery */ + tags?: string[]; +} + +/** + * Result of applying a single agent from a preset. + */ +export interface PresetApplyResult { + agent: string; + status: 'installed' | 'skipped' | 'error'; + reason?: string; +} diff --git a/packages/squad-sdk/src/resolution.ts b/packages/squad-sdk/src/resolution.ts index fa4bf8324..a35245f82 100644 --- a/packages/squad-sdk/src/resolution.ts +++ b/packages/squad-sdk/src/resolution.ts @@ -599,3 +599,83 @@ export function resolveExternalStateDir(projectKey: string, create: boolean = tr return projectsDir; } + +// ============================================================================ +// SQUAD_HOME — roaming squad root (Issue #1038) +// ============================================================================ + +/** + * Resolve the squad home directory — a roaming squad root for personal agents + * and presets that follows the user across machines. + * + * Resolution order: + * 1. `SQUAD_HOME` env var (explicit override, e.g. a synced folder) + * 2. `~/.squad/` (conventional default — user's home dir) + * + * Unlike `resolveGlobalSquadPath()` (which returns platform-specific app config), + * squad home is a **squad root** — it can contain `agents/`, `presets/`, etc. + * + * @param create - Whether to create the directory if missing (default: false). + * @returns Absolute path to the squad home directory, or null if it doesn't + * exist and `create` is false. + */ +export function resolveSquadHome(create: boolean = false): string | null { + const envHome = process.env['SQUAD_HOME']; + const homeDir = envHome + ? path.resolve(envHome) + : path.join(os.homedir(), '.squad'); + + if (storage.existsSync(homeDir)) { + if (!storage.isDirectorySync(homeDir)) { + throw new Error(`SQUAD_HOME path exists but is not a directory: ${homeDir}`); + } + return homeDir; + } + + if (create) { + storage.mkdirSync(homeDir, { recursive: true }); + return homeDir; + } + + return null; +} + +/** + * Ensure the squad home directory exists with standard structure. + * Creates `agents/` and `presets/` subdirectories. + * + * Idempotent — safe to call multiple times. + * + * @returns Absolute path to the squad home directory. + */ +export function ensureSquadHome(): string { + const homeDir = resolveSquadHome(true)!; + + const agentsDir = path.join(homeDir, 'agents'); + if (!storage.existsSync(agentsDir)) { + storage.mkdirSync(agentsDir, { recursive: true }); + } + + const presetsDir = path.join(homeDir, 'presets'); + if (!storage.existsSync(presetsDir)) { + storage.mkdirSync(presetsDir, { recursive: true }); + } + + return homeDir; +} + +/** + * Resolve the presets directory within squad home. + * + * @returns Absolute path to `/presets/`, or null if squad home + * doesn't exist. + */ +export function resolvePresetsDir(): string | null { + const homeDir = resolveSquadHome(); + if (!homeDir) return null; + + const presetsDir = path.join(homeDir, 'presets'); + if (!storage.existsSync(presetsDir) || !storage.isDirectorySync(presetsDir)) return null; + + return presetsDir; +} diff --git a/test/presets.test.ts b/test/presets.test.ts new file mode 100644 index 000000000..9d6b705ed --- /dev/null +++ b/test/presets.test.ts @@ -0,0 +1,522 @@ +/** + * Tests for SQUAD_HOME resolution and preset system. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdirSync, rmSync, existsSync, writeFileSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { randomBytes } from 'node:crypto'; +import { resolveSquadHome, ensureSquadHome, resolvePresetsDir } from '@bradygaster/squad-sdk/resolution'; +import { listPresets, loadPreset, applyPreset, savePreset, seedBuiltinPresets } from '@bradygaster/squad-sdk/presets'; + +const TMP = join(process.cwd(), `.test-presets-${randomBytes(4).toString('hex')}`); + +function scaffold(...dirs: string[]): void { + for (const d of dirs) { + mkdirSync(join(TMP, d), { recursive: true }); + } +} + +function writeFile(relativePath: string, content: string): void { + const fullPath = join(TMP, relativePath); + mkdirSync(join(fullPath, '..'), { recursive: true }); + writeFileSync(fullPath, content); +} + +// ============================================================================ +// resolveSquadHome() +// ============================================================================ + +describe('resolveSquadHome()', () => { + const originalEnv = process.env['SQUAD_HOME']; + + beforeEach(() => { + if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true }); + mkdirSync(TMP, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true }); + if (originalEnv !== undefined) { + process.env['SQUAD_HOME'] = originalEnv; + } else { + delete process.env['SQUAD_HOME']; + } + }); + + it('returns null when SQUAD_HOME points to a nonexistent path', () => { + // Point SQUAD_HOME to a nonexistent path + process.env['SQUAD_HOME'] = join(TMP, 'nonexistent'); + expect(resolveSquadHome()).toBeNull(); + }); + + it('returns the directory when SQUAD_HOME points to an existing dir', () => { + const homeDir = join(TMP, 'my-squad-home'); + mkdirSync(homeDir, { recursive: true }); + process.env['SQUAD_HOME'] = homeDir; + + expect(resolveSquadHome()).toBe(homeDir); + }); + + it('creates directory when create=true and SQUAD_HOME is set', () => { + const homeDir = join(TMP, 'new-squad-home'); + process.env['SQUAD_HOME'] = homeDir; + + expect(existsSync(homeDir)).toBe(false); + const result = resolveSquadHome(true); + expect(result).toBe(homeDir); + expect(existsSync(homeDir)).toBe(true); + }); +}); + +// ============================================================================ +// ensureSquadHome() +// ============================================================================ + +describe('ensureSquadHome()', () => { + const originalEnv = process.env['SQUAD_HOME']; + + beforeEach(() => { + if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true }); + mkdirSync(TMP, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true }); + if (originalEnv !== undefined) { + process.env['SQUAD_HOME'] = originalEnv; + } else { + delete process.env['SQUAD_HOME']; + } + }); + + it('creates agents/ and presets/ subdirectories', () => { + const homeDir = join(TMP, 'ensure-home'); + process.env['SQUAD_HOME'] = homeDir; + + const result = ensureSquadHome(); + expect(result).toBe(homeDir); + expect(existsSync(join(homeDir, 'agents'))).toBe(true); + expect(existsSync(join(homeDir, 'presets'))).toBe(true); + }); + + it('is idempotent', () => { + const homeDir = join(TMP, 'idempotent-home'); + process.env['SQUAD_HOME'] = homeDir; + + ensureSquadHome(); + ensureSquadHome(); // should not throw + expect(existsSync(join(homeDir, 'agents'))).toBe(true); + }); +}); + +// ============================================================================ +// Preset loading +// ============================================================================ + +describe('listPresets()', () => { + const originalEnv = process.env['SQUAD_HOME']; + + beforeEach(() => { + if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true }); + mkdirSync(TMP, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true }); + if (originalEnv !== undefined) { + process.env['SQUAD_HOME'] = originalEnv; + } else { + delete process.env['SQUAD_HOME']; + } + }); + + it('returns empty array when no presets directory exists', () => { + process.env['SQUAD_HOME'] = join(TMP, 'no-presets'); + expect(listPresets()).toEqual([]); + }); + + it('returns empty array when presets directory is empty', () => { + const homeDir = join(TMP, 'empty-presets'); + mkdirSync(join(homeDir, 'presets'), { recursive: true }); + process.env['SQUAD_HOME'] = homeDir; + + expect(listPresets()).toEqual([]); + }); + + it('lists presets with valid manifest', () => { + const homeDir = join(TMP, 'has-presets'); + process.env['SQUAD_HOME'] = homeDir; + + scaffold('has-presets/presets/my-team/agents/dev'); + writeFile('has-presets/presets/my-team/preset.json', JSON.stringify({ + name: 'my-team', + version: '1.0.0', + description: 'Test preset', + agents: [{ name: 'dev', role: 'developer' }], + })); + + const presets = listPresets(); + expect(presets).toHaveLength(1); + expect(presets[0]!.name).toBe('my-team'); + expect(presets[0]!.agents).toHaveLength(1); + }); + + it('skips directories without valid preset.json', () => { + const homeDir = join(TMP, 'mixed-presets'); + process.env['SQUAD_HOME'] = homeDir; + + scaffold('mixed-presets/presets/valid/agents/dev'); + scaffold('mixed-presets/presets/invalid'); + writeFile('mixed-presets/presets/valid/preset.json', JSON.stringify({ + name: 'valid', + version: '1.0.0', + description: 'Valid preset', + agents: [{ name: 'dev', role: 'developer' }], + })); + writeFile('mixed-presets/presets/invalid/preset.json', '{ broken json'); + + const presets = listPresets(); + expect(presets).toHaveLength(1); + expect(presets[0]!.name).toBe('valid'); + }); +}); + +// ============================================================================ +// loadPreset() +// ============================================================================ + +describe('loadPreset()', () => { + const originalEnv = process.env['SQUAD_HOME']; + + beforeEach(() => { + if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true }); + mkdirSync(TMP, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true }); + if (originalEnv !== undefined) { + process.env['SQUAD_HOME'] = originalEnv; + } else { + delete process.env['SQUAD_HOME']; + } + }); + + it('returns null for nonexistent preset', () => { + const homeDir = join(TMP, 'load-preset'); + mkdirSync(join(homeDir, 'presets'), { recursive: true }); + process.env['SQUAD_HOME'] = homeDir; + + expect(loadPreset('nope')).toBeNull(); + }); + + it('loads a valid preset', () => { + const homeDir = join(TMP, 'load-valid'); + process.env['SQUAD_HOME'] = homeDir; + + scaffold('load-valid/presets/test-preset/agents/alpha'); + writeFile('load-valid/presets/test-preset/preset.json', JSON.stringify({ + name: 'test-preset', + version: '2.0.0', + description: 'A test', + agents: [ + { name: 'alpha', role: 'lead', description: 'The lead' }, + ], + })); + + const preset = loadPreset('test-preset'); + expect(preset).not.toBeNull(); + expect(preset!.version).toBe('2.0.0'); + expect(preset!.agents[0]!.name).toBe('alpha'); + }); +}); + +// ============================================================================ +// applyPreset() +// ============================================================================ + +describe('applyPreset()', () => { + const originalEnv = process.env['SQUAD_HOME']; + + beforeEach(() => { + if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true }); + mkdirSync(TMP, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true }); + if (originalEnv !== undefined) { + process.env['SQUAD_HOME'] = originalEnv; + } else { + delete process.env['SQUAD_HOME']; + } + }); + + it('installs preset agents into target directory', () => { + const homeDir = join(TMP, 'apply-home'); + process.env['SQUAD_HOME'] = homeDir; + + // Create preset + scaffold('apply-home/presets/starter/agents/dev'); + writeFile('apply-home/presets/starter/preset.json', JSON.stringify({ + name: 'starter', + version: '1.0.0', + description: 'Starter preset', + agents: [{ name: 'dev', role: 'developer' }], + })); + writeFile('apply-home/presets/starter/agents/dev/charter.md', '# Dev Agent'); + + // Create target + const targetDir = join(TMP, 'target-squad', 'agents'); + mkdirSync(targetDir, { recursive: true }); + + const results = applyPreset('starter', targetDir); + expect(results).toHaveLength(1); + expect(results[0]!.status).toBe('installed'); + expect(existsSync(join(targetDir, 'dev', 'charter.md'))).toBe(true); + expect(readFileSync(join(targetDir, 'dev', 'charter.md'), 'utf-8')).toBe('# Dev Agent'); + }); + + it('skips existing agents by default', () => { + const homeDir = join(TMP, 'apply-skip'); + process.env['SQUAD_HOME'] = homeDir; + + scaffold('apply-skip/presets/starter/agents/dev'); + writeFile('apply-skip/presets/starter/preset.json', JSON.stringify({ + name: 'starter', + version: '1.0.0', + description: 'Starter preset', + agents: [{ name: 'dev', role: 'developer' }], + })); + writeFile('apply-skip/presets/starter/agents/dev/charter.md', '# New Charter'); + + // Pre-existing agent + const targetDir = join(TMP, 'target-skip', 'agents'); + mkdirSync(join(targetDir, 'dev'), { recursive: true }); + writeFileSync(join(targetDir, 'dev', 'charter.md'), '# Old Charter'); + + const results = applyPreset('starter', targetDir); + expect(results[0]!.status).toBe('skipped'); + expect(readFileSync(join(targetDir, 'dev', 'charter.md'), 'utf-8')).toBe('# Old Charter'); + }); + + it('overwrites existing agents with --force', () => { + const homeDir = join(TMP, 'apply-force'); + process.env['SQUAD_HOME'] = homeDir; + + scaffold('apply-force/presets/starter/agents/dev'); + writeFile('apply-force/presets/starter/preset.json', JSON.stringify({ + name: 'starter', + version: '1.0.0', + description: 'Starter preset', + agents: [{ name: 'dev', role: 'developer' }], + })); + writeFile('apply-force/presets/starter/agents/dev/charter.md', '# New Charter'); + + const targetDir = join(TMP, 'target-force', 'agents'); + mkdirSync(join(targetDir, 'dev'), { recursive: true }); + writeFileSync(join(targetDir, 'dev', 'charter.md'), '# Old Charter'); + + const results = applyPreset('starter', targetDir, { force: true }); + expect(results[0]!.status).toBe('installed'); + expect(readFileSync(join(targetDir, 'dev', 'charter.md'), 'utf-8')).toBe('# New Charter'); + }); + + it('returns error for nonexistent preset', () => { + const homeDir = join(TMP, 'apply-missing'); + mkdirSync(join(homeDir, 'presets'), { recursive: true }); + process.env['SQUAD_HOME'] = homeDir; + + const results = applyPreset('nope', '/tmp/anywhere'); + expect(results[0]!.status).toBe('error'); + }); +}); + +// ============================================================================ +// seedBuiltinPresets() +// ============================================================================ + +describe('seedBuiltinPresets()', () => { + const originalEnv = process.env['SQUAD_HOME']; + + beforeEach(() => { + if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true }); + mkdirSync(TMP, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true }); + if (originalEnv !== undefined) { + process.env['SQUAD_HOME'] = originalEnv; + } else { + delete process.env['SQUAD_HOME']; + } + }); + + it('seeds the default preset into squad home', () => { + const homeDir = join(TMP, 'seed-home'); + process.env['SQUAD_HOME'] = homeDir; + + const seeded = seedBuiltinPresets(); + expect(seeded).toContain('default'); + + // Verify preset was copied + expect(existsSync(join(homeDir, 'presets', 'default', 'preset.json'))).toBe(true); + expect(existsSync(join(homeDir, 'presets', 'default', 'agents', 'lead', 'charter.md'))).toBe(true); + }); + + it('does not overwrite existing presets', () => { + const homeDir = join(TMP, 'seed-existing'); + process.env['SQUAD_HOME'] = homeDir; + + // Create a custom default preset + mkdirSync(join(homeDir, 'presets', 'default'), { recursive: true }); + writeFileSync(join(homeDir, 'presets', 'default', 'custom-marker.txt'), 'mine'); + + const seeded = seedBuiltinPresets(); + expect(seeded).not.toContain('default'); + expect(existsSync(join(homeDir, 'presets', 'default', 'custom-marker.txt'))).toBe(true); + }); +}); + +// ============================================================================ +// resolvePresetsDir() +// ============================================================================ + +describe('resolvePresetsDir()', () => { + const originalEnv = process.env['SQUAD_HOME']; + + beforeEach(() => { + if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true }); + mkdirSync(TMP, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true }); + if (originalEnv !== undefined) { + process.env['SQUAD_HOME'] = originalEnv; + } else { + delete process.env['SQUAD_HOME']; + } + }); + + it('returns null when squad home does not exist', () => { + process.env['SQUAD_HOME'] = join(TMP, 'nonexistent'); + expect(resolvePresetsDir()).toBeNull(); + }); + + it('returns null when presets/ does not exist', () => { + const homeDir = join(TMP, 'no-presets-dir'); + mkdirSync(homeDir, { recursive: true }); + process.env['SQUAD_HOME'] = homeDir; + + expect(resolvePresetsDir()).toBeNull(); + }); + + it('returns presets/ path when it exists', () => { + const homeDir = join(TMP, 'with-presets'); + mkdirSync(join(homeDir, 'presets'), { recursive: true }); + process.env['SQUAD_HOME'] = homeDir; + + expect(resolvePresetsDir()).toBe(join(homeDir, 'presets')); + }); +}); + +// ============================================================================ +// savePreset() +// ============================================================================ + +describe('savePreset()', () => { + const originalEnv = process.env['SQUAD_HOME']; + + beforeEach(() => { + if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true }); + mkdirSync(TMP, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true }); + if (originalEnv !== undefined) { + process.env['SQUAD_HOME'] = originalEnv; + } else { + delete process.env['SQUAD_HOME']; + } + }); + + it('saves current project agents as a named preset', () => { + const homeDir = join(TMP, 'save-home'); + mkdirSync(homeDir, { recursive: true }); + process.env['SQUAD_HOME'] = homeDir; + + // Create a fake project squad with agents + const squadDir = join(TMP, 'project', '.squad'); + const agentsDir = join(squadDir, 'agents'); + mkdirSync(join(agentsDir, 'helper'), { recursive: true }); + writeFileSync(join(agentsDir, 'helper', 'charter.md'), '## Helper — Utility Agent\nHelps with misc tasks.'); + + const destDir = savePreset('my-team', squadDir); + + expect(existsSync(destDir)).toBe(true); + expect(existsSync(join(destDir, 'preset.json'))).toBe(true); + expect(existsSync(join(destDir, 'agents', 'helper', 'charter.md'))).toBe(true); + + // Verify manifest + const manifest = JSON.parse(readFileSync(join(destDir, 'preset.json'), 'utf-8')); + expect(manifest.name).toBe('my-team'); + expect(manifest.agents).toHaveLength(1); + expect(manifest.agents[0].name).toBe('helper'); + }); + + it('throws if preset already exists without --force', () => { + const homeDir = join(TMP, 'save-dup'); + mkdirSync(join(homeDir, 'presets', 'existing'), { recursive: true }); + writeFileSync(join(homeDir, 'presets', 'existing', 'preset.json'), '{}'); + process.env['SQUAD_HOME'] = homeDir; + + const squadDir = join(TMP, 'proj', '.squad'); + mkdirSync(join(squadDir, 'agents', 'a1'), { recursive: true }); + writeFileSync(join(squadDir, 'agents', 'a1', 'charter.md'), '# Agent'); + + expect(() => savePreset('existing', squadDir)).toThrow(/already exists/); + }); + + it('overwrites existing preset with force', () => { + const homeDir = join(TMP, 'save-force'); + mkdirSync(join(homeDir, 'presets', 'team'), { recursive: true }); + writeFileSync(join(homeDir, 'presets', 'team', 'preset.json'), '{}'); + process.env['SQUAD_HOME'] = homeDir; + + const squadDir = join(TMP, 'proj2', '.squad'); + mkdirSync(join(squadDir, 'agents', 'bot'), { recursive: true }); + writeFileSync(join(squadDir, 'agents', 'bot', 'charter.md'), '# Bot'); + + const destDir = savePreset('team', squadDir, { force: true }); + expect(existsSync(join(destDir, 'agents', 'bot', 'charter.md'))).toBe(true); + }); + + it('round-trips: save then apply to a new project', () => { + const homeDir = join(TMP, 'roundtrip'); + mkdirSync(homeDir, { recursive: true }); + process.env['SQUAD_HOME'] = homeDir; + + // Create source project with 2 agents + const srcSquad = join(TMP, 'src-proj', '.squad'); + mkdirSync(join(srcSquad, 'agents', 'alpha'), { recursive: true }); + mkdirSync(join(srcSquad, 'agents', 'beta'), { recursive: true }); + writeFileSync(join(srcSquad, 'agents', 'alpha', 'charter.md'), '## Alpha — Lead\nLeads the team.'); + writeFileSync(join(srcSquad, 'agents', 'beta', 'charter.md'), '## Beta — Reviewer\nReviews code.'); + + // Save as preset + savePreset('my-squad', srcSquad, { description: 'My custom squad' }); + + // Apply to new project + const destAgents = join(TMP, 'dest-proj', '.squad', 'agents'); + mkdirSync(destAgents, { recursive: true }); + + const results = applyPreset('my-squad', destAgents); + expect(results.filter(r => r.status === 'installed')).toHaveLength(2); + expect(readFileSync(join(destAgents, 'alpha', 'charter.md'), 'utf-8')).toContain('Alpha'); + expect(readFileSync(join(destAgents, 'beta', 'charter.md'), 'utf-8')).toContain('Beta'); + }); +});