diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index a123b745a..bb1403867 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -108,6 +108,15 @@ const VERSION = getPackageVersion(); async function main(): Promise { const args = process.argv.slice(2); + + // --team-root flag: override team root for resolution + const teamRootIdx = args.indexOf('--team-root'); + if (teamRootIdx !== -1 && args[teamRootIdx + 1]) { + process.env['SQUAD_TEAM_ROOT'] = args[teamRootIdx + 1]!; + // Remove --team-root and its value from args + args.splice(teamRootIdx, 2); + } + const hasGlobal = args.includes('--global'); // --economy activates economy mode for this session (sets env var for spawner) const hasEconomy = args.includes('--economy'); @@ -193,6 +202,9 @@ async function main(): Promise { console.log(` Flags: --docker (force Docker), --port (dashboard port)`); console.log(` ${BOLD}schedule${RESET} Manage scheduled tasks`); console.log(` Usage: schedule list | run | init | status`); + console.log(` ${BOLD}personal${RESET} Manage your personal squad (ambient agents)`); + console.log(` Usage: personal init | list | add --role | remove `); + 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 ]`); console.log(` ${BOLD}copilot-bridge${RESET} Check Copilot ACP stdio compatibility`); @@ -216,6 +228,7 @@ async function main(): Promise { console.log(` ${BOLD}--help, -h${RESET} Show help`); console.log(` ${BOLD}--global${RESET} Use personal (global) squad path (for init, upgrade)`); console.log(` ${BOLD}--economy${RESET} Activate economy mode for this session (cheaper models)`); + console.log(` ${BOLD}--team-root${RESET} Override team root path for resolution`); console.log(`\nInstallation:`); console.log(` npm install --save-dev @bradygaster/squad-cli`); console.log(`\nInsider channel:`); @@ -571,6 +584,19 @@ async function main(): Promise { return; } + if (cmd === 'personal') { + const { runPersonal } = await import('./cli/commands/personal.js'); + const subcommand = args[1] || 'list'; + await runPersonal(process.cwd(), subcommand, args.slice(2)); + return; + } + + if (cmd === 'cast') { + const { runCast } = await import('./cli/commands/cast.js'); + await runCast(process.cwd()); + return; + } + if (cmd === 'upstream') { const { upstreamCommand } = await import('./cli/commands/upstream.js'); await upstreamCommand(args.slice(1)); diff --git a/packages/squad-cli/src/cli/commands/cast.ts b/packages/squad-cli/src/cli/commands/cast.ts new file mode 100644 index 000000000..34cd4c134 --- /dev/null +++ b/packages/squad-cli/src/cli/commands/cast.ts @@ -0,0 +1,101 @@ +/** + * squad cast — show current session cast (project + personal agents merged) + * + * Displays the merged session cast with project agents and personal agents, + * highlighting which agents come from the personal squad. + * + * @module cli/commands/cast + */ + +import { LocalAgentSource } from '@bradygaster/squad-sdk/config/agent-source'; +import { resolvePersonalAgents, mergeSessionCast } from '@bradygaster/squad-sdk/agents/personal'; +import { resolveSquadPaths } from '@bradygaster/squad-sdk/resolution'; +import { BOLD, RESET, DIM, GREEN, YELLOW } from '../core/output.js'; +import { fatal } from '../core/errors.js'; + +/** + * Run the cast command — show merged session cast. + */ +export async function runCast(cwd: string): Promise { + // Resolve squad paths + const paths = resolveSquadPaths(cwd); + if (!paths) { + fatal('No squad found. Run "squad init" first.'); + } + + // Discover project agents + const projectSource = new LocalAgentSource(paths.teamDir); + const projectAgents = await projectSource.listAgents(); + + // Discover personal agents + const personalAgents = await resolvePersonalAgents(); + + // Merge session cast + const cast = mergeSessionCast(projectAgents, personalAgents); + + if (cast.length === 0) { + console.log('\nNo agents found in current session cast.\n'); + return; + } + + // Count project vs personal + const projectCount = projectAgents.length; + const personalCount = personalAgents.filter( + pa => !projectAgents.some(pra => pra.name.toLowerCase() === pa.name.toLowerCase()) + ).length; + + console.log(`\n${BOLD}Session Cast${RESET} (${cast.length} agents):\n`); + + if (projectCount > 0) { + console.log(` Project agents: ${projectCount}`); + } + if (personalCount > 0) { + console.log(` Personal agents: ${personalCount} ${YELLOW}(ambient)${RESET}`); + } + console.log(); + + // Calculate column widths + const maxNameLen = Math.max(...cast.map(a => a.name.length), 4); + const maxRoleLen = Math.max(...cast.map(a => a.role.length), 4); + + // Header + console.log( + ` ${'Name'.padEnd(maxNameLen)} ` + + `${'Role'.padEnd(maxRoleLen)} ` + + `Origin Ghost Protocol` + ); + console.log( + ` ${'─'.repeat(maxNameLen)} ` + + `${'─'.repeat(maxRoleLen)} ` + + `${'─'.repeat(13)} ${'─'.repeat(14)}` + ); + + // Rows + for (const agent of cast) { + const isPersonal = 'personal' in agent; + const nameDisplay = isPersonal + ? `👤 ${agent.name}`.padEnd(maxNameLen + 3) // +3 for emoji width + : agent.name.padEnd(maxNameLen); + + const origin = isPersonal ? `${YELLOW}personal${RESET}` : 'project'; + const originPadded = isPersonal ? origin + ' '.repeat(6) : origin.padEnd(13); + + const ghostProtocol = isPersonal && agent.personal.ghostProtocol + ? `${GREEN}✓ enforced${RESET}` + : '–'; + + console.log( + ` ${nameDisplay} ` + + `${agent.role.padEnd(maxRoleLen)} ` + + `${originPadded} ${ghostProtocol}` + ); + } + + console.log(); + + // Legend + if (personalCount > 0) { + console.log(`${DIM}👤 = Personal agent (ambient, Ghost Protocol enforced in projects)${RESET}`); + console.log(); + } +} diff --git a/packages/squad-cli/src/cli/commands/personal.ts b/packages/squad-cli/src/cli/commands/personal.ts new file mode 100644 index 000000000..d597eca54 --- /dev/null +++ b/packages/squad-cli/src/cli/commands/personal.ts @@ -0,0 +1,255 @@ +/** + * squad personal — manage personal squad agents (ambient team) + * + * Subcommands: + * squad personal init — bootstrap personal squad directory + * squad personal list — list personal agents + * squad personal add — add a personal agent + * squad personal remove — remove a personal agent + * + * @module cli/commands/personal + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { resolveGlobalSquadPath, resolvePersonalSquadDir } from '@bradygaster/squad-sdk/resolution'; +import { resolvePersonalAgents } from '@bradygaster/squad-sdk/agents/personal'; +import { success, warn, info, BOLD, RESET, DIM } from '../core/output.js'; +import { fatal } from '../core/errors.js'; + +/** + * Entry point for `squad personal` subcommands. + */ +export async function runPersonal(cwd: string, subcommand: string, args: string[]): Promise { + switch (subcommand) { + case 'init': + await personalInit(); + break; + case 'list': + await personalList(); + break; + case 'add': { + const name = args[0]; + if (!name) { + fatal('Usage: squad personal add --role '); + } + const roleIdx = args.indexOf('--role'); + const role = (roleIdx !== -1 && args[roleIdx + 1]) ? args[roleIdx + 1] : 'agent'; + await personalAdd(name, role); + break; + } + case 'remove': { + const name = args[0]; + if (!name) { + fatal('Usage: squad personal remove '); + } + await personalRemove(name); + break; + } + default: + fatal( + `Unknown personal subcommand: ${subcommand}\n` + + ` Available: init, list, add --role , remove `, + ); + } +} + +// ============================================================================ +// Subcommand: init +// ============================================================================ + +async function personalInit(): Promise { + const globalDir = resolveGlobalSquadPath(); + const personalDir = path.join(globalDir, 'personal-squad'); + + if (fs.existsSync(personalDir)) { + warn(`Personal squad already initialized at ${personalDir}`); + return; + } + + // Create directory structure + const agentsDir = path.join(personalDir, 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + + // Create config.json + const config = { + defaultModel: 'auto', + ghostProtocol: true, + }; + const configPath = path.join(personalDir, 'config.json'); + fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8'); + + success('Personal squad initialized'); + info(` Path: ${personalDir}`); + info(` Add agents with: squad personal add --role `); +} + +// ============================================================================ +// Subcommand: list +// ============================================================================ + +async function personalList(): Promise { + const personalDir = resolvePersonalSquadDir(); + + if (!personalDir) { + info('No personal squad found.'); + info(' Run `squad personal init` to create your personal squad.'); + return; + } + + const agents = await resolvePersonalAgents(); + + if (agents.length === 0) { + info('No personal agents found.'); + info(` Add agents with: squad personal add --role `); + return; + } + + console.log(`\n${BOLD}Personal Squad Agents${RESET} (${agents.length}):\n`); + + // Calculate column widths + const maxNameLen = Math.max(...agents.map(a => a.name.length), 4); + const maxRoleLen = Math.max(...agents.map(a => a.role.length), 4); + + // Header + console.log( + ` ${'Name'.padEnd(maxNameLen)} ` + + `${'Role'.padEnd(maxRoleLen)} ` + + `Charter Path` + ); + console.log( + ` ${'─'.repeat(maxNameLen)} ` + + `${'─'.repeat(maxRoleLen)} ` + + `${'─'.repeat(40)}` + ); + + // Rows + for (const agent of agents) { + const charterPath = path.join(agent.personal.sourceDir, 'charter.md'); + const relativePath = path.relative(process.cwd(), charterPath); + console.log( + ` ${agent.name.padEnd(maxNameLen)} ` + + `${agent.role.padEnd(maxRoleLen)} ` + + `${DIM}${relativePath}${RESET}` + ); + } + + console.log(); +} + +// ============================================================================ +// Subcommand: add +// ============================================================================ + +async function personalAdd(name: string, role: string): Promise { + const personalDir = resolvePersonalSquadDir(); + + if (!personalDir) { + fatal('Personal squad not initialized. Run `squad personal init` first.'); + } + + const agentDir = path.join(personalDir, 'agents', name); + + if (fs.existsSync(agentDir)) { + warn(`Agent '${name}' already exists at ${agentDir}`); + return; + } + + // Create agent directory + fs.mkdirSync(agentDir, { recursive: true }); + + // Create charter.md + const charterContent = generatePersonalCharterTemplate(name, role); + const charterPath = path.join(agentDir, 'charter.md'); + fs.writeFileSync(charterPath, charterContent, 'utf-8'); + + // Create empty history.md + const historyPath = path.join(agentDir, 'history.md'); + fs.writeFileSync(historyPath, '# History\n\n\n', 'utf-8'); + + success(`Added personal agent: ${name}`); + info(` Role: ${role}`); + info(` Charter: ${charterPath}`); + info(` Edit the charter to customize this agent's behavior.`); +} + +// ============================================================================ +// Subcommand: remove +// ============================================================================ + +async function personalRemove(name: string): Promise { + const personalDir = resolvePersonalSquadDir(); + + if (!personalDir) { + fatal('Personal squad not initialized. Run `squad personal init` first.'); + } + + const agentDir = path.join(personalDir, 'agents', name); + + if (!fs.existsSync(agentDir)) { + fatal(`Agent '${name}' not found in personal squad`); + } + + // Remove agent directory recursively + fs.rmSync(agentDir, { recursive: true, force: true }); + + success(`Removed personal agent: ${name}`); +} + +// ============================================================================ +// Helpers +// ============================================================================ + +/** + * Generate a personal agent charter template. + * Note: PR #3 will add a proper personal-charter.md template to templates/. + * For now, we use a simplified inline template. + */ +function generatePersonalCharterTemplate(name: string, role: string): string { + return `# ${name} — ${role} + +> Your one-line personality statement — what makes you tick + +## Identity + +- **Name:** ${name} +- **Role:** ${role} +- **Expertise:** [Your 2-3 specific skills] +- **Style:** [How you communicate — direct? thorough? opinionated?] + +## What I Own + +- [Area of responsibility 1] +- [Area of responsibility 2] +- [Area of responsibility 3] + +## How I Work + +- [Key approach or principle 1] +- [Key approach or principle 2] +- [Pattern or convention I follow] + +## Boundaries + +**I handle:** [types of work this agent does] + +**I don't handle:** [types of work that belong to other team members] + +**When I'm unsure:** I say so and suggest who might know. + +## Model + +- **Preferred:** auto +- **Rationale:** Coordinator selects the best model based on task type + +## Collaboration + +This is a personal agent — you're ambient across all projects. +Ghost protocol is enforced in project contexts (observe, suggest, never modify). + +## Voice + +[1-2 sentences describing personality. Be specific — you have opinions, +preferences, and a style that's distinctly yours.] +`; +} diff --git a/packages/squad-sdk/package.json b/packages/squad-sdk/package.json index 5fc8902e3..87f8b5318 100644 --- a/packages/squad-sdk/package.json +++ b/packages/squad-sdk/package.json @@ -30,6 +30,10 @@ "types": "./dist/agents/index.d.ts", "import": "./dist/agents/index.js" }, + "./agents/personal": { + "types": "./dist/agents/personal.d.ts", + "import": "./dist/agents/personal.js" + }, "./adapter": { "types": "./dist/adapter/types.d.ts", "import": "./dist/adapter/types.js" diff --git a/packages/squad-sdk/src/agents/index.ts b/packages/squad-sdk/src/agents/index.ts index e6f8f3e5f..ff14e4e09 100644 --- a/packages/squad-sdk/src/agents/index.ts +++ b/packages/squad-sdk/src/agents/index.ts @@ -71,6 +71,14 @@ export { type OnboardResult, } from './onboarding.js'; +// --- Personal Squad Agents --- +export { + resolvePersonalAgents, + mergeSessionCast, + type PersonalAgentMeta, + type PersonalAgentManifest, +} from './personal.js'; + // --- Charter Types --- export interface AgentCharter { diff --git a/packages/squad-sdk/src/agents/personal.ts b/packages/squad-sdk/src/agents/personal.ts new file mode 100644 index 000000000..e200f4974 --- /dev/null +++ b/packages/squad-sdk/src/agents/personal.ts @@ -0,0 +1,96 @@ +/** + * Personal Squad Agent Resolution + * + * Discovers and merges personal agents from the user's personal squad directory + * into project session casts. Personal agents are ambient — they're automatically + * available in all project contexts with ghost protocol enforced. + * + * @module agents/personal + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { resolvePersonalSquadDir } from '../resolution.js'; +import { AgentManifest } from '../config/agent-source.js'; + +/** Metadata tag for personal agents in a session cast */ +export interface PersonalAgentMeta { + /** Always 'personal' for personal squad agents */ + origin: 'personal'; + /** Absolute path to the personal agent's directory */ + sourceDir: string; + /** Whether ghost protocol is enforced (always true in project context) */ + ghostProtocol: boolean; +} + +/** A project agent manifest augmented with personal origin info */ +export type PersonalAgentManifest = AgentManifest & { + personal: PersonalAgentMeta; +}; + +/** + * Discover personal agents from the user's personal squad directory. + * Returns empty array if personal squad is disabled or doesn't exist. + */ +export async function resolvePersonalAgents(): Promise { + const personalDir = resolvePersonalSquadDir(); + if (!personalDir) return []; + + const agentsDir = path.join(personalDir, 'agents'); + if (!fs.existsSync(agentsDir)) return []; + + const entries = await fs.promises.readdir(agentsDir, { withFileTypes: true }); + const agents: PersonalAgentManifest[] = []; + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const charterPath = path.join(agentsDir, entry.name, 'charter.md'); + if (!fs.existsSync(charterPath)) continue; + + const charterContent = await fs.promises.readFile(charterPath, 'utf-8'); + const meta = parseCharterMetadataBasic(charterContent); + + agents.push({ + name: entry.name, + role: meta.role || 'personal', + source: 'personal', + personal: { + origin: 'personal', + sourceDir: path.join(agentsDir, entry.name), + ghostProtocol: true, + }, + }); + } + + return agents; +} + +/** Basic charter metadata parser for personal agents */ +function parseCharterMetadataBasic(content: string): { role?: string; name?: string } { + const roleMatch = content.match(/\*\*Role:\*\*\s*(.+)/); + const nameMatch = content.match(/\*\*Name:\*\*\s*(.+)/); + return { + role: roleMatch?.[1]?.trim(), + name: nameMatch?.[1]?.trim(), + }; +} + +/** + * Merge personal agents into a project session cast. + * Personal agents are tagged with origin: 'personal' and ghost protocol is enforced. + * Duplicate names: project agents take precedence over personal agents. + */ +export function mergeSessionCast( + projectAgents: AgentManifest[], + personalAgents: PersonalAgentManifest[] +): (AgentManifest | PersonalAgentManifest)[] { + const projectNames = new Set(projectAgents.map(a => a.name.toLowerCase())); + + // Filter out personal agents that conflict with project agent names + const uniquePersonal = personalAgents.filter( + a => !projectNames.has(a.name.toLowerCase()) + ); + + return [...projectAgents, ...uniquePersonal]; +} diff --git a/packages/squad-sdk/src/index.ts b/packages/squad-sdk/src/index.ts index 903c56885..dfac014fb 100644 --- a/packages/squad-sdk/src/index.ts +++ b/packages/squad-sdk/src/index.ts @@ -10,10 +10,12 @@ const pkg = require('../package.json'); export const VERSION: string = pkg.version; // Export public API -export { resolveSquad, resolveGlobalSquadPath, ensureSquadPath, loadDirConfig, isConsultMode } from './resolution.js'; +export { resolveSquad, resolveGlobalSquadPath, resolvePersonalSquadDir, ensureSquadPath, ensureSquadPathTriple, loadDirConfig, isConsultMode } from './resolution.js'; export type { SquadDirConfig, ResolvedSquadPaths } from './resolution.js'; export * from './config/index.js'; export * from './agents/onboarding.js'; +export { resolvePersonalAgents, mergeSessionCast } from './agents/personal.js'; +export type { PersonalAgentMeta, PersonalAgentManifest } from './agents/personal.js'; export * from './casting/index.js'; export * from './skills/index.js'; export { selectResponseTier, getTier } from './coordinator/response-tiers.js'; diff --git a/packages/squad-sdk/src/resolution.ts b/packages/squad-sdk/src/resolution.ts index cbc3acefe..fe45a8bfd 100644 --- a/packages/squad-sdk/src/resolution.ts +++ b/packages/squad-sdk/src/resolution.ts @@ -47,6 +47,8 @@ export interface ResolvedSquadPaths { projectDir: string; /** Team identity root (agents, casting, skills) */ teamDir: string; + /** User's personal squad dir, null if not found or disabled */ + personalDir: string | null; config: SquadDirConfig | null; name: '.squad' | '.ai-team'; isLegacy: boolean; @@ -199,6 +201,7 @@ export function resolveSquadPaths(startDir?: string): ResolvedSquadPaths | null mode: 'remote', projectDir, teamDir, + personalDir: resolvePersonalSquadDir(), config, name, isLegacy, @@ -210,6 +213,7 @@ export function resolveSquadPaths(startDir?: string): ResolvedSquadPaths | null mode: 'local', projectDir, teamDir: projectDir, + personalDir: resolvePersonalSquadDir(), config, name, isLegacy, @@ -254,6 +258,25 @@ export function resolveGlobalSquadPath(): string { return globalDir; } +/** + * Resolves the user's personal squad directory. + * Returns null if SQUAD_NO_PERSONAL is set or directory doesn't exist. + * + * Platform paths: + * - Windows: %APPDATA%/squad/personal-squad + * - macOS: ~/Library/Application Support/squad/personal-squad + * - Linux: $XDG_CONFIG_HOME/squad/personal-squad or ~/.config/squad/personal-squad + */ +export function resolvePersonalSquadDir(): string | null { + if (process.env['SQUAD_NO_PERSONAL']) return null; + + const globalDir = resolveGlobalSquadPath(); + const personalDir = path.join(globalDir, 'personal-squad'); + + if (!fs.existsSync(personalDir)) return null; + return personalDir; +} + /** * Validate that a file path is within `.squad/` or the system temp directory. * @@ -323,6 +346,33 @@ export function ensureSquadPathDual(filePath: string, projectDir: string, teamDi ); } +/** + * Validates a file path is inside one of three allowed directories: + * projectDir, teamDir, personalDir, or system temp. + * Extends ensureSquadPathDual() for triple-root (project + team + personal). + */ +export function ensureSquadPathTriple( + filePath: string, + projectDir: string, + teamDir: string, + personalDir: string | null +): string { + const resolved = path.resolve(filePath); + const tmpDir = os.tmpdir(); + + const allowed = [projectDir, teamDir, personalDir, tmpDir].filter(Boolean) as string[]; + + for (const dir of allowed) { + if (resolved.startsWith(path.resolve(dir) + path.sep) || resolved === path.resolve(dir)) { + return resolved; + } + } + + throw new Error( + `Path "${resolved}" is outside all allowed directories: ${allowed.join(', ')}` + ); +} + /** * ensureSquadPath that works with resolved dual-root paths. * Convenience wrapper around ensureSquadPathDual.