Skip to content

feat: add --pipe mode for automation + fix --json output capture#75

Open
talmosko-code wants to merge 2 commits intoMadAppGang:mainfrom
talmosko-code:feat/pipe-mode
Open

feat: add --pipe mode for automation + fix --json output capture#75
talmosko-code wants to merge 2 commits intoMadAppGang:mainfrom
talmosko-code:feat/pipe-mode

Conversation

@talmosko-code
Copy link
Copy Markdown

Problem

Claudish has two issues that prevent programmatic/automation use:

1. --json output is broken

claude-runner.ts uses stdio: "inherit" in spawn(), so Claude Code's JSON output goes directly to the terminal instead of being captured by claudish. Result: JSON wrapper with empty result field.

To reproduce:

echo "hello" | npx claudish --model openrouter/healer-alpha --json
# Result: {"result": "", ...}  ← empty!

Root cause: Line 387 in claude-runner.tsstdio: "inherit" means Claude Code's stdout is connected directly to the parent's stdout. Claudish never sees the output, so it generates JSON with an empty result.

2. No pipe/automation mode

Current options:

  • Interactive (claudish): Shows TUI, requires PTY — unreliable for automation
  • Single-shot (claudish --model X "prompt"): Exits after one prompt
  • --stdin (echo "prompt" | claudish --stdin): Same as single-shot, exits after one prompt

What's missing: a persistent mode for sending multiple prompts programmatically while maintaining session context. Essential for bots, CI/CD pipelines, and automation frameworks.

Solution

Fix 1: Capture stdout when --json is used

When config.jsonOutput is true, spawn with stdio: ["inherit", "pipe", "inherit"] to capture Claude Code's stdout before it reaches the terminal.

Fix 2: New --pipe flag

Persistent stdin/stdout mode for automation:

  • Spawns Claude Code with -p --output-format stream-json --verbose
  • Relays stdin → Claude Code's stdin and Claude Code's stdout → stdout
  • No TUI — clean stream I/O for programmatic use
# Pipe mode for automation:
echo "implement feature X" | claudish --pipe --model openrouter@provider/model

# JSON output (now fixed):
echo "hello" | claudish --json --model google@gemini-3-pro

Changes

  • types.ts: Add pipe: boolean config field
  • cli.ts: Add --pipe flag parsing, exclude from interactive fallback, update help text and examples
  • claude-runner.ts: Pipe mode (piped I/O with stdin/stdout relay) + JSON mode (capture stdout instead of inheriting)

Why this matters

Claudish's tagline is "Run Claude Code with any AI model." Without programmatic access, it can only be used by humans at a terminal. This limits use in:

  • CI/CD pipelines — automated code review
  • Chatbots and Discord bots — programmatic responses
  • AI automation frameworks — integration with tools like OpenClaw
  • Batch processing — running multiple prompts sequentially
  • Testing and benchmarking — programmatic model comparison

The pipe mode makes claudish a proper API-like interface for Claude Code, usable both interactively (TUI) and programmatically (pipe).

Happy to iterate on the implementation based on feedback!

Tal Moskovich and others added 2 commits March 17, 2026 20:45
## Problem

Claudish has two issues that prevent programmatic use:

1. **--json output is broken**: spawn() uses stdio:'inherit', so Claude Code's
   JSON output goes directly to terminal instead of being captured by claudish.
   Result: JSON with empty 'result' field.

2. **No automation mode**: Only options are interactive (TUI, needs PTY) or
   single-shot (exits after one prompt). No way to send prompts programmatically
   while maintaining session context.

## Changes

- Add --pipe flag: persistent stdin/stdout mode for automation (no TUI)
- Fix --json: capture stdout when jsonOutput is true (stdio:'pipe' for stdout)
- Pipe mode args: -p --output-format stream-json --verbose for structured output
- Update help text with --pipe documentation and examples

## Usage

  # Pipe mode (automation):
  echo 'implement feature X' | claudish --pipe --model openrouter@provider/model

  # JSON output (now fixed):
  echo 'hello' | claudish --json --model google@gemini-3-pro

Co-authored-by: OpenClaw <openclaw@example.com>
@erudenko
Copy link
Copy Markdown
Member

Good problem identification on the JSON capture issue with `stdio: "inherit"`.

A few things I noticed:

In the JSON mode branch, `capturedOutput += data.toString()` accumulates all stdout but the variable is never read afterward. The fix pipes stdout through to the user, which is the same as before. The captured data goes nowhere.

The docs say pipe mode is "persistent stdin/stdout loop" where "each line on stdin is a prompt" but Claude Code's `-p` flag (which this adds) exits after one prompt. So `--pipe` is functionally the same as `--stdin` with JSON output, not a persistent loop.

The stdin relay has no backpressure handling. `proc.stdin?.write(data)` doesn't check the return value, so large inputs could lose data if Claude Code's buffer fills up. Fine for small prompts but worth noting.

`--verbose` is always added in pipe mode, which could be noisy for automation use cases where you only want the structured JSON.

Also if Claude Code exits unexpectedly, the stdin relay listeners are never cleaned up. Should handle `proc.on("close")`.

The concept is useful though. Both the JSON capture fix and the pipe mode have real use cases, they just need different implementations. Happy to discuss the right approach if you want to iterate on this.

@erudenko
Copy link
Copy Markdown
Member

Took a closer look. The use cases are real (machine-readable output for automation, stdin relay for scripting) but the implementation has a few gaps. Let me be specific so it's easy to fix.

1. capturedOutput is accumulated but never used

// Line ~420 in the jsonOutput branch
capturedOutput += data.toString();
// Also write to real stdout so user sees progress
process.stdout.write(data);

capturedOutput grows with every chunk but nothing ever reads it. After the process exits, the variable is just garbage collected. If the intent was to re-emit structured JSON at the end, that code is missing. If you just need passthrough, drop capturedOutput entirely and only keep process.stdout.write(data).

2. Pipe mode isn't actually persistent

The docs and help text say:

# Each line on stdin is a prompt; responses stream to stdout as JSON

But the implementation adds -p to Claude Code args, which is print mode. Claude Code in -p mode takes one prompt and exits. So --pipe behaves identically to --stdin with --output-format stream-json. There's no loop, no line-by-line prompt processing.

If you want actual persistent mode, you'd need to either:

  • Spawn a new Claude Code process per line from stdin
  • Or use Claude Code's conversation mode and relay messages through, which is closer to what the stdin relay code does but Claude Code doesn't support that via -p

If single-shot is the intended behavior, the docs should say that instead of "persistent stdin/stdout loop."

3. No cleanup on process exit

process.stdin.on("data", (data: string) => {
  proc.stdin?.write(data);
});

If Claude Code exits (crash, timeout, ctrl-c from inside), these listeners stay attached to process.stdin forever. The Node process won't exit cleanly because stdin is still in flowing mode with resume(). Need something like:

proc.on("close", () => {
  process.stdin.pause();
  process.stdin.removeAllListeners("data");
});

4. No backpressure on stdin relay

proc.stdin?.write(data) returns a boolean indicating if the buffer is full. If it returns false, you should pause process.stdin until proc.stdin.on("drain") fires. For small prompts this won't matter but piping a large file will lose data silently.

5. --verbose forced in pipe mode

claudeArgs.push("--verbose");

Pipe mode is for automation. Automation tools parse structured output. --verbose adds human-readable noise that breaks parsers. This should be opt-in (user passes -v themselves), not forced.

6. --dangerouslyDisableSandbox vs --dangerously-skip-permissions

The pipe mode checks both config.autoApprove and config.dangerous but single-shot mode only handles autoApprove. If these flags matter for pipe mode they should work consistently across all modes, otherwise it looks like pipe mode has special safety bypass logic.

The flag parsing in cli.ts is clean though. config.pipe = true with interactive = false and the interactive-mode guard update are all correct.

Happy to help think through the persistent-mode design if that's what you're going for.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants