mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
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:
parent
b847bfaa63
commit
f07bb4aaba
28 changed files with 676 additions and 112 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1681,6 +1681,7 @@ dependencies = [
|
|||
"fabro-static",
|
||||
"fabro-store",
|
||||
"fabro-telemetry",
|
||||
"fabro-template",
|
||||
"fabro-test",
|
||||
"fabro-types",
|
||||
"fabro-util",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
114
docs/superpowers/plans/2026-05-06-cli-input-overrides.md
Normal file
114
docs/superpowers/plans/2026-05-06-cli-input-overrides.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
|
|
@ -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..."));
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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)?,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
171
lib/crates/fabro-config/src/input_overrides.rs
Normal file
171
lib/crates/fabro-config/src/input_overrides.rs
Normal 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()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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())))?;
|
||||
|
||||
|
|
|
|||
|
|
@ -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)?)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue