Skip to content

Commit a5466cf

Browse files
committed
feat: support piping between agents (foo.md | bar.md)
Add stdout TTY detection to disable post-run menu and markdown rendering when output is piped to another command. This enables chaining agents: `foo.claude.md | bar.claude.md` - Add isStdoutTTY option to CliRunner (alongside existing isStdinTTY) - Update shouldShowMenu to check both stdin AND stdout are TTYs - Add defense-in-depth check in showPostRunMenu - Add tests for piping scenarios
1 parent eeac20d commit a5466cf

File tree

4 files changed

+97
-5
lines changed

4 files changed

+97
-5
lines changed

src/cli-runner.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,4 +333,59 @@ Content without command`);
333333
expect(result.errorMessage).toContain("No command specified");
334334
});
335335
});
336+
337+
describe("piping support (isStdoutTTY)", () => {
338+
it("accepts isStdoutTTY option", async () => {
339+
env.addFile("/test/pipe.echo.md", `---
340+
---
341+
Test piping`);
342+
343+
// Simulates: md pipe.echo.md | other-command
344+
// When piping, stdout is not a TTY
345+
const runner = new CliRunner({
346+
env,
347+
isStdinTTY: true,
348+
isStdoutTTY: false, // stdout piped to another command
349+
cwd: "/test",
350+
});
351+
352+
const result = await runner.run(["node", "md", "/test/pipe.echo.md", "--_dry-run"]);
353+
expect(result.exitCode).toBe(0);
354+
});
355+
356+
it("accepts both stdin and stdout as non-TTY (middle of pipeline)", async () => {
357+
env.addFile("/test/middle.echo.md", `---
358+
---
359+
Middle of pipeline`);
360+
361+
// Simulates: first.md | md middle.echo.md | last.md
362+
const runner = new CliRunner({
363+
env,
364+
isStdinTTY: false, // stdin from pipe
365+
isStdoutTTY: false, // stdout to pipe
366+
stdinContent: "piped input",
367+
cwd: "/test",
368+
});
369+
370+
const result = await runner.run(["node", "md", "/test/middle.echo.md", "--_dry-run"]);
371+
expect(result.exitCode).toBe(0);
372+
});
373+
374+
it("defaults isStdoutTTY when not provided", async () => {
375+
env.addFile("/test/default.echo.md", `---
376+
---
377+
Test default`);
378+
379+
// When isStdoutTTY is not provided, it should default to process.stdout.isTTY
380+
const runner = new CliRunner({
381+
env,
382+
isStdinTTY: true,
383+
// isStdoutTTY not provided - should use process.stdout.isTTY
384+
cwd: "/test",
385+
});
386+
387+
const result = await runner.run(["node", "md", "/test/default.echo.md", "--_dry-run"]);
388+
expect(result.exitCode).toBe(0);
389+
});
390+
});
336391
});

src/cli-runner.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ export interface CliRunnerOptions {
107107
processEnv?: Record<string, string | undefined>;
108108
cwd?: string;
109109
isStdinTTY?: boolean;
110+
isStdoutTTY?: boolean;
110111
stdinContent?: string;
111112
promptInput?: (message: string) => Promise<string>;
112113
/** Custom prompt with history function (for testing) */
@@ -119,6 +120,7 @@ export class CliRunner {
119120
private processEnv: Record<string, string | undefined>;
120121
private cwd: string;
121122
private isStdinTTY: boolean;
123+
private isStdoutTTY: boolean;
122124
private stdinContent: string | undefined;
123125
private promptInput: (message: string) => Promise<string>;
124126
private promptInputWithHistory: (message: string, defaultValue?: string) => Promise<string>;
@@ -128,6 +130,7 @@ export class CliRunner {
128130
this.processEnv = options.processEnv ?? process.env;
129131
this.cwd = options.cwd ?? process.cwd();
130132
this.isStdinTTY = options.isStdinTTY ?? Boolean(process.stdin.isTTY);
133+
this.isStdoutTTY = options.isStdoutTTY ?? Boolean(process.stdout.isTTY);
131134
this.stdinContent = options.stdinContent;
132135
// Lazy-load input prompt only when actually needed
133136
this.promptInput = options.promptInput ?? (async (msg) => {
@@ -392,7 +395,8 @@ export class CliRunner {
392395
startSpinner(preview);
393396

394397
// Determine if we should capture output for post-run menu
395-
const shouldShowMenu = this.isStdinTTY && !parsed.noMenu;
398+
// Disable when piping (stdout not TTY) to support: foo.md | bar.md
399+
const shouldShowMenu = this.isStdinTTY && this.isStdoutTTY && !parsed.noMenu;
396400
const captureMode = shouldShowMenu ? "tee" as const : false;
397401

398402
const runResult = await runCommand({
@@ -537,8 +541,9 @@ export class CliRunner {
537541
}
538542

539543
// Determine if we should capture output for post-run menu
540-
// Only capture when: TTY, not piped, menu not disabled
541-
const shouldShowMenu = this.isStdinTTY && !parsed.noMenu;
544+
// Only capture when: TTY (stdin+stdout), not piped, menu not disabled
545+
// Checking stdout.isTTY enables piping: foo.md | bar.md
546+
const shouldShowMenu = this.isStdinTTY && this.isStdoutTTY && !parsed.noMenu;
542547
// Always capture stderr when in interactive mode for failure menu
543548
const captureMode = shouldShowMenu ? "tee" as const : false;
544549

src/post-run-menu.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,3 +206,34 @@ describe("saveToFile", () => {
206206
expect(result).toBe(false);
207207
});
208208
});
209+
210+
describe("showPostRunMenu TTY behavior", () => {
211+
/**
212+
* Note: showPostRunMenu checks process.stdin.isTTY and process.stdout.isTTY directly.
213+
* These are read-only properties that can't be easily mocked in tests.
214+
*
215+
* The expected behavior (tested indirectly via CliRunner):
216+
* - Returns undefined when process.stdin.isTTY is false
217+
* - Returns undefined when process.stdout.isTTY is false (piping support)
218+
* - Returns undefined when output is empty
219+
* - Only shows menu when both stdin and stdout are TTYs and output is non-empty
220+
*
221+
* This enables piping: foo.md | bar.md
222+
* - foo.md has stdout piped, so menu is skipped
223+
* - bar.md receives clean output without menu interference
224+
*/
225+
226+
it("documents TTY requirements for menu display", () => {
227+
// This test documents the expected behavior rather than testing it directly
228+
// The actual TTY checks are in showPostRunMenu():
229+
// if (!output.trim() || !process.stdin.isTTY || !process.stdout.isTTY) {
230+
// return undefined;
231+
// }
232+
233+
// Integration tests in cli-runner.test.ts verify:
234+
// - isStdoutTTY option is accepted
235+
// - Both stdin and stdout non-TTY works (middle of pipeline)
236+
237+
expect(true).toBe(true); // Placeholder - behavior documented above
238+
});
239+
});

src/post-run-menu.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -349,8 +349,9 @@ export const postRunMenu = createPrompt<PostRunMenuResult, PostRunMenuConfig>(
349349
export async function showPostRunMenu(
350350
output: string
351351
): Promise<PostRunMenuResult | undefined> {
352-
// Don't show menu if no output or not a TTY
353-
if (!output.trim() || !process.stdin.isTTY) {
352+
// Don't show menu if no output or not a TTY (stdin AND stdout)
353+
// Checking stdout enables piping: foo.md | bar.md
354+
if (!output.trim() || !process.stdin.isTTY || !process.stdout.isTTY) {
354355
return undefined;
355356
}
356357

0 commit comments

Comments
 (0)