mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-07 08:27:12 +00:00
Add fabro config show command
This commit is contained in:
parent
5947513217
commit
960ae3b3ac
10 changed files with 417 additions and 2 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1373,6 +1373,7 @@ dependencies = [
|
|||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sha2",
|
||||
"shlex",
|
||||
"tempfile",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
52
lib/crates/fabro-cli/src/commands/config/mod.rs
Normal file
52
lib/crates/fabro-cli/src/commands/config/mod.rs
Normal 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(())
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
pub mod asset;
|
||||
pub mod config;
|
||||
pub mod doctor;
|
||||
pub mod exec;
|
||||
pub mod graph;
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
9
lib/crates/fabro-cli/tests/cmd/config/help.trycmd
Normal file
9
lib/crates/fabro-cli/tests/cmd/config/help.trycmd
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
```console
|
||||
$ fabro config --help
|
||||
[..]
|
||||
...
|
||||
|
||||
$ fabro config show --help
|
||||
[..]
|
||||
...
|
||||
```
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue