From 1ad3943267869fe4eb8ca0d1da2e682c452b37e2 Mon Sep 17 00:00:00 2001 From: Kevin <6215184+kevinkod@users.noreply.github.com> Date: Sat, 2 May 2026 03:19:29 +0200 Subject: [PATCH] feat(init): prompt for harness when --ai is absent and stdin is a TTY (#36) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `specflow init` no longer silently defaults to Claude when the user hasn't passed --ai. In an interactive terminal it now lists the 8 supported harnesses and asks the user to pick one (Enter = Claude default, invalid input re-prompts). When stdin isn't a TTY (CI, scripts, piped input), the silent Claude default is preserved — zero regression for automated usage. Implementation: - New pure module src/cli/harness_picker.ts encapsulates the list, default, and re-prompt loop with an injectable readLine for testing. - Parser: ai field becomes Harness | null. The handler resolves null to a concrete harness via Deno.stdin.isTerminal() + pickHarness. - TerminalPrompt is left untouched — its select() doesn't support empty-input-as-default and the picker has its own loop semantics, so reusing it would have required a richer signature than this ticket needs. Closes #36 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cli/handlers/init_handler.ts | 26 ++++++----- src/cli/harness_picker.ts | 39 +++++++++++++++++ src/cli/parser.ts | 13 +++++- tests/cli/harness_picker_test.ts | 63 +++++++++++++++++++++++++++ tests/cli/parser_test.ts | 12 ++--- tests/integration/init_cursor_test.ts | 5 ++- 6 files changed, 138 insertions(+), 20 deletions(-) create mode 100644 src/cli/harness_picker.ts create mode 100644 tests/cli/harness_picker_test.ts diff --git a/src/cli/handlers/init_handler.ts b/src/cli/handlers/init_handler.ts index b0e96f6..2280822 100644 --- a/src/cli/handlers/init_handler.ts +++ b/src/cli/handlers/init_handler.ts @@ -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"; @@ -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 { const cwd = Deno.cwd(); @@ -37,9 +40,10 @@ export async function runInit(intent: InitIntent): Promise { 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; } diff --git a/src/cli/harness_picker.ts b/src/cli/harness_picker.ts new file mode 100644 index 0000000..491c89e --- /dev/null +++ b/src/cli/harness_picker.ts @@ -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`, + ); + } +} diff --git a/src/cli/parser.ts b/src/cli/parser.ts index 76187e6..2b8cf72 100644 --- a/src/cli/parser.ts +++ b/src/cli/parser.ts @@ -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" @@ -16,7 +22,8 @@ export type Intent = | "windsurf" | "copilot" | "opencode" - | "antigravity"; + | "antigravity" + | null; force: boolean; } | { kind: "self-update"; checkOnly: boolean } @@ -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" && diff --git a/tests/cli/harness_picker_test.ts b/tests/cli/harness_picker_test.ts new file mode 100644 index 0000000..e48d792 --- /dev/null +++ b/tests/cli/harness_picker_test.ts @@ -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): { + 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); +}); diff --git a/tests/cli/parser_test.ts b/tests/cli/parser_test.ts index 946fcbf..e75a836 100644 --- a/tests/cli/parser_test.ts +++ b/tests/cli/parser_test.ts @@ -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, }); }); @@ -29,7 +29,7 @@ Deno.test("parseArgs returns init intent with --here", () => { projectName: null, here: true, noGit: false, - ai: "claude", + ai: null, force: false, }); }); @@ -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, }); }); @@ -106,7 +106,7 @@ Deno.test("parseArgs init with --force", () => { projectName: "demo", here: false, noGit: false, - ai: "claude", + ai: null, force: true, }); }); @@ -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", () => { diff --git a/tests/integration/init_cursor_test.ts b/tests/integration/init_cursor_test.ts index 4b6d6d0..1f53da6 100644 --- a/tests/integration/init_cursor_test.ts +++ b/tests/integration/init_cursor_test.ts @@ -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);