diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a16d8a..aab7acc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,7 +107,7 @@ jobs: echo "bun unexpectedly available on the sanitized PATH" >&2 exit 1 fi - hunk --help | grep 'Usage: hunk' + hunk --help | grep '^Usage:$' prebuilt-npm: name: Verify prebuilt npm package (${{ matrix.os }}) diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 3ad7332..3ff2e90 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -75,7 +75,7 @@ jobs: echo "bun unexpectedly available on the sanitized PATH" >&2 exit 1 fi - hunk --help | grep 'Usage: hunk' + hunk --help | grep '^Usage:$' - name: Stage prebuilt npm packages run: bun run build:prebuilt:npm diff --git a/README.md b/README.md index 5a1895a..43e2a85 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,8 @@ Requirements: ## Quick start ```bash -hunk # show help +hunk # launch the default review UI (same as `hunk diff`) +hunk --help # show top-level help hunk --version # print the installed version ``` diff --git a/src/core/cli.ts b/src/core/cli.ts index 5282ebb..d457747 100644 --- a/src/core/cli.ts +++ b/src/core/cli.ts @@ -101,9 +101,11 @@ function renderCliVersion() { } /** Build the top-level help text shown by bare `hunk` and `hunk --help`. */ -function renderCliHelp() { +export function renderCliHelp() { return [ - "Usage: hunk [options]", + "Usage:", + " hunk", + " hunk [options]", "", "Desktop-inspired terminal diff viewer for agent-authored changesets.", "", @@ -139,6 +141,7 @@ function renderCliHelp() { " --exclude-untracked hide untracked files in working tree reviews", "", "Notes:", + " Bare `hunk` starts the default review UI (`hunk diff`) in an interactive terminal.", " Run `hunk --help` for command-specific syntax and options.", "", ].join("\n"); @@ -445,7 +448,12 @@ async function parseDifftoolCommand(tokens: string[], argv: string[]): Promise

{ const args = argv.slice(2); const [commandName, ...rest] = args; - if (!commandName || commandName === "help" || commandName === "--help" || commandName === "-h") { + if (!commandName) { + return { kind: "bare" }; + } + + if (commandName === "help" || commandName === "--help" || commandName === "-h") { return { kind: "help", text: renderCliHelp() }; } diff --git a/src/core/startup.ts b/src/core/startup.ts index b7b5ecb..57675f1 100644 --- a/src/core/startup.ts +++ b/src/core/startup.ts @@ -8,9 +8,15 @@ import { usesPipedPatchInput, type ControllingTerminal, } from "./terminal"; -import type { AppBootstrap, CliInput, ParsedCliInput, SessionCommandInput } from "./types"; +import type { + AppBootstrap, + CliInput, + PagerCommandInput, + ParsedCliInput, + SessionCommandInput, +} from "./types"; import { canReloadInput } from "./watch"; -import { parseCli } from "./cli"; +import { parseCli, renderCliHelp } from "./cli"; export type StartupPlan = | { @@ -44,6 +50,7 @@ export interface StartupDeps { loadAppBootstrapImpl?: typeof loadAppBootstrap; usesPipedPatchInputImpl?: typeof usesPipedPatchInput; openControllingTerminalImpl?: typeof openControllingTerminal; + stdinIsTTY?: boolean; } /** Normalize startup work so help, pager, and app-bootstrap paths can be tested directly. */ @@ -60,8 +67,9 @@ export async function prepareStartupPlan( const loadAppBootstrapImpl = deps.loadAppBootstrapImpl ?? loadAppBootstrap; const usesPipedPatchInputImpl = deps.usesPipedPatchInputImpl ?? usesPipedPatchInput; const openControllingTerminalImpl = deps.openControllingTerminalImpl ?? openControllingTerminal; + const stdinIsTTY = deps.stdinIsTTY ?? Boolean(process.stdin.isTTY); - let parsedCliInput = await parseCliImpl(argv); + const parsedCliInput = await parseCliImpl(argv); if (parsedCliInput.kind === "help") { return { @@ -83,9 +91,32 @@ export async function prepareStartupPlan( }; } - if (parsedCliInput.kind === "pager") { + const bareInvocation = parsedCliInput.kind === "bare"; + let appCliInput: CliInput | PagerCommandInput; + + if (bareInvocation) { + // Keep bare `hunk` ergonomic in interactive shells while preserving pager-style stdin flows. + if (stdinIsTTY) { + appCliInput = { kind: "git", staged: false, options: {} }; + } else { + appCliInput = { kind: "pager", options: {} }; + } + } else if (parsedCliInput.kind === "pager") { + appCliInput = parsedCliInput; + } else { + appCliInput = parsedCliInput; + } + + if (appCliInput.kind === "pager") { const stdinText = await readStdinText(); + if (stdinText.length === 0 && bareInvocation) { + return { + kind: "help", + text: renderCliHelp(), + }; + } + if (!looksLikePatchInputImpl(stdinText)) { return { kind: "plain-text-pager", @@ -93,18 +124,18 @@ export async function prepareStartupPlan( }; } - parsedCliInput = { + appCliInput = { kind: "patch", file: "-", text: stdinText, options: { - ...parsedCliInput.options, + ...appCliInput.options, pager: true, }, }; } - const runtimeCliInput = resolveRuntimeCliInputImpl(parsedCliInput); + const runtimeCliInput = resolveRuntimeCliInputImpl(appCliInput); const configured = resolveConfiguredCliInputImpl(runtimeCliInput); const cliInput = configured.input; diff --git a/src/core/types.ts b/src/core/types.ts index 13d4b28..80dcf86 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -78,6 +78,10 @@ export interface HelpCommandInput { text: string; } +export interface BareCommandInput { + kind: "bare"; +} + export interface PagerCommandInput { kind: "pager"; options: CommonOptions; @@ -232,6 +236,7 @@ export type CliInput = export type ParsedCliInput = | CliInput | HelpCommandInput + | BareCommandInput | PagerCommandInput | McpServeCommandInput | SessionCommandInput; diff --git a/test/cli.test.ts b/test/cli.test.ts index d11746a..536f7ec 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -22,34 +22,33 @@ afterEach(() => { }); describe("parseCli", () => { - test("prints help when no subcommand is passed", async () => { + test("returns a bare invocation marker when no subcommand is passed", async () => { const parsed = await parseCli(["bun", "hunk"]); - expect(parsed.kind).toBe("help"); - if (parsed.kind !== "help") { - throw new Error("Expected top-level help output."); - } - - expect(parsed.text).toContain("Usage:"); - expect(parsed.text).toContain("hunk diff"); - expect(parsed.text).toContain("hunk show"); - expect(parsed.text).toContain("Global options:"); - expect(parsed.text).toContain("Common review options:"); - expect(parsed.text).toContain("auto-reload when the current diff input changes"); - expect(parsed.text).toContain("Git diff options:"); - expect(parsed.text).toContain("Notes:"); - expect(parsed.text).toContain( - "Run `hunk --help` for command-specific syntax and options.", - ); - expect(parsed.text).not.toContain("Config:"); - expect(parsed.text).not.toContain("Examples:"); + expect(parsed).toEqual({ kind: "bare" }); }); - test("prints the same top-level help for --help", async () => { - const bare = await parseCli(["bun", "hunk"]); + test("prints top-level help for explicit --help", async () => { const explicit = await parseCli(["bun", "hunk", "--help"]); - expect(explicit).toEqual(bare); + expect(explicit.kind).toBe("help"); + if (explicit.kind !== "help") { + throw new Error("Expected explicit help output."); + } + + expect(explicit.text).toContain("Usage:"); + expect(explicit.text).toContain("hunk diff"); + expect(explicit.text).toContain("hunk show"); + expect(explicit.text).toContain("Global options:"); + expect(explicit.text).toContain("Common review options:"); + expect(explicit.text).toContain("auto-reload when the current diff input changes"); + expect(explicit.text).toContain("Git diff options:"); + expect(explicit.text).toContain("Notes:"); + expect(explicit.text).toContain( + "Run `hunk --help` for command-specific syntax and options.", + ); + expect(explicit.text).not.toContain("Config:"); + expect(explicit.text).not.toContain("Examples:"); }); test("prints the package version for --version and version", async () => { diff --git a/test/startup.test.ts b/test/startup.test.ts index dcea9b7..0fcd6a8 100644 --- a/test/startup.test.ts +++ b/test/startup.test.ts @@ -17,10 +17,44 @@ function createBootstrap(input: CliInput): AppBootstrap { } describe("startup planning", () => { - test("returns help output without entering app startup", async () => { - let loaded = false; + test("defaults bare interactive invocations to working-tree diff startup", async () => { + const seenInputs: CliInput[] = []; const plan = await prepareStartupPlan(["bun", "hunk"], { + parseCliImpl: async () => ({ kind: "bare" }), + stdinIsTTY: true, + resolveRuntimeCliInputImpl(input) { + seenInputs.push(input); + return input; + }, + resolveConfiguredCliInputImpl(input) { + seenInputs.push(input); + return { input } as never; + }, + loadAppBootstrapImpl: async (input) => { + seenInputs.push(input); + return createBootstrap(input); + }, + usesPipedPatchInputImpl: () => false, + }); + + expect(plan.kind).toBe("app"); + if (plan.kind !== "app") { + throw new Error("Expected app startup plan."); + } + + expect(plan.cliInput).toEqual({ + kind: "git", + staged: false, + options: {}, + }); + expect(seenInputs).toHaveLength(3); + }); + + test("returns help output for explicit --help without entering app startup", async () => { + let loaded = false; + + const plan = await prepareStartupPlan(["bun", "hunk", "--help"], { parseCliImpl: async () => ({ kind: "help", text: "Usage: hunk\n" }), loadAppBootstrapImpl: async () => { loaded = true; @@ -32,6 +66,28 @@ describe("startup planning", () => { expect(loaded).toBe(false); }); + test("keeps bare non-interactive invocation on help when stdin is empty", async () => { + let loaded = false; + + const plan = await prepareStartupPlan(["bun", "hunk"], { + parseCliImpl: async () => ({ kind: "bare" }), + stdinIsTTY: false, + readStdinText: async () => "", + looksLikePatchInputImpl: () => false, + loadAppBootstrapImpl: async () => { + loaded = true; + throw new Error("unreachable"); + }, + }); + + expect(plan.kind).toBe("help"); + if (plan.kind !== "help") { + throw new Error("Expected help output."); + } + expect(plan.text).toContain("Usage:"); + expect(loaded).toBe(false); + }); + test("passes the MCP serve command through without app bootstrap work", async () => { let loaded = false; @@ -121,6 +177,45 @@ describe("startup planning", () => { expect(seenInputs).toHaveLength(3); }); + test("normalizes bare non-interactive diff stdin into patch app startup", async () => { + const seenInputs: CliInput[] = []; + + const plan = await prepareStartupPlan(["bun", "hunk"], { + parseCliImpl: async () => ({ kind: "bare" }), + stdinIsTTY: false, + readStdinText: async () => "diff --git a/a.ts b/a.ts\n@@ -1 +1 @@\n-old\n+new\n", + looksLikePatchInputImpl: () => true, + resolveRuntimeCliInputImpl(input) { + seenInputs.push(input); + return input; + }, + resolveConfiguredCliInputImpl(input) { + seenInputs.push(input); + return { input } as never; + }, + loadAppBootstrapImpl: async (input) => { + seenInputs.push(input); + return createBootstrap(input); + }, + usesPipedPatchInputImpl: () => false, + }); + + expect(plan.kind).toBe("app"); + if (plan.kind !== "app") { + throw new Error("Expected app startup plan."); + } + + expect(plan.cliInput).toMatchObject({ + kind: "patch", + file: "-", + text: "diff --git a/a.ts b/a.ts\n@@ -1 +1 @@\n-old\n+new\n", + options: { + pager: true, + }, + }); + expect(seenInputs).toHaveLength(3); + }); + test("rejects watch mode for stdin-backed patch inputs", async () => { const cliInput: CliInput = { kind: "patch",