diff --git a/packages/cli/__tests__/commands/start.test.ts b/packages/cli/__tests__/commands/start.test.ts index 964f0a6741..6fdb843fd5 100644 --- a/packages/cli/__tests__/commands/start.test.ts +++ b/packages/cli/__tests__/commands/start.test.ts @@ -3148,4 +3148,98 @@ describe("start command — global registry mutations", () => { else process.env["AO_GLOBAL_CONFIG"] = origGlobalEnv; } }); + + it("writes interactive agent overrides to a config that started as flat local format", async () => { + const repoDir = join(tmpDir, "current"); + createFakeRepo(repoDir, "https://github.com/org/current.git"); + const flatConfigPath = join(repoDir, "agent-orchestrator.yaml"); + writeFileSync( + flatConfigPath, + [ + "agent: claude-code", + "runtime: process", + "workspace: worktree", + ].join("\n"), + ); + const globalConfigPath = join(tmpDir, "global-agent-orchestrator.yaml"); + writeFileSync( + globalConfigPath, + [ + "defaults:", + " runtime: process", + " workspace: worktree", + " agent: claude-code", + " notifiers: []", + "projects:", + " agent-orchestrator_48321dec7a:", + ` path: ${repoDir}`, + " sessionPrefix: current", + " defaultBranch: main", + ].join("\n"), + ); + + mockConfigRef.current = makeConfig({ + "agent-orchestrator_48321dec7a": makeProject({ + name: "Current", + path: repoDir, + sessionPrefix: "current", + }), + }); + (mockConfigRef.current as Record).configPath = flatConfigPath; + + const origEnv = process.env["AO_CONFIG_PATH"]; + const origGlobalEnv = process.env["AO_GLOBAL_CONFIG"]; + process.env["AO_CONFIG_PATH"] = flatConfigPath; + process.env["AO_GLOBAL_CONFIG"] = globalConfigPath; + + const detectAgent = await import("../../src/lib/detect-agent.js"); + vi.mocked(detectAgent.detectAvailableAgents).mockResolvedValue([ + { name: "codex", displayName: "Codex" }, + { name: "opencode", displayName: "OpenCode" }, + ]); + mockPromptSelect.mockResolvedValueOnce("codex").mockResolvedValueOnce("opencode"); + const originalStdinTty = process.stdin.isTTY; + const originalStdoutTty = process.stdout.isTTY; + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + + try { + await program.parseAsync([ + "node", + "test", + "start", + "--interactive", + "--no-dashboard", + "--no-orchestrator", + ]); + + const flatAfter = parseYaml(readFileSync(flatConfigPath, "utf-8")) as { + projects?: unknown; + orchestrator?: { agent?: string }; + worker?: { agent?: string }; + path?: string; + }; + expect(flatAfter.projects).toBeUndefined(); + expect(flatAfter.path).toBeUndefined(); + expect(flatAfter.orchestrator).toMatchObject({ + agent: "codex", + }); + expect(flatAfter.worker).toMatchObject({ + agent: "opencode", + }); + } finally { + Object.defineProperty(process.stdin, "isTTY", { + value: originalStdinTty, + configurable: true, + }); + Object.defineProperty(process.stdout, "isTTY", { + value: originalStdoutTty, + configurable: true, + }); + if (origEnv === undefined) delete process.env["AO_CONFIG_PATH"]; + else process.env["AO_CONFIG_PATH"] = origEnv; + if (origGlobalEnv === undefined) delete process.env["AO_GLOBAL_CONFIG"]; + else process.env["AO_GLOBAL_CONFIG"] = origGlobalEnv; + } + }); }); diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 79850587c1..d04cf9c635 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -137,8 +137,39 @@ function readProjectBehaviorConfig(projectPath: string): LocalProjectConfig { return {}; } -function writeProjectBehaviorConfig(projectPath: string, config: LocalProjectConfig): void { - writeLocalProjectConfig(projectPath, config); +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function ensureRecord(value: unknown): Record { + return isRecord(value) ? value : {}; +} + +function ensureWrappedProjectConfigContainer( + rawConfig: Record, +): Record> { + if (!isRecord(rawConfig.projects)) { + rawConfig.projects = {}; + } + return rawConfig.projects as Record>; +} + +function getProjectConfig( + projects: Record>, + projectId: string, + fallback: ProjectConfig, +): Record { + const projectConfig = projects[projectId]; + if (isRecord(projectConfig)) return projectConfig; + return { ...(fallback as unknown as Record) }; +} + +function writeProjectBehaviorConfig( + projectPath: string, + config: LocalProjectConfig, + configPath?: string, +): void { + writeLocalProjectConfig(projectPath, config, configPath); } /** @@ -1650,11 +1681,13 @@ export function registerStart(program: Command): void { // ── Handle "new orchestrator" choice (deferred from already-running check) ── if (startNewOrchestrator) { const rawYaml = readFileSync(config.configPath, "utf-8"); - const rawConfig = yamlParse(rawYaml); + const rawConfig = ensureRecord(yamlParse(rawYaml)); + const projects = ensureWrappedProjectConfigContainer(rawConfig); + const projectForNewOrchestrator = getProjectConfig(projects, projectId, project); // Collect existing prefixes to avoid collisions const existingPrefixes = new Set( - Object.values(rawConfig.projects as Record>) + Object.values(projects) .map((p) => p.sessionPrefix as string) .filter(Boolean), ); @@ -1665,10 +1698,10 @@ export function registerStart(program: Command): void { const suffix = Math.random().toString(36).slice(2, 6); newId = `${projectId}-${suffix}`; newPrefix = generateSessionPrefix(newId); - } while (rawConfig.projects[newId] || existingPrefixes.has(newPrefix)); + } while (projects[newId] || existingPrefixes.has(newPrefix)); - rawConfig.projects[newId] = { - ...rawConfig.projects[projectId], + projects[newId] = { + ...(projectForNewOrchestrator as Record), sessionPrefix: newPrefix, }; const nextYaml = isCanonicalGlobalConfigPath(config.configPath) @@ -1732,11 +1765,29 @@ export function registerStart(program: Command): void { console.log(chalk.dim(` ✓ Saved to ${project.path}/agent-orchestrator.yaml\n`)); } else { const rawYaml = readFileSync(config.configPath, "utf-8"); - const rawConfig = yamlParse(rawYaml); - const proj = rawConfig.projects[projectId]; - proj.orchestrator = { ...(proj.orchestrator ?? {}), agent: orchestratorAgent }; - proj.worker = { ...(proj.worker ?? {}), agent: workerAgent }; - writeFileSync(config.configPath, configToYaml(rawConfig as Record)); + const rawConfig = ensureRecord(yamlParse(rawYaml)); + if ("projects" in rawConfig) { + const projects = ensureWrappedProjectConfigContainer(rawConfig); + const proj = getProjectConfig(projects, projectId, project); + proj.orchestrator = { ...(proj.orchestrator ?? {}), agent: orchestratorAgent }; + proj.worker = { ...(proj.worker ?? {}), agent: workerAgent }; + projects[projectId] = proj; + writeFileSync( + config.configPath, + configToYaml(rawConfig as Record), + ); + } else { + const nextLocalConfig = rawConfig as LocalProjectConfig; + nextLocalConfig.orchestrator = { + ...(nextLocalConfig.orchestrator ?? {}), + agent: orchestratorAgent, + }; + nextLocalConfig.worker = { + ...(nextLocalConfig.worker ?? {}), + agent: workerAgent, + }; + writeProjectBehaviorConfig(project.path, nextLocalConfig, config.configPath); + } console.log(chalk.dim(` ✓ Saved to ${config.configPath}\n`)); } config = loadConfig(config.configPath);