From 960ae3b3acbf0e9e55c158da3210216eb6057ff0 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 27 Mar 2026 12:22:32 -0400 Subject: [PATCH] Add fabro config show command --- Cargo.lock | 1 + docs/reference/cli.mdx | 24 +- lib/crates/fabro-cli/Cargo.toml | 3 +- lib/crates/fabro-cli/src/args.rs | 23 ++ .../fabro-cli/src/commands/config/mod.rs | 52 ++++ lib/crates/fabro-cli/src/commands/mod.rs | 1 + lib/crates/fabro-cli/src/main.rs | 28 ++ lib/crates/fabro-cli/tests/cli.rs | 277 ++++++++++++++++++ .../fabro-cli/tests/cmd/config/help.trycmd | 9 + lib/crates/fabro-cli/tests/trycmd.rs | 1 + 10 files changed, 417 insertions(+), 2 deletions(-) create mode 100644 lib/crates/fabro-cli/src/commands/config/mod.rs create mode 100644 lib/crates/fabro-cli/tests/cmd/config/help.trycmd diff --git a/Cargo.lock b/Cargo.lock index de7e21654..36898d1b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1373,6 +1373,7 @@ dependencies = [ "semver", "serde", "serde_json", + "serde_yaml", "sha2", "shlex", "tempfile", diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx index cc010e2ac..e5b83bbe2 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -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 ``` - diff --git a/lib/crates/fabro-cli/Cargo.toml b/lib/crates/fabro-cli/Cargo.toml index 0ac564507..2973471c5 100644 --- a/lib/crates/fabro-cli/Cargo.toml +++ b/lib/crates/fabro-cli/Cargo.toml @@ -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" \ No newline at end of file +trycmd = "0.15" diff --git a/lib/crates/fabro-cli/src/args.rs b/lib/crates/fabro-cli/src/args.rs index 43116fbab..f144276b8 100644 --- a/lib/crates/fabro-cli/src/args.rs +++ b/lib/crates/fabro-cli/src/args.rs @@ -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, +} + #[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)] diff --git a/lib/crates/fabro-cli/src/commands/config/mod.rs b/lib/crates/fabro-cli/src/commands/config/mod.rs new file mode 100644 index 000000000..62191783f --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/config/mod.rs @@ -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 { + 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(()) +} diff --git a/lib/crates/fabro-cli/src/commands/mod.rs b/lib/crates/fabro-cli/src/commands/mod.rs index f79717014..1070b8caa 100644 --- a/lib/crates/fabro-cli/src/commands/mod.rs +++ b/lib/crates/fabro-cli/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod asset; +pub mod config; pub mod doctor; pub mod exec; pub mod graph; diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index 2d3ca6d84..80f532e72 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -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"), + } + } } diff --git a/lib/crates/fabro-cli/tests/cli.rs b/lib/crates/fabro-cli/tests/cli.rs index 825250fcb..a319e64ee 100644 --- a/lib/crates/fabro-cli/tests/cli.rs +++ b/lib/crates/fabro-cli/tests/cli.rs @@ -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")); +} diff --git a/lib/crates/fabro-cli/tests/cmd/config/help.trycmd b/lib/crates/fabro-cli/tests/cmd/config/help.trycmd new file mode 100644 index 000000000..b9f098c65 --- /dev/null +++ b/lib/crates/fabro-cli/tests/cmd/config/help.trycmd @@ -0,0 +1,9 @@ +```console +$ fabro config --help +[..] +... + +$ fabro config show --help +[..] +... +``` diff --git a/lib/crates/fabro-cli/tests/trycmd.rs b/lib/crates/fabro-cli/tests/trycmd.rs index f4a432055..70c256b9e 100644 --- a/lib/crates/fabro-cli/tests/trycmd.rs +++ b/lib/crates/fabro-cli/tests/trycmd.rs @@ -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");