diff --git a/.changeset/setup-runtime-selection.md b/.changeset/setup-runtime-selection.md new file mode 100644 index 0000000..d492e36 --- /dev/null +++ b/.changeset/setup-runtime-selection.md @@ -0,0 +1,5 @@ +--- +"@gh-symphony/cli": patch +--- + +Add runtime selection to `gh-symphony setup` so issue #390 users can choose Codex or Claude Code during onboarding, pass `--runtime` in non-interactive setup, and receive a clear install hint when the selected runtime command is missing from `PATH`. diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index f70605a..98db75d 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -1,7 +1,7 @@ import { constants } from "node:fs"; import { execFileSync, spawnSync } from "node:child_process"; import { access, mkdir, readFile, stat } from "node:fs/promises"; -import { delimiter, isAbsolute, join, resolve } from "node:path"; +import { isAbsolute, join, resolve } from "node:path"; import { parseWorkflowMarkdown, type ParsedWorkflow, @@ -56,6 +56,7 @@ import { readGitHubProjectBinding, renderIssueWorkflowPreview, } from "./workflow.js"; +import { commandExistsOnPath } from "../utils/command-exists-on-path.js"; type DoctorStatus = "pass" | "warn" | "fail"; type DoctorRemediationStatus = "applied" | "skipped" | "manual"; @@ -583,69 +584,6 @@ function formatGhSymphonyCommand( return `gh-symphony ${commandArgs.join(" ")}`; } -function getCommandCandidates( - binary: string, - deps: Pick -): string[] { - if (deps.platform !== "win32") { - return [binary]; - } - - const pathExts = (deps.pathExtEnv ?? ".COM;.EXE;.BAT;.CMD") - .split(";") - .map((ext) => ext.trim()) - .filter(Boolean); - const normalizedBinary = binary.toLowerCase(); - if (pathExts.some((ext) => normalizedBinary.endsWith(ext.toLowerCase()))) { - return [binary]; - } - - return [binary, ...pathExts.map((ext) => `${binary}${ext}`)]; -} - -async function commandExistsOnPath( - binary: string, - deps: Pick< - DoctorDependencies, - "access" | "pathEnv" | "pathExtEnv" | "platform" - > -): Promise { - if (!binary) { - return false; - } - - const candidates = getCommandCandidates(binary, deps); - if (isAbsolute(binary) || binary.includes("/") || binary.includes("\\")) { - for (const candidate of candidates) { - try { - await deps.access(resolve(candidate), constants.X_OK); - return true; - } catch { - continue; - } - } - - return false; - } - - for (const segment of (deps.pathEnv ?? "").split(delimiter)) { - if (!segment) { - continue; - } - for (const command of candidates) { - const candidate = join(segment, command); - try { - await deps.access(candidate, constants.X_OK); - return true; - } catch { - continue; - } - } - } - - return false; -} - function toDoctorClaudeCheck(check: ClaudePreflightCheck): DoctorCheckResult { const id: DoctorCheckId = check.id; if (check.status === "pass") { diff --git a/packages/cli/src/commands/setup.test.ts b/packages/cli/src/commands/setup.test.ts index 27d83ee..f40e86c 100644 --- a/packages/cli/src/commands/setup.test.ts +++ b/packages/cli/src/commands/setup.test.ts @@ -36,6 +36,7 @@ import * as p from "@clack/prompts"; import setupCommand from "./setup.js"; import * as ghAuth from "../github/gh-auth.js"; import * as githubClient from "../github/client.js"; +import * as commandExists from "../utils/command-exists-on-path.js"; const MOCK_PROJECT_SUMMARY = { id: "PVT_setup_1", @@ -167,10 +168,12 @@ describe("setup command", () => { vi.spyOn(githubClient, "getProjectDetail").mockResolvedValue( MOCK_PROJECT_DETAIL ); + vi.spyOn(commandExists, "commandExistsOnPath").mockResolvedValue(true); }); afterEach(() => { process.chdir(originalCwd); + vi.unstubAllEnvs(); }); it("reports removed project/workspace setup flags with migration guidance", async () => { @@ -193,7 +196,7 @@ describe("setup command", () => { ); expect(stderrWrite).toHaveBeenCalledWith( expect.stringContaining( - "Supported flags: --non-interactive, --output, --skip-skills. Deprecated no-op: --skip-context." + "Supported flags: --non-interactive, --output, --runtime, --skip-skills. Deprecated no-op: --skip-context." ) ); }); @@ -258,6 +261,7 @@ describe("setup command", () => { expect(errorOutput).toContain("gh-symphony setup"); } expect(errorOutput).not.toMatch(/[가-힣]/); + expect(p.select).not.toHaveBeenCalled(); expect(githubClient.listUserProjects).not.toHaveBeenCalled(); expect(process.exitCode).toBe(1); } @@ -305,6 +309,99 @@ describe("setup command", () => { expect(project).not.toHaveProperty("repositories"); }); + it("writes Claude runtime config from non-interactive --runtime claude-code", async () => { + const cwd = await mkdtemp(join(tmpdir(), "setup-non-interactive-claude-")); + const configDir = await mkdtemp( + join(tmpdir(), "setup-non-interactive-claude-config-") + ); + initializeGitRemote(cwd); + process.chdir(cwd); + vi.mocked(commandExists.commandExistsOnPath).mockResolvedValueOnce(false); + + const stdoutWrite = vi + .spyOn(process.stdout, "write") + .mockImplementation(() => true); + + await setupCommand(["--non-interactive", "--runtime", "claude-code"], { + configDir, + verbose: false, + json: false, + noColor: true, + }); + + const workflow = await readFile(join(cwd, "WORKFLOW.md"), "utf8"); + const stdout = stdoutWrite.mock.calls + .map(([chunk]) => String(chunk)) + .join(""); + + expect(workflow).toContain("kind: claude-print"); + expect(workflow).toContain("command: claude"); + expect(stdout).toContain("Agent runtime claude-print"); + expect(p.log.warn).toHaveBeenCalledWith( + expect.stringContaining( + "Selected runtime 'claude-print' requires the 'claude' command" + ) + ); + }); + + it("keeps non-interactive JSON setup output parseable when the runtime is missing", async () => { + const cwd = await mkdtemp(join(tmpdir(), "setup-json-runtime-cwd-")); + const configDir = await mkdtemp( + join(tmpdir(), "setup-json-runtime-config-") + ); + initializeGitRemote(cwd); + process.chdir(cwd); + vi.mocked(commandExists.commandExistsOnPath).mockResolvedValueOnce(false); + + const stdoutWrite = vi + .spyOn(process.stdout, "write") + .mockImplementation(() => true); + const stderrWrite = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + + await setupCommand(["--non-interactive", "--runtime", "claude-code"], { + configDir, + verbose: false, + json: true, + noColor: true, + }); + + const stdout = stdoutWrite.mock.calls + .map(([chunk]) => String(chunk)) + .join(""); + + expect(JSON.parse(stdout)).toMatchObject({ + status: "created", + runtime: "claude-print", + }); + expect(p.log.warn).not.toHaveBeenCalled(); + expect(stderrWrite).toHaveBeenCalledWith( + expect.stringContaining( + "Warning: Selected runtime 'claude-print' requires the 'claude' command" + ) + ); + }); + + it("rejects unsupported setup runtime presets", async () => { + const stderrWrite = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + + await setupCommand(["--non-interactive", "--runtime", "claud-print"], { + configDir: "/tmp/unused", + verbose: false, + json: false, + noColor: true, + }); + + expect(process.exitCode).toBe(1); + expect(stderrWrite).toHaveBeenCalledWith( + "Error: Unsupported runtime 'claud-print'. Choose one of: codex-app-server, claude-print.\n" + ); + expect(githubClient.listUserProjects).not.toHaveBeenCalled(); + }); + it("shows a final summary and writes the selected repositories in interactive mode", async () => { const cwd = await mkdtemp(join(tmpdir(), "setup-interactive-cwd-")); const configDir = await mkdtemp( @@ -314,6 +411,7 @@ describe("setup command", () => { process.chdir(cwd); vi.mocked(p.select) + .mockResolvedValueOnce("codex-app-server" as never) .mockResolvedValueOnce(MOCK_PROJECT_SUMMARY.id as never) .mockResolvedValueOnce("wait" as never) .mockResolvedValueOnce("active" as never) @@ -348,6 +446,25 @@ describe("setup command", () => { expect.stringContaining("Repository: current working directory"), "Final summary" ); + expect(p.outro).toHaveBeenCalledWith( + expect.stringContaining( + "Repository runtime is ready for codex-app-server." + ) + ); + const selectMessages = vi + .mocked(p.select) + .mock.calls.map(([input]) => input.message); + expect(selectMessages).toEqual( + expect.arrayContaining([ + "Step 1/5 — Select the agent runtime:", + "Step 2/5 — Select a GitHub Project board:", + expect.stringContaining("Step 3/5 — Map column"), + expect.stringContaining("Step 5/5 — Choose one priority source:"), + ]) + ); + expect(vi.mocked(p.confirm).mock.calls[0]?.[0]).toMatchObject({ + message: expect.stringContaining("Step 4/5 — Enable blocker check?"), + }); }); it("validates state mappings before prompting for blocker checks", async () => { @@ -359,6 +476,7 @@ describe("setup command", () => { process.chdir(cwd); vi.mocked(p.select) + .mockResolvedValueOnce("codex-app-server" as never) .mockResolvedValueOnce(MOCK_PROJECT_SUMMARY.id as never) .mockResolvedValueOnce("wait" as never) .mockResolvedValueOnce("wait" as never) @@ -390,6 +508,7 @@ describe("setup command", () => { { name: "priority: p1", color: "ffaa00", description: null }, ]); vi.mocked(p.select) + .mockResolvedValueOnce("codex-app-server" as never) .mockResolvedValueOnce(MOCK_PROJECT_SUMMARY.id as never) .mockResolvedValueOnce("wait" as never) .mockResolvedValueOnce("active" as never) @@ -486,6 +605,7 @@ describe("setup command", () => { process.chdir(cwd); vi.mocked(p.select) + .mockResolvedValueOnce("codex-app-server" as never) .mockResolvedValueOnce(MOCK_PROJECT_SUMMARY.id as never) .mockResolvedValueOnce("wait" as never) .mockResolvedValueOnce("active" as never) diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index d8a3acc..aaa5680 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -1,4 +1,5 @@ import * as p from "@clack/prompts"; +import { access } from "node:fs/promises"; import { resolve } from "node:path"; import type { GlobalOptions } from "../index.js"; import { @@ -35,19 +36,29 @@ import { promptLegacyGhSymphonyCleanup, warnDeprecatedSkipContext, } from "./workflow-init.js"; +import { commandExistsOnPath } from "../utils/command-exists-on-path.js"; import { toWorkflowLifecycleConfig, validateStateMapping, } from "../mapping/smart-defaults.js"; import { initRepoRuntime } from "../repo-runtime.js"; +import { + isSupportedInitRuntime, + normalizeInitRuntime, + resolveRuntimeCommand, + type InitRuntimeKind, +} from "../workflow/workflow-runtime.js"; type SetupFlags = { nonInteractive: boolean; output?: string; + runtime?: string; skipSkills: boolean; skipContext: boolean; }; +const SETUP_RUNTIME_CHOICES = "codex-app-server, claude-print"; + function parseSetupFlags(args: string[]): SetupFlags { const flags: SetupFlags = { nonInteractive: false, @@ -64,9 +75,19 @@ function parseSetupFlags(args: string[]): SetupFlags { flags.nonInteractive = true; break; case "--output": + if (!next || next.startsWith("-")) { + throw new Error("Option '--output' argument missing"); + } flags.output = next; i += 1; break; + case "--runtime": + if (!next || next.startsWith("-")) { + throw new Error("Option '--runtime' argument missing"); + } + flags.runtime = next; + i += 1; + break; case "--skip-skills": flags.skipSkills = true; break; @@ -76,7 +97,7 @@ function parseSetupFlags(args: string[]): SetupFlags { default: if (arg?.startsWith("-")) { throw new Error( - `Unknown option '${arg}'. Removed project/workspace flags are no longer supported; run 'gh-symphony setup' from inside the target repository. Supported flags: --non-interactive, --output, --skip-skills. Deprecated no-op: --skip-context.` + `Unknown option '${arg}'. Removed project/workspace flags are no longer supported; run 'gh-symphony setup' from inside the target repository. Supported flags: --non-interactive, --output, --runtime, --skip-skills. Deprecated no-op: --skip-context.` ); } } @@ -85,6 +106,69 @@ function parseSetupFlags(args: string[]): SetupFlags { return flags; } +function resolveSetupRuntime(runtime: string | undefined): string { + return normalizeInitRuntime(runtime ?? "codex-app-server"); +} + +function validateSetupRuntime(runtime: string): string | null { + if (isSupportedInitRuntime(runtime)) { + return null; + } + return `Unsupported runtime '${runtime}'. Choose one of: ${SETUP_RUNTIME_CHOICES}.`; +} + +async function promptRuntimeSelection(): Promise { + return abortIfCancelled( + p.select({ + message: "Step 1/5 — Select the agent runtime:", + options: [ + { + value: "codex-app-server", + label: "codex-app-server", + hint: "Codex app-server JSON-RPC runtime", + }, + { + value: "claude-print", + label: "claude-print", + hint: "Claude Code non-interactive stream-json runtime", + }, + ], + }) + ); +} + +function runtimeInstallHint(runtime: string): string { + const command = resolveRuntimeCommand(runtime); + if (runtime === "claude-print") { + return `Selected runtime '${runtime}' requires the '${command}' command, but it was not found on PATH. Install Claude Code and confirm '${command} --version' works before running 'gh-symphony repo start'.`; + } + return `Selected runtime '${runtime}' requires the '${command}' command, but it was not found on PATH. Install Codex and confirm '${command} --version' works before running 'gh-symphony repo start'.`; +} + +async function checkRuntimeInstall(runtime: string): Promise { + const command = resolveRuntimeCommand(runtime); + return commandExistsOnPath(command, { + access, + pathEnv: process.env.PATH, + pathExtEnv: process.env.PATHEXT, + platform: process.platform, + }); +} + +async function warnIfRuntimeMissing( + runtime: string, + options: Pick +): Promise { + if (!(await checkRuntimeInstall(runtime))) { + const hint = runtimeInstallHint(runtime); + if (options.json) { + process.stderr.write(`Warning: ${hint}\n`); + return; + } + p.log.warn(hint); + } +} + function displayScopeError( error: GitHubScopeError, retryCommand: string @@ -146,7 +230,7 @@ async function selectProjectSummary( const selectedProjectId = await abortIfCancelled( p.select({ - message: "Step 1/4 — Select a GitHub Project board:", + message: "Step 2/5 — Select a GitHub Project board:", options: projects.map((project) => ({ value: project.id, label: `${project.owner.login}/${project.title}`, @@ -164,6 +248,7 @@ function printNonInteractiveSummary(input: { githubProjectId: string; workflowPath: string; runtimeDir: string; + runtime: string; repository: string; }): void { process.stdout.write( @@ -171,6 +256,7 @@ function printNonInteractiveSummary(input: { `GitHub Project ${input.githubProjectTitle} (${input.githubProjectId})`, `Repository ${input.repository}`, `WORKFLOW.md ${input.workflowPath}`, + `Agent runtime ${input.runtime}`, `Runtime ${input.runtimeDir}`, "Ready. Run 'gh-symphony repo start' to begin orchestration.", ] @@ -211,6 +297,14 @@ async function runNonInteractive( flags: SetupFlags, options: GlobalOptions ): Promise { + const selectedRuntime = resolveSetupRuntime(flags.runtime); + const runtimeError = validateSetupRuntime(selectedRuntime); + if (runtimeError) { + process.stderr.write(`Error: ${runtimeError}\n`); + process.exitCode = 1; + return; + } + let token: string; try { token = getGhToken(); @@ -296,7 +390,7 @@ async function runNonInteractive( priority, includePriorityTemplates: !priorityField, mappings, - runtime: "codex", + runtime: selectedRuntime, skipSkills: flags.skipSkills, skipContext: flags.skipContext, }); @@ -309,7 +403,7 @@ async function runNonInteractive( priorityField, priority, includePriorityTemplates: !priorityField, - runtime: "codex", + runtime: selectedRuntime, skipSkills: flags.skipSkills, skipContext: flags.skipContext, }); @@ -319,11 +413,13 @@ async function runNonInteractive( workflowFile: workflowPath, }); + await warnIfRuntimeMissing(selectedRuntime, options); if (options.json) { process.stdout.write( JSON.stringify({ status: "created", output: workflowPath, + runtime: selectedRuntime, runtimeDir: runtime.configDir, repository: `${runtime.repository.owner}/${runtime.repository.name}`, githubProjectId: projectDetail.id, @@ -336,6 +432,7 @@ async function runNonInteractive( githubProjectTitle: projectDetail.title, githubProjectId: projectDetail.id, workflowPath, + runtime: selectedRuntime, runtimeDir: runtime.configDir, repository: `${runtime.repository.owner}/${runtime.repository.name}`, }); @@ -343,7 +440,7 @@ async function runNonInteractive( async function runInteractive( flags: SetupFlags, - _options: GlobalOptions + options: GlobalOptions ): Promise { p.intro("gh-symphony — One-command Setup"); @@ -369,6 +466,8 @@ async function runInteractive( return; } + const selectedRuntime = await promptRuntimeSelection(); + const projectsSpinner = p.spinner(); projectsSpinner.start("Loading GitHub Project boards..."); @@ -416,7 +515,7 @@ async function runInteractive( projectDetail.linkedRepositories ); const mappings = await promptStateMappings(statusField, { - stepLabel: "Step 2/4", + stepLabel: "Step 3/5", }); const workflowValidation = validateStateMapping(mappings); if (!workflowValidation.valid) { @@ -433,7 +532,7 @@ async function runInteractive( const lifecycleBase = toWorkflowLifecycleConfig(statusField.name, mappings); const blockerCheckStates = await promptBlockerCheck(lifecycleBase, { - stepLabel: "Step 3/4", + stepLabel: "Step 4/5", }); const lifecycle = toWorkflowLifecycleConfig(statusField.name, mappings, { blockerCheckStates, @@ -443,7 +542,7 @@ async function runInteractive( const { priority, priorityField } = await promptPriorityConfig({ priorityResolution, labelNames: priorityLabelNames, - stepLabel: "Step 4/4", + stepLabel: "Step 5/5", }); const workflowPath = resolve(flags.output ?? "WORKFLOW.md"); @@ -457,7 +556,7 @@ async function runInteractive( includePriorityTemplates: priority.source === "disabled", mappings, lifecycle, - runtime: "codex", + runtime: selectedRuntime, skipSkills: flags.skipSkills, skipContext: flags.skipContext, }); @@ -466,6 +565,7 @@ async function runInteractive( [ `GitHub Project: ${projectDetail.title}`, `Authenticated: ${login}`, + `Agent runtime: ${selectedRuntime}`, `Repository: current working directory`, "", renderDryRunPreview(workflowPath, workflowPlan, ecosystemPlan).trimEnd(), @@ -498,7 +598,7 @@ async function runInteractive( priority, lifecycle, includePriorityTemplates: priority.source === "disabled", - runtime: "codex", + runtime: selectedRuntime, skipSkills: flags.skipSkills, skipContext: flags.skipContext, }); @@ -516,7 +616,8 @@ async function runInteractive( return; } + await warnIfRuntimeMissing(selectedRuntime, options); p.outro( - "Repository runtime is ready.\n Run 'gh-symphony repo start' to begin orchestration." + `Repository runtime is ready for ${selectedRuntime}.\n Run 'gh-symphony repo start' to begin orchestration.` ); } diff --git a/packages/cli/src/completion.ts b/packages/cli/src/completion.ts index 915a31f..01c33c0 100644 --- a/packages/cli/src/completion.ts +++ b/packages/cli/src/completion.ts @@ -42,6 +42,7 @@ const COMMAND_OPTIONS: Record = { setup: [ "--non-interactive", "--output", + "--runtime", "--skip-skills", "--skip-context", ...GLOBAL_OPTIONS, diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 1a09754..ba5f744 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -335,6 +335,10 @@ function createProgram(): { program: Command; wasInvoked: () => boolean } { .description("Run the one-command first-run setup flow") .option("--non-interactive", "Run without prompts") .option("--output ", "Write WORKFLOW.md to a custom path") + .option( + "--runtime ", + "Runtime preset: codex-app-server or claude-print" + ) .option("--skip-skills", "Skip runtime skill generation") .option("--skip-context", "Deprecated no-op") .allowExcessArguments(false) @@ -344,6 +348,7 @@ function createProgram(): { program: Command; wasInvoked: () => boolean } { const args: string[] = []; pushOption(args, "--non-interactive", values.nonInteractive); pushOption(args, "--output", values.output); + pushOption(args, "--runtime", values.runtime); pushOption(args, "--skip-skills", values.skipSkills); pushOption(args, "--skip-context", values.skipContext); await invokeHandler("setup", args, values); diff --git a/packages/cli/src/utils/command-exists-on-path.ts b/packages/cli/src/utils/command-exists-on-path.ts new file mode 100644 index 0000000..ba739fa --- /dev/null +++ b/packages/cli/src/utils/command-exists-on-path.ts @@ -0,0 +1,71 @@ +import { constants } from "node:fs"; +import { delimiter, isAbsolute, join, resolve } from "node:path"; + +type AccessFn = typeof import("node:fs/promises").access; + +type CommandExistsDependencies = { + access: AccessFn; + pathEnv: string | undefined; + pathExtEnv: string | undefined; + platform: NodeJS.Platform; +}; + +function getCommandCandidates( + binary: string, + deps: Pick +): string[] { + if (deps.platform !== "win32") { + return [binary]; + } + + const pathExts = (deps.pathExtEnv ?? ".COM;.EXE;.BAT;.CMD") + .split(";") + .map((ext) => ext.trim()) + .filter(Boolean); + const normalizedBinary = binary.toLowerCase(); + if (pathExts.some((ext) => normalizedBinary.endsWith(ext.toLowerCase()))) { + return [binary]; + } + + return [binary, ...pathExts.map((ext) => `${binary}${ext}`)]; +} + +export async function commandExistsOnPath( + binary: string, + deps: CommandExistsDependencies +): Promise { + if (!binary) { + return false; + } + + const candidates = getCommandCandidates(binary, deps); + if (isAbsolute(binary) || binary.includes("/") || binary.includes("\\")) { + for (const candidate of candidates) { + try { + await deps.access(resolve(candidate), constants.X_OK); + return true; + } catch { + continue; + } + } + + return false; + } + + for (const segment of (deps.pathEnv ?? "").split(delimiter)) { + if (!segment) { + continue; + } + for (const command of candidates) { + const candidate = join(segment, command); + try { + await deps.access(candidate, constants.X_OK); + return true; + } catch { + continue; + } + } + } + + return false; +}