diff --git a/index.js b/index.js index 6f23d24ff..bd7c6a3b6 100644 --- a/index.js +++ b/index.js @@ -15,19 +15,43 @@ function fatal(msg) { process.exit(1); } -// Detect squad directory — .squad/ first, fall back to .ai-team/ -function detectSquadDir(dest) { - const squadDir = path.join(dest, '.squad'); - const aiTeamDir = path.join(dest, '.ai-team'); - +function resolveSquadPaths(projectDir) { + const squadDir = path.join(projectDir, '.squad'); + const aiTeamDir = path.join(projectDir, '.ai-team'); + const configPath = path.join(squadDir, 'config.json'); + + let config = null; + if (fs.existsSync(configPath)) { + try { + config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + if (config && typeof config.teamRoot === 'string' && config.teamRoot.trim()) { + return { + mode: 'remote', + projectDir: squadDir, + teamDir: path.resolve(projectDir, config.teamRoot), + config, + name: '.squad', + isLegacy: false + }; + } + } catch { + config = null; + } + } + if (fs.existsSync(squadDir)) { - return { path: squadDir, name: '.squad', isLegacy: false }; + return { mode: 'local', projectDir: squadDir, teamDir: squadDir, config, name: '.squad', isLegacy: false }; } if (fs.existsSync(aiTeamDir)) { - return { path: aiTeamDir, name: '.ai-team', isLegacy: true }; + return { mode: 'local', projectDir: aiTeamDir, teamDir: aiTeamDir, config, name: '.ai-team', isLegacy: true }; } - // Default for new installations - return { path: squadDir, name: '.squad', isLegacy: false }; + return { mode: 'local', projectDir: squadDir, teamDir: squadDir, config, name: '.squad', isLegacy: false }; +} + +// Backward-compatible wrapper for legacy call sites that only need team directory metadata +function detectSquadDir(dest) { + const resolved = resolveSquadPaths(dest); + return { path: resolved.teamDir, name: resolved.name, isLegacy: resolved.isLegacy }; } function showDeprecationWarning() { @@ -45,7 +69,8 @@ process.on('uncaughtException', (err) => { const root = __dirname; const dest = process.cwd(); const pkg = require(path.join(root, 'package.json')); -const cmd = process.argv[2]; +const args = process.argv.slice(2); +const cmd = args[0]; // --version / --help if (cmd === '--version' || cmd === '-v') { @@ -73,12 +98,21 @@ if (cmd === '--help' || cmd === '-h' || cmd === 'help') { console.log(` Default: squad-export.json (use --out to override)`); console.log(` ${BOLD}import${RESET} Import squad from an export file`); console.log(` Usage: import [--force]`); + console.log(` ${BOLD}doctor${RESET} Validate squad setup integrity`); + console.log(` ${BOLD}hub${RESET} Manage Company of Squads hub workspaces`); + console.log(` Usage: hub init | hub add | hub list`); + console.log(` ${BOLD}link${RESET} Link current project to a remote squad team root`); + console.log(` Usage: link --team-root `); + console.log(` ${BOLD}repo${RESET} Manage standalone squad repos`); + console.log(` Usage: repo init | repo link `); console.log(` ${BOLD}scrub-emails${RESET} Remove email addresses from Squad state files`); console.log(` Usage: scrub-emails [directory] (default: .ai-team/)`); console.log(` ${BOLD}help${RESET} Show this help message`); console.log(`\nFlags:`); console.log(` ${BOLD}--version, -v${RESET} Print version`); console.log(` ${BOLD}--help, -h${RESET} Show help`); + console.log(` ${BOLD}--mode${RESET} Init mode: local (default) or remote`); + console.log(` ${BOLD}--team-root${RESET} Team root path for remote mode/linking`); console.log(`\nInsider channel: npx github:bradygaster/squad#insider\n`); process.exit(0); } @@ -99,14 +133,50 @@ function copyRecursive(src, target) { } } +function getFlagValue(flagName) { + const idx = process.argv.indexOf(flagName); + return (idx !== -1 && process.argv[idx + 1]) ? process.argv[idx + 1] : null; +} + +function writeRemoteConfig(projectSquadDir, teamRootPath) { + const relativeTeamRoot = path.relative(dest, teamRootPath) || '.'; + fs.mkdirSync(projectSquadDir, { recursive: true }); + fs.writeFileSync(path.join(projectSquadDir, 'config.json'), JSON.stringify({ + version: 1, + teamRoot: relativeTeamRoot, + projectKey: null + }, null, 2) + '\n'); +} + +function ensureProjectLocalArtifacts(projectSquadDir) { + fs.mkdirSync(path.join(projectSquadDir, 'decisions', 'inbox'), { recursive: true }); + fs.mkdirSync(path.join(projectSquadDir, 'log'), { recursive: true }); + fs.mkdirSync(path.join(projectSquadDir, 'orchestration-log'), { recursive: true }); + const decisionsPath = path.join(projectSquadDir, 'decisions.md'); + if (!fs.existsSync(decisionsPath)) { + fs.writeFileSync(decisionsPath, ''); + } +} + +function ensureTeamIdentityArtifacts(teamRootPath) { + fs.mkdirSync(path.join(teamRootPath, 'agents'), { recursive: true }); + fs.mkdirSync(path.join(teamRootPath, 'casting'), { recursive: true }); + fs.mkdirSync(path.join(teamRootPath, 'skills'), { recursive: true }); + fs.mkdirSync(path.join(teamRootPath, 'identity'), { recursive: true }); + fs.mkdirSync(path.join(teamRootPath, 'plugins'), { recursive: true }); + if (!fs.existsSync(path.join(teamRootPath, 'team.md'))) fs.writeFileSync(path.join(teamRootPath, 'team.md'), '## Members\n'); + if (!fs.existsSync(path.join(teamRootPath, 'routing.md'))) fs.writeFileSync(path.join(teamRootPath, 'routing.md'), ''); + if (!fs.existsSync(path.join(teamRootPath, 'ceremonies.md'))) fs.writeFileSync(path.join(teamRootPath, 'ceremonies.md'), ''); +} + // --- Watch subcommand (Ralph local watchdog) --- if (cmd === 'watch') { const { execSync } = require('child_process'); - const squadDirInfo = detectSquadDir(dest); - if (squadDirInfo.isLegacy) showDeprecationWarning(); - const teamMd = path.join(squadDirInfo.path, 'team.md'); + const squadPaths = resolveSquadPaths(dest); + if (squadPaths.isLegacy) showDeprecationWarning(); + const teamMd = path.join(squadPaths.teamDir, 'team.md'); if (!fs.existsSync(teamMd)) { fatal('No squad found — run init first.'); } @@ -721,8 +791,8 @@ if (cmd === 'plugin') { fatal('Usage: squad plugin marketplace add|remove|list|browse'); } - const squadDirInfo = detectSquadDir(dest); - const pluginsDir = path.join(squadDirInfo.path, 'plugins'); + const squadPaths = resolveSquadPaths(dest); + const pluginsDir = path.join(squadPaths.teamDir, 'plugins'); const marketplacesFile = path.join(pluginsDir, 'marketplaces.json'); function readMarketplaces() { @@ -1029,6 +1099,501 @@ if (cmd === 'import') { process.exit(0); } +// --- Hub subcommand --- +if (cmd === 'hub') { + const subCmd = args[1]; + const hubFile = path.join(dest, 'squad-hub.json'); + const squadsDir = path.join(dest, 'squads'); + + function readHub() { + try { + const hub = JSON.parse(fs.readFileSync(hubFile, 'utf8')); + if (!Array.isArray(hub.squads)) hub.squads = []; + return hub; + } catch (err) { + fatal(`Invalid hub config: ${err.message}`); + } + } + + function writeHub(hub) { + fs.writeFileSync(hubFile, JSON.stringify(hub, null, 2) + '\n'); + } + + function countMembers(teamMdPath) { + if (!fs.existsSync(teamMdPath)) return 0; + const lines = fs.readFileSync(teamMdPath, 'utf8').split('\n'); + let inMembers = false; + let count = 0; + for (const line of lines) { + if (line.startsWith('## Members')) { inMembers = true; continue; } + if (inMembers && line.startsWith('## ')) break; + if (!inMembers) continue; + const trimmed = line.trim(); + if (!trimmed.startsWith('|')) continue; + if (trimmed.includes('---') || /^(\|\s*Name\s*\|)/i.test(trimmed)) continue; + const cells = trimmed.split('|').map(c => c.trim()).filter(Boolean); + if (cells.length > 0) count++; + } + return count; + } + + if (subCmd === 'init') { + fs.mkdirSync(squadsDir, { recursive: true }); + writeHub({ + version: 1, + name: 'Squad Hub', + created_at: new Date().toISOString(), + squads: [] + }); + console.log(`${GREEN}✅${RESET} Hub initialized. Add a squad with: npx github:bradygaster/squad hub add `); + process.exit(0); + } + + if (subCmd === 'add') { + const projectName = args[2]; + if (!projectName) { + fatal('Usage: squad hub add '); + } + if (!fs.existsSync(hubFile)) { + fatal('No hub found — run hub init first.'); + } + + const hub = readHub(); + if (hub.squads.some(s => s.key === projectName)) { + fatal(`Squad '${projectName}' already exists.`); + } + + const squadRoot = path.join(squadsDir, projectName); + fs.mkdirSync(path.join(squadRoot, 'decisions', 'inbox'), { recursive: true }); + fs.mkdirSync(path.join(squadRoot, 'agents'), { recursive: true }); + fs.mkdirSync(path.join(squadRoot, 'casting'), { recursive: true }); + fs.mkdirSync(path.join(squadRoot, 'skills'), { recursive: true }); + fs.mkdirSync(path.join(squadRoot, 'identity'), { recursive: true }); + fs.mkdirSync(path.join(squadRoot, 'log'), { recursive: true }); + fs.mkdirSync(path.join(squadRoot, 'orchestration-log'), { recursive: true }); + + const teamMd = path.join(squadRoot, 'team.md'); + const routingMd = path.join(squadRoot, 'routing.md'); + const ceremoniesMd = path.join(squadRoot, 'ceremonies.md'); + const decisionsMd = path.join(squadRoot, 'decisions.md'); + if (!fs.existsSync(teamMd)) fs.writeFileSync(teamMd, '## Members\n'); + if (!fs.existsSync(routingMd)) fs.writeFileSync(routingMd, ''); + if (!fs.existsSync(ceremoniesMd)) fs.writeFileSync(ceremoniesMd, ''); + if (!fs.existsSync(decisionsMd)) fs.writeFileSync(decisionsMd, ''); + + hub.squads.push({ + key: projectName, + created_at: new Date().toISOString(), + linked_project: null + }); + writeHub(hub); + + console.log(`${GREEN}✅${RESET} Squad '${projectName}' created at squads/${projectName}/`); + console.log(` Link a project with: npx github:bradygaster/squad link --team-root /squads/${projectName}`); + process.exit(0); + } + + if (subCmd === 'list') { + if (!fs.existsSync(hubFile)) { + fatal('No hub found — run hub init first.'); + } + + const hub = readHub(); + const squads = Array.isArray(hub.squads) ? hub.squads : []; + console.log(`📊 Squad Hub — ${squads.length} squads`); + console.log(); + console.log(` ${'Name'.padEnd(18)}${'Members'.padEnd(10)}Linked Project`); + console.log(` ${'────'.padEnd(18)}${'───────'.padEnd(10)}──────────────`); + for (const squad of squads) { + const key = squad.key || '(unknown)'; + const members = countMembers(path.join(squadsDir, key, 'team.md')); + const membersLabel = members > 0 ? `${members} agents` : '(empty)'; + const linkedProject = squad.linked_project || '(unlinked)'; + console.log(` ${key.padEnd(18)}${membersLabel.padEnd(10)}${linkedProject}`); + } + console.log(); + process.exit(0); + } + + fatal('Usage: squad hub init | hub add | hub list'); +} + +// --- Doctor subcommand --- +if (cmd === 'doctor') { + const checks = []; + let passed = 0; + let failed = 0; + let warnings = 0; + + const squadDir = path.join(dest, '.squad'); + const legacyDir = path.join(dest, '.ai-team'); + const hubFile = path.join(dest, 'squad-hub.json'); + const squadsDir = path.join(dest, 'squads'); + const squadPaths = resolveSquadPaths(dest); + const configCandidates = [ + path.join(squadPaths.projectDir, 'config.json'), + path.join(squadDir, 'config.json'), + path.join(legacyDir, 'config.json') + ]; + const configPath = configCandidates.find(p => fs.existsSync(p)); + + function existsDir(p) { + return fs.existsSync(p) && fs.statSync(p).isDirectory(); + } + + function addPass(message) { + checks.push(`${GREEN}✅${RESET} ${message}`); + passed++; + } + + function addFail(message) { + checks.push(`${RED}❌${RESET} ${message}`); + failed++; + } + + function addWarning(message) { + checks.push(`${YELLOW}⚠️${RESET} ${message}`); + warnings++; + } + + function parseJson(filePath) { + try { + return { ok: true, data: JSON.parse(fs.readFileSync(filePath, 'utf8')) }; + } catch (err) { + return { ok: false, error: err.message }; + } + } + + function hasMembersHeader(filePath) { + if (!fs.existsSync(filePath)) return false; + try { + const content = fs.readFileSync(filePath, 'utf8'); + return /^##\s+Members\b/m.test(content); + } catch { + return false; + } + } + + function countAgentDirs(agentsPath) { + if (!existsDir(agentsPath)) return 0; + try { + return fs.readdirSync(agentsPath) + .filter(entry => existsDir(path.join(agentsPath, entry))).length; + } catch { + return 0; + } + } + + const hasHubFile = fs.existsSync(hubFile); + const mode = hasHubFile ? 'hub' : (configPath ? 'remote' : 'local'); + const hasSquadDir = existsDir(squadDir); + const hasLegacyDir = existsDir(legacyDir); + const activeLocalDir = hasSquadDir ? squadDir : (hasLegacyDir ? legacyDir : squadDir); + + console.log(`\n${BOLD}🩺 Squad Doctor${RESET}`); + console.log('═══════════════'); + console.log(); + console.log(`Mode: ${mode}`); + console.log(); + + if (mode === 'hub') { + if (!fs.existsSync(hubFile)) { + addFail('squad-hub.json — file not found'); + } else { + const hubParsed = parseJson(hubFile); + let manifestKeys = []; + + if (hubParsed.ok) { + addPass('squad-hub.json is valid JSON'); + const hub = hubParsed.data; + if (Array.isArray(hub.squads)) { + manifestKeys = hub.squads + .map(s => typeof s === 'string' ? s : s && s.key) + .filter(Boolean); + } else if (hub.squads && typeof hub.squads === 'object') { + manifestKeys = Object.keys(hub.squads); + } + } else { + addFail(`squad-hub.json — invalid JSON (${hubParsed.error})`); + } + + if (existsDir(squadsDir)) { + addPass('squads/ directory exists'); + } else { + addFail('squads/ directory — not found'); + } + + for (const key of manifestKeys) { + const squadPath = path.join(squadsDir, key); + const teamPath = path.join(squadPath, 'team.md'); + if (existsDir(squadPath)) { + addPass(`squads/${key}/ directory exists`); + } else { + addFail(`squads/${key}/ directory — not found`); + } + if (fs.existsSync(teamPath)) { + addPass(`squads/${key}/team.md found`); + } else { + addFail(`squads/${key}/team.md — file not found`); + } + } + + if (existsDir(squadsDir)) { + const extraDirs = fs.readdirSync(squadsDir) + .filter(entry => existsDir(path.join(squadsDir, entry))) + .filter(entry => !manifestKeys.includes(entry)); + for (const extra of extraDirs) { + addWarning(`squads/${extra}/ exists but is not in squad-hub.json`); + } + } + } + } else if (mode === 'remote') { + if (hasSquadDir || hasLegacyDir) { + addPass('.squad/ or .ai-team/ directory exists'); + } else { + addFail('.squad/ or .ai-team/ directory — not found'); + } + + const localDecisions = path.join(activeLocalDir, 'decisions.md'); + const localLogDir = path.join(activeLocalDir, 'log'); + const localOrchDir = path.join(activeLocalDir, 'orchestration-log'); + if (fs.existsSync(localDecisions)) { + addPass('decisions.md found'); + } else { + addFail('decisions.md — file not found'); + } + if (existsDir(localLogDir)) { + addPass('log/ directory exists'); + } else { + addFail('log/ directory — not found'); + } + if (existsDir(localOrchDir)) { + addPass('orchestration-log/ directory exists'); + } else { + addFail('orchestration-log/ directory — not found'); + } + + let remoteTeamRoot = null; + if (!configPath) { + addFail('config.json — file not found'); + } else { + const parsed = parseJson(configPath); + if (!parsed.ok) { + addFail(`config.json — invalid JSON (${parsed.error})`); + } else if (!parsed.data.teamRoot || typeof parsed.data.teamRoot !== 'string') { + addFail('config.json — missing "teamRoot" field'); + } else { + addPass('config.json is valid JSON with teamRoot'); + remoteTeamRoot = path.isAbsolute(parsed.data.teamRoot) + ? parsed.data.teamRoot + : path.resolve(path.dirname(configPath), parsed.data.teamRoot); + if (path.isAbsolute(parsed.data.teamRoot)) { + addWarning('teamRoot is an absolute path — less portable'); + } + } + } + + if (remoteTeamRoot && existsDir(remoteTeamRoot)) { + addPass('teamRoot path resolves to an existing directory'); + } else { + addFail('teamRoot path resolves to an existing directory — not found'); + } + + if (remoteTeamRoot) { + const remoteTeamMd = path.join(remoteTeamRoot, 'team.md'); + const remoteAgentsDir = path.join(remoteTeamRoot, 'agents'); + const remoteRegistryPath = path.join(remoteTeamRoot, 'casting', 'registry.json'); + + if (fs.existsSync(remoteTeamMd)) { + addPass('teamRoot/team.md found'); + } else { + addFail('teamRoot/team.md — file not found'); + } + + const remoteAgentCount = countAgentDirs(remoteAgentsDir); + if (remoteAgentCount > 0) { + addPass('teamRoot/agents/ directory exists with at least one agent'); + } else { + addFail('teamRoot/agents/ directory with at least one agent — not found'); + } + + if (!fs.existsSync(remoteRegistryPath)) { + addFail('teamRoot/casting/registry.json — file not found'); + } else { + const parsedRegistry = parseJson(remoteRegistryPath); + if (parsedRegistry.ok) { + addPass('teamRoot/casting/registry.json found and valid JSON'); + } else { + addFail(`teamRoot/casting/registry.json — invalid JSON (${parsedRegistry.error})`); + } + } + } else { + addFail('teamRoot/team.md — cannot validate without a valid teamRoot'); + addFail('teamRoot/agents/ directory with at least one agent — cannot validate without a valid teamRoot'); + addFail('teamRoot/casting/registry.json — cannot validate without a valid teamRoot'); + } + } else { + if (hasSquadDir || hasLegacyDir) { + addPass('.squad/ or .ai-team/ directory exists'); + } else { + addFail('.squad/ or .ai-team/ directory — not found'); + } + + const teamMd = path.join(activeLocalDir, 'team.md'); + const routingMd = path.join(activeLocalDir, 'routing.md'); + const ceremoniesMd = path.join(activeLocalDir, 'ceremonies.md'); + const decisionsMd = path.join(activeLocalDir, 'decisions.md'); + const agentsDir = path.join(activeLocalDir, 'agents'); + const registryPath = path.join(activeLocalDir, 'casting', 'registry.json'); + + if (!fs.existsSync(teamMd)) { + addFail('team.md — file not found'); + } else if (hasMembersHeader(teamMd)) { + addPass('team.md found with ## Members header'); + } else { + addFail('team.md found but missing ## Members header'); + } + + if (fs.existsSync(routingMd)) { + addPass('routing.md found'); + } else { + addFail('routing.md — file not found'); + } + if (fs.existsSync(ceremoniesMd)) { + addPass('ceremonies.md found'); + } else { + addFail('ceremonies.md — file not found'); + } + if (fs.existsSync(decisionsMd)) { + addPass('decisions.md found'); + } else { + addFail('decisions.md — file not found'); + } + + const agentCount = countAgentDirs(agentsDir); + if (agentCount > 0) { + addPass('agents/ directory exists with at least one agent'); + } else { + addFail('agents/ directory with at least one agent — not found'); + } + + if (!fs.existsSync(registryPath)) { + addFail('casting/registry.json — file not found'); + } else { + const parsedRegistry = parseJson(registryPath); + if (parsedRegistry.ok) { + addPass('casting/registry.json found and valid JSON'); + } else { + addFail(`casting/registry.json — invalid JSON (${parsedRegistry.error})`); + } + } + + if (hasLegacyDir) { + addWarning('.ai-team/ exists — consider running: npx github:bradygaster/squad upgrade --migrate-directory'); + } + } + + for (const line of checks) { + console.log(line); + } + console.log(); + console.log(`Summary: ${passed} passed, ${failed} failed, ${warnings} warning${warnings === 1 ? '' : 's'}`); + process.exit(0); +} + +// --- Link subcommand --- +if (cmd === 'link') { + const teamRootArg = getFlagValue('--team-root'); + if (!teamRootArg) { + fatal('Usage: squad link --team-root '); + } + + const teamRootPath = path.resolve(teamRootArg); + if (!fs.existsSync(teamRootPath) || !fs.statSync(teamRootPath).isDirectory()) { + fatal(`Team root not found or not a directory: ${teamRootArg}`); + } + if (!fs.existsSync(path.join(teamRootPath, 'team.md'))) { + fatal(`Invalid team root: missing team.md at ${teamRootPath}`); + } + + const projectSquadDir = path.join(dest, '.squad'); + const configPath = path.join(projectSquadDir, 'config.json'); + if (fs.existsSync(configPath)) { + fatal('.squad/config.json already exists — refusing to overwrite existing link.'); + } + + writeRemoteConfig(projectSquadDir, teamRootPath); + ensureProjectLocalArtifacts(projectSquadDir); + + console.log(`${GREEN}✅${RESET} Linked to team at ${teamRootPath}.`); + console.log(` Project decisions and logs stay in .squad/ (local).`); + process.exit(0); +} + +// --- Repo subcommand --- +if (cmd === 'repo') { + const subCmd = args[1]; + + if (subCmd === 'init') { + ensureTeamIdentityArtifacts(dest); + ensureProjectLocalArtifacts(dest); + const manifestPath = path.join(dest, 'squad-manifest.json'); + const manifest = { + version: 1, + projects: [], + created_at: new Date().toISOString() + }; + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n'); + console.log(`${GREEN}✅${RESET} Squad repo initialized.`); + console.log(` Link a project with: npx github:bradygaster/squad repo link (from the project dir)`); + process.exit(0); + } + + if (subCmd === 'link') { + const squadRepoArg = args[2]; + if (!squadRepoArg) { + fatal('Usage: squad repo link '); + } + + const squadRepoPath = path.resolve(squadRepoArg); + if (!fs.existsSync(squadRepoPath) || !fs.statSync(squadRepoPath).isDirectory()) { + fatal(`Squad repo path not found or not a directory: ${squadRepoArg}`); + } + + const manifestPath = path.join(squadRepoPath, 'squad-manifest.json'); + if (!fs.existsSync(manifestPath)) { + fatal(`Invalid squad repo: missing squad-manifest.json at ${squadRepoPath}`); + } + + let manifest; + try { + manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + } catch (err) { + fatal(`Invalid squad-manifest.json: ${err.message}`); + } + if (!Array.isArray(manifest.projects)) manifest.projects = []; + + const projectSquadDir = path.join(dest, '.squad'); + writeRemoteConfig(projectSquadDir, squadRepoPath); + ensureProjectLocalArtifacts(projectSquadDir); + + const projectRef = path.relative(squadRepoPath, dest) || '.'; + const alreadyLinked = manifest.projects.some(p => { + if (typeof p === 'string') return p === projectRef; + return p && typeof p === 'object' && p.path === projectRef; + }); + if (!alreadyLinked) { + manifest.projects.push({ path: projectRef, linked_at: new Date().toISOString() }); + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n'); + } + + console.log(`${GREEN}✅${RESET} Project linked to squad repo at ${squadRepoPath}.`); + process.exit(0); + } + + fatal('Usage: squad repo init | repo link '); +} + // Split history into portable knowledge and project learnings function splitHistory(history, sourceProject) { const lines = history.split('\n'); @@ -1115,6 +1680,33 @@ try { const isUpgrade = cmd === 'upgrade'; const isSelfUpgrade = isUpgrade && process.argv.includes('--self'); const isMigrateDirectory = isUpgrade && process.argv.includes('--migrate-directory'); +const initMode = getFlagValue('--mode') || 'local'; +const initTeamRootArg = getFlagValue('--team-root'); + +if (!isUpgrade && initMode !== 'local' && initMode !== 'remote') { + fatal(`Unsupported --mode value: ${initMode}. Use 'local' or 'remote'.`); +} + +if (!isUpgrade && initMode === 'remote') { + if (!initTeamRootArg) { + fatal('Remote mode requires --team-root '); + } + + const teamRootPath = path.resolve(initTeamRootArg); + if (!fs.existsSync(teamRootPath) || !fs.statSync(teamRootPath).isDirectory()) { + fatal(`Team root not found or not a directory: ${initTeamRootArg}`); + } + + const projectSquadDir = path.join(dest, '.squad'); + writeRemoteConfig(projectSquadDir, teamRootPath); + ensureProjectLocalArtifacts(projectSquadDir); + ensureTeamIdentityArtifacts(teamRootPath); + + console.log(`${GREEN}✅${RESET} Squad initialized in remote mode.`); + console.log(` Team state: ${teamRootPath}`); + console.log(` Project artifacts: .squad/ (local)`); + process.exit(0); +} // Handle --migrate-directory flag: rename .ai-team/ to .squad/ if (isMigrateDirectory) { @@ -1331,14 +1923,13 @@ if (isUpgrade) { if (isAlreadyCurrent) { // Still run missing migrations in case a prior upgrade was interrupted - const currentSquadDir = fs.existsSync(path.join(dest, '.squad')) - ? path.join(dest, '.squad') : path.join(dest, '.ai-team'); - runMigrations(dest, oldVersion, currentSquadDir); + const currentSquad = resolveSquadPaths(dest); + runMigrations(dest, oldVersion, currentSquad.teamDir); // Even if already current, update copilot-instructions.md if @copilot is enabled const copilotInstructionsSrc = path.join(root, 'templates', 'copilot-instructions.md'); const copilotInstructionsDest = path.join(dest, '.github', 'copilot-instructions.md'); - const teamMd = path.join(currentSquadDir, 'team.md'); + const teamMd = path.join(currentSquad.teamDir, 'team.md'); const copilotEnabled = fs.existsSync(teamMd) && fs.readFileSync(teamMd, 'utf8').includes('🤖 Coding Agent'); if (copilotEnabled && fs.existsSync(copilotInstructionsSrc)) { @@ -1395,21 +1986,7 @@ if (isUpgrade) { console.log(`${GREEN}✓${RESET} .github/agents/squad.agent.md (v${pkg.version})`); } -// Detect or determine squad directory (.squad/ for new, detect for upgrades) -// Detect squad directory for dual-path support (.squad/ or .ai-team/) -const squadInfo = (() => { - const squadDir = path.join(dest, '.squad'); - const aiTeamDir = path.join(dest, '.ai-team'); - - if (fs.existsSync(squadDir)) { - return { path: squadDir, name: '.squad', isLegacy: false }; - } - if (fs.existsSync(aiTeamDir)) { - return { path: aiTeamDir, name: '.ai-team', isLegacy: true }; - } - // Default for new installations - return { path: squadDir, name: '.squad', isLegacy: false }; -})(); +const squadInfo = resolveSquadPaths(dest); // Show deprecation warning if using .ai-team/ (but not on new installs) if (squadInfo.isLegacy) { @@ -1417,12 +1994,12 @@ if (squadInfo.isLegacy) { } // Pre-create drop-box, orchestration-log, casting, skills, plugins, and identity directories (additive-only) -const inboxDir = path.join(squadInfo.path, 'decisions', 'inbox'); -const orchLogDir = path.join(squadInfo.path, 'orchestration-log'); -const castingDir = path.join(squadInfo.path, 'casting'); -const skillsDir = path.join(squadInfo.path, 'skills'); -const pluginsDir = path.join(squadInfo.path, 'plugins'); -const identityDir = path.join(squadInfo.path, 'identity'); +const inboxDir = path.join(squadInfo.projectDir, 'decisions', 'inbox'); +const orchLogDir = path.join(squadInfo.projectDir, 'orchestration-log'); +const castingDir = path.join(squadInfo.teamDir, 'casting'); +const skillsDir = path.join(squadInfo.teamDir, 'skills'); +const pluginsDir = path.join(squadInfo.teamDir, 'plugins'); +const identityDir = path.join(squadInfo.teamDir, 'identity'); try { fs.mkdirSync(inboxDir, { recursive: true }); fs.mkdirSync(orchLogDir, { recursive: true }); @@ -1519,7 +2096,7 @@ if (!isUpgrade) { } // Copy default ceremonies config -const ceremoniesDest = path.join(squadInfo.path, 'ceremonies.md'); +const ceremoniesDest = path.join(squadInfo.teamDir, 'ceremonies.md'); if (!fs.existsSync(ceremoniesDest)) { const ceremoniesSrc = path.join(root, 'templates', 'ceremonies.md'); fs.copyFileSync(ceremoniesSrc, ceremoniesDest); @@ -1533,7 +2110,7 @@ if (!fs.existsSync(ceremoniesDest)) { const copilotInstructionsSrc = path.join(root, 'templates', 'copilot-instructions.md'); const copilotInstructionsDest = path.join(dest, '.github', 'copilot-instructions.md'); if (isUpgrade) { - const teamMd = path.join(squadInfo.path, 'team.md'); + const teamMd = path.join(squadInfo.teamDir, 'team.md'); const copilotEnabled = fs.existsSync(teamMd) && fs.readFileSync(teamMd, 'utf8').includes('🤖 Coding Agent'); if (copilotEnabled && fs.existsSync(copilotInstructionsSrc)) { @@ -1573,7 +2150,7 @@ if (isUpgrade) { console.log(`${GREEN}✓${RESET} ${BOLD}upgraded${RESET} ${templatesDestName}/`); // Run migrations applicable for this version jump - runMigrations(dest, oldVersion || '0.0.0', squadInfo.path); + runMigrations(dest, oldVersion || '0.0.0', squadInfo.teamDir); } else if (fs.existsSync(templatesDest)) { console.log(`${DIM}${templatesDestName}/ already exists — skipping (run 'upgrade' to update)${RESET}`); } else { @@ -1616,7 +2193,7 @@ if (fs.existsSync(workflowsSrc) && fs.statSync(workflowsSrc).isDirectory()) { if (isUpgrade) { // Scrub email addresses from existing squad directory console.log(`${DIM}Scrubbing email addresses from ${squadInfo.name}/ files...${RESET}`); - const scrubResult = scrubEmailsFromDirectory(squadInfo.path); + const scrubResult = scrubEmailsFromDirectory(squadInfo.teamDir); const scrubbed = Array.isArray(scrubResult) ? scrubResult : []; if (scrubbed.length > 0) { console.log(`${GREEN}✓${RESET} Scrubbed email addresses from ${scrubbed.length} file(s)`); @@ -1627,7 +2204,7 @@ if (isUpgrade) { console.log(`\n${DIM}${squadInfo.name}/ untouched — your team state is safe${RESET}`); // Hint about new features available after upgrade - const teamMd = path.join(squadInfo.path, 'team.md'); + const teamMd = path.join(squadInfo.teamDir, 'team.md'); const copilotEnabled = fs.existsSync(teamMd) && fs.readFileSync(teamMd, 'utf8').includes('🤖 Coding Agent'); if (!copilotEnabled) { diff --git a/test/remote-mode.test.js b/test/remote-mode.test.js new file mode 100644 index 000000000..960a8b261 --- /dev/null +++ b/test/remote-mode.test.js @@ -0,0 +1,346 @@ +const { describe, it, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { execFileSync } = require('child_process'); +const os = require('os'); + +const CLI = path.join(__dirname, '..', 'index.js'); + +function runSquad(args, cwd) { + try { + const result = execFileSync(process.execPath, [CLI, ...args], { + cwd, + encoding: 'utf8', + timeout: 15000, + env: { ...process.env, NO_COLOR: '1' }, + }); + return { stdout: result, exitCode: 0 }; + } catch (err) { + return { + stdout: (err.stdout || '') + (err.stderr || ''), + exitCode: err.status ?? 1, + }; + } +} + +function makeTempDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'squad-remote-mode-test-')); +} + +function cleanDir(dir) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch {} +} + +function stripAnsi(text) { + return text.replace(/\x1B\[[0-9;]*m/g, ''); +} + +describe('Remote mode init', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = makeTempDir(); + }); + + afterEach(() => { + cleanDir(tmpDir); + }); + + it('--mode remote --team-root creates thin local config and local/team artifacts', () => { + const projectDir = path.join(tmpDir, 'project'); + const teamRoot = path.join(tmpDir, 'team-root'); + fs.mkdirSync(projectDir, { recursive: true }); + fs.mkdirSync(teamRoot, { recursive: true }); + + const result = runSquad(['--mode', 'remote', '--team-root', teamRoot], projectDir); + assert.equal(result.exitCode, 0, `remote init should succeed: ${result.stdout}`); + + const projectSquadDir = path.join(projectDir, '.squad'); + const configPath = path.join(projectSquadDir, 'config.json'); + assert.ok(fs.existsSync(configPath), '.squad/config.json should exist'); + + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + const expectedRelative = path.relative(projectDir, teamRoot) || '.'; + assert.equal(config.teamRoot, expectedRelative, 'config should store relative teamRoot path'); + assert.ok(!fs.existsSync(path.join(projectSquadDir, 'team.md')), 'project .squad should remain thin in remote mode'); + + assert.ok(fs.existsSync(path.join(projectSquadDir, 'decisions.md')), 'project decisions.md should exist'); + assert.ok(fs.existsSync(path.join(projectSquadDir, 'decisions', 'inbox')), 'project decisions/inbox should exist'); + assert.ok(fs.existsSync(path.join(projectSquadDir, 'log')), 'project log/ should exist'); + assert.ok(fs.existsSync(path.join(projectSquadDir, 'orchestration-log')), 'project orchestration-log/ should exist'); + + assert.ok(fs.existsSync(path.join(teamRoot, 'team.md')), 'teamRoot/team.md should exist'); + assert.ok(fs.existsSync(path.join(teamRoot, 'agents')), 'teamRoot/agents should exist'); + assert.ok(fs.existsSync(path.join(teamRoot, 'casting')), 'teamRoot/casting should exist'); + assert.ok(fs.existsSync(path.join(teamRoot, 'skills')), 'teamRoot/skills should exist'); + assert.ok(fs.existsSync(path.join(teamRoot, 'identity')), 'teamRoot/identity should exist'); + }); + + it('local mode (no flags) still initializes project-local squad normally', () => { + const projectDir = path.join(tmpDir, 'project'); + fs.mkdirSync(projectDir, { recursive: true }); + + const result = runSquad([], projectDir); + assert.equal(result.exitCode, 0, `local init should succeed: ${result.stdout}`); + + assert.ok(fs.existsSync(path.join(projectDir, '.squad')), 'local init should create .squad/'); + assert.ok(fs.existsSync(path.join(projectDir, '.squad', 'ceremonies.md')), 'local init should create .squad/ceremonies.md'); + assert.ok(!fs.existsSync(path.join(projectDir, '.squad', 'config.json')), 'local init should not create remote config.json'); + }); + + it('fails if --team-root does not exist', () => { + const projectDir = path.join(tmpDir, 'project'); + const missingTeamRoot = path.join(tmpDir, 'does-not-exist'); + fs.mkdirSync(projectDir, { recursive: true }); + + const result = runSquad(['--mode', 'remote', '--team-root', missingTeamRoot], projectDir); + assert.notEqual(result.exitCode, 0, 'remote init should fail when teamRoot is missing'); + assert.ok(result.stdout.includes('Team root not found or not a directory'), `unexpected output: ${result.stdout}`); + }); +}); + +describe('Link command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = makeTempDir(); + }); + + afterEach(() => { + cleanDir(tmpDir); + }); + + it('link --team-root writes config pointing to an existing team root', () => { + const projectDir = path.join(tmpDir, 'project'); + const teamRoot = path.join(tmpDir, 'shared-team'); + fs.mkdirSync(projectDir, { recursive: true }); + fs.mkdirSync(teamRoot, { recursive: true }); + fs.writeFileSync(path.join(teamRoot, 'team.md'), '## Members\n'); + + const result = runSquad(['link', '--team-root', teamRoot], projectDir); + assert.equal(result.exitCode, 0, `link should succeed: ${result.stdout}`); + + const configPath = path.join(projectDir, '.squad', 'config.json'); + assert.ok(fs.existsSync(configPath), '.squad/config.json should exist after link'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + assert.equal(config.teamRoot, path.relative(projectDir, teamRoot) || '.', 'link should store relative teamRoot path'); + + assert.ok(fs.existsSync(path.join(projectDir, '.squad', 'decisions.md')), 'link should create local decisions.md'); + assert.ok(fs.existsSync(path.join(projectDir, '.squad', 'log')), 'link should create local log/ dir'); + assert.ok(fs.existsSync(path.join(projectDir, '.squad', 'orchestration-log')), 'link should create local orchestration-log/ dir'); + }); + + it('fails if team root is missing team.md', () => { + const projectDir = path.join(tmpDir, 'project'); + const invalidTeamRoot = path.join(tmpDir, 'invalid-team-root'); + fs.mkdirSync(projectDir, { recursive: true }); + fs.mkdirSync(invalidTeamRoot, { recursive: true }); + + const result = runSquad(['link', '--team-root', invalidTeamRoot], projectDir); + assert.notEqual(result.exitCode, 0, 'link should fail for invalid team root'); + assert.ok(result.stdout.includes('Invalid team root: missing team.md'), `unexpected output: ${result.stdout}`); + }); +}); + +describe('Repo commands', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = makeTempDir(); + }); + + afterEach(() => { + cleanDir(tmpDir); + }); + + it('repo init creates squad-manifest.json', () => { + const squadRepoDir = path.join(tmpDir, 'squad-repo'); + fs.mkdirSync(squadRepoDir, { recursive: true }); + + const result = runSquad(['repo', 'init'], squadRepoDir); + assert.equal(result.exitCode, 0, `repo init should succeed: ${result.stdout}`); + assert.ok(fs.existsSync(path.join(squadRepoDir, 'squad-manifest.json')), 'repo init should create squad-manifest.json'); + }); + + it('repo link creates project config and updates repo manifest', () => { + const squadRepoDir = path.join(tmpDir, 'squad-repo'); + const projectDir = path.join(tmpDir, 'project'); + fs.mkdirSync(squadRepoDir, { recursive: true }); + fs.mkdirSync(projectDir, { recursive: true }); + + const initResult = runSquad(['repo', 'init'], squadRepoDir); + assert.equal(initResult.exitCode, 0, `repo init should succeed: ${initResult.stdout}`); + + const linkResult = runSquad(['repo', 'link', squadRepoDir], projectDir); + assert.equal(linkResult.exitCode, 0, `repo link should succeed: ${linkResult.stdout}`); + + const configPath = path.join(projectDir, '.squad', 'config.json'); + assert.ok(fs.existsSync(configPath), 'repo link should create .squad/config.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + assert.equal(config.teamRoot, path.relative(projectDir, squadRepoDir) || '.', 'repo link should store relative repo path'); + + const manifest = JSON.parse(fs.readFileSync(path.join(squadRepoDir, 'squad-manifest.json'), 'utf8')); + const expectedProjectRef = path.relative(squadRepoDir, projectDir) || '.'; + const hasProject = manifest.projects.some(project => { + if (typeof project === 'string') return project === expectedProjectRef; + return project && project.path === expectedProjectRef; + }); + assert.ok(hasProject, 'repo link should add project reference to manifest'); + }); + + it('repo link fails when target squad repo has no manifest', () => { + const projectDir = path.join(tmpDir, 'project'); + const invalidRepo = path.join(tmpDir, 'invalid-squad-repo'); + fs.mkdirSync(projectDir, { recursive: true }); + fs.mkdirSync(invalidRepo, { recursive: true }); + + const result = runSquad(['repo', 'link', invalidRepo], projectDir); + assert.notEqual(result.exitCode, 0, 'repo link should fail without squad-manifest.json'); + assert.ok(result.stdout.includes('missing squad-manifest.json'), `unexpected output: ${result.stdout}`); + }); +}); + +describe('Hub commands', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = makeTempDir(); + }); + + afterEach(() => { + cleanDir(tmpDir); + }); + + it('hub init creates squad-hub.json and squads directory', () => { + const hubDir = path.join(tmpDir, 'hub'); + fs.mkdirSync(hubDir, { recursive: true }); + + const result = runSquad(['hub', 'init'], hubDir); + assert.equal(result.exitCode, 0, `hub init should succeed: ${result.stdout}`); + assert.ok(fs.existsSync(path.join(hubDir, 'squad-hub.json')), 'hub init should create squad-hub.json'); + assert.ok(fs.existsSync(path.join(hubDir, 'squads')), 'hub init should create squads/ directory'); + }); + + it('hub add creates squad structure and updates squad-hub.json', () => { + const hubDir = path.join(tmpDir, 'hub'); + fs.mkdirSync(hubDir, { recursive: true }); + runSquad(['hub', 'init'], hubDir); + + const addResult = runSquad(['hub', 'add', 'checkout-api'], hubDir); + assert.equal(addResult.exitCode, 0, `hub add should succeed: ${addResult.stdout}`); + + const squadRoot = path.join(hubDir, 'squads', 'checkout-api'); + assert.ok(fs.existsSync(path.join(squadRoot, 'team.md')), 'hub add should create team.md'); + assert.ok(fs.existsSync(path.join(squadRoot, 'agents')), 'hub add should create agents/ directory'); + assert.ok(fs.existsSync(path.join(squadRoot, 'casting')), 'hub add should create casting/ directory'); + + const hubConfig = JSON.parse(fs.readFileSync(path.join(hubDir, 'squad-hub.json'), 'utf8')); + const hasEntry = Array.isArray(hubConfig.squads) && hubConfig.squads.some(s => s && s.key === 'checkout-api'); + assert.ok(hasEntry, 'hub add should append squad entry to squad-hub.json'); + }); + + it('hub add fails if hub is not initialized', () => { + const hubDir = path.join(tmpDir, 'hub'); + fs.mkdirSync(hubDir, { recursive: true }); + + const result = runSquad(['hub', 'add', 'no-hub-yet'], hubDir); + assert.notEqual(result.exitCode, 0, 'hub add should fail when no hub exists'); + assert.ok(result.stdout.includes('No hub found'), `unexpected output: ${result.stdout}`); + }); + + it('hub list shows squads and does not crash', () => { + const hubDir = path.join(tmpDir, 'hub'); + fs.mkdirSync(hubDir, { recursive: true }); + runSquad(['hub', 'init'], hubDir); + runSquad(['hub', 'add', 'payments'], hubDir); + + const listResult = runSquad(['hub', 'list'], hubDir); + assert.equal(listResult.exitCode, 0, `hub list should succeed: ${listResult.stdout}`); + assert.ok(listResult.stdout.includes('payments') || listResult.stdout.includes('Squad Hub'), + `hub list output should include squad data: ${listResult.stdout}`); + }); +}); + +describe('Doctor command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = makeTempDir(); + }); + + afterEach(() => { + cleanDir(tmpDir); + }); + + it('doctor on healthy local setup reports no failures', () => { + const projectDir = path.join(tmpDir, 'project'); + fs.mkdirSync(projectDir, { recursive: true }); + const initResult = runSquad([], projectDir); + assert.equal(initResult.exitCode, 0, `init should succeed: ${initResult.stdout}`); + + fs.mkdirSync(path.join(projectDir, '.squad', 'agents', 'scribe'), { recursive: true }); + fs.mkdirSync(path.join(projectDir, '.squad', 'casting'), { recursive: true }); + fs.writeFileSync(path.join(projectDir, '.squad', 'team.md'), '## Members\n'); + fs.writeFileSync(path.join(projectDir, '.squad', 'routing.md'), ''); + fs.writeFileSync(path.join(projectDir, '.squad', 'decisions.md'), ''); + fs.writeFileSync(path.join(projectDir, '.squad', 'casting', 'registry.json'), '{"agents": []}\n'); + + const doctorResult = runSquad(['doctor'], projectDir); + const output = stripAnsi(doctorResult.stdout); + assert.equal(doctorResult.exitCode, 0, `doctor should run: ${doctorResult.stdout}`); + assert.ok(output.includes('Mode: local'), `doctor should detect local mode: ${output}`); + assert.match(output, /Summary:\s+\d+\s+passed,\s+0\s+failed/, `doctor should report all green: ${output}`); + }); + + it('doctor on empty directory reports failures', () => { + const emptyDir = path.join(tmpDir, 'empty'); + fs.mkdirSync(emptyDir, { recursive: true }); + + const doctorResult = runSquad(['doctor'], emptyDir); + const output = stripAnsi(doctorResult.stdout); + assert.equal(doctorResult.exitCode, 0, 'doctor should complete even when checks fail'); + assert.match(output, /Summary:\s+\d+\s+passed,\s+[1-9]\d*\s+failed/, `doctor should report failures: ${output}`); + }); + + it('doctor in remote mode validates teamRoot path', () => { + const projectDir = path.join(tmpDir, 'project'); + const teamRoot = path.join(tmpDir, 'remote-team'); + fs.mkdirSync(projectDir, { recursive: true }); + fs.mkdirSync(teamRoot, { recursive: true }); + + const initResult = runSquad(['--mode', 'remote', '--team-root', teamRoot], projectDir); + assert.equal(initResult.exitCode, 0, `remote init should succeed: ${initResult.stdout}`); + + const configPath = path.join(projectDir, '.squad', 'config.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + config.teamRoot = teamRoot; + fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n'); + fs.mkdirSync(path.join(teamRoot, 'agents', 'scribe'), { recursive: true }); + fs.mkdirSync(path.join(teamRoot, 'casting'), { recursive: true }); + fs.writeFileSync(path.join(teamRoot, 'casting', 'registry.json'), '{"agents": []}\n'); + + const doctorResult = runSquad(['doctor'], projectDir); + const output = stripAnsi(doctorResult.stdout); + assert.equal(doctorResult.exitCode, 0, `doctor should run: ${doctorResult.stdout}`); + assert.ok(output.includes('Mode: remote'), `doctor should detect remote mode: ${output}`); + assert.ok(output.includes('teamRoot path resolves to an existing directory'), `doctor should validate teamRoot: ${output}`); + assert.ok(!output.includes('teamRoot path resolves to an existing directory — not found'), + `doctor should resolve teamRoot in remote mode: ${output}`); + }); + + it('doctor on hub validates configured squads', () => { + const hubDir = path.join(tmpDir, 'hub'); + fs.mkdirSync(hubDir, { recursive: true }); + runSquad(['hub', 'init'], hubDir); + runSquad(['hub', 'add', 'catalog'], hubDir); + + const doctorResult = runSquad(['doctor'], hubDir); + const output = stripAnsi(doctorResult.stdout); + assert.equal(doctorResult.exitCode, 0, `doctor should run for hub: ${doctorResult.stdout}`); + assert.ok(output.includes('Mode: hub'), `doctor should detect hub mode: ${output}`); + assert.ok(output.includes('squads/catalog/team.md found'), `doctor should validate hub squad team.md: ${output}`); + }); +});