Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/setup-runtime-selection.md
Original file line number Diff line number Diff line change
@@ -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`.
66 changes: 2 additions & 64 deletions packages/cli/src/commands/doctor.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -583,69 +584,6 @@ function formatGhSymphonyCommand(
return `gh-symphony ${commandArgs.join(" ")}`;
}

function getCommandCandidates(
binary: string,
deps: Pick<DoctorDependencies, "platform" | "pathExtEnv">
): 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<boolean> {
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") {
Expand Down
122 changes: 121 additions & 1 deletion packages/cli/src/commands/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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."
)
);
});
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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(
Expand All @@ -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)
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading