From e1c85cd2825efe543094c4fc94ec69e2291c9b33 Mon Sep 17 00:00:00 2001 From: stack72 Date: Tue, 19 May 2026 23:11:13 +0100 Subject: [PATCH] fix(cli): replace stdin auto-detect with explicit --stdin flag readStdin() blocks forever on pipes that never close (test harnesses, subprocess spawners), hanging method run and workflow run in non-TTY contexts. Replace auto-detection with an explicit --stdin flag that callers must pass to opt into reading piped input, matching the jq -n pattern of explicit stdin control. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/swamp-model/SKILL.md | 56 +++++++++---------- .../skills/swamp-model/references/examples.md | 10 ++-- .../swamp-model/references/scenarios.md | 10 ++-- .claude/skills/swamp-workflow/SKILL.md | 49 ++++++++-------- .../swamp-workflow/references/scenarios.md | 12 ++-- design/inputs.md | 18 +++--- src/cli/commands/model_method_run.ts | 10 ++-- src/cli/commands/workflow_run.ts | 10 ++-- src/infrastructure/io/stdin_reader.ts | 19 +------ 9 files changed, 91 insertions(+), 103 deletions(-) diff --git a/.claude/skills/swamp-model/SKILL.md b/.claude/skills/swamp-model/SKILL.md index 73b0ce87..a193e772 100644 --- a/.claude/skills/swamp-model/SKILL.md +++ b/.claude/skills/swamp-model/SKILL.md @@ -59,31 +59,31 @@ definitions referenced across multiple workflows. ## Quick Reference -| Task | Command | -| ------------------- | ---------------------------------------------------------------- | -| Search model types | `swamp model type search [query] --json` | -| Describe a type | `swamp model type describe --json` | -| Create model input | `swamp model create --json` | -| Create with args | `swamp model create --global-arg key=value --json` | -| Search models | `swamp model search [query] --json` | -| Get model details | `swamp model get --json` | -| Edit model input | `swamp model edit [id_or_name]` | -| Delete a model | `swamp model delete --json` | -| Validate model | `swamp model validate [id_or_name] --json` | -| Validate by label | `swamp model validate [id_or_name] --label policy --json` | -| Validate by method | `swamp model validate [id_or_name] --method create --json` | -| Evaluate input(s) | `swamp model evaluate [id_or_name] --json` | -| Run a method | `swamp model method run ` | -| Run with inputs | `swamp model method run --input key=value` | -| Run from stdin | `echo '{"k":"v"}' \| swamp model method run ` | -| Direct type exec | `swamp model @ method run --input k=v` | -| Skip all checks | `swamp model method run --skip-checks` | -| Skip check by name | `swamp model method run --skip-check ` | -| Skip check by label | `swamp model method run --skip-check-label ` | -| Search outputs | `swamp model output search [query] --json` | -| Get output details | `swamp model output get --json` | -| View output logs | `swamp model output logs --json` | -| View output data | `swamp model output data --json` | +| Task | Command | +| ------------------- | -------------------------------------------------------------------- | +| Search model types | `swamp model type search [query] --json` | +| Describe a type | `swamp model type describe --json` | +| Create model input | `swamp model create --json` | +| Create with args | `swamp model create --global-arg key=value --json` | +| Search models | `swamp model search [query] --json` | +| Get model details | `swamp model get --json` | +| Edit model input | `swamp model edit [id_or_name]` | +| Delete a model | `swamp model delete --json` | +| Validate model | `swamp model validate [id_or_name] --json` | +| Validate by label | `swamp model validate [id_or_name] --label policy --json` | +| Validate by method | `swamp model validate [id_or_name] --method create --json` | +| Evaluate input(s) | `swamp model evaluate [id_or_name] --json` | +| Run a method | `swamp model method run ` | +| Run with inputs | `swamp model method run --input key=value` | +| Run from stdin | `echo '{"k":"v"}' \| swamp model method run --stdin` | +| Direct type exec | `swamp model @ method run --input k=v` | +| Skip all checks | `swamp model method run --skip-checks` | +| Skip check by name | `swamp model method run --skip-check ` | +| Skip check by label | `swamp model method run --skip-check-label ` | +| Search outputs | `swamp model output search [query] --json` | +| Get output details | `swamp model output get --json` | +| View output logs | `swamp model output logs --json` | +| View output data | `swamp model output data --json` | ## Repository Structure @@ -424,9 +424,9 @@ swamp model method run my-deploy create --input config.timeout=30 # dot notatio swamp model method run my-deploy create --input 'tags:json=["prod","west"]' # :json suffix for arrays/objects swamp model method run my-deploy create --input '{"environment": "prod"}' # legacy single-shot JSON swamp model method run my-deploy create --input-file inputs.yaml -echo '{"environment": "prod"}' | swamp model method run my-deploy create # stdin auto-detected -printf '{"environment":"dev"}\n{"environment":"prod"}' | swamp model method run my-deploy create # NDJSON: one run per line -swamp data query 'modelName == "source"' --json | jq -c '.results[] | {environment: .attributes.env}' | swamp model method run my-deploy create # pipe composition +echo '{"environment": "prod"}' | swamp model method run my-deploy create --stdin +printf '{"environment":"dev"}\n{"environment":"prod"}' | swamp model method run my-deploy create --stdin # NDJSON: one run per line +swamp data query 'modelName == "source"' --json | jq -c '.results[] | {environment: .attributes.env}' | swamp model method run my-deploy create --stdin swamp model method run my-deploy create --last-evaluated swamp model method run my-deploy create --skip-checks swamp model method run my-deploy create --skip-check valid-region diff --git a/.claude/skills/swamp-model/references/examples.md b/.claude/skills/swamp-model/references/examples.md index d5533363..15a980f7 100644 --- a/.claude/skills/swamp-model/references/examples.md +++ b/.claude/skills/swamp-model/references/examples.md @@ -204,19 +204,19 @@ swamp model method run my-deploy deploy --input '{"environment": "production"}' # YAML file input swamp model method run my-deploy deploy --input-file inputs.yaml -# Piped stdin (auto-detected, no flag needed) -echo '{"environment": "production"}' | swamp model method run my-deploy deploy +# Piped stdin (explicit --stdin flag required) +echo '{"environment": "production"}' | swamp model method run my-deploy deploy --stdin # NDJSON from stdin: one run per line -printf '{"environment":"dev"}\n{"environment":"prod"}' | swamp model method run my-deploy deploy +printf '{"environment":"dev"}\n{"environment":"prod"}' | swamp model method run my-deploy deploy --stdin # Pipe from data query via jq swamp data query 'modelName == "source"' --json \ | jq -c '.results[] | {environment: .attributes.env}' \ - | swamp model method run my-deploy deploy + | swamp model method run my-deploy deploy --stdin # Stdin + --input overrides (--input wins on conflict) -echo '{"environment": "dev"}' | swamp model method run my-deploy deploy --input dryRun=true +echo '{"environment": "dev"}' | swamp model method run my-deploy deploy --stdin --input dryRun=true ``` **Input file format (inputs.yaml)**: diff --git a/.claude/skills/swamp-model/references/scenarios.md b/.claude/skills/swamp-model/references/scenarios.md index a67161d9..9d62b550 100644 --- a/.claude/skills/swamp-model/references/scenarios.md +++ b/.claude/skills/swamp-model/references/scenarios.md @@ -217,8 +217,8 @@ swamp model validate my-instance --json ``` User wants runtime parameterization → Use inputs schema Values change per invocation → --input or --input-file -Values come from another command → Pipe stdin (auto-detected) -Batch run over query results → data query --json | jq | method run +Values come from another command → Pipe with --stdin +Batch run over query results → data query --json | jq | method run --stdin ``` ### Step-by-Step @@ -302,14 +302,14 @@ swamp model method run my-deploy deploy --input-file inputs/production.yaml **6. Alternative: Pipe inputs from another command** ```bash -# Stdin is auto-detected — pipe JSON and it becomes the inputs +# Pass --stdin to read piped JSON as inputs echo '{"environment": "production", "replicas": 5}' \ - | swamp model method run my-deploy deploy + | swamp model method run my-deploy deploy --stdin # Batch: run deploy for each result from a data query swamp data query 'modelName == "infra" && attributes.status == "pending"' --json \ | jq -c '.results[] | {environment: .attributes.env, replicas: .attributes.count}' \ - | swamp model method run my-deploy deploy + | swamp model method run my-deploy deploy --stdin ``` ### CEL Paths Used diff --git a/.claude/skills/swamp-workflow/SKILL.md b/.claude/skills/swamp-workflow/SKILL.md index 395f5877..810aa89a 100644 --- a/.claude/skills/swamp-workflow/SKILL.md +++ b/.claude/skills/swamp-workflow/SKILL.md @@ -24,25 +24,25 @@ run. ## Quick Reference -| Task | Command | -| ------------------ | ------------------------------------------------------ | -| Get schema | `swamp workflow schema get --json` | -| Search workflows | `swamp workflow search [query] --json` | -| Get a workflow | `swamp workflow get --json` | -| Create a workflow | `swamp workflow create --json` | -| Edit a workflow | `swamp workflow edit [id_or_name]` | -| Delete a workflow | `swamp workflow delete --json` | -| Validate workflow | `swamp workflow validate [id_or_name] --json` | -| Evaluate workflow | `swamp workflow evaluate --json` | -| Run a workflow | `swamp workflow run ` | -| Run with inputs | `swamp workflow run --input key=value` | -| Run from stdin | `echo '{"k":"v"}' \| swamp workflow run ` | -| View run history | `swamp workflow history search --json` | -| Get latest run | `swamp workflow history get --json` | -| View run logs | `swamp workflow history logs --json` | -| List workflow data | `swamp data list --workflow --json` | -| Query wf data | `swamp data query 'tags.workflow == ""'` | -| Get workflow data | `swamp data get --workflow --json` | +| Task | Command | +| ------------------ | ------------------------------------------------------------- | +| Get schema | `swamp workflow schema get --json` | +| Search workflows | `swamp workflow search [query] --json` | +| Get a workflow | `swamp workflow get --json` | +| Create a workflow | `swamp workflow create --json` | +| Edit a workflow | `swamp workflow edit [id_or_name]` | +| Delete a workflow | `swamp workflow delete --json` | +| Validate workflow | `swamp workflow validate [id_or_name] --json` | +| Evaluate workflow | `swamp workflow evaluate --json` | +| Run a workflow | `swamp workflow run ` | +| Run with inputs | `swamp workflow run --input key=value` | +| Run from stdin | `echo '{"k":"v"}' \| swamp workflow run --stdin` | +| View run history | `swamp workflow history search --json` | +| Get latest run | `swamp workflow history get --json` | +| View run logs | `swamp workflow history logs --json` | +| List workflow data | `swamp data list --workflow --json` | +| Query wf data | `swamp data query 'tags.workflow == ""'` | +| Get workflow data | `swamp data get --workflow --json` | ## Repository Structure @@ -259,13 +259,13 @@ swamp workflow run my-workflow --input environment=production --input replicas=3 swamp workflow run my-workflow --input 'tags:json=["prod","west"]' # :json suffix for arrays/objects swamp workflow run my-workflow --input '{"environment": "production"}' # legacy single-shot JSON swamp workflow run my-workflow --input-file inputs.yaml -echo '{"environment": "prod"}' | swamp workflow run my-workflow # stdin auto-detected -printf '{"environment":"dev"}\n{"environment":"prod"}' | swamp workflow run my-workflow # NDJSON: one run per line +echo '{"environment": "prod"}' | swamp workflow run my-workflow --stdin +printf '{"environment":"dev"}\n{"environment":"prod"}' | swamp workflow run my-workflow --stdin # NDJSON: one run per line swamp workflow run my-workflow --last-evaluated # Use pre-evaluated workflow ``` -Piped stdin is auto-detected. JSON objects, JSON arrays, NDJSON (one JSON per -line), and YAML are supported. Multiple items (array or NDJSON) produce one +Pass `--stdin` to read piped input. JSON objects, JSON arrays, NDJSON (one JSON +per line), and YAML are supported. Multiple items (array or NDJSON) produce one workflow run per item. `--input` key=value overrides are deep-merged onto each stdin item. @@ -274,7 +274,8 @@ stdin item. | Flag | Description | | ------------------- | ------------------------------------------------------------------ | | `--input ` | Input values (key=value repeatable, or JSON) | -| `--input-file ` | Input values from YAML file (cannot combine with piped stdin) | +| `--input-file ` | Input values from YAML file (cannot combine with `--stdin`) | +| `--stdin` | Read inputs from stdin (piped data) | | `--last-evaluated` | Use previously evaluated workflow (skip eval and input validation) | | `--driver ` | Override execution driver for all steps (e.g. `raw`, `docker`) | diff --git a/.claude/skills/swamp-workflow/references/scenarios.md b/.claude/skills/swamp-workflow/references/scenarios.md index 29ead400..9d341f6f 100644 --- a/.claude/skills/swamp-workflow/references/scenarios.md +++ b/.claude/skills/swamp-workflow/references/scenarios.md @@ -580,17 +580,17 @@ Run a workflow for each result from a data query using Unix pipes and `jq`. # Run workflow once per pending item from a data query swamp data query 'modelName == "source" && attributes.status == "pending"' --json \ | jq -c '.results[] | {environment: .attributes.env}' \ - | swamp workflow run deploy-pipeline + | swamp workflow run deploy-pipeline --stdin # NDJSON: run workflow once per line printf '{"environment":"dev"}\n{"environment":"prod"}' \ - | swamp workflow run deploy-pipeline + | swamp workflow run deploy-pipeline --stdin # Stdin + --input overrides (--input wins on conflict) echo '{"environment": "dev"}' \ - | swamp workflow run deploy-pipeline --input dryRun=true + | swamp workflow run deploy-pipeline --stdin --input dryRun=true ``` -Stdin is auto-detected — no flag needed. JSON objects, JSON arrays, NDJSON, and -YAML are all supported. Multiple items produce one workflow run per item. -Execution stops on the first failure. +Pass `--stdin` to read piped input. JSON objects, JSON arrays, NDJSON, and YAML +are all supported. Multiple items produce one workflow run per item. Execution +stops on the first failure. diff --git a/design/inputs.md b/design/inputs.md index b876db73..eacf7ed8 100644 --- a/design/inputs.md +++ b/design/inputs.md @@ -233,10 +233,10 @@ via `--input-file` with YAML/JSON, or via the legacy single-shot ### Reading inputs from stdin -Both `method run` and `workflow run` auto-detect piped stdin. When data is piped -to the command, it is read and parsed as inputs — no flag needed. This enables -Unix pipe composition following the same pattern as `vault put`, `model edit`, -and `workflow edit`. +Both `method run` and `workflow run` accept piped stdin via the `--stdin` flag. +When `--stdin` is passed, the command reads stdin until EOF and parses it as +inputs. This enables Unix pipe composition following the same pattern as `jq -n` +(explicit opt-in). The input format is detected automatically: @@ -250,22 +250,22 @@ executed once per item. Each execution is discrete — it produces its own data artifacts, runs pre-flight checks, and reports independently. Execution stops on the first failure. -Piped stdin and `--input-file` cannot be combined. `--input` key=value overrides -can be combined with piped stdin — they are deep-merged onto each stdin item (the +`--stdin` and `--input-file` cannot be combined. `--input` key=value overrides +can be combined with `--stdin` — they are deep-merged onto each stdin item (the `--input` values win on conflict). ```sh # Single JSON object from stdin -echo '{"run": "echo hello"}' | swamp model method run my-model execute +echo '{"run": "echo hello"}' | swamp model method run my-model execute --stdin # NDJSON: run method once per line printf '{"run":"echo a"}\n{"run":"echo b"}' \ - | swamp model method run my-model execute + | swamp model method run my-model execute --stdin # Pipe from data query via jq, with static overrides swamp data query 'modelName == "source"' --json \ | jq -c '.results[] | {run: .attributes.command}' \ - | swamp model method run target-model execute --input env=prod + | swamp model method run target-model execute --stdin --input env=prod ``` ## Input Routing for Direct Type Execution diff --git a/src/cli/commands/model_method_run.ts b/src/cli/commands/model_method_run.ts index 7c704e13..5b638725 100644 --- a/src/cli/commands/model_method_run.ts +++ b/src/cli/commands/model_method_run.ts @@ -89,11 +89,11 @@ export const modelMethodRunCommand = new Command() ) .example( "Pipe inputs from stdin", - 'echo \'{"env":"prod"}\' | swamp model method run my-server deploy', + 'echo \'{"env":"prod"}\' | swamp model method run my-server deploy --stdin', ) .example( "Batch run via NDJSON from stdin", - 'printf \'{"env":"dev"}\\n{"env":"prod"}\' | swamp model method run my-server deploy', + 'printf \'{"env":"dev"}\\n{"env":"prod"}\' | swamp model method run my-server deploy --stdin', ) .description( "Execute a method on a model. With @type prefix, auto-creates the definition if needed.", @@ -115,8 +115,9 @@ export const modelMethodRunCommand = new Command() }) .option( "--input-file ", - "Input values from YAML file (cannot combine with piped stdin)", + "Input values from YAML file (cannot combine with --stdin)", ) + .option("--stdin", "Read inputs from stdin (piped data)", { default: false }) .option( "--tag ", "Add tag to produced data (KEY=VALUE, repeatable)", @@ -198,8 +199,7 @@ export const modelMethodRunCommand = new Command() ctx.logger .debug`Running method '${methodName}' on model: ${modelIdOrName}`; - // Auto-detect piped stdin (returns null when stdin is a TTY) - const stdinContent = await readStdin(); + const stdinContent = options.stdin ? await readStdin() : null; let stdinItems: Record[] | null = null; if (stdinContent !== null) { if (options.inputFile) { diff --git a/src/cli/commands/workflow_run.ts b/src/cli/commands/workflow_run.ts index f9d3102f..2ba0d1c4 100644 --- a/src/cli/commands/workflow_run.ts +++ b/src/cli/commands/workflow_run.ts @@ -92,11 +92,11 @@ export const workflowRunCommand = new Command() .example("Skip reports", "swamp workflow run deploy-pipeline --skip-reports") .example( "Pipe inputs from stdin", - 'echo \'{"env":"prod"}\' | swamp workflow run deploy-pipeline', + 'echo \'{"env":"prod"}\' | swamp workflow run deploy-pipeline --stdin', ) .example( "Batch run via NDJSON from stdin", - 'printf \'{"env":"dev"}\\n{"env":"prod"}\' | swamp workflow run deploy-pipeline', + 'printf \'{"env":"dev"}\\n{"env":"prod"}\' | swamp workflow run deploy-pipeline --stdin', ) .arguments("") .option( @@ -113,8 +113,9 @@ export const workflowRunCommand = new Command() }) .option( "--input-file ", - "Input values from YAML file (cannot combine with piped stdin)", + "Input values from YAML file (cannot combine with --stdin)", ) + .option("--stdin", "Read inputs from stdin (piped data)", { default: false }) .option( "--tag ", "Add tag to produced data (KEY=VALUE, repeatable)", @@ -174,8 +175,7 @@ export const workflowRunCommand = new Command() const lastEvaluated = options.lastEvaluated as boolean; - // Auto-detect piped stdin (returns null when stdin is a TTY) - const stdinContent = await readStdin(); + const stdinContent = options.stdin ? await readStdin() : null; let stdinItems: Record[] | null = null; if (stdinContent !== null) { if (options.inputFile) { diff --git a/src/infrastructure/io/stdin_reader.ts b/src/infrastructure/io/stdin_reader.ts index 5441b9d8..44cba8ae 100644 --- a/src/infrastructure/io/stdin_reader.ts +++ b/src/infrastructure/io/stdin_reader.ts @@ -18,23 +18,10 @@ // along with Swamp. If not, see . /** - * Reads content from stdin if data is available. + * Reads all content from stdin until EOF. * - * Returns null if stdin is a TTY (interactive mode) or if no data is available. - * Returns the content as a string if data was piped to stdin. - * - * This allows commands to auto-detect piped input without requiring flags. - * - * @example - * ```ts - * const stdinContent = await readStdin(); - * if (stdinContent !== null) { - * // Process piped content - * const data = parseYaml(stdinContent); - * } else { - * // Continue with interactive mode - * } - * ``` + * Returns null if stdin is a TTY. Callers should gate invocation behind + * an explicit `--stdin` flag to avoid blocking on pipes that never close. */ export async function readStdin(): Promise { // If stdin is a TTY, no piped data available