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
26 changes: 15 additions & 11 deletions src/cli/handlers/init_handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { resolve } from "@std/path";
import { bold, dim, green, red, yellow } from "@std/fmt/colors";
import { InitProjectUseCase } from "../../application/init_project.ts";
import { findHarness } from "../harnesses.ts";
import { DEFAULT_HARNESS, type HarnessKey, pickHarness } from "../harness_picker.ts";
import { DenoFsWriter } from "../../infrastructure/deno_fs_writer.ts";
import { DenoGit } from "../../infrastructure/deno_git.ts";
import { FsLockStore } from "../../infrastructure/fs_lock_store.ts";
Expand All @@ -12,18 +13,20 @@ export type InitIntent = {
projectName: string | null;
here: boolean;
noGit: boolean;
ai:
| "claude"
| "cursor"
| "codex"
| "gemini"
| "windsurf"
| "copilot"
| "opencode"
| "antigravity";
ai: HarnessKey | null;
force: boolean;
};

function resolveHarnessKey(explicit: HarnessKey | null): HarnessKey {
if (explicit !== null) return explicit;
if (!Deno.stdin.isTerminal()) return DEFAULT_HARNESS;
return pickHarness({
readLine: () => prompt("Choose [1-8]:"),
log: (s) => console.log(s),
errLog: (s) => console.error(red(s)),
});
}

export async function runInit(intent: InitIntent): Promise<number> {
const cwd = Deno.cwd();

Expand All @@ -37,9 +40,10 @@ export async function runInit(intent: InitIntent): Promise<number> {
return 2;
}

const harness = findHarness(intent.ai);
const aiKey = resolveHarnessKey(intent.ai);
const harness = findHarness(aiKey);
if (!harness) {
console.error(red(`error: unknown harness '${intent.ai}'`));
console.error(red(`error: unknown harness '${aiKey}'`));
return 2;
}

Expand Down
39 changes: 39 additions & 0 deletions src/cli/harness_picker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { HARNESSES } from "./harnesses.ts";

export type HarnessKey =
| "claude"
| "cursor"
| "codex"
| "gemini"
| "windsurf"
| "copilot"
| "opencode"
| "antigravity";

export const DEFAULT_HARNESS: HarnessKey = "claude";

export type PickerIO = {
readLine: () => string | null;
log: (s: string) => void;
errLog: (s: string) => void;
};

export function pickHarness(io: PickerIO): HarnessKey {
io.log("Choose your AI harness (press Enter for default):");
for (let i = 0; i < HARNESSES.length; i++) {
const h = HARNESSES[i];
const marker = h.key === DEFAULT_HARNESS ? " (default)" : "";
io.log(` ${i + 1}) ${h.displayName}${marker}`);
}
while (true) {
const raw = (io.readLine() ?? "").trim();
if (raw === "") return DEFAULT_HARNESS;
const idx = parseInt(raw, 10) - 1;
if (Number.isInteger(idx) && idx >= 0 && idx < HARNESSES.length) {
return HARNESSES[idx].key as HarnessKey;
}
io.errLog(
`invalid choice "${raw}" — expected 1-${HARNESSES.length} or empty for default`,
);
}
}
13 changes: 11 additions & 2 deletions src/cli/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ export type Intent =
projectName: string | null;
here: boolean;
noGit: boolean;
/**
* Harness key when `--ai` was passed on the CLI; `null` when the user
* did not specify one. The init handler is responsible for resolving
* `null` to a concrete harness — interactively when stdin is a TTY,
* silently to the default when it isn't (preserves CI behaviour).
*/
ai:
| "claude"
| "cursor"
Expand All @@ -16,7 +22,8 @@ export type Intent =
| "windsurf"
| "copilot"
| "opencode"
| "antigravity";
| "antigravity"
| null;
force: boolean;
}
| { kind: "self-update"; checkOnly: boolean }
Expand Down Expand Up @@ -50,8 +57,10 @@ export function parseArgs(argv: string[]): Intent {

const [command, ...rest] = parsed._.map(String);
if (command === "init") {
const aiRaw = typeof parsed.ai === "string" ? parsed.ai : "claude";
const aiProvided = typeof parsed.ai === "string";
const aiRaw = aiProvided ? (parsed.ai as string) : null;
if (
aiRaw !== null &&
aiRaw !== "claude" &&
aiRaw !== "cursor" &&
aiRaw !== "codex" &&
Expand Down
63 changes: 63 additions & 0 deletions tests/cli/harness_picker_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { assertEquals } from "@std/assert";
import { DEFAULT_HARNESS, type PickerIO, pickHarness } from "../../src/cli/harness_picker.ts";
import { HARNESSES } from "../../src/cli/harnesses.ts";

function makeIO(inputs: ReadonlyArray<string | null>): {
io: PickerIO;
logs: string[];
errs: string[];
} {
const queue = [...inputs];
const logs: string[] = [];
const errs: string[] = [];
return {
io: {
readLine: () => (queue.length > 0 ? queue.shift()! : null),
log: (s: string) => logs.push(s),
errLog: (s: string) => errs.push(s),
},
logs,
errs,
};
}

Deno.test("pickHarness returns the default on empty input", () => {
const { io } = makeIO([""]);
assertEquals(pickHarness(io), DEFAULT_HARNESS);
});

Deno.test("pickHarness lists every supported harness exactly once", () => {
const { io, logs } = makeIO([""]);
pickHarness(io);
// 1 header line + 1 line per harness
assertEquals(logs.length, 1 + HARNESSES.length);
for (let i = 0; i < HARNESSES.length; i++) {
assertEquals(logs[i + 1].includes(`${i + 1})`), true);
assertEquals(logs[i + 1].includes(HARNESSES[i].displayName), true);
}
});

Deno.test("pickHarness returns the picked harness for each valid 1-based index", () => {
for (let i = 0; i < HARNESSES.length; i++) {
const { io } = makeIO([String(i + 1)]);
assertEquals(pickHarness(io), HARNESSES[i].key);
}
});

Deno.test("pickHarness re-prompts on out-of-range and non-numeric input until a valid choice", () => {
const { io, errs } = makeIO(["0", "9", "abc", "3"]);
// index 2 (= "3") = codex in HARNESSES order
assertEquals(pickHarness(io), HARNESSES[2].key);
assertEquals(errs.length, 3);
for (const e of errs) assertEquals(e.includes("invalid choice"), true);
});

Deno.test("pickHarness treats whitespace-only input as the default", () => {
const { io } = makeIO([" "]);
assertEquals(pickHarness(io), DEFAULT_HARNESS);
});

Deno.test("pickHarness treats null (EOF / Ctrl-D) as the default", () => {
const { io } = makeIO([null]);
assertEquals(pickHarness(io), DEFAULT_HARNESS);
});
12 changes: 6 additions & 6 deletions tests/cli/parser_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Deno.test("parseArgs returns init intent with a project name", () => {
projectName: "my-project",
here: false,
noGit: false,
ai: "claude",
ai: null,
force: false,
});
});
Expand All @@ -29,7 +29,7 @@ Deno.test("parseArgs returns init intent with --here", () => {
projectName: null,
here: true,
noGit: false,
ai: "claude",
ai: null,
force: false,
});
});
Expand All @@ -40,7 +40,7 @@ Deno.test("parseArgs returns init intent with --no-git", () => {
projectName: "x",
here: false,
noGit: true,
ai: "claude",
ai: null,
force: false,
});
});
Expand Down Expand Up @@ -106,7 +106,7 @@ Deno.test("parseArgs init with --force", () => {
projectName: "demo",
here: false,
noGit: false,
ai: "claude",
ai: null,
force: true,
});
});
Expand Down Expand Up @@ -144,9 +144,9 @@ Deno.test("parseArgs returns unknown for invalid --ai value", () => {
});
});

Deno.test("parseArgs init defaults --ai to claude", () => {
Deno.test("parseArgs init leaves --ai null when not provided (resolved by handler)", () => {
const r = parseArgs(["init", "demo"]);
if (r.kind === "init") assertEquals(r.ai, "claude");
if (r.kind === "init") assertEquals(r.ai, null);
});

Deno.test("parseArgs accepts init --ai codex", () => {
Expand Down
5 changes: 4 additions & 1 deletion tests/integration/init_cursor_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,10 @@ Deno.test("specflow init --ai cursor scaffolds a Cursor layout", async () => {
});
});

Deno.test("specflow init (no --ai) still defaults to Claude", async () => {
Deno.test("specflow init (no --ai) defaults to Claude in non-TTY environments", async () => {
// Deno.Command pipes stdin, so Deno.stdin.isTerminal() is false here.
// The init handler must therefore skip the interactive harness picker
// and fall through to the default — preserving CI / scripted usage.
await withTempDir(async (parent) => {
const { code } = await runSpecflow(["init", "demo", "--no-git"], { cwd: parent });
assertEquals(code, 0);
Expand Down
Loading