From f07bb4aaba8c51ca148e2b9fefb154e37cd97ebd Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp <19+brynary@users.noreply.github.com> Date: Sat, 9 May 2026 07:00:35 -0700 Subject: [PATCH] feat(cli): support sparse input overrides (#222) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add repeatable `-I` / `--input KEY=VALUE` CLI overrides for workflow run inputs on `fabro run`, `fabro create`, and `fabro preflight`. CLI inputs are sparse per-key overrides that merge over the resolved config inputs (preserving unrelated inherited values), unlike TOML `[run.inputs]` which still replaces wholesale. - Manifest bundling and graph-level goal resolution render workflow source with the effective inputs before structural scanning, so input-driven `@prompt`, `import`, and `stack.child_workflow` paths get bundled correctly. - Persist raw `KEY=VALUE` strings on `ManifestArgs.input` so server-side replay applies the same sparse overrides on top of merged config. - Review-driven cleanups: shared `TemplateContext::for_input_scan` helper for the recurring "render inputs but defer goal" idiom (replaces 4 sites), `#[derive(Default)]` on `ManifestBuildInput` to drop boilerplate, inline trivial `apply_input_overrides` wrapper, drop a redundant clone, and tighten the parser/test helpers. ## Test plan - [ ] `cargo nextest run -p fabro-cli -p fabro-config -p fabro-server -p fabro-template -p fabro-workflow` - [ ] `cargo +nightly-2026-04-14 fmt --check --all` - [ ] `cargo +nightly-2026-04-14 clippy -p fabro-cli -p fabro-config -p fabro-server -p fabro-template -p fabro-workflow --all-targets -- -D warnings` - [ ] Smoke: `fabro run -I key=value --input other=42` overrides those keys while preserving unrelated inherited inputs - [ ] Smoke: `-I` accepts strings, integers, floats, booleans, empty values; rejects arrays, inline tables, datetimes; rejects missing `=` and empty key - [ ] Smoke: input-driven `@prompts/{{ inputs.foo }}` and `stack.child_workflow="{{ inputs.bar }}/workflow.fabro"` paths bundle correctly when overridden via `-I` 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- Cargo.lock | 1 + .../administration/server-configuration.mdx | 2 +- docs/public/api-reference/fabro-api.yaml | 5 + docs/public/execution/run-configuration.mdx | 12 +- docs/public/reference/cli.mdx | 3 + docs/public/workflows/variables.mdx | 18 +- .../plans/2026-05-06-cli-input-overrides.md | 114 +++++++++ lib/crates/fabro-cli/Cargo.toml | 1 + lib/crates/fabro-cli/src/args.rs | 13 ++ lib/crates/fabro-cli/src/commands/graph.rs | 9 +- .../fabro-cli/src/commands/preflight.rs | 13 +- .../fabro-cli/src/commands/run/create.rs | 1 + .../fabro-cli/src/commands/run/overrides.rs | 17 +- lib/crates/fabro-cli/src/commands/validate.rs | 9 +- lib/crates/fabro-cli/src/main.rs | 59 +++++ lib/crates/fabro-cli/src/manifest_builder.rs | 221 ++++++++++++++---- lib/crates/fabro-cli/tests/it/cmd/create.rs | 5 +- .../fabro-cli/tests/it/cmd/preflight.rs | 5 +- lib/crates/fabro-cli/tests/it/cmd/run.rs | 5 +- .../tests/manifest_path_round_trip.rs | 10 +- .../fabro-config/src/input_overrides.rs | 171 ++++++++++++++ lib/crates/fabro-config/src/lib.rs | 2 + lib/crates/fabro-server/src/run_manifest.rs | 63 ++++- lib/crates/fabro-template/src/lib.rs | 8 + .../fabro-workflow/src/operations/create.rs | 9 +- .../fabro-workflow/src/transforms/import.rs | 4 +- .../src/transforms/variable_expansion.rs | 4 +- .../src/models/manifest-args.ts | 4 + 28 files changed, 676 insertions(+), 112 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-06-cli-input-overrides.md create mode 100644 lib/crates/fabro-config/src/input_overrides.rs diff --git a/Cargo.lock b/Cargo.lock index e93e7d480..f9c7ee93c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1681,6 +1681,7 @@ dependencies = [ "fabro-static", "fabro-store", "fabro-telemetry", + "fabro-template", "fabro-test", "fabro-types", "fabro-util", diff --git a/docs/public/administration/server-configuration.mdx b/docs/public/administration/server-configuration.mdx index 5c41b7434..3373f02a9 100644 --- a/docs/public/administration/server-configuration.mdx +++ b/docs/public/administration/server-configuration.mdx @@ -230,7 +230,7 @@ On a same-machine setup, `settings.toml` is the shared machine-default layer und On a remote setup, the client bundles workflow, project, and user config into the run manifest. The server then layers those bundled client configs over its own local defaults for run-shaped fields. Server-owned values like `[server.storage]`, `[server.api]`, `[server.web]`, `[features]`, and `[server.scheduler]` always come from the server machine's own `settings.toml` or `fabro server start` flags. -Merge rules follow the normative matrix: `[run.inputs]` replaces wholesale, `[run.sandbox.env]` and `[run.sandbox.daytona.labels]` merge by key, `[run.prepare.steps]` replaces whole-list, and `[[run.hooks]]` merge by optional `id`. Most other fields use "higher-precedence wins" field-wise merging. +Merge rules follow the normative matrix: TOML `[run.inputs]` tables replace wholesale, CLI `-I` / `--input` values replayed from run manifests merge per key at highest precedence, `[run.sandbox.env]` and `[run.sandbox.daytona.labels]` merge by key, `[run.prepare.steps]` replaces whole-list, and `[[run.hooks]]` merge by optional `id`. Most other fields use "higher-precedence wins" field-wise merging. ### `[server.logging]` section diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index 1751fa1b2..5695ae2fa 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -4794,6 +4794,11 @@ components: type: array items: type: string + input: + type: array + description: Raw repeated CLI input overrides, each in `KEY=VALUE` form. + items: + type: string ManifestTarget: type: object diff --git a/docs/public/execution/run-configuration.mdx b/docs/public/execution/run-configuration.mdx index d499e95b7..588018448 100644 --- a/docs/public/execution/run-configuration.mdx +++ b/docs/public/execution/run-configuration.mdx @@ -317,7 +317,15 @@ digraph CI { If a workflow template references an undefined input like `{{ inputs.langauge }}`, Fabro raises an error immediately. -`[run.inputs]` replaces wholesale across layers. Unlike labels, inputs do not merge by key — the highest-precedence layer that sets `inputs` wins its entire map. +TOML `[run.inputs]` tables replace wholesale across layers. Unlike labels, TOML input tables do not merge by key — the highest-precedence config layer that sets `inputs` wins its entire map. + +CLI input flags are sparse overrides on top of the resolved config inputs: + +```bash +fabro run .fabro/workflows/ci/workflow.toml -I repo_name=fabro-2 --input language=rust +``` + +Repeat `-I` / `--input` to override multiple keys. CLI input flags have the highest precedence, merge per key, and preserve unrelated inherited inputs. Duplicate CLI keys are accepted; the last value wins. ### `[run.artifacts]` @@ -460,7 +468,7 @@ provider = "daytona" name = "my-project-snapshot" ``` -Project defaults and workflow config values merge per the normative merge matrix: most fields merge by field (higher-precedence wins per key), `run.inputs` replaces wholesale, `run.sandbox.env` sticky-merges by key, and `run.prepare.steps` replaces whole-list. +Project defaults and workflow config values merge per the normative merge matrix: most fields merge by field (higher-precedence wins per key), TOML `run.inputs` tables replace wholesale, CLI input flags merge per key at highest precedence, `run.sandbox.env` sticky-merges by key, and `run.prepare.steps` replaces whole-list. ### Machine defaults diff --git a/docs/public/reference/cli.mdx b/docs/public/reference/cli.mdx index 5abeaa31d..95feb16e8 100644 --- a/docs/public/reference/cli.mdx +++ b/docs/public/reference/cli.mdx @@ -311,6 +311,7 @@ fabro create [OPTIONS] | `--provider ` | Override default LLM provider | | `--sandbox ` | Sandbox for agent tools
Values: `local`, `docker`, `daytona` | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | +| `-I, --input ` | Override a workflow input value (repeatable, format: KEY=VALUE) | | `-v, --verbose` | Enable verbose output | ### `fabro discord` @@ -684,6 +685,7 @@ fabro preflight [OPTIONS] | `--provider ` | Override default LLM provider | | `--sandbox ` | Sandbox for agent tools
Values: `local`, `docker`, `daytona` | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | +| `-I, --input ` | Override a workflow input value (repeatable, format: KEY=VALUE) | | `-v, --verbose` | Enable verbose output | ### `fabro provider` @@ -848,6 +850,7 @@ fabro run [OPTIONS] | `--provider ` | Override default LLM provider | | `--sandbox ` | Sandbox for agent tools
Values: `local`, `docker`, `daytona` | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | +| `-I, --input ` | Override a workflow input value (repeatable, format: KEY=VALUE) | | `-v, --verbose` | Enable verbose output | ### `fabro sandbox` diff --git a/docs/public/workflows/variables.mdx b/docs/public/workflows/variables.mdx index 2680d5e37..8e58bd678 100644 --- a/docs/public/workflows/variables.mdx +++ b/docs/public/workflows/variables.mdx @@ -12,7 +12,7 @@ Workflow and prompt templates can reference: | Expression | Resolves to | |---|---| | `{{ goal }}` | The workflow goal | -| `{{ inputs.name }}` | A value from `[run.inputs]` | +| `{{ inputs.name }}` | A value from `[run.inputs]`, optionally overridden by CLI input flags | Environment variables are **not** available in workflow or prompt templates. Use `{{ env.NAME }}` only in config strings and HTTP hook headers. @@ -51,6 +51,14 @@ digraph Check { } ``` +Override individual inputs at run time with repeatable `-I` / `--input` flags: + +```bash +fabro run .fabro/workflows/check/workflow.toml -I repo_name=fabro-2 --input language=rust +``` + +CLI input values use TOML scalar parsing when possible. Quoted strings, booleans, integers, and floats keep their typed values; unquoted bare text falls back to a string. Empty values such as `foo=` are accepted as empty strings. Arrays, inline tables, and datetimes are rejected. + ## `goal` Agent and prompt nodes also receive the workflow goal at runtime: @@ -91,13 +99,13 @@ You can also emit literal braces with expressions such as `{{ '{{' }}` when need ## Input merging -`[run.inputs]` intentionally replaces the inherited map wholesale rather than merging by key. Whichever layer has the highest precedence and sets `[run.inputs]` wins its entire map. +TOML `[run.inputs]` tables intentionally replace the inherited map wholesale rather than merging by key. Whichever TOML layer has the highest precedence and sets `[run.inputs]` wins its entire map. + +CLI input flags are different: they are sparse per-key overrides applied after config resolution, so unrelated inherited inputs remain available. If a key is repeated on the CLI, the last value wins. | Source | Priority | |---|---| -| CLI flags (`-V key=value`, repeated) | Highest | +| CLI flags (`-I key=value` / `--input key=value`, repeated; per-key merge) | Highest | | `workflow.toml` `[run.inputs]` | | | `.fabro/project.toml` `[run.inputs]` | | | `~/.fabro/settings.toml` `[run.inputs]` | Lowest | - -If you need per-key overrides on top of inherited defaults, set each input explicitly in the winning layer. diff --git a/docs/superpowers/plans/2026-05-06-cli-input-overrides.md b/docs/superpowers/plans/2026-05-06-cli-input-overrides.md new file mode 100644 index 000000000..38b2ad8df --- /dev/null +++ b/docs/superpowers/plans/2026-05-06-cli-input-overrides.md @@ -0,0 +1,114 @@ +# CLI Input Overrides Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add repeatable `-I/--input key=value` CLI overrides for workflow run inputs. + +**Architecture:** Keep config-file `[run.inputs]` whole-map replacement semantics unchanged. Parse CLI input flags as a separate sparse override layer, then apply those key-level overrides after effective workflow settings are resolved. Manifest bundling must use the same effective inputs when scanning structural DOT references so input-driven `@prompt`, `import`, and `stack.child_workflow` paths are included in created run manifests. + +**Tech Stack:** Rust, clap, Axum server manifest flow, OpenAPI/progenitor-generated `fabro-api` types, generated TypeScript Axios client, TOML value parsing, nextest. + +--- + +## Summary + +Add repeatable CLI input overrides for `fabro run`, `fabro create`, and `fabro preflight` so documented `-I key=value` usage works. CLI inputs override individual keys in the effective `run.inputs` map while preserving other inherited inputs. + +## Behavior Decisions + +| Input | Result | +|---|---| +| `foo=` | accepted as empty string | +| `foo=bar` | accepted as string `"bar"` via fallback | +| `foo="bar"` | accepted as TOML string `"bar"` | +| `foo=false` | accepted as boolean `false` | +| `foo=3` | accepted as integer `3` | +| `foo=0.75` | accepted as float `0.75` | +| `foo=2026-05-06` | rejected; datetimes are not input scalars for this CLI flag | +| `foo=[1]` | rejected; arrays are not supported | +| `foo={a=1}` | rejected; inline tables are not supported | +| `foo` | rejected; missing `=` | +| `=bar` | rejected; empty key | +| duplicate keys | accepted; last value wins | + +## Implementation Tasks + +- [x] Add a shared input-override parser in `fabro-config`. + - Parse raw `KEY=VALUE` strings into `HashMap`. + - Split only on the first `=`. + - Apply the behavior table above exactly. + - Return errors that include the key when one is available and explain the failure reason. + - Do not echo full raw `KEY=VALUE` strings in error messages unless the input has no parseable key and the structure itself is the error. + - Add unit tests for every row in the behavior table. + +- [x] Add `-I, --input ` to run-like CLI args. + - Add a shared clap args struct in `lib/crates/fabro-cli/src/args.rs`. + - Flatten it into `RunArgs` and `PreflightArgs`; `create` inherits `RunArgs`. + - Add parser tests for `fabro run workflow.toml -I foo=bar`, `fabro create workflow.toml --input foo=bar`, and `fabro preflight workflow.toml -I foo=bar`. + - Add a regression test that top-level `fabro -V` still parses as version. + +- [x] Apply CLI inputs as sparse settings overrides. + - Do not put parsed CLI inputs directly in `RunLayer.inputs`, because that would trigger whole-map replacement semantics. + - After `WorkflowSettingsBuilder::build()`, extend `settings.run.inputs` with parsed CLI overrides. + - Make `fabro run/preflight/create` preserve inherited inputs when only one key is overridden by `-I`. + - Keep the existing TOML `[run.inputs]` replacement test unchanged and passing. + +- [x] Make manifest bundling input-aware before structural scanning. + - Ensure `build_run_manifest()` computes effective settings with CLI input overrides before calling workflow collection. + - Render each DOT source used only for manifest scanning with `TemplateContext::new().with_goal("{{ goal }}").with_inputs(effective_inputs.clone())` before parsing for structural references. + - Keep the manifest's stored workflow and file sources as original source text; rendering is only for discovery. + - Apply this to root workflows and imported workflow files before scanning `goal`, node `prompt`, node `import`, and `stack.child_workflow` / `stack.child_dotfile`. + - Add manifest-builder tests where `-I` supplies a dynamic `@prompt` path, `import` path, and `stack.child_workflow` path, and assert the referenced files/workflows are bundled. + +- [x] Make graph-level manifest goal resolution input-aware. + - Update `resolve_manifest_goal()` precedence 3 so graph-level `goal` attributes are parsed from the same rendered root DOT source used for manifest structural scanning. + - Keep precedence 1 (`--goal` / `--goal-file`) and precedence 2 (`run.goal`) unchanged. + - Preserve the manifest's stored root workflow source as original source text. + - Add a manifest-builder test for `graph [goal="@prompts/{{ inputs.goal_file }}"]` with `-I goal_file=goal.md` and assert the manifest goal has `path == "prompts/goal.md"` and `text == `. + +- [x] Persist and replay input overrides through run manifests. + - Add `input: string[]` to `ManifestArgs` in `docs/public/api-reference/fabro-api.yaml`. + - Update `run_manifest_args()` and `preflight_manifest_args()` to include raw repeated input args. + - Update every `types::ManifestArgs` construction site and default/fixture in Rust. + - Update `manifest_args_is_empty()` so input-only manifests are not dropped. + - Add a test where the only CLI override is `-I foo=bar` and assert `manifest.args.input == ["foo=bar"]`. + +- [x] Apply manifest input overrides on the server. + - In `prepare_manifest()`, parse `manifest.args.input` with the shared parser. + - Apply parsed inputs after `WorkflowSettingsBuilder::build()` and before any prepared settings are used. + - Add a server manifest replay test where project/workflow config has multiple inputs, manifest args override one key, and the unrelated inherited key remains. + +- [x] Regenerate API clients. + - Run `cargo build -p fabro-api` so progenitor regenerates Rust API types. + - Run `cd lib/packages/fabro-api-client && bun run generate` so the TypeScript Axios client includes `ManifestArgs.input`. + - Include both Rust and TypeScript generated diffs in the implementation change set. + +- [x] Update docs. + - Update `docs/public/workflows/variables.mdx` to document `-I/--input key=value`. + - Update `docs/public/execution/run-configuration.mdx` and `docs/public/administration/server-configuration.mdx` so all `[run.inputs]` precedence and merge-semantics docs distinguish TOML whole-map replacement from CLI per-key overrides. + - Refresh generated CLI docs after adding the clap flag. + - State that CLI input flags are highest precedence and merge per key. + - Keep the existing warning that TOML `[run.inputs]` layers replace the whole inherited map. + +## Test Plan + +- Parser tests in `fabro-config` cover the full behavior table. +- CLI parse tests cover `run`, `create`, `preflight`, `--input`, `-I`, and top-level version parsing. +- Settings tests prove CLI input overrides preserve unrelated inherited inputs and TOML `[run.inputs]` replacement behavior remains unchanged. +- Manifest builder tests cover input-driven `@prompt`, `import`, and `stack.child_workflow` bundling. +- Manifest goal tests cover input-driven graph-level `goal="@..."` resolution. +- Manifest replay tests cover input-only manifest args and server-side application of input overrides. +- Verification commands: + - `cargo build -p fabro-api` + - `cd lib/packages/fabro-api-client && bun run generate` + - `cargo dev docs refresh` + - `cargo dev docs check` + - targeted CLI/config/server tests + - `cargo nextest run -p fabro-cli -p fabro-config -p fabro-server` + +## Assumptions + +- CLI input flags are sparse per-key overrides, not whole-map replacement. +- `--input` is the long flag; `--var` is not added. +- Input keys remain flat strings. +- TOML datetimes, arrays, and inline tables are rejected for CLI input overrides. diff --git a/lib/crates/fabro-cli/Cargo.toml b/lib/crates/fabro-cli/Cargo.toml index 864ef3fb4..eb12c99f0 100644 --- a/lib/crates/fabro-cli/Cargo.toml +++ b/lib/crates/fabro-cli/Cargo.toml @@ -50,6 +50,7 @@ fabro-redact.workspace = true fabro-util = { path = "../fabro-util" } fabro-http.workspace = true fabro-static.workspace = true +fabro-template = { path = "../fabro-template" } clap.workspace = true clap_complete.workspace = true cli-table.workspace = true diff --git a/lib/crates/fabro-cli/src/args.rs b/lib/crates/fabro-cli/src/args.rs index 4204ebb88..d3f27eb35 100644 --- a/lib/crates/fabro-cli/src/args.rs +++ b/lib/crates/fabro-cli/src/args.rs @@ -168,6 +168,13 @@ pub(crate) struct ServerConnectionArgs { pub(crate) target: ServerTargetArgs, } +#[derive(Args, Debug, Clone, Default)] +pub(crate) struct InputOverrideArgs { + /// Override a workflow input value (repeatable, format: KEY=VALUE) + #[arg(short = 'I', long = "input", value_name = "KEY=VALUE")] + pub(crate) values: Vec, +} + #[derive(Debug, Clone, Copy, ValueEnum)] pub(crate) enum CliSandboxProvider { Local, @@ -200,6 +207,9 @@ pub(crate) struct RunArgs { #[command(flatten)] pub(crate) target: ServerTargetArgs, + #[command(flatten)] + pub(crate) inputs: InputOverrideArgs, + /// Path to a .fabro workflow file or .toml task config #[arg(required = true)] pub(crate) workflow: Option, @@ -266,6 +276,9 @@ pub(crate) struct PreflightArgs { #[command(flatten)] pub(crate) target: ServerTargetArgs, + #[command(flatten)] + pub(crate) inputs: InputOverrideArgs, + /// Path to a .fabro workflow file or .toml task config pub(crate) workflow: PathBuf, diff --git a/lib/crates/fabro-cli/src/commands/graph.rs b/lib/crates/fabro-cli/src/commands/graph.rs index 4e2297645..dbfae41fc 100644 --- a/lib/crates/fabro-cli/src/commands/graph.rs +++ b/lib/crates/fabro-cli/src/commands/graph.rs @@ -33,13 +33,10 @@ pub(crate) async fn run( let printer = base_ctx.printer(); let ctx = base_ctx.with_target(&args.target)?; let built = build_run_manifest(ManifestBuildInput { - workflow: args.workflow.clone(), - cwd: ctx.cwd().to_path_buf(), - run_overrides: None, - cli_overrides: None, - args: None, - run_id: None, + workflow: args.workflow.clone(), + cwd: ctx.cwd().to_path_buf(), user_settings_path: Some(active_settings_path(None)), + ..Default::default() })?; let client = ctx.server().await?; let preflight = client.run_preflight(built.manifest.clone()).await?; diff --git a/lib/crates/fabro-cli/src/commands/preflight.rs b/lib/crates/fabro-cli/src/commands/preflight.rs index 1ea977593..a3d1dbdf6 100644 --- a/lib/crates/fabro-cli/src/commands/preflight.rs +++ b/lib/crates/fabro-cli/src/commands/preflight.rs @@ -22,13 +22,14 @@ pub(crate) async fn execute( let cli_args_config = preflight_args_overrides(&args)?; let manifest = build_run_manifest(ManifestBuildInput { - workflow: args.workflow.clone(), - cwd: ctx.cwd().to_path_buf(), - run_overrides: cli_args_config.run, - cli_overrides: cli_args_config.cli, - args: preflight_manifest_args(&args), - run_id: None, + workflow: args.workflow.clone(), + cwd: ctx.cwd().to_path_buf(), + run_overrides: cli_args_config.run, + cli_overrides: cli_args_config.cli, + input_overrides: cli_args_config.input_overrides, + args: preflight_manifest_args(&args), user_settings_path: Some(active_settings_path(None)), + ..Default::default() })?; let spinner = (!ctx.json_output()).then(|| cyan_spinner("Running checks...")); diff --git a/lib/crates/fabro-cli/src/commands/run/create.rs b/lib/crates/fabro-cli/src/commands/run/create.rs index 89397f70d..c56a89024 100644 --- a/lib/crates/fabro-cli/src/commands/run/create.rs +++ b/lib/crates/fabro-cli/src/commands/run/create.rs @@ -43,6 +43,7 @@ pub(crate) async fn create_run( cwd, run_overrides: cli_args_config.run, cli_overrides: cli_args_config.cli, + input_overrides: cli_args_config.input_overrides, args: run_manifest_args(args), run_id, user_settings_path: Some(active_settings_path(None)), diff --git a/lib/crates/fabro-cli/src/commands/run/overrides.rs b/lib/crates/fabro-cli/src/commands/run/overrides.rs index c1b73177b..e24d88743 100644 --- a/lib/crates/fabro-cli/src/commands/run/overrides.rs +++ b/lib/crates/fabro-cli/src/commands/run/overrides.rs @@ -4,7 +4,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Result, anyhow}; use fabro_config::{ CliLayer, CliOutputLayer, ReplaceMap, RunExecutionLayer, RunGoalLayer, RunLayer, RunModelLayer, - RunSandboxLayer, + RunSandboxLayer, parse_input_overrides, }; use fabro_sandbox::SandboxProvider; use fabro_types::settings::cli::OutputVerbosity; @@ -15,8 +15,9 @@ use crate::args::{PreflightArgs, RunArgs}; #[derive(Clone, Debug, Default)] pub(crate) struct ManifestSettingsOverrides { - pub(crate) run: Option, - pub(crate) cli: Option, + pub(crate) run: Option, + pub(crate) cli: Option, + pub(crate) input_overrides: HashMap, } fn sparse_flag(value: bool) -> Option { @@ -147,8 +148,9 @@ pub(crate) fn run_args_overrides(args: &RunArgs) -> Result Result anyhow::Result<()> { let printer = base_ctx.printer(); let built = build_run_manifest(ManifestBuildInput { - workflow: args.workflow.clone(), - cwd: base_ctx.cwd().to_path_buf(), - run_overrides: None, - cli_overrides: None, - args: None, - run_id: None, + workflow: args.workflow.clone(), + cwd: base_ctx.cwd().to_path_buf(), user_settings_path: Some(active_settings_path(None)), + ..Default::default() })?; let response = manifest_validation::validate_manifest(&RunLayer::default(), &built.manifest)?; let diagnostics = api_diagnostics_to_local(&response.workflow.diagnostics); diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index 82bffe02e..adf4562d6 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -548,6 +548,7 @@ mod tests { AuthCommand, AuthNamespace, Commands, InstallGitHubStrategyArg, ModelsCommand, ProviderCommand, ProviderNamespace, }; + use clap::error::ErrorKind; use temp_env::with_var; use tokio::runtime::Runtime; @@ -1177,6 +1178,64 @@ destination = "{destination}" } } + #[test] + fn parse_run_input_short_flag() { + let cli = Cli::try_parse_from(["fabro", "run", "workflow.toml", "-I", "foo=bar"]) + .expect("should parse"); + match *cli.command.unwrap() { + Commands::RunCmd(RunCommands::Run(args)) => { + assert_eq!(args.inputs.values, vec!["foo=bar"]); + } + _ => panic!("unexpected command variant"), + } + } + + #[test] + fn run_manifest_args_preserves_input_only_manifest_args() { + let cli = Cli::try_parse_from(["fabro", "run", "workflow.toml", "-I", "foo=bar"]) + .expect("should parse"); + match *cli.command.unwrap() { + Commands::RunCmd(RunCommands::Run(args)) => { + let manifest_args = manifest_builder::run_manifest_args(&args) + .expect("input-only args should be retained"); + assert_eq!(manifest_args.input, vec!["foo=bar"]); + } + _ => panic!("unexpected command variant"), + } + } + + #[test] + fn parse_create_input_long_flag() { + let cli = Cli::try_parse_from(["fabro", "create", "workflow.toml", "--input", "foo=bar"]) + .expect("should parse"); + match *cli.command.unwrap() { + Commands::RunCmd(RunCommands::Create(args)) => { + assert_eq!(args.inputs.values, vec!["foo=bar"]); + } + _ => panic!("unexpected command variant"), + } + } + + #[test] + fn parse_preflight_input_short_flag() { + let cli = Cli::try_parse_from(["fabro", "preflight", "workflow.toml", "-I", "foo=bar"]) + .expect("should parse"); + match *cli.command.unwrap() { + Commands::Preflight(args) => { + assert_eq!(args.inputs.values, vec!["foo=bar"]); + } + _ => panic!("unexpected command variant"), + } + } + + #[test] + fn parse_top_level_short_version_still_reports_version() { + let Err(err) = Cli::try_parse_from(["fabro", "-V"]) else { + panic!("should report version"); + }; + assert_eq!(err.kind(), ErrorKind::DisplayVersion); + } + #[test] fn parse_run_storage_dir_after_subcommand_is_rejected() { let result = Cli::try_parse_from([ diff --git a/lib/crates/fabro-cli/src/manifest_builder.rs b/lib/crates/fabro-cli/src/manifest_builder.rs index e57952644..6e66543f2 100644 --- a/lib/crates/fabro-cli/src/manifest_builder.rs +++ b/lib/crates/fabro-cli/src/manifest_builder.rs @@ -13,6 +13,7 @@ use fabro_config::run::{resolve_run_goal_from_layer, resolve_run_goal_from_names use fabro_config::{CliLayer, DaytonaDockerfileLayer, RunLayer, WorkflowSettingsBuilder}; use fabro_graphviz::graph::AttrValue; use fabro_graphviz::parser; +use fabro_template::{TemplateContext, render as render_template}; use fabro_types::settings::run::{ResolvedGoalSource, ResolvedRunGoal}; use fabro_types::{DirtyStatus, GitContext, PreRunPushOutcome, RunId, WorkflowSettings}; use fabro_workflow::ManifestPath; @@ -22,12 +23,13 @@ use fabro_workflow::git::{ use crate::args::{PreflightArgs, RunArgs}; -#[derive(Debug)] +#[derive(Debug, Default)] pub struct ManifestBuildInput { pub workflow: PathBuf, pub cwd: PathBuf, pub run_overrides: Option, pub cli_overrides: Option, + pub input_overrides: HashMap, pub args: Option, pub run_id: Option, /// Path to the user settings file (for inclusion in @@ -43,6 +45,7 @@ pub struct BuiltManifest { struct CollectContext<'a> { cwd: &'a Path, + inputs: &'a HashMap, workflows: HashMap, visited_workflows: HashSet, } @@ -89,15 +92,17 @@ pub fn build_run_manifest(input: ManifestBuildInput) -> Result { { workflow_settings_builder = workflow_settings_builder.user_file(path)?; } - let workflow_settings = workflow_settings_builder + let mut workflow_settings = workflow_settings_builder .build() .context("failed to resolve manifest settings")?; + workflow_settings.run.inputs.extend(input.input_overrides); let target_path = root_resolution.dot_path.clone(); let target_manifest_path = manifest_path_from_absolute(&target_path, &input.cwd)?; let target_key = target_manifest_path.to_string(); let mut context = CollectContext { cwd: &input.cwd, + inputs: &workflow_settings.run.inputs, workflows: HashMap::new(), visited_workflows: HashSet::new(), }; @@ -132,10 +137,13 @@ pub fn build_run_manifest(input: ManifestBuildInput) -> Result { let working_directory = project::resolve_working_directory_from_run(&workflow_settings.run, &input.cwd); + let rendered_root_source = + render_workflow_scan_source(&root_source, &target_path, &workflow_settings.run.inputs)?; + let goal = resolve_manifest_goal( input.run_overrides.as_ref(), &workflow_settings, - &root_source, + &rendered_root_source, &target_path, &working_directory, )?; @@ -180,6 +188,7 @@ pub(crate) fn run_manifest_args(args: &RunArgs) -> Option { .then(|| fabro_sandbox::SandboxProvider::Local.to_string()) }), docker_image: None, + input: args.inputs.values.clone(), verbose: args.verbose.then_some(true), worktree_mode: args.in_place.then(|| "never".to_string()), }; @@ -199,6 +208,7 @@ pub(crate) fn preflight_manifest_args(args: &PreflightArgs) -> Option, visited_imports: &mut HashSet, ) -> Result<()> { - let graph = parser::parse(&workflow.source).map_err(|err| { + let rendered_source = render_workflow_scan_source( + &workflow.source, + &workflow.absolute_dot_path, + context.inputs, + )?; + let graph = parser::parse(&rendered_source).map_err(|err| { anyhow!( "Failed to parse {}: {err}", workflow.absolute_dot_path.display() @@ -353,6 +368,15 @@ fn collect_workflow_files( Ok(()) } +fn render_workflow_scan_source( + source: &str, + path: &Path, + inputs: &HashMap, +) -> Result { + render_template(source, &TemplateContext::for_input_scan(inputs.clone())) + .with_context(|| format!("Failed to render {} for manifest scanning", path.display())) +} + fn collect_workflow_config_files( context: &CollectContext<'_>, config: &types::ManifestWorkflowConfig, @@ -643,6 +667,7 @@ fn manifest_args_is_empty(args: &types::ManifestArgs) -> bool { && args.provider.is_none() && args.sandbox.is_none() && args.docker_image.is_none() + && args.input.is_empty() && args.verbose.is_none() && args.worktree_mode.is_none() } @@ -699,13 +724,9 @@ mod tests { .unwrap(); let built = build_run_manifest(ManifestBuildInput { - workflow: PathBuf::from(".fabro/workflows/demo/workflow.toml"), - cwd: project.to_path_buf(), - run_overrides: None, - cli_overrides: None, - args: None, - run_id: None, - user_settings_path: None, + workflow: PathBuf::from(".fabro/workflows/demo/workflow.toml"), + cwd: project.to_path_buf(), + ..Default::default() }) .unwrap(); @@ -740,6 +761,134 @@ mod tests { ); } + #[test] + fn build_manifest_uses_input_overrides_for_structural_file_scanning() { + let temp = tempfile::tempdir().unwrap(); + let project = temp.path(); + let workflow_dir = project.join(".fabro/workflows/demo"); + let child_dir = project.join(".fabro/workflows/child"); + std::fs::create_dir_all(workflow_dir.join("prompts")).unwrap(); + std::fs::create_dir_all(workflow_dir.join("imports")).unwrap(); + std::fs::create_dir_all(&child_dir).unwrap(); + std::fs::write(project.join(".fabro/project.toml"), "_version = 1\n").unwrap(); + std::fs::write( + workflow_dir.join("workflow.toml"), + "_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n", + ) + .unwrap(); + std::fs::write( + workflow_dir.join("workflow.fabro"), + r#"digraph Demo { + graph [goal="Demo"] + start [shape=Mdiamond] + exit [shape=Msquare] + plan [prompt="@prompts/{{ inputs.prompt_file }}"] + imported [import="./imports/{{ inputs.import_file }}"] + child [shape=house, stack.child_workflow="../{{ inputs.child_workflow }}/workflow.fabro"] + start -> plan -> imported -> child -> exit + }"#, + ) + .unwrap(); + std::fs::write(workflow_dir.join("prompts/plan.md"), "plan it").unwrap(); + std::fs::write( + workflow_dir.join("imports/checks.fabro"), + r"digraph Checks { start [shape=Mdiamond] exit [shape=Msquare] start -> exit }", + ) + .unwrap(); + std::fs::write( + child_dir.join("workflow.fabro"), + r"digraph Child { start [shape=Mdiamond] exit [shape=Msquare] start -> exit }", + ) + .unwrap(); + + let built = build_run_manifest(ManifestBuildInput { + workflow: PathBuf::from(".fabro/workflows/demo/workflow.toml"), + cwd: project.to_path_buf(), + input_overrides: HashMap::from([ + ( + "prompt_file".to_string(), + toml::Value::String("plan.md".to_string()), + ), + ( + "import_file".to_string(), + toml::Value::String("checks.fabro".to_string()), + ), + ( + "child_workflow".to_string(), + toml::Value::String("child".to_string()), + ), + ]), + ..Default::default() + }) + .unwrap(); + + let root = &built.manifest.workflows[".fabro/workflows/demo/workflow.fabro"]; + assert!( + root.source.contains("{{ inputs.prompt_file }}"), + "manifest should store original workflow source" + ); + assert!( + root.files + .contains_key(".fabro/workflows/demo/prompts/plan.md") + ); + assert!( + root.files + .contains_key(".fabro/workflows/demo/imports/checks.fabro") + ); + assert!( + built + .manifest + .workflows + .contains_key(".fabro/workflows/child/workflow.fabro") + ); + } + + #[test] + fn build_manifest_uses_input_overrides_for_graph_goal_file_resolution() { + let temp = tempfile::tempdir().unwrap(); + let project = temp.path(); + let workflow_dir = project.join(".fabro/workflows/demo"); + std::fs::create_dir_all(workflow_dir.join("prompts")).unwrap(); + std::fs::write(project.join(".fabro/project.toml"), "_version = 1\n").unwrap(); + std::fs::write( + workflow_dir.join("workflow.toml"), + "_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n", + ) + .unwrap(); + std::fs::write( + workflow_dir.join("workflow.fabro"), + r#"digraph Demo { + graph [goal="@prompts/{{ inputs.goal_file }}"] + start [shape=Mdiamond] + exit [shape=Msquare] + start -> exit + }"#, + ) + .unwrap(); + std::fs::write(workflow_dir.join("prompts/goal.md"), "ship it").unwrap(); + + let built = build_run_manifest(ManifestBuildInput { + workflow: PathBuf::from(".fabro/workflows/demo/workflow.toml"), + cwd: project.to_path_buf(), + input_overrides: HashMap::from([( + "goal_file".to_string(), + toml::Value::String("goal.md".to_string()), + )]), + ..Default::default() + }) + .unwrap(); + + let goal = built.manifest.goal.expect("manifest goal should resolve"); + assert_eq!(goal.path.as_deref(), Some("prompts/goal.md")); + assert_eq!(goal.text, "ship it"); + assert_eq!(goal.type_, types::ManifestGoalType::Graph); + let root = &built.manifest.workflows[".fabro/workflows/demo/workflow.fabro"]; + assert!( + root.source.contains("{{ inputs.goal_file }}"), + "manifest should store original workflow source" + ); + } + /// A relative `[run.goal] file = "..."` declared in `.fabro/project.toml` /// must resolve against the directory of `.fabro/project.toml`, not against /// the invocation cwd. We exercise this by invoking from a subdirectory @@ -779,13 +928,9 @@ file = "prompts/goal.md" .unwrap(); let built = build_run_manifest(ManifestBuildInput { - workflow: PathBuf::from(".fabro/workflows/demo/workflow.toml"), - cwd: project.to_path_buf(), - run_overrides: None, - cli_overrides: None, - args: None, - run_id: None, - user_settings_path: None, + workflow: PathBuf::from(".fabro/workflows/demo/workflow.toml"), + cwd: project.to_path_buf(), + ..Default::default() }) .unwrap(); @@ -832,13 +977,9 @@ file = "prompts/goal.md" .unwrap(); let built = build_run_manifest(ManifestBuildInput { - workflow: PathBuf::from(".fabro/workflows/demo/workflow.toml"), - cwd: project.to_path_buf(), - run_overrides: None, - cli_overrides: None, - args: None, - run_id: None, - user_settings_path: None, + workflow: PathBuf::from(".fabro/workflows/demo/workflow.toml"), + cwd: project.to_path_buf(), + ..Default::default() }) .unwrap(); @@ -897,13 +1038,9 @@ working_dir = "repos/target" .unwrap(); let built = build_run_manifest(ManifestBuildInput { - workflow: PathBuf::from(".fabro/workflows/demo/workflow.toml"), - cwd: workspace.to_path_buf(), - run_overrides: None, - cli_overrides: None, - args: None, - run_id: None, - user_settings_path: None, + workflow: PathBuf::from(".fabro/workflows/demo/workflow.toml"), + cwd: workspace.to_path_buf(), + ..Default::default() }) .unwrap(); @@ -952,13 +1089,9 @@ repository = "target" .unwrap(); let built = build_run_manifest(ManifestBuildInput { - workflow: PathBuf::from(".fabro/workflows/demo/workflow.toml"), - cwd: workspace.to_path_buf(), - run_overrides: None, - cli_overrides: None, - args: None, - run_id: None, - user_settings_path: None, + workflow: PathBuf::from(".fabro/workflows/demo/workflow.toml"), + cwd: workspace.to_path_buf(), + ..Default::default() }) .unwrap(); @@ -1022,13 +1155,9 @@ exit 1 temp_env::with_var("PATH", Some(path), || { temp_env::with_var("FABRO_PROMPT_ENV_LOG", Some(helper_log.as_os_str()), || { let built = build_run_manifest(ManifestBuildInput { - workflow: PathBuf::from(".fabro/workflows/demo/workflow.toml"), - cwd: workspace.clone(), - run_overrides: None, - cli_overrides: None, - args: None, - run_id: None, - user_settings_path: None, + workflow: PathBuf::from(".fabro/workflows/demo/workflow.toml"), + cwd: workspace.clone(), + ..Default::default() }) .unwrap(); diff --git a/lib/crates/fabro-cli/tests/it/cmd/create.rs b/lib/crates/fabro-cli/tests/it/cmd/create.rs index 5ca9aab1b..6cace1acf 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/create.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/create.rs @@ -42,11 +42,12 @@ fn help() { --json Output as JSON [env: FABRO_JSON=] --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + -I, --input Override a workflow input value (repeatable, format: KEY=VALUE) --dry-run Execute with simulated LLM backend - --auto-approve Auto-approve all human gates --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --goal Override the workflow goal (available as {{ goal }} in prompts) + --auto-approve Auto-approve all human gates --quiet Suppress non-essential output [env: FABRO_QUIET=] + --goal Override the workflow goal (available as {{ goal }} in prompts) --goal-file Read the workflow goal from a file --model Override default LLM model --provider Override default LLM provider diff --git a/lib/crates/fabro-cli/tests/it/cmd/preflight.rs b/lib/crates/fabro-cli/tests/it/cmd/preflight.rs index da886760c..c11ebabb7 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/preflight.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/preflight.rs @@ -23,11 +23,12 @@ fn help() { --json Output as JSON [env: FABRO_JSON=] --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + -I, --input Override a workflow input value (repeatable, format: KEY=VALUE) --goal Override the workflow goal (available as {{ goal }} in prompts) - --goal-file Read the workflow goal from a file --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --model Override default LLM model + --goal-file Read the workflow goal from a file --quiet Suppress non-essential output [env: FABRO_QUIET=] + --model Override default LLM model --provider Override default LLM provider -v, --verbose Enable verbose output --sandbox Sandbox for agent tools [possible values: local, docker, daytona] diff --git a/lib/crates/fabro-cli/tests/it/cmd/run.rs b/lib/crates/fabro-cli/tests/it/cmd/run.rs index 581889f08..0d385f35a 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/run.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/run.rs @@ -141,11 +141,12 @@ fn help() { --json Output as JSON [env: FABRO_JSON=] --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + -I, --input Override a workflow input value (repeatable, format: KEY=VALUE) --dry-run Execute with simulated LLM backend - --auto-approve Auto-approve all human gates --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --goal Override the workflow goal (available as {{ goal }} in prompts) + --auto-approve Auto-approve all human gates --quiet Suppress non-essential output [env: FABRO_QUIET=] + --goal Override the workflow goal (available as {{ goal }} in prompts) --goal-file Read the workflow goal from a file --model Override default LLM model --provider Override default LLM provider diff --git a/lib/crates/fabro-cli/tests/manifest_path_round_trip.rs b/lib/crates/fabro-cli/tests/manifest_path_round_trip.rs index 12f725e4d..9cf4c50f8 100644 --- a/lib/crates/fabro-cli/tests/manifest_path_round_trip.rs +++ b/lib/crates/fabro-cli/tests/manifest_path_round_trip.rs @@ -29,13 +29,9 @@ fn cli_built_manifest_resolves_user_global_at_path() { std::fs::write(workflow_dir.join("prompts/hello.md"), "hello from bundle").unwrap(); let built = build_run_manifest(ManifestBuildInput { - workflow: workflow_dir.join("workflow.fabro"), - cwd: project, - run_overrides: None, - cli_overrides: None, - args: None, - run_id: None, - user_settings_path: None, + workflow: workflow_dir.join("workflow.fabro"), + cwd: project, + ..Default::default() }) .unwrap(); diff --git a/lib/crates/fabro-config/src/input_overrides.rs b/lib/crates/fabro-config/src/input_overrides.rs new file mode 100644 index 000000000..f2edef7f4 --- /dev/null +++ b/lib/crates/fabro-config/src/input_overrides.rs @@ -0,0 +1,171 @@ +use std::collections::HashMap; + +use thiserror::Error; + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum InputOverrideParseError { + #[error("input override `{raw}` is missing `=`; expected KEY=VALUE")] + MissingEquals { raw: String }, + #[error("input override key cannot be empty")] + EmptyKey, + #[error("input override `{key}` does not support {kind} values")] + UnsupportedValue { key: String, kind: &'static str }, +} + +#[must_use] +fn unsupported_kind(value: &toml::Value) -> Option<&'static str> { + match value { + toml::Value::String(_) + | toml::Value::Integer(_) + | toml::Value::Float(_) + | toml::Value::Boolean(_) => None, + toml::Value::Datetime(_) => Some("datetime"), + toml::Value::Array(_) => Some("array"), + toml::Value::Table(_) => Some("inline table"), + } +} + +fn parse_input_value(key: &str, raw_value: &str) -> Result { + if raw_value.is_empty() { + return Ok(toml::Value::String(String::new())); + } + + let document = format!("value = {raw_value}"); + let Ok(mut table) = document.parse::() else { + return Ok(toml::Value::String(raw_value.to_string())); + }; + let value = table + .remove("value") + .expect("`value` key was just written to the document"); + if let Some(kind) = unsupported_kind(&value) { + return Err(InputOverrideParseError::UnsupportedValue { + key: key.to_string(), + kind, + }); + } + Ok(value) +} + +pub fn parse_input_overrides( + raw_inputs: &[String], +) -> Result, InputOverrideParseError> { + let mut parsed = HashMap::new(); + for raw in raw_inputs { + let Some((key, raw_value)) = raw.split_once('=') else { + return Err(InputOverrideParseError::MissingEquals { raw: raw.clone() }); + }; + if key.is_empty() { + return Err(InputOverrideParseError::EmptyKey); + } + parsed.insert(key.to_string(), parse_input_value(key, raw_value)?); + } + Ok(parsed) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_one(key: &str, raw_value: &str) -> Result { + parse_input_overrides(&[format!("{key}={raw_value}")]) + .map(|mut parsed| parsed.remove(key).expect("input should be present")) + } + + #[test] + fn empty_value_is_empty_string() { + assert_eq!( + parse_one("foo", "").unwrap(), + toml::Value::String(String::new()) + ); + } + + #[test] + fn bare_value_falls_back_to_string() { + assert_eq!( + parse_one("foo", "bar").unwrap(), + toml::Value::String("bar".to_string()) + ); + } + + #[test] + fn quoted_toml_string_is_accepted() { + assert_eq!( + parse_one("foo", "\"bar\"").unwrap(), + toml::Value::String("bar".to_string()) + ); + } + + #[test] + fn boolean_is_accepted() { + assert_eq!( + parse_one("foo", "false").unwrap(), + toml::Value::Boolean(false) + ); + } + + #[test] + fn integer_is_accepted() { + assert_eq!(parse_one("foo", "3").unwrap(), toml::Value::Integer(3)); + } + + #[test] + fn float_is_accepted() { + assert_eq!(parse_one("foo", "0.75").unwrap(), toml::Value::Float(0.75)); + } + + #[test] + fn datetime_is_rejected() { + let err = parse_one("foo", "2026-05-06").unwrap_err(); + assert_eq!(err, InputOverrideParseError::UnsupportedValue { + key: "foo".to_string(), + kind: "datetime", + }); + assert!(err.to_string().contains("foo")); + assert!(!err.to_string().contains("2026-05-06")); + } + + #[test] + fn array_is_rejected() { + let err = parse_one("foo", "[1]").unwrap_err(); + assert_eq!(err, InputOverrideParseError::UnsupportedValue { + key: "foo".to_string(), + kind: "array", + }); + assert!(!err.to_string().contains("[1]")); + } + + #[test] + fn inline_table_is_rejected() { + let err = parse_one("foo", "{a=1}").unwrap_err(); + assert_eq!(err, InputOverrideParseError::UnsupportedValue { + key: "foo".to_string(), + kind: "inline table", + }); + assert!(!err.to_string().contains("{a=1}")); + } + + #[test] + fn missing_equals_is_rejected() { + let err = parse_input_overrides(&["foo".to_string()]).unwrap_err(); + assert_eq!(err, InputOverrideParseError::MissingEquals { + raw: "foo".to_string(), + }); + } + + #[test] + fn empty_key_is_rejected() { + let err = parse_input_overrides(&["=bar".to_string()]).unwrap_err(); + assert_eq!(err, InputOverrideParseError::EmptyKey); + assert!(!err.to_string().contains("=bar")); + } + + #[test] + fn duplicate_key_uses_last_value() { + let parsed = + parse_input_overrides(&["foo=first".to_string(), "foo=second".to_string()]).unwrap(); + assert_eq!( + parsed.get("foo"), + Some(&toml::Value::String("second".to_string())) + ); + } +} diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs index d440c714e..73dc06989 100644 --- a/lib/crates/fabro-config/src/lib.rs +++ b/lib/crates/fabro-config/src/lib.rs @@ -15,6 +15,7 @@ pub mod daemon; pub mod envfile; pub mod error; pub mod home; +pub mod input_overrides; mod load; pub mod logging; pub mod parse; @@ -35,6 +36,7 @@ pub use builders::{ pub use error::{Error, Result}; pub use fabro_util::path::expand_tilde; pub use home::Home; +pub use input_overrides::{InputOverrideParseError, parse_input_overrides}; pub use layers::{ CliAuthLayer, CliExecAgentLayer, CliExecLayer, CliExecModelLayer, CliLayer, CliLoggingLayer, CliOutputLayer, CliTargetLayer, CliUpdatesLayer, DaytonaDockerfileLayer, DaytonaSandboxLayer, diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index 5e2a20608..23e3830b2 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -11,7 +11,7 @@ use fabro_config::run::parse_run_layer_from_settings_toml; use fabro_config::{ CliLayer, CliOutputLayer, DaytonaDockerfileLayer, DockerSandboxLayer, LocalSandboxLayer, ReplaceMap, RunExecutionLayer, RunLayer, RunModelLayer, RunSandboxLayer, - WorkflowSettingsBuilder, + WorkflowSettingsBuilder, parse_input_overrides, }; use fabro_graphviz::graph::{Graph, is_llm_handler_type}; use fabro_graphviz::render::apply_direction; @@ -61,8 +61,9 @@ pub(crate) struct PreparedManifest { #[derive(Clone, Debug, Default)] struct ManifestSettingsOverrides { - run: Option, - cli: Option, + run: Option, + cli: Option, + input_overrides: HashMap, } #[cfg(test)] @@ -88,7 +89,8 @@ pub(crate) fn prepare_manifest( .ok_or_else(|| anyhow!("manifest target path is missing from workflows map"))?; let root_source = workflow_input.source.clone(); - let args_overrides = manifest_args_overrides(manifest.args.as_ref()); + let args_overrides = + manifest_args_overrides(manifest.args.as_ref()).context("failed to parse manifest args")?; let workflow_run_layer = root_workflow_run_layer(&workflow_input)?; let mut workflow_settings_builder = WorkflowSettingsBuilder::new().server_run_defaults(manifest_run_defaults.clone()); @@ -123,6 +125,7 @@ pub(crate) fn prepare_manifest( let mut settings = workflow_settings_builder .build() .context("failed to resolve manifest settings")?; + settings.run.inputs.extend(args_overrides.input_overrides); if let Some(goal) = manifest.goal.as_ref() { settings.run.goal = Some(RunGoal::Inline(InterpString::parse(&goal.text))); } @@ -304,9 +307,11 @@ fn root_workflow_run_layer(workflow: &BundledWorkflow) -> Result { Ok(run) } -fn manifest_args_overrides(args: Option<&types::ManifestArgs>) -> ManifestSettingsOverrides { +fn manifest_args_overrides( + args: Option<&types::ManifestArgs>, +) -> Result { let Some(args) = args else { - return ManifestSettingsOverrides::default(); + return Ok(ManifestSettingsOverrides::default()); }; let model = (args.model.is_some() || args.provider.is_some()).then(|| RunModelLayer { @@ -374,7 +379,11 @@ fn manifest_args_overrides(args: Option<&types::ManifestArgs>) -> ManifestSettin }) }); - ManifestSettingsOverrides { run, cli } + Ok(ManifestSettingsOverrides { + run, + cli, + input_overrides: parse_input_overrides(&args.input)?, + }) } fn parse_worktree_mode_arg(value: &str) -> Option { @@ -1707,6 +1716,7 @@ root = "/srv/fabro" provider: None, sandbox: None, docker_image: None, + input: Vec::new(), verbose: None, worktree_mode: None, }); @@ -1719,6 +1729,45 @@ root = "/srv/fabro" ); } + #[test] + fn prepare_manifest_applies_input_args_as_sparse_overrides() { + let server_settings = manifest_run_defaults(Some(&server_settings_fixture( + r#" +_version = 1 + +[run.inputs] +keep = "server" +override = "server" +"#, + ))); + let mut manifest = minimal_manifest(); + manifest.args = Some(types::ManifestArgs { + auto_approve: None, + dry_run: None, + label: Vec::new(), + model: None, + no_retro: None, + preserve_sandbox: None, + provider: None, + sandbox: None, + docker_image: None, + input: vec!["override=cli".to_string()], + verbose: None, + worktree_mode: None, + }); + + let prepared = prepare_manifest(&server_settings, &manifest).unwrap(); + + assert_eq!( + prepared.settings.run.inputs.get("keep"), + Some(&toml::Value::String("server".to_string())) + ); + assert_eq!( + prepared.settings.run.inputs.get("override"), + Some(&toml::Value::String("cli".to_string())) + ); + } + #[test] fn prepare_manifest_prefers_bundled_settings_without_duplication() { let server_settings = manifest_run_defaults(Some(&server_settings_fixture( diff --git a/lib/crates/fabro-template/src/lib.rs b/lib/crates/fabro-template/src/lib.rs index 99b917430..7bec0f6a0 100644 --- a/lib/crates/fabro-template/src/lib.rs +++ b/lib/crates/fabro-template/src/lib.rs @@ -32,6 +32,14 @@ impl TemplateContext { self } + /// Context that interpolates inputs but leaves `{{ goal }}` as a literal + /// pass-through — used for structural pre-rendering before the goal is + /// known (e.g. manifest scanning, import resolution). + #[must_use] + pub fn for_input_scan(inputs: HashMap) -> Self { + Self::new().with_goal("{{ goal }}").with_inputs(inputs) + } + #[must_use] pub fn with_env_lookup(mut self, env: &E) -> Self where diff --git a/lib/crates/fabro-workflow/src/operations/create.rs b/lib/crates/fabro-workflow/src/operations/create.rs index a573b77c5..8872b792e 100644 --- a/lib/crates/fabro-workflow/src/operations/create.rs +++ b/lib/crates/fabro-workflow/src/operations/create.rs @@ -304,13 +304,8 @@ pub(super) fn preprocess_and_validate( goal_override: Option<&str>, ) -> Result { let inputs = run_inputs(settings); - let source = render_template( - dot_source, - &TemplateContext::new() - .with_goal("{{ goal }}") - .with_inputs(inputs.clone()), - ) - .map_err(|error| Error::Parse(format!("template expansion failed: {error}")))?; + let source = render_template(dot_source, &TemplateContext::for_input_scan(inputs.clone())) + .map_err(|error| Error::Parse(format!("template expansion failed: {error}")))?; let mut parsed = pipeline::parse(&source)?; apply_goal_override(&mut parsed.graph, goal_override); diff --git a/lib/crates/fabro-workflow/src/transforms/import.rs b/lib/crates/fabro-workflow/src/transforms/import.rs index 3ab0b9d5d..464692846 100644 --- a/lib/crates/fabro-workflow/src/transforms/import.rs +++ b/lib/crates/fabro-workflow/src/transforms/import.rs @@ -155,9 +155,7 @@ impl ImportTransform { Self::with_import_stack(import_stack, resolved_file.path.clone(), |import_stack| { let rendered_source = render_template( &resolved_file.content, - &TemplateContext::new() - .with_goal("{{ goal }}") - .with_inputs(self.inputs.clone()), + &TemplateContext::for_input_scan(self.inputs.clone()), ) .map_err(|error| ImportPrepareError::Hard(Error::Validation(error.to_string())))?; diff --git a/lib/crates/fabro-workflow/src/transforms/variable_expansion.rs b/lib/crates/fabro-workflow/src/transforms/variable_expansion.rs index 3549177d3..854050eaf 100644 --- a/lib/crates/fabro-workflow/src/transforms/variable_expansion.rs +++ b/lib/crates/fabro-workflow/src/transforms/variable_expansion.rs @@ -25,9 +25,7 @@ impl TemplateTransform { } fn resolved_goal(&self, graph: &Graph) -> Result { - let ctx = TemplateContext::new() - .with_goal("{{ goal }}") - .with_inputs(self.inputs.clone()); + let ctx = TemplateContext::for_input_scan(self.inputs.clone()); Ok(render_template(graph.goal(), &ctx)?) } } diff --git a/lib/packages/fabro-api-client/src/models/manifest-args.ts b/lib/packages/fabro-api-client/src/models/manifest-args.ts index f41dc6b5b..4f9c7de6e 100644 --- a/lib/packages/fabro-api-client/src/models/manifest-args.ts +++ b/lib/packages/fabro-api-client/src/models/manifest-args.ts @@ -35,5 +35,9 @@ export interface ManifestArgs { */ 'worktree_mode'?: string; 'label'?: Array; + /** + * Raw repeated CLI input overrides, each in `KEY=VALUE` form. + */ + 'input'?: Array; }