From 953554a465378a182b0e623b914b36430ed7e67a Mon Sep 17 00:00:00 2001 From: Paul Yuknewicz Date: Sat, 25 Apr 2026 12:45:52 -0700 Subject: [PATCH 1/9] feat: add SQUAD_HOME env var and preset system (#1038) Add SQUAD_HOME environment variable support for a roaming squad root directory, and a preset system for reusable agent configurations. SQUAD_HOME: - New resolveSquadHome() resolves SQUAD_HOME env var or falls back to ~/.squad/ - ensureSquadHome() creates the directory if it doesn't exist - resolvePresetsDir() returns the presets subdirectory within SQUAD_HOME Preset System: - Presets are named collections of agent charters in a standard layout - Built-in 'default' preset ships with 5 agents: lead, reviewer, devrel, security, docs - Users can install presets to SQUAD_HOME and apply them to any project - Collision policy: skip existing agents by default, --force to overwrite CLI Commands: - squad preset list - list available presets - squad preset show - show preset details - squad preset apply - apply preset agents to current project - squad preset init - seed built-in presets to SQUAD_HOME Resolves #1038 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/squad-home-presets.md | 12 + packages/squad-cli/src/cli-entry.ts | 10 + packages/squad-cli/src/cli/commands/preset.ts | 191 ++++++++ packages/squad-sdk/package.json | 6 +- packages/squad-sdk/src/index.ts | 2 +- .../builtin/default/agents/devrel/charter.md | 30 ++ .../builtin/default/agents/docs/charter.md | 30 ++ .../builtin/default/agents/lead/charter.md | 29 ++ .../default/agents/reviewer/charter.md | 29 ++ .../default/agents/security/charter.md | 30 ++ .../src/presets/builtin/default/preset.json | 34 ++ packages/squad-sdk/src/presets/index.ts | 216 +++++++++ packages/squad-sdk/src/presets/types.ts | 48 ++ packages/squad-sdk/src/resolution.ts | 77 ++++ test/presets.test.ts | 424 ++++++++++++++++++ 15 files changed, 1166 insertions(+), 2 deletions(-) create mode 100644 .changeset/squad-home-presets.md create mode 100644 packages/squad-cli/src/cli/commands/preset.ts create mode 100644 packages/squad-sdk/src/presets/builtin/default/agents/devrel/charter.md create mode 100644 packages/squad-sdk/src/presets/builtin/default/agents/docs/charter.md create mode 100644 packages/squad-sdk/src/presets/builtin/default/agents/lead/charter.md create mode 100644 packages/squad-sdk/src/presets/builtin/default/agents/reviewer/charter.md create mode 100644 packages/squad-sdk/src/presets/builtin/default/agents/security/charter.md create mode 100644 packages/squad-sdk/src/presets/builtin/default/preset.json create mode 100644 packages/squad-sdk/src/presets/index.ts create mode 100644 packages/squad-sdk/src/presets/types.ts create mode 100644 test/presets.test.ts 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..a95b5e1f0 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -238,6 +238,9 @@ 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 | apply `); + console.log(` [--force] | init`); 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 ]`); @@ -895,6 +898,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..b9395fa7f --- /dev/null +++ b/packages/squad-cli/src/cli/commands/preset.ts @@ -0,0 +1,191 @@ +/** + * squad preset — manage squad presets (curated agent collections) + * + * Subcommands: + * squad preset list — list available presets + * squad preset show — show preset details + * squad preset apply — install preset agents into current squad + * squad preset init — initialize presets directory in squad home + * + * @module cli/commands/preset + */ + +import path from 'node:path'; +import { resolveSquadHome, ensureSquadHome, resolvePresetsDir } from '@bradygaster/squad-sdk/resolution'; +import { listPresets, loadPreset, applyPreset, 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': + await presetInit(); + break; + default: + fatal( + `Unknown preset subcommand: ${subcommand}\n` + + ` Available: list | show | apply [--force] | init`, + ); + } +} + +// ============================================================================ +// Subcommand: init +// ============================================================================ + +async function presetInit(): Promise { + 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.`); +} + +// ============================================================================ +// Subcommand: list +// ============================================================================ + +async function presetList(): Promise { + const presetsDir = resolvePresetsDir(); + + if (!presetsDir) { + info('No presets directory found.'); + info(' Run `squad preset init` to set up presets in squad home.'); + info(' Or set SQUAD_HOME to point to your squad home directory.'); + 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`); +} diff --git a/packages/squad-sdk/package.json b/packages/squad-sdk/package.json index 3e9e3e184..828add1b9 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{cpSync}=require('fs');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..f9f5c2cc8 --- /dev/null +++ b/packages/squad-sdk/src/presets/index.ts @@ -0,0 +1,216 @@ +/** + * 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 } 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 statSync(p).isDirectory(); } catch { return false; } +} + + +/** + * 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[] { + const presetsDir = resolvePresetsDir(); + if (!presetsDir) { + return [{ agent: presetName, status: 'error', reason: 'No presets directory found. Run `squad preset init` first.' }]; + } + + 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) { + 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 { + 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; +} + +// ============================================================================ +// 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); + + if (isDirSync(srcPath)) { + 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..40286fa93 100644 --- a/packages/squad-sdk/src/resolution.ts +++ b/packages/squad-sdk/src/resolution.ts @@ -599,3 +599,80 @@ 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)) { + 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)) return null; + + return presetsDir; +} diff --git a/test/presets.test.ts b/test/presets.test.ts new file mode 100644 index 000000000..909736680 --- /dev/null +++ b/test/presets.test.ts @@ -0,0 +1,424 @@ +/** + * 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, 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 is not set and ~/.squad does not exist', () => { + // 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')); + }); +}); From ba714d472bc4f160e8f6a68018ab998c2ad99ba9 Mon Sep 17 00:00:00 2001 From: Paul Yuknewicz Date: Sat, 25 Apr 2026 13:06:58 -0700 Subject: [PATCH 2/9] feat: add squad preset save and round-trip quick start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 'squad preset save ' command that captures your current project's agents as a reusable preset in SQUAD_HOME. This completes the quick-start loop: 1. squad preset apply default # start with a preset 2. (customize agents) # make it yours 3. squad preset save my-team # save as your preset 4. cd other-repo && squad preset apply my-team # reuse everywhere Also adds 4 new tests including full round-trip test (save → apply). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/squad-cli/src/cli-entry.ts | 2 +- packages/squad-cli/src/cli/commands/preset.ts | 34 +++++- packages/squad-sdk/src/presets/index.ts | 81 ++++++++++++++ test/presets.test.ts | 100 +++++++++++++++++- 4 files changed, 213 insertions(+), 4 deletions(-) diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index a95b5e1f0..613f4dde4 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -239,7 +239,7 @@ async function main(): Promise { 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 | apply `); + console.log(` Usage: preset list | show | apply | save | init`); console.log(` [--force] | init`); console.log(` ${BOLD}cast${RESET} Show current session cast (project + personal agents)`); console.log(` ${BOLD}rc${RESET} Start Remote Control bridge (phone/browser → Copilot)`); diff --git a/packages/squad-cli/src/cli/commands/preset.ts b/packages/squad-cli/src/cli/commands/preset.ts index b9395fa7f..1c5133455 100644 --- a/packages/squad-cli/src/cli/commands/preset.ts +++ b/packages/squad-cli/src/cli/commands/preset.ts @@ -12,7 +12,7 @@ import path from 'node:path'; import { resolveSquadHome, ensureSquadHome, resolvePresetsDir } from '@bradygaster/squad-sdk/resolution'; -import { listPresets, loadPreset, applyPreset, seedBuiltinPresets } from '@bradygaster/squad-sdk/presets'; +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'; @@ -45,10 +45,21 @@ export async function runPreset(cwd: string, subcommand: string, args: string[]) case 'init': await presetInit(); 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` + - ` Available: list | show | apply [--force] | init`, + ` Available: list | show | apply [--force] | save | init`, ); } } @@ -189,3 +200,22 @@ async function presetApply(cwd: string, name: string, force: boolean): Promise 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 to ${destDir}`); + info(` Use it in any project: squad preset apply ${name}`); + } catch (err) { + fatal(String(err)); + } +} diff --git a/packages/squad-sdk/src/presets/index.ts b/packages/squad-sdk/src/presets/index.ts index f9f5c2cc8..e361dab55 100644 --- a/packages/squad-sdk/src/presets/index.ts +++ b/packages/squad-sdk/src/presets/index.ts @@ -137,6 +137,87 @@ export function installPreset(sourceDir: string, name: string): string { 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 { + 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 // ============================================================================ diff --git a/test/presets.test.ts b/test/presets.test.ts index 909736680..4f940dbb4 100644 --- a/test/presets.test.ts +++ b/test/presets.test.ts @@ -7,7 +7,7 @@ import { mkdirSync, rmSync, existsSync, writeFileSync, readFileSync } from 'node import { join } from 'node:path'; import { randomBytes } from 'node:crypto'; import { resolveSquadHome, ensureSquadHome, resolvePresetsDir } from '@bradygaster/squad-sdk/resolution'; -import { listPresets, loadPreset, applyPreset, seedBuiltinPresets } from '@bradygaster/squad-sdk/presets'; +import { listPresets, loadPreset, applyPreset, savePreset, seedBuiltinPresets } from '@bradygaster/squad-sdk/presets'; const TMP = join(process.cwd(), `.test-presets-${randomBytes(4).toString('hex')}`); @@ -422,3 +422,101 @@ describe('resolvePresetsDir()', () => { 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'); + }); +}); From d8ff31afc1b7361d0f7a17083365e1999091feaa Mon Sep 17 00:00:00 2001 From: Paul Yuknewicz Date: Sat, 25 Apr 2026 13:10:32 -0700 Subject: [PATCH 3/9] feat: add --preset flag to squad init squad init --preset default # init + apply preset in one command squad init --preset my-team # use your custom preset Automatically seeds built-in presets if needed. The quick start is now: 1. squad init --preset default 2. (customize agents) 3. squad preset save my-team 4. squad init --preset my-team # any new repo, one command Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/squad-cli/src/cli-entry.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index 613f4dde4..7401dfe24 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`); @@ -312,8 +313,28 @@ 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 path = await import('node:path'); + // Ensure built-in presets are available + seedBuiltinPresets(); + const targetAgentsDir = path.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 (errors.length > 0 && installed.length === 0) { + console.error(`❌ Failed to apply preset '${presetName}'. Run 'squad preset list' to see available presets.`); + } + } + }).catch(err => { fatal(err.message); }); return; From 97bb0d0e20790db2ecfd6f68ac5c2a1e19120291 Mon Sep 17 00:00:00 2001 From: Paul Yuknewicz Date: Sat, 25 Apr 2026 13:29:45 -0700 Subject: [PATCH 4/9] preset save: clarify save location and tip about export for full snapshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Show preset save path clearly in output - Add tip about 'squad export' for full squad snapshots (casting, skills, routing) — useful for sharing configured squads or publishing to agent toolboxes - Update module docs with save path and export distinction Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/squad-cli/src/cli/commands/preset.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/squad-cli/src/cli/commands/preset.ts b/packages/squad-cli/src/cli/commands/preset.ts index 1c5133455..db79964ad 100644 --- a/packages/squad-cli/src/cli/commands/preset.ts +++ b/packages/squad-cli/src/cli/commands/preset.ts @@ -1,12 +1,20 @@ /** * 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 */ @@ -213,8 +221,13 @@ async function presetSave(cwd: string, name: string, force: boolean, description try { const destDir = savePreset(name, squadDir, { force, description }); - success(`Preset '${name}' saved to ${destDir}`); + 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)); } From 1ac73b50d75570c188dd74a4280de6cd01bd7d4a Mon Sep 17 00:00:00 2001 From: Paul Yuknewicz Date: Sat, 25 Apr 2026 13:42:16 -0700 Subject: [PATCH 5/9] Add 'squad preset init --remote' for one-command roaming setup - 'squad preset init --remote' creates a private GitHub repo (squad-home) via gh CLI and sets up ~/.squad/ as a clone. On a second machine, the same command detects the existing repo and clones it. - All 'preset not found' errors now nudge users toward 'squad preset init --remote' - Plain 'squad preset init' tips about --remote for roaming - Handles all cases: fresh setup, existing ~/.squad/, second machine clone Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/squad-cli/src/cli-entry.ts | 6 +- packages/squad-cli/src/cli/commands/preset.ts | 162 +++++++++++++++++- packages/squad-sdk/src/presets/index.ts | 2 +- 3 files changed, 161 insertions(+), 9 deletions(-) diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index 7401dfe24..02c8e7d76 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -240,7 +240,7 @@ async function main(): Promise { 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 | apply | save | init`); + console.log(` Usage: preset list | show | apply | save | init [--remote]`); console.log(` [--force] | init`); console.log(` ${BOLD}cast${RESET} Show current session cast (project + personal agents)`); console.log(` ${BOLD}rc${RESET} Start Remote Control bridge (phone/browser → Copilot)`); @@ -331,7 +331,9 @@ async function main(): Promise { console.log(`✅ Applied preset '${presetName}': ${installed.length} agents installed`); } if (errors.length > 0 && installed.length === 0) { - console.error(`❌ Failed to apply preset '${presetName}'. Run 'squad preset list' to see available presets.`); + console.error(`❌ Preset '${presetName}' not found.`); + console.error(` Set up your squad home first: squad preset init --remote`); + console.error(` Then try again: squad init --preset ${presetName}`); } } }).catch(err => { diff --git a/packages/squad-cli/src/cli/commands/preset.ts b/packages/squad-cli/src/cli/commands/preset.ts index db79964ad..3b5ee9d96 100644 --- a/packages/squad-cli/src/cli/commands/preset.ts +++ b/packages/squad-cli/src/cli/commands/preset.ts @@ -19,6 +19,8 @@ */ 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'; @@ -50,9 +52,11 @@ export async function runPreset(cwd: string, subcommand: string, args: string[]) await presetApply(cwd, name!, force); break; } - case 'init': - await presetInit(); + case 'init': { + const remote = args.includes('--remote'); + await presetInit(remote); break; + } case 'save': { const name = args[0]; if (!name) { @@ -67,7 +71,7 @@ export async function runPreset(cwd: string, subcommand: string, args: string[]) default: fatal( `Unknown preset subcommand: ${subcommand}\n` + - ` Available: list | show | apply [--force] | save | init`, + ` Available: list | show | apply [--force] | save | init [--remote]`, ); } } @@ -76,7 +80,12 @@ export async function runPreset(cwd: string, subcommand: string, args: string[]) // Subcommand: init // ============================================================================ -async function presetInit(): Promise { +async function presetInit(remote: boolean): Promise { + if (remote) { + await presetInitRemote(); + return; + } + const homeDir = ensureSquadHome(); const presetsDir = path.join(homeDir, 'presets'); @@ -88,6 +97,147 @@ async function presetInit(): Promise { 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}`); } // ============================================================================ @@ -99,8 +249,8 @@ async function presetList(): Promise { if (!presetsDir) { info('No presets directory found.'); - info(' Run `squad preset init` to set up presets in squad home.'); - info(' Or set SQUAD_HOME to point to your squad home directory.'); + info(' Run `squad preset init --remote` to set up with a GitHub repo (recommended).'); + info(' Or `squad preset init` for local-only setup.'); return; } diff --git a/packages/squad-sdk/src/presets/index.ts b/packages/squad-sdk/src/presets/index.ts index e361dab55..6aecfa3f8 100644 --- a/packages/squad-sdk/src/presets/index.ts +++ b/packages/squad-sdk/src/presets/index.ts @@ -84,7 +84,7 @@ export function applyPreset( ): PresetApplyResult[] { const presetsDir = resolvePresetsDir(); if (!presetsDir) { - return [{ agent: presetName, status: 'error', reason: 'No presets directory found. Run `squad preset init` first.' }]; + 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); From 000092de392ea154919f537e8ab9e97fbe7876fa Mon Sep 17 00:00:00 2001 From: Paul Yuknewicz Date: Sat, 25 Apr 2026 13:45:45 -0700 Subject: [PATCH 6/9] squad init --preset auto-creates squad home if missing When no presets directory exists, 'squad init --preset default' now automatically initializes squad home and seeds built-in presets instead of failing. Tips user about '--remote' for roaming. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/squad-cli/src/cli-entry.ts | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index 02c8e7d76..07f7ec24b 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -319,10 +319,21 @@ async function main(): Promise { runInit(dest, { includeWorkflows: !noWorkflows && !hasGlobal, sdk, roles, isGlobal: hasGlobal }).then(async () => { if (presetName) { const { seedBuiltinPresets, applyPreset } = await import('@bradygaster/squad-sdk/presets'); - const path = await import('node:path'); - // Ensure built-in presets are available - seedBuiltinPresets(); - const targetAgentsDir = path.join(dest, '.squad', 'agents'); + 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'); @@ -330,10 +341,11 @@ async function main(): Promise { 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.`); - console.error(` Set up your squad home first: squad preset init --remote`); - console.error(` Then try again: squad init --preset ${presetName}`); + console.error(`❌ Preset '${presetName}' not found. Run 'squad preset list' to see available presets.`); } } }).catch(err => { From 099d55eb165f0de4e85ff302b6508019d348ccd1 Mon Sep 17 00:00:00 2001 From: Paul Yuknewicz Date: Sat, 25 Apr 2026 13:48:09 -0700 Subject: [PATCH 7/9] fix: validate SQUAD_HOME is a directory, sanitize preset/agent names Address Copilot review comments flagged by Tamir: - resolveSquadHome() now throws if path exists but is not a directory - resolvePresetsDir() checks isDirectorySync before returning - Added validateName() to reject path traversal in preset/agent names - Build step now cleans dist/presets/builtin before copying (prevents stale files) - Fixed misleading test name Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/squad-sdk/package.json | 2 +- packages/squad-sdk/src/presets/index.ts | 18 ++++++++++++++++++ packages/squad-sdk/src/resolution.ts | 5 ++++- test/presets.test.ts | 2 +- 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/squad-sdk/package.json b/packages/squad-sdk/package.json index 828add1b9..cb7757b11 100644 --- a/packages/squad-sdk/package.json +++ b/packages/squad-sdk/package.json @@ -226,7 +226,7 @@ ], "scripts": { "prepublishOnly": "npm run build", - "build": "tsc -p tsconfig.json && node -e \"const{cpSync}=require('fs');cpSync('src/presets/builtin','dist/presets/builtin',{recursive:true,filter:(s)=>!s.endsWith('.ts')})\"" + "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/presets/index.ts b/packages/squad-sdk/src/presets/index.ts index 6aecfa3f8..9afd6bbaf 100644 --- a/packages/squad-sdk/src/presets/index.ts +++ b/packages/squad-sdk/src/presets/index.ts @@ -24,6 +24,13 @@ function isDirSync(p: string): boolean { try { return statSync(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. @@ -82,6 +89,10 @@ export function applyPreset( 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.' }]; @@ -97,6 +108,11 @@ export function applyPreset( 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); @@ -154,6 +170,8 @@ export function savePreset( 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}`); diff --git a/packages/squad-sdk/src/resolution.ts b/packages/squad-sdk/src/resolution.ts index 40286fa93..a35245f82 100644 --- a/packages/squad-sdk/src/resolution.ts +++ b/packages/squad-sdk/src/resolution.ts @@ -626,6 +626,9 @@ export function resolveSquadHome(create: boolean = false): string | null { : 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; } @@ -672,7 +675,7 @@ export function resolvePresetsDir(): string | null { if (!homeDir) return null; const presetsDir = path.join(homeDir, 'presets'); - if (!storage.existsSync(presetsDir)) return null; + if (!storage.existsSync(presetsDir) || !storage.isDirectorySync(presetsDir)) return null; return presetsDir; } diff --git a/test/presets.test.ts b/test/presets.test.ts index 4f940dbb4..9d6b705ed 100644 --- a/test/presets.test.ts +++ b/test/presets.test.ts @@ -44,7 +44,7 @@ describe('resolveSquadHome()', () => { } }); - it('returns null when SQUAD_HOME is not set and ~/.squad does not exist', () => { + 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(); From 0157319aeb7752271dd80ccc9405d5d671dd8b06 Mon Sep 17 00:00:00 2001 From: Paul Yuknewicz Date: Sat, 25 Apr 2026 13:58:42 -0700 Subject: [PATCH 8/9] fix: address remaining Copilot review comments - Use lstatSync in copyDirRecursive to skip symlinks (prevents following symlinks into unintended locations) - Clean dest dir before copy on --force (prevents stale files from renamed/deleted agents lingering) - Remove duplicate glyphs in CLI output (success()/warn() already add markers) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/squad-cli/src/cli/commands/preset.ts | 4 ++-- packages/squad-sdk/src/presets/index.ts | 14 +++++++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/squad-cli/src/cli/commands/preset.ts b/packages/squad-cli/src/cli/commands/preset.ts index 3b5ee9d96..b8af0036e 100644 --- a/packages/squad-cli/src/cli/commands/preset.ts +++ b/packages/squad-cli/src/cli/commands/preset.ts @@ -339,11 +339,11 @@ async function presetApply(cwd: string, name: string, force: boolean): Promise Date: Sat, 25 Apr 2026 14:20:45 -0700 Subject: [PATCH 9/9] fix: wrap preset usage text to stay under 80-char UX gate Split long pipe-separated usage strings into multi-line format in both preset.ts (error message) and cli-entry.ts (help output) so the ux-gates.test.ts line-length check passes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/squad-cli/src/cli-entry.ts | 5 +++-- packages/squad-cli/src/cli/commands/preset.ts | 7 ++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index 07f7ec24b..af2f6ce31 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -240,8 +240,9 @@ async function main(): Promise { 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 | apply | save | init [--remote]`); - console.log(` [--force] | init`); + 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 ]`); diff --git a/packages/squad-cli/src/cli/commands/preset.ts b/packages/squad-cli/src/cli/commands/preset.ts index b8af0036e..a498ad2f6 100644 --- a/packages/squad-cli/src/cli/commands/preset.ts +++ b/packages/squad-cli/src/cli/commands/preset.ts @@ -71,7 +71,12 @@ export async function runPreset(cwd: string, subcommand: string, args: string[]) default: fatal( `Unknown preset subcommand: ${subcommand}\n` + - ` Available: list | show | apply [--force] | save | init [--remote]`, + `Usage:\n` + + ` squad preset list\n` + + ` squad preset show \n` + + ` squad preset apply [--force]\n` + + ` squad preset save \n` + + ` squad preset init [--remote]`, ); } }