Browse Source

fix(cli): handle stdin redirection in CI environments (#9121)

- Add stdinIsTTY check to shouldUsePlainTextMode() - Ink requires raw mode on stdin
- Only error on empty stdin when no prompt is provided (allows: cline 'prompt' < /dev/null)
- Fixes crash in GitHub Actions and other CI environments
Robin Newhouse 2 months ago
parent
commit
6d8fb85
4 changed files with 293 additions and 10 deletions
  1. 5 0
      .changeset/fix-cli-stdin-ci.md
  2. 31 10
      cli/src/index.ts
  3. 194 0
      cli/src/utils/mode-selection.test.ts
  4. 63 0
      cli/src/utils/mode-selection.ts

+ 5 - 0
.changeset/fix-cli-stdin-ci.md

@@ -0,0 +1,5 @@
+---
+"cline": patch
+---
+
+Fix CLI crashing in CI environments and with stdin redirection (e.g., `cline "prompt" < /dev/null`). Now checks both stdin and stdout TTY status before using Ink, and only errors on empty stdin when no prompt is provided.

+ 31 - 10
cli/src/index.ts

@@ -37,6 +37,7 @@ import { CLINE_CLI_DIR, getCliBinaryPath } from "./utils/path"
 import { readStdinIfPiped } from "./utils/piped"
 import { runPlainTextTask } from "./utils/plain-text-task"
 import { applyProviderConfig } from "./utils/provider-config"
+import { selectOutputMode } from "./utils/mode-selection"
 import { getValidCliProviders, isValidCliProvider } from "./utils/providers"
 import { autoUpdateOnStartup, checkForUpdates } from "./utils/update"
 import { initializeCliContext } from "./vscode-context"
@@ -101,22 +102,32 @@ function applyTaskOptions(options: TaskOptions): void {
 	}
 }
 
+/**
+ * Get mode selection result using the extracted, testable selectOutputMode function.
+ * This wrapper provides the current process TTY state.
+ */
+function getModeSelection(options: TaskOptions) {
+	return selectOutputMode({
+		stdoutIsTTY: process.stdout.isTTY === true,
+		stdinIsTTY: process.stdin.isTTY === true,
+		stdinWasPiped: options.stdinWasPiped ?? false,
+		json: options.json,
+		yolo: options.yolo,
+	})
+}
+
 /**
  * Determine if plain text mode should be used based on options and environment.
  */
 function shouldUsePlainTextMode(options: TaskOptions): boolean {
-	const isTTY = process.stdout.isTTY === true
-	return !isTTY || !!options.stdinWasPiped || !!options.json || !!options.yolo
+	return getModeSelection(options).usePlainTextMode
 }
 
 /**
  * Get the reason for using plain text mode (for telemetry).
  */
 function getPlainTextModeReason(options: TaskOptions): string {
-	if (options.yolo) return "yolo_flag"
-	if (options.json) return "json"
-	if (options.stdinWasPiped) return "piped_stdin"
-	return "redirected_output"
+	return getModeSelection(options).reason
 }
 
 /**
@@ -883,8 +894,18 @@ program
 		// Always check for piped stdin content
 		const stdinInput = await readStdinIfPiped()
 
-		// Error if stdin was piped but empty (e.g., `echo "" | cline`)
-		if (stdinInput === "") {
+		// Track whether stdin was actually piped (even if empty) vs not piped (null)
+		// stdinInput === null means stdin wasn't piped (TTY or not FIFO/file)
+		// stdinInput === "" means stdin was piped but empty
+		// stdinInput has content means stdin was piped with data
+		const stdinWasPiped = stdinInput !== null
+
+		// Error if stdin was piped but empty AND no prompt was provided
+		// This handles:
+		// - `echo "" | cline` -> error (empty stdin, no prompt)
+		// - `cline "prompt"` in GitHub Actions -> OK (empty stdin ignored, has prompt)
+		// - `cat file | cline "explain"` -> OK (has stdin AND prompt)
+		if (stdinInput === "" && !prompt) {
 			printWarning("Empty input received from stdin. Please provide content to process.")
 			exit(1)
 		}
@@ -912,14 +933,14 @@ program
 			await resumeTask(options.taskId, {
 				...options,
 				initialPrompt: effectivePrompt,
-				stdinWasPiped: !!stdinInput,
+				stdinWasPiped,
 			})
 			return
 		}
 
 		if (effectivePrompt) {
 			// Pass stdinWasPiped flag so runTask knows to use plain text mode
-			await runTask(effectivePrompt, { ...options, stdinWasPiped: !!stdinInput })
+			await runTask(effectivePrompt, { ...options, stdinWasPiped })
 		} else {
 			// Show welcome prompt if no prompt given
 			await showWelcome(options)

+ 194 - 0
cli/src/utils/mode-selection.test.ts

@@ -0,0 +1,194 @@
+import { describe, expect, it } from "vitest"
+import { selectOutputMode } from "./mode-selection"
+
+describe("selectOutputMode", () => {
+	describe("interactive mode (Ink)", () => {
+		it("should use interactive mode when both stdin and stdout are TTY", () => {
+			const result = selectOutputMode({
+				stdoutIsTTY: true,
+				stdinIsTTY: true,
+				stdinWasPiped: false,
+			})
+			expect(result.usePlainTextMode).toBe(false)
+			expect(result.reason).toBe("interactive")
+		})
+	})
+
+	describe("yolo flag", () => {
+		it("should use plain text mode when --yolo flag is set", () => {
+			const result = selectOutputMode({
+				stdoutIsTTY: true,
+				stdinIsTTY: true,
+				stdinWasPiped: false,
+				yolo: true,
+			})
+			expect(result.usePlainTextMode).toBe(true)
+			expect(result.reason).toBe("yolo_flag")
+		})
+
+		it("should prioritize yolo over other flags", () => {
+			const result = selectOutputMode({
+				stdoutIsTTY: false,
+				stdinIsTTY: false,
+				stdinWasPiped: true,
+				json: true,
+				yolo: true,
+			})
+			expect(result.reason).toBe("yolo_flag")
+		})
+	})
+
+	describe("json flag", () => {
+		it("should use plain text mode when --json flag is set", () => {
+			const result = selectOutputMode({
+				stdoutIsTTY: true,
+				stdinIsTTY: true,
+				stdinWasPiped: false,
+				json: true,
+			})
+			expect(result.usePlainTextMode).toBe(true)
+			expect(result.reason).toBe("json")
+		})
+	})
+
+	describe("piped stdin", () => {
+		it("should use plain text mode when stdin was piped (echo x | cline)", () => {
+			const result = selectOutputMode({
+				stdoutIsTTY: true,
+				stdinIsTTY: false, // piped stdin is not a TTY
+				stdinWasPiped: true,
+			})
+			expect(result.usePlainTextMode).toBe(true)
+			expect(result.reason).toBe("piped_stdin")
+		})
+
+		it("should use plain text mode when stdin was piped but empty (echo '' | cline 'prompt')", () => {
+			const result = selectOutputMode({
+				stdoutIsTTY: true,
+				stdinIsTTY: false,
+				stdinWasPiped: true, // empty pipe still counts as piped
+			})
+			expect(result.usePlainTextMode).toBe(true)
+			expect(result.reason).toBe("piped_stdin")
+		})
+	})
+
+	describe("stdin redirected (< /dev/null)", () => {
+		it("should use plain text mode when stdin is redirected from /dev/null", () => {
+			// cline "prompt" < /dev/null
+			// stdin is not a TTY, but also not a FIFO/file, so stdinWasPiped=false
+			const result = selectOutputMode({
+				stdoutIsTTY: true,
+				stdinIsTTY: false, // redirected, not a TTY
+				stdinWasPiped: false, // /dev/null is a character device, not FIFO
+			})
+			expect(result.usePlainTextMode).toBe(true)
+			expect(result.reason).toBe("stdin_redirected")
+		})
+	})
+
+	describe("stdout redirected", () => {
+		it("should use plain text mode when stdout is redirected to file", () => {
+			// cline "prompt" > output.txt
+			const result = selectOutputMode({
+				stdoutIsTTY: false,
+				stdinIsTTY: true,
+				stdinWasPiped: false,
+			})
+			expect(result.usePlainTextMode).toBe(true)
+			expect(result.reason).toBe("stdout_redirected")
+		})
+
+		it("should use plain text mode when stdout is piped", () => {
+			// cline "prompt" | grep something
+			const result = selectOutputMode({
+				stdoutIsTTY: false,
+				stdinIsTTY: true,
+				stdinWasPiped: false,
+			})
+			expect(result.usePlainTextMode).toBe(true)
+			expect(result.reason).toBe("stdout_redirected")
+		})
+	})
+
+	describe("GitHub Actions scenarios", () => {
+		it("should use plain text mode in GitHub Actions (stdin is empty FIFO)", () => {
+			// In GitHub Actions: stdin is an empty FIFO pipe
+			// stdinIsTTY=false, stdinWasPiped=true (FIFO detected)
+			const result = selectOutputMode({
+				stdoutIsTTY: true, // GitHub Actions stdout is TTY-like
+				stdinIsTTY: false,
+				stdinWasPiped: true, // empty FIFO still counts as piped
+			})
+			expect(result.usePlainTextMode).toBe(true)
+		})
+
+		it("should use plain text mode with --yolo in CI", () => {
+			const result = selectOutputMode({
+				stdoutIsTTY: false,
+				stdinIsTTY: false,
+				stdinWasPiped: false,
+				yolo: true,
+			})
+			expect(result.usePlainTextMode).toBe(true)
+			expect(result.reason).toBe("yolo_flag")
+		})
+	})
+
+	describe("real-world scenarios", () => {
+		it("cline (no args, interactive terminal)", () => {
+			const result = selectOutputMode({
+				stdoutIsTTY: true,
+				stdinIsTTY: true,
+				stdinWasPiped: false,
+			})
+			expect(result.usePlainTextMode).toBe(false)
+		})
+
+		it('cline "prompt" (prompt arg, interactive terminal)', () => {
+			const result = selectOutputMode({
+				stdoutIsTTY: true,
+				stdinIsTTY: true,
+				stdinWasPiped: false,
+			})
+			expect(result.usePlainTextMode).toBe(false)
+		})
+
+		it('cat file | cline "explain"', () => {
+			const result = selectOutputMode({
+				stdoutIsTTY: true,
+				stdinIsTTY: false,
+				stdinWasPiped: true,
+			})
+			expect(result.usePlainTextMode).toBe(true)
+		})
+
+		it('cline --yolo "prompt"', () => {
+			const result = selectOutputMode({
+				stdoutIsTTY: true,
+				stdinIsTTY: true,
+				stdinWasPiped: false,
+				yolo: true,
+			})
+			expect(result.usePlainTextMode).toBe(true)
+		})
+
+		it('cline "prompt" < /dev/null', () => {
+			const result = selectOutputMode({
+				stdoutIsTTY: true,
+				stdinIsTTY: false,
+				stdinWasPiped: false,
+			})
+			expect(result.usePlainTextMode).toBe(true)
+		})
+
+		it('cline "prompt" > output.log', () => {
+			const result = selectOutputMode({
+				stdoutIsTTY: false,
+				stdinIsTTY: true,
+				stdinWasPiped: false,
+			})
+			expect(result.usePlainTextMode).toBe(true)
+		})
+	})
+})

+ 63 - 0
cli/src/utils/mode-selection.ts

@@ -0,0 +1,63 @@
+/**
+ * Mode selection logic for CLI - determines whether to use Ink (interactive) or plain text mode
+ *
+ * This is extracted as a pure function for testability. The decision tree:
+ * - Plain text mode when output is redirected (stdout not TTY)
+ * - Plain text mode when input is redirected (stdin not TTY) - Ink requires raw mode
+ * - Plain text mode when stdin was piped (e.g., echo "x" | cline)
+ * - Plain text mode when --json flag is used
+ * - Plain text mode when --yolo flag is used
+ * - Otherwise: Interactive Ink mode
+ */
+
+export interface ModeSelectionInput {
+	/** Is stdout connected to a TTY (interactive terminal)? */
+	stdoutIsTTY: boolean
+	/** Is stdin connected to a TTY (interactive terminal)? */
+	stdinIsTTY: boolean
+	/** Was stdin piped (FIFO or file), even if empty? */
+	stdinWasPiped: boolean
+	/** --json flag for machine-readable output */
+	json?: boolean
+	/** --yolo flag for auto-approve mode */
+	yolo?: boolean
+}
+
+export interface ModeSelectionResult {
+	/** Use plain text mode instead of Ink */
+	usePlainTextMode: boolean
+	/** Reason for the mode selection (for telemetry/debugging) */
+	reason: "interactive" | "yolo_flag" | "json" | "piped_stdin" | "stdin_redirected" | "stdout_redirected"
+}
+
+/**
+ * Determine whether to use plain text mode or interactive Ink mode
+ *
+ * @param input - Environment and option flags
+ * @returns Mode selection result with reason
+ */
+export function selectOutputMode(input: ModeSelectionInput): ModeSelectionResult {
+	// Priority order matters - check most specific flags first
+
+	if (input.yolo) {
+		return { usePlainTextMode: true, reason: "yolo_flag" }
+	}
+
+	if (input.json) {
+		return { usePlainTextMode: true, reason: "json" }
+	}
+
+	if (input.stdinWasPiped) {
+		return { usePlainTextMode: true, reason: "piped_stdin" }
+	}
+
+	if (!input.stdinIsTTY) {
+		return { usePlainTextMode: true, reason: "stdin_redirected" }
+	}
+
+	if (!input.stdoutIsTTY) {
+		return { usePlainTextMode: true, reason: "stdout_redirected" }
+	}
+
+	return { usePlainTextMode: false, reason: "interactive" }
+}