Add fabro config show command

This commit is contained in:
Bryan Helmkamp 2026-03-27 12:22:32 -04:00
parent 5947513217
commit 960ae3b3ac
No known key found for this signature in database
10 changed files with 417 additions and 2 deletions

1
Cargo.lock generated
View file

@ -1373,6 +1373,7 @@ dependencies = [
"semver",
"serde",
"serde_json",
"serde_yaml",
"sha2",
"shlex",
"tempfile",

View file

@ -35,6 +35,29 @@ CLI flags always override `cli.toml` values, which override hardcoded defaults.
---
## `fabro config show`
Print the merged `FabroConfig` as YAML.
```bash
fabro config show
fabro config show demo
fabro config show run.toml
```
With no argument, Fabro prints the merged ambient defaults from `~/.fabro/cli.toml` and the nearest `fabro.toml`.
When you pass a workflow name or path:
- If it resolves to a `.toml` run config, Fabro overlays that run config on top of the ambient defaults before printing.
- If it resolves to a `.fabro` graph file, Fabro prints the ambient defaults unchanged.
| Argument | Description |
|---|---|
| `[WORKFLOW]` | Optional workflow name, `.fabro` workflow path, or `.toml` run config path |
---
## `fabro run`
Launch a workflow from a `.fabro` workflow file or `.toml` task config.
@ -811,4 +834,3 @@ Open the Fabro Discord community invite in your default browser.
```bash
fabro discord
```

View file

@ -69,6 +69,7 @@ git2.workspace = true
axum = "0.8"
open = "5"
serde_json.workspace = true
serde_yaml = "0.9"
tempfile = "3"
sha2.workspace = true
shlex = "1"
@ -88,4 +89,4 @@ predicates = "3"
serde_json.workspace = true
tempfile = "3"
httpmock = "0.8"
trycmd = "0.15"
trycmd = "0.15"

View file

@ -542,6 +542,12 @@ pub(crate) struct DfArgs {
pub(crate) verbose: bool,
}
#[derive(Args)]
pub(crate) struct ConfigShowArgs {
/// Optional workflow name, .fabro path, or .toml run config to overlay
pub(crate) workflow: Option<PathBuf>,
}
#[derive(Clone, ValueEnum)]
pub(crate) enum SkillDir {
Claude,
@ -767,6 +773,8 @@ pub(crate) enum Commands {
Skill(SkillNamespace),
/// Manage secrets in ~/.fabro/.env
Secret(SecretNamespace),
/// Inspect merged configuration
Config(ConfigNamespace),
/// Workflow operations
Workflow(WorkflowNamespace),
/// Open the Discord community in the browser
@ -840,6 +848,9 @@ impl Commands {
SecretCommand::Rm(_) => "secret rm",
SecretCommand::Set(_) => "secret set",
},
Self::Config(ns) => match &ns.command {
ConfigCommand::Show(_) => "config show",
},
Self::Workflow(ns) => match &ns.command {
WorkflowCommand::List(_) => "workflow list",
WorkflowCommand::Create(_) => "workflow create",
@ -916,6 +927,18 @@ pub(crate) enum SecretCommand {
Set(SecretSetArgs),
}
#[derive(Args)]
pub(crate) struct ConfigNamespace {
#[command(subcommand)]
pub(crate) command: ConfigCommand,
}
#[derive(Subcommand)]
pub(crate) enum ConfigCommand {
/// Print the merged FabroConfig as YAML
Show(ConfigShowArgs),
}
#[derive(Args)]
pub(crate) struct SystemNamespace {
#[command(subcommand)]

View file

@ -0,0 +1,52 @@
use std::io::Write;
use std::path::Path;
use anyhow::bail;
use fabro_config::FabroConfig;
use crate::args::{ConfigCommand, ConfigNamespace, ConfigShowArgs};
use crate::cli_config;
pub fn dispatch(ns: ConfigNamespace) -> anyhow::Result<()> {
match ns.command {
ConfigCommand::Show(args) => show_command(&args),
}
}
fn merged_config(workflow: Option<&Path>) -> anyhow::Result<FabroConfig> {
let mut config = cli_config::load_cli_config(None)?;
let cwd = std::env::current_dir()?;
if let Some((_config_path, project_config)) =
fabro_config::project::discover_project_config(&cwd)?
{
config.merge_overlay(project_config);
}
if let Some(workflow) = workflow {
let (resolved_path, _dot_path, run_config) =
crate::commands::run::execute::resolve_workflow_source(workflow)?;
if let Some(run_config) = run_config {
config.merge_overlay(run_config);
} else if !resolved_path.is_file() {
bail!("Workflow not found: {}", resolved_path.display());
}
}
Ok(config)
}
pub fn show_command(args: &ConfigShowArgs) -> anyhow::Result<()> {
let config = merged_config(args.workflow.as_deref())?;
let mut yaml = serde_yaml::to_string(&config)?;
if !yaml.ends_with('\n') {
yaml.push('\n');
}
let stdout = std::io::stdout();
let mut handle = stdout.lock();
handle.write_all(yaml.as_bytes())?;
Ok(())
}

View file

@ -1,4 +1,5 @@
pub mod asset;
pub mod config;
pub mod doctor;
pub mod exec;
pub mod graph;

View file

@ -202,6 +202,7 @@ async fn main_inner() -> (String, Result<()>) {
}
Commands::Pr(ns) => commands::pr::dispatch(ns).await?,
Commands::Secret(ns) => commands::secret::dispatch(ns)?,
Commands::Config(ns) => commands::config::dispatch(ns)?,
Commands::Workflow(ns) => commands::workflow::dispatch(ns)?,
Commands::Skill(ns) => commands::skill::dispatch(ns)?,
Commands::Upgrade(args) => {
@ -361,4 +362,31 @@ mod tests {
_ => panic!("unexpected command variant"),
}
}
#[test]
fn parse_config_show_command() {
let cli = Cli::try_parse_from(["fabro", "config", "show"]).expect("should parse");
assert_eq!(cli.command.name(), "config show");
match *cli.command {
Commands::Config(ConfigNamespace {
command: ConfigCommand::Show(args),
}) => {
assert!(args.workflow.is_none());
}
_ => panic!("unexpected command variant"),
}
}
#[test]
fn parse_config_show_with_workflow() {
let cli = Cli::try_parse_from(["fabro", "config", "show", "demo"]).expect("should parse");
match *cli.command {
Commands::Config(ConfigNamespace {
command: ConfigCommand::Show(args),
}) => {
assert_eq!(args.workflow, Some(std::path::PathBuf::from("demo")));
}
_ => panic!("unexpected command variant"),
}
}
}

View file

@ -1,4 +1,6 @@
use assert_cmd::Command;
use fabro_config::mcp::McpTransport;
use fabro_config::FabroConfig;
use predicates::prelude::*;
#[allow(deprecated)]
@ -8,6 +10,133 @@ fn arc() -> Command {
cmd
}
fn parse_config_show(stdout: &[u8]) -> FabroConfig {
serde_yaml::from_slice(stdout).expect("stdout should be valid YAML FabroConfig")
}
fn setup_config_show_fixture() -> (tempfile::TempDir, tempfile::TempDir) {
let home = tempfile::tempdir().unwrap();
let project = tempfile::tempdir().unwrap();
let home_fabro = home.path().join(".fabro");
std::fs::create_dir_all(&home_fabro).unwrap();
std::fs::write(
home_fabro.join("cli.toml"),
r#"
verbose = true
[llm]
model = "cli-model"
provider = "openai"
[vars]
cli_only = "1"
shared = "cli"
[checkpoint]
exclude_globs = ["cli-only", "shared"]
[[hooks]]
name = "shared"
event = "run_start"
command = "echo cli"
[mcp_servers.shared]
type = "stdio"
command = ["echo", "cli"]
[sandbox]
provider = "daytona"
[sandbox.daytona]
labels = { cli_only = "1", shared = "cli" }
[sandbox.env]
CLI_ONLY = "1"
SHARED = "cli"
"#,
)
.unwrap();
std::fs::write(
project.path().join("fabro.toml"),
r#"
version = 1
[fabro]
root = "fabro"
[llm]
model = "project-model"
[vars]
project_only = "1"
shared = "project"
[[hooks]]
name = "project"
event = "run_complete"
command = "echo project"
"#,
)
.unwrap();
let workflow_dir = project.path().join("fabro").join("workflows").join("demo");
std::fs::create_dir_all(&workflow_dir).unwrap();
std::fs::write(
workflow_dir.join("workflow.toml"),
r#"
version = 1
goal = "demo goal"
[llm]
model = "run-model"
provider = "anthropic"
[vars]
run_only = "1"
shared = "run"
[checkpoint]
exclude_globs = ["run-only", "shared"]
[[hooks]]
name = "shared"
event = "run_start"
command = "echo run"
[[hooks]]
name = "run-only"
event = "run_complete"
command = "echo run-only"
[mcp_servers.shared]
type = "stdio"
command = ["echo", "run"]
[mcp_servers.run_only]
type = "stdio"
command = ["echo", "run-only"]
[sandbox.daytona]
labels = { run_only = "1", shared = "run" }
[sandbox.env]
RUN_ONLY = "1"
SHARED = "run"
"#,
)
.unwrap();
std::fs::write(
project.path().join("standalone.fabro"),
"digraph Test { start -> end }",
)
.unwrap();
(home, project)
}
// == LLM: prompt ==============================================================
#[test]
@ -1225,3 +1354,151 @@ fn bug4_attach_respects_verbose_from_spec() {
"bug4: attach should show verbose stats when spec.verbose=true.\nstderr: {stderr}"
);
}
#[test]
fn config_show_merges_cli_and_project_defaults() {
let (home, project) = setup_config_show_fixture();
let output = arc()
.env("HOME", home.path())
.current_dir(project.path())
.args(["config", "show"])
.assert()
.success()
.get_output()
.stdout
.clone();
let cfg = parse_config_show(&output);
let llm = cfg.llm.as_ref().expect("llm config");
assert_eq!(llm.model.as_deref(), Some("project-model"));
assert_eq!(llm.provider.as_deref(), Some("openai"));
assert_eq!(cfg.goal.as_deref(), None);
assert_eq!(cfg.fabro.as_ref().map(|f| f.root.as_str()), Some("fabro"));
let vars = cfg.vars.as_ref().expect("vars");
assert_eq!(vars.get("cli_only").map(String::as_str), Some("1"));
assert_eq!(vars.get("project_only").map(String::as_str), Some("1"));
assert_eq!(vars.get("shared").map(String::as_str), Some("project"));
let sandbox = cfg.sandbox.as_ref().expect("sandbox");
let labels = sandbox
.daytona
.as_ref()
.and_then(|d| d.labels.as_ref())
.expect("daytona labels");
assert_eq!(labels.get("cli_only").map(String::as_str), Some("1"));
assert_eq!(labels.get("shared").map(String::as_str), Some("cli"));
}
#[test]
fn config_show_workflow_name_applies_run_overlay_and_deep_merges() {
let (home, project) = setup_config_show_fixture();
let output = arc()
.env("HOME", home.path())
.current_dir(project.path())
.args(["config", "show", "demo"])
.assert()
.success()
.get_output()
.stdout
.clone();
let cfg = parse_config_show(&output);
let llm = cfg.llm.as_ref().expect("llm config");
assert_eq!(cfg.goal.as_deref(), Some("demo goal"));
assert_eq!(llm.model.as_deref(), Some("run-model"));
assert_eq!(llm.provider.as_deref(), Some("anthropic"));
let vars = cfg.vars.as_ref().expect("vars");
assert_eq!(vars.get("cli_only").map(String::as_str), Some("1"));
assert_eq!(vars.get("project_only").map(String::as_str), Some("1"));
assert_eq!(vars.get("run_only").map(String::as_str), Some("1"));
assert_eq!(vars.get("shared").map(String::as_str), Some("run"));
assert_eq!(
cfg.checkpoint.exclude_globs,
vec![
"cli-only".to_string(),
"run-only".to_string(),
"shared".to_string()
]
);
assert_eq!(cfg.hooks.len(), 3);
let shared_hook = cfg
.hooks
.iter()
.find(|hook| hook.name.as_deref() == Some("shared"))
.expect("shared hook");
assert_eq!(shared_hook.command.as_deref(), Some("echo run"));
assert!(cfg
.hooks
.iter()
.any(|hook| hook.name.as_deref() == Some("project")));
assert!(cfg
.hooks
.iter()
.any(|hook| hook.name.as_deref() == Some("run-only")));
match &cfg.mcp_servers["shared"].transport {
McpTransport::Stdio { command, .. } => assert_eq!(command, &vec!["echo", "run"]),
other => panic!("unexpected MCP transport: {other:?}"),
}
assert!(cfg.mcp_servers.contains_key("run_only"));
let sandbox = cfg.sandbox.as_ref().expect("sandbox");
let labels = sandbox
.daytona
.as_ref()
.and_then(|d| d.labels.as_ref())
.expect("daytona labels");
assert_eq!(labels.get("cli_only").map(String::as_str), Some("1"));
assert_eq!(labels.get("run_only").map(String::as_str), Some("1"));
assert_eq!(labels.get("shared").map(String::as_str), Some("run"));
let env = sandbox.env.as_ref().expect("sandbox env");
assert_eq!(env.get("CLI_ONLY").map(String::as_str), Some("1"));
assert_eq!(env.get("RUN_ONLY").map(String::as_str), Some("1"));
assert_eq!(env.get("SHARED").map(String::as_str), Some("run"));
}
#[test]
fn config_show_fabro_path_matches_ambient_defaults() {
let (home, project) = setup_config_show_fixture();
let ambient = arc()
.env("HOME", home.path())
.current_dir(project.path())
.args(["config", "show"])
.assert()
.success()
.get_output()
.stdout
.clone();
let graph = arc()
.env("HOME", home.path())
.current_dir(project.path())
.args(["config", "show", "standalone.fabro"])
.assert()
.success()
.get_output()
.stdout
.clone();
assert_eq!(parse_config_show(&graph), parse_config_show(&ambient));
}
#[test]
fn config_show_missing_run_config_errors() {
let (home, project) = setup_config_show_fixture();
arc()
.env("HOME", home.path())
.current_dir(project.path())
.args(["config", "show", "missing.toml"])
.assert()
.failure()
.stderr(predicate::str::contains("Failed to read"));
}

View file

@ -0,0 +1,9 @@
```console
$ fabro config --help
[..]
...
$ fabro config show --help
[..]
...
```

View file

@ -10,6 +10,7 @@ macro_rules! trycmd_subcommand {
}
trycmd_subcommand!(cli_cp, "cp");
trycmd_subcommand!(cli_config, "config");
trycmd_subcommand!(cli_doctor, "doctor");
trycmd_subcommand!(cli_exec, "exec");
trycmd_subcommand!(cli_init, "init");