feat(cli): support sparse input overrides (#222)

## 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 <workflow> -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) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-09 07:00:35 -07:00 committed by GitHub
parent b847bfaa63
commit f07bb4aaba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 676 additions and 112 deletions

1
Cargo.lock generated
View file

@ -1681,6 +1681,7 @@ dependencies = [
"fabro-static",
"fabro-store",
"fabro-telemetry",
"fabro-template",
"fabro-test",
"fabro-types",
"fabro-util",

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -311,6 +311,7 @@ fabro create [OPTIONS] <WORKFLOW>
| `--provider <provider>` | Override default LLM provider |
| `--sandbox <sandbox>` | Sandbox for agent tools<br />Values: `local`, `docker`, `daytona` |
| `--server <server>` | Fabro server target: http(s) URL or absolute Unix socket path |
| `-I, --input <key=value>` | Override a workflow input value (repeatable, format: KEY=VALUE) |
| `-v, --verbose` | Enable verbose output |
### `fabro discord`
@ -684,6 +685,7 @@ fabro preflight [OPTIONS] <WORKFLOW>
| `--provider <provider>` | Override default LLM provider |
| `--sandbox <sandbox>` | Sandbox for agent tools<br />Values: `local`, `docker`, `daytona` |
| `--server <server>` | Fabro server target: http(s) URL or absolute Unix socket path |
| `-I, --input <key=value>` | Override a workflow input value (repeatable, format: KEY=VALUE) |
| `-v, --verbose` | Enable verbose output |
### `fabro provider`
@ -848,6 +850,7 @@ fabro run [OPTIONS] <WORKFLOW>
| `--provider <provider>` | Override default LLM provider |
| `--sandbox <sandbox>` | Sandbox for agent tools<br />Values: `local`, `docker`, `daytona` |
| `--server <server>` | Fabro server target: http(s) URL or absolute Unix socket path |
| `-I, --input <key=value>` | Override a workflow input value (repeatable, format: KEY=VALUE) |
| `-v, --verbose` | Enable verbose output |
### `fabro sandbox`

View file

@ -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.

View file

@ -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<String, toml::Value>`.
- 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 <KEY=VALUE>` 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 == <contents of prompts/goal.md>`.
- [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.

View file

@ -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

View file

@ -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<String>,
}
#[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<PathBuf>,
@ -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,

View file

@ -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?;

View file

@ -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..."));

View file

@ -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)),

View file

@ -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<RunLayer>,
pub(crate) cli: Option<CliLayer>,
pub(crate) run: Option<RunLayer>,
pub(crate) cli: Option<CliLayer>,
pub(crate) input_overrides: HashMap<String, toml::Value>,
}
fn sparse_flag(value: bool) -> Option<bool> {
@ -147,8 +148,9 @@ pub(crate) fn run_args_overrides(args: &RunArgs) -> Result<ManifestSettingsOverr
};
Ok(ManifestSettingsOverrides {
run: Some(run),
cli: cli_layer_for_verbose(args.verbose),
run: Some(run),
cli: cli_layer_for_verbose(args.verbose),
input_overrides: parse_input_overrides(&args.inputs.values)?,
})
}
@ -170,8 +172,9 @@ pub(crate) fn preflight_args_overrides(args: &PreflightArgs) -> Result<ManifestS
};
Ok(ManifestSettingsOverrides {
run: Some(run),
cli: cli_layer_for_verbose(args.verbose),
run: Some(run),
cli: cli_layer_for_verbose(args.verbose),
input_overrides: parse_input_overrides(&args.inputs.values)?,
})
}

View file

@ -17,13 +17,10 @@ pub(crate) fn run(
) -> 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);

View file

@ -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([

View file

@ -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<RunLayer>,
pub cli_overrides: Option<CliLayer>,
pub input_overrides: HashMap<String, toml::Value>,
pub args: Option<types::ManifestArgs>,
pub run_id: Option<RunId>,
/// 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<String, toml::Value>,
workflows: HashMap<String, types::ManifestWorkflow>,
visited_workflows: HashSet<String>,
}
@ -89,15 +92,17 @@ pub fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManifest> {
{
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<BuiltManifest> {
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<types::ManifestArgs> {
.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<types::Man
.sandbox
.map(|provider| fabro_sandbox::SandboxProvider::from(provider).to_string()),
docker_image: None,
input: args.inputs.values.clone(),
verbose: args.verbose.then_some(true),
worktree_mode: None,
};
@ -266,7 +276,12 @@ fn collect_workflow_files(
files: &mut HashMap<String, types::ManifestFileEntry>,
visited_imports: &mut HashSet<String>,
) -> 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<String, toml::Value>,
) -> Result<String> {
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();

View file

@ -42,11 +42,12 @@ fn help() {
--json Output as JSON [env: FABRO_JSON=]
--server <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 <KEY=VALUE> 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 <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 <GOAL> Override the workflow goal (available as {{ goal }} in prompts)
--goal-file <GOAL_FILE> Read the workflow goal from a file
--model <MODEL> Override default LLM model
--provider <PROVIDER> Override default LLM provider

View file

@ -23,11 +23,12 @@ fn help() {
--json Output as JSON [env: FABRO_JSON=]
--server <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 <KEY=VALUE> Override a workflow input value (repeatable, format: KEY=VALUE)
--goal <GOAL> Override the workflow goal (available as {{ goal }} in prompts)
--goal-file <GOAL_FILE> Read the workflow goal from a file
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--model <MODEL> Override default LLM model
--goal-file <GOAL_FILE> Read the workflow goal from a file
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--model <MODEL> Override default LLM model
--provider <PROVIDER> Override default LLM provider
-v, --verbose Enable verbose output
--sandbox <SANDBOX> Sandbox for agent tools [possible values: local, docker, daytona]

View file

@ -141,11 +141,12 @@ fn help() {
--json Output as JSON [env: FABRO_JSON=]
--server <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 <KEY=VALUE> 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 <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 <GOAL> Override the workflow goal (available as {{ goal }} in prompts)
--goal-file <GOAL_FILE> Read the workflow goal from a file
--model <MODEL> Override default LLM model
--provider <PROVIDER> Override default LLM provider

View file

@ -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();

View file

@ -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<toml::Value, InputOverrideParseError> {
if raw_value.is_empty() {
return Ok(toml::Value::String(String::new()));
}
let document = format!("value = {raw_value}");
let Ok(mut table) = document.parse::<toml::Table>() 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<HashMap<String, toml::Value>, 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<toml::Value, InputOverrideParseError> {
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()))
);
}
}

View file

@ -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,

View file

@ -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<RunLayer>,
cli: Option<CliLayer>,
run: Option<RunLayer>,
cli: Option<CliLayer>,
input_overrides: HashMap<String, toml::Value>,
}
#[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<RunLayer> {
Ok(run)
}
fn manifest_args_overrides(args: Option<&types::ManifestArgs>) -> ManifestSettingsOverrides {
fn manifest_args_overrides(
args: Option<&types::ManifestArgs>,
) -> Result<ManifestSettingsOverrides> {
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<WorktreeMode> {
@ -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(

View file

@ -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<String, toml::Value>) -> Self {
Self::new().with_goal("{{ goal }}").with_inputs(inputs)
}
#[must_use]
pub fn with_env_lookup<E>(mut self, env: &E) -> Self
where

View file

@ -304,13 +304,8 @@ pub(super) fn preprocess_and_validate(
goal_override: Option<&str>,
) -> Result<Validated, Error> {
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);

View file

@ -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())))?;

View file

@ -25,9 +25,7 @@ impl TemplateTransform {
}
fn resolved_goal(&self, graph: &Graph) -> Result<String, Error> {
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)?)
}
}

View file

@ -35,5 +35,9 @@ export interface ManifestArgs {
*/
'worktree_mode'?: string;
'label'?: Array<string>;
/**
* Raw repeated CLI input overrides, each in `KEY=VALUE` form.
*/
'input'?: Array<string>;
}