diff --git a/lib/crates/fabro-cli/src/args.rs b/lib/crates/fabro-cli/src/args.rs index ebaea83d9..82ba57d9a 100644 --- a/lib/crates/fabro-cli/src/args.rs +++ b/lib/crates/fabro-cli/src/args.rs @@ -17,6 +17,10 @@ pub(crate) const LONG_VERSION: &str = concat!( #[derive(Args)] pub(crate) struct GlobalArgs { + /// Output as JSON + #[arg(long, global = true, env = "FABRO_JSON", value_parser = clap::builder::BoolishValueParser::new())] + pub json: bool, + /// Enable DEBUG-level logging (default is INFO) #[arg(long, global = true, env = "FABRO_DEBUG", value_parser = clap::builder::BoolishValueParser::new())] pub debug: bool, @@ -48,6 +52,13 @@ pub(crate) struct GlobalArgs { pub server_url: Option, } +impl GlobalArgs { + pub(crate) fn require_no_json(&self) -> anyhow::Result<()> { + anyhow::ensure!(!self.json, "--json is not supported for this command"); + Ok(()) + } +} + #[derive(Debug, Clone, Copy, ValueEnum)] pub(crate) enum CliSandboxProvider { Local, @@ -188,10 +199,6 @@ pub(crate) struct RunsListArgs { #[command(flatten)] pub(crate) filter: RunFilterArgs, - /// Output as JSON - #[arg(long)] - pub(crate) json: bool, - /// Show all runs, not just running (like docker ps -a) #[arg(short = 'a', long)] pub(crate) all: bool, @@ -313,10 +320,6 @@ pub(crate) struct AssetListArgs { /// Filter to assets from a specific retry attempt #[arg(long)] pub(crate) retry: Option, - - /// Output as JSON - #[arg(long)] - pub(crate) json: bool, } #[derive(Args)] @@ -495,10 +498,6 @@ pub(crate) struct WaitArgs { /// Poll interval in milliseconds #[arg(long, value_name = "MS", default_value = "1000")] pub(crate) interval: u64, - - /// Output conclusion as JSON - #[arg(long)] - pub(crate) json: bool, } #[derive(Args)] diff --git a/lib/crates/fabro-cli/src/commands/asset/cp.rs b/lib/crates/fabro-cli/src/commands/asset/cp.rs index 022ba6fd6..edae3d399 100644 --- a/lib/crates/fabro-cli/src/commands/asset/cp.rs +++ b/lib/crates/fabro-cli/src/commands/asset/cp.rs @@ -7,7 +7,7 @@ use fabro_workflow::assets::{AssetEntry, scan_assets}; use fabro_workflow::run_lookup::{resolve_run, runs_base}; use crate::args::{AssetCpArgs, GlobalArgs}; -use crate::shared::split_run_path; +use crate::shared::{print_json_pretty, split_run_path}; use crate::user_config::load_user_settings_with_globals; pub(super) fn cp_command(args: &AssetCpArgs, globals: &GlobalArgs) -> Result<()> { @@ -64,10 +64,20 @@ pub(super) fn cp_command(args: &AssetCpArgs, globals: &GlobalArgs) -> Result<()> dest_file.display() ) })?; - println!("Copied {} to {}", entry.relative_path, dest_file.display()); + if globals.json { + print_json_pretty(&serde_json::json!({ + "copied": [{ + "relative_path": entry.relative_path, + "destination": dest_file.display().to_string(), + }], + }))?; + } else { + println!("Copied {} to {}", entry.relative_path, dest_file.display()); + } return Ok(()); } + let mut copied = Vec::new(); if args.tree { for entry in &entries { let relative_dest = PathBuf::from(&entry.node_slug) @@ -84,6 +94,10 @@ pub(super) fn cp_command(args: &AssetCpArgs, globals: &GlobalArgs) -> Result<()> dest_file.display() ) })?; + copied.push(serde_json::json!({ + "relative_path": entry.relative_path, + "destination": dest_file.display().to_string(), + })); } } else { let mut by_filename: Vec<(String, &AssetEntry)> = Vec::with_capacity(entries.len()); @@ -116,14 +130,22 @@ pub(super) fn cp_command(args: &AssetCpArgs, globals: &GlobalArgs) -> Result<()> dest_file.display() ) })?; + copied.push(serde_json::json!({ + "relative_path": entry.relative_path, + "destination": dest_file.display().to_string(), + })); } } - println!( - "Copied {} asset(s) to {}", - entries.len(), - args.dest.display() - ); + if globals.json { + print_json_pretty(&serde_json::json!({ "copied": copied }))?; + } else { + println!( + "Copied {} asset(s) to {}", + entries.len(), + args.dest.display() + ); + } Ok(()) } diff --git a/lib/crates/fabro-cli/src/commands/asset/list.rs b/lib/crates/fabro-cli/src/commands/asset/list.rs index 7f459841b..dca6faa45 100644 --- a/lib/crates/fabro-cli/src/commands/asset/list.rs +++ b/lib/crates/fabro-cli/src/commands/asset/list.rs @@ -19,7 +19,7 @@ pub(super) fn list_command(args: &AssetListArgs, globals: &GlobalArgs) -> Result args.retry, )?; - if args.json { + if globals.json { println!("{}", serde_json::to_string_pretty(&entries)?); return Ok(()); } diff --git a/lib/crates/fabro-cli/src/commands/config/mod.rs b/lib/crates/fabro-cli/src/commands/config/mod.rs index 009c74f8b..16db80b36 100644 --- a/lib/crates/fabro-cli/src/commands/config/mod.rs +++ b/lib/crates/fabro-cli/src/commands/config/mod.rs @@ -2,6 +2,7 @@ use std::io::Write; use std::path::Path; use crate::args::{GlobalArgs, SettingsArgs}; +use crate::shared::print_json_pretty; use crate::user_config; use fabro_config::{ConfigLayer, FabroSettings}; @@ -18,6 +19,10 @@ fn merged_config(workflow: Option<&Path>, globals: &GlobalArgs) -> anyhow::Resul pub(crate) fn execute(args: &SettingsArgs, globals: &GlobalArgs) -> anyhow::Result<()> { let config = merged_config(args.workflow.as_deref(), globals)?; + if globals.json { + print_json_pretty(&config)?; + return Ok(()); + } let mut yaml = serde_yaml::to_string(&config)?; if !yaml.ends_with('\n') { yaml.push('\n'); diff --git a/lib/crates/fabro-cli/src/commands/doctor.rs b/lib/crates/fabro-cli/src/commands/doctor.rs index 3b3f38f3a..f3e1959ea 100644 --- a/lib/crates/fabro-cli/src/commands/doctor.rs +++ b/lib/crates/fabro-cli/src/commands/doctor.rs @@ -22,6 +22,8 @@ use regex::Regex; #[cfg(feature = "server")] use semver::Version; +use crate::args::GlobalArgs; +use crate::shared::print_json_pretty; use crate::user_config::load_user_settings; // --------------------------------------------------------------------------- @@ -949,17 +951,25 @@ async fn probe_url(http: &reqwest::Client, url: &str) -> Result<(), String> { .map_err(|e| e.to_string()) } -pub(crate) async fn run_doctor(verbose: bool, live: bool) -> i32 { +pub(crate) async fn run_doctor( + verbose: bool, + live: bool, + globals: &GlobalArgs, +) -> Result { let styles = Styles::detect_stdout(); - - let spinner = indicatif::ProgressBar::new_spinner(); - spinner.set_style( - indicatif::ProgressStyle::with_template("{spinner:.cyan} {msg}") - .expect("valid template") - .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", ""]), - ); - spinner.set_message("Running checks…"); - spinner.enable_steady_tick(std::time::Duration::from_millis(80)); + let spinner = if globals.json { + None + } else { + let spinner = indicatif::ProgressBar::new_spinner(); + spinner.set_style( + indicatif::ProgressStyle::with_template("{spinner:.cyan} {msg}") + .expect("valid template") + .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", ""]), + ); + spinner.set_message("Running checks…"); + spinner.enable_steady_tick(std::time::Duration::from_millis(80)); + Some(spinner) + }; // Gather state let cli_settings = load_user_settings().unwrap_or_default(); @@ -1209,15 +1219,21 @@ pub(crate) async fn run_doctor(verbose: bool, live: bool) -> i32 { sections, }; - spinner.finish_and_clear(); + if let Some(spinner) = spinner { + spinner.finish_and_clear(); + } - let term_width = console::Term::stderr().size().1; - print!( - "{}", - report.render(&styles, verbose, None, Some(term_width)) - ); + if globals.json { + print_json_pretty(&report)?; + } else { + let term_width = console::Term::stderr().size().1; + print!( + "{}", + report.render(&styles, verbose, None, Some(term_width)) + ); + } - i32::from(report.has_errors()) + Ok(i32::from(report.has_errors())) } // --------------------------------------------------------------------------- diff --git a/lib/crates/fabro-cli/src/commands/exec.rs b/lib/crates/fabro-cli/src/commands/exec.rs index 5eb9ca596..cd962bc21 100644 --- a/lib/crates/fabro-cli/src/commands/exec.rs +++ b/lib/crates/fabro-cli/src/commands/exec.rs @@ -1,7 +1,7 @@ use anyhow::Result; #[cfg(feature = "server")] use fabro_agent::cli::run_with_args_and_client; -use fabro_agent::cli::{AgentArgs, run_with_args}; +use fabro_agent::cli::{AgentArgs, OutputFormat, run_with_args}; use fabro_config::mcp::McpServerEntry; use fabro_mcp::config::McpServerConfig; @@ -19,6 +19,9 @@ pub(crate) async fn execute(mut args: AgentArgs, globals: &GlobalArgs) -> Result exec_defaults.and_then(|a| a.permissions), exec_defaults.and_then(|a| a.output_format), ); + if globals.json { + args.output_format = Some(OutputFormat::Json); + } #[cfg(feature = "server")] let resolved = user_config::resolve_mode( globals.storage_dir.as_deref(), diff --git a/lib/crates/fabro-cli/src/commands/graph.rs b/lib/crates/fabro-cli/src/commands/graph.rs index 5fa3cd891..e0fedb9a5 100644 --- a/lib/crates/fabro-cli/src/commands/graph.rs +++ b/lib/crates/fabro-cli/src/commands/graph.rs @@ -11,13 +11,19 @@ use fabro_validate::Severity; use fabro_workflow::operations::{ValidateInput, WorkflowInput, validate}; use tracing::debug; -use crate::args::{GraphArgs, GraphDirection}; -use crate::shared::{print_diagnostics, read_workflow_file, relative_path}; +use crate::args::{GlobalArgs, GraphArgs, GraphDirection}; +use crate::shared::{ + absolute_or_current, print_diagnostics, print_json_pretty, read_workflow_file, relative_path, +}; static RANKDIR_RE: LazyLock = LazyLock::new(|| regex::Regex::new(r"rankdir\s*=\s*\w+").unwrap()); -pub(crate) fn run(args: &GraphArgs, styles: &Styles) -> anyhow::Result<()> { +pub(crate) fn run(args: &GraphArgs, styles: &Styles, globals: &GlobalArgs) -> anyhow::Result<()> { + if globals.json && args.output.is_none() { + globals.require_no_json()?; + } + let cwd = std::env::current_dir()?; let settings = ConfigLayer::for_workflow(&args.workflow, &cwd)? .combine(ConfigLayer::user()?) @@ -43,6 +49,12 @@ pub(crate) fn run(args: &GraphArgs, styles: &Styles) -> anyhow::Result<()> { if let Some(ref output_path) = args.output { std::fs::write(output_path, &rendered)?; + if globals.json { + print_json_pretty(&serde_json::json!({ + "path": absolute_or_current(output_path), + "format": args.format.to_string(), + }))?; + } } else { std::io::stdout().write_all(&rendered)?; } diff --git a/lib/crates/fabro-cli/src/commands/install.rs b/lib/crates/fabro-cli/src/commands/install.rs index 327f7a877..b727ea3ac 100644 --- a/lib/crates/fabro-cli/src/commands/install.rs +++ b/lib/crates/fabro-cli/src/commands/install.rs @@ -22,6 +22,7 @@ use tokio::sync::oneshot; use tokio::task::spawn_blocking; use super::doctor; +use crate::args::GlobalArgs; use crate::shared::provider_auth::{ prompt_and_validate_key, prompt_confirm, provider_display_name, run_openai_oauth_or_api_key, write_env_file, @@ -486,7 +487,8 @@ async fn setup_github_app( Ok(env_pairs) } -pub(crate) async fn run_install(web_url: &str) -> Result<()> { +pub(crate) async fn run_install(web_url: &str, globals: &GlobalArgs) -> Result<()> { + globals.require_no_json()?; let s = Styles::detect_stderr(); let emoji = console::Emoji("⚒️ ", ""); @@ -759,7 +761,7 @@ pub(crate) async fn run_install(web_url: &str) -> Result<()> { // Reload .env so doctor sees the values we just wrote let _ = dotenvy::from_path(&env_path); eprintln!(); - doctor::run_doctor(true, true).await; + let _ = doctor::run_doctor(true, true, globals).await?; } eprintln!(); diff --git a/lib/crates/fabro-cli/src/commands/llm/chat.rs b/lib/crates/fabro-cli/src/commands/llm/chat.rs index 5cff44972..4220730a8 100644 --- a/lib/crates/fabro-cli/src/commands/llm/chat.rs +++ b/lib/crates/fabro-cli/src/commands/llm/chat.rs @@ -11,6 +11,7 @@ pub(super) async fn execute( cli_settings: &FabroSettings, globals: &GlobalArgs, ) -> Result<()> { + globals.require_no_json()?; let llm_defaults = cli_settings.llm.as_ref(); if args.model.is_none() { args.model = llm_defaults.and_then(|l| l.model.clone()); diff --git a/lib/crates/fabro-cli/src/commands/llm/prompt.rs b/lib/crates/fabro-cli/src/commands/llm/prompt.rs index 06333258c..3cd66e468 100644 --- a/lib/crates/fabro-cli/src/commands/llm/prompt.rs +++ b/lib/crates/fabro-cli/src/commands/llm/prompt.rs @@ -30,10 +30,10 @@ pub(super) async fn execute( client, base_url: resolved.server_base_url, }; - run_prompt_via_server(args, &server).await?; + run_prompt_via_server(args, &server, globals.json).await?; } crate::user_config::ExecutionMode::Standalone => { - run_prompt(args).await?; + run_prompt(args, globals.json).await?; } } } @@ -41,7 +41,7 @@ pub(super) async fn execute( #[cfg(not(feature = "server"))] { let _ = globals; - run_prompt(args).await?; + run_prompt(args, globals.json).await?; } Ok(()) diff --git a/lib/crates/fabro-cli/src/commands/model.rs b/lib/crates/fabro-cli/src/commands/model.rs index fc247708d..855bd3fa7 100644 --- a/lib/crates/fabro-cli/src/commands/model.rs +++ b/lib/crates/fabro-cli/src/commands/model.rs @@ -35,5 +35,5 @@ pub(crate) async fn execute(command: Option, globals: &GlobalArgs } }; - run_models(command, server).await + run_models(command, server, globals.json).await } diff --git a/lib/crates/fabro-cli/src/commands/parse.rs b/lib/crates/fabro-cli/src/commands/parse.rs index 40473d91b..dfb3f44bb 100644 --- a/lib/crates/fabro-cli/src/commands/parse.rs +++ b/lib/crates/fabro-cli/src/commands/parse.rs @@ -3,10 +3,11 @@ use std::io::Write; use fabro_config::project::resolve_workflow; use fabro_graphviz::parser::parse_ast; -use crate::args::ParseArgs; +use crate::args::{GlobalArgs, ParseArgs}; use crate::shared::read_workflow_file; -pub(crate) fn run(args: &ParseArgs) -> anyhow::Result<()> { +pub(crate) fn run(args: &ParseArgs, globals: &GlobalArgs) -> anyhow::Result<()> { + let _ = globals; let stdout = std::io::stdout(); run_to(args, stdout.lock()) } diff --git a/lib/crates/fabro-cli/src/commands/pr/close.rs b/lib/crates/fabro-cli/src/commands/pr/close.rs index 6fbaba0d9..9c2df808d 100644 --- a/lib/crates/fabro-cli/src/commands/pr/close.rs +++ b/lib/crates/fabro-cli/src/commands/pr/close.rs @@ -6,6 +6,7 @@ use fabro_workflow::run_lookup::runs_base; use tracing::info; use crate::args::{GlobalArgs, PrCloseArgs}; +use crate::shared::print_json_pretty; use crate::user_config::load_user_settings_with_globals; pub(super) async fn close_command( @@ -15,13 +16,14 @@ pub(super) async fn close_command( ) -> Result<()> { let cli_settings = load_user_settings_with_globals(globals)?; let base = runs_base(&cli_settings.storage_dir()); - close_from(&base, args, github_app).await + close_from(&base, args, github_app, globals).await } async fn close_from( base: &Path, args: PrCloseArgs, github_app: Option, + globals: &GlobalArgs, ) -> Result<()> { let (record, _run_dir) = super::load_pr_record(base, &args.run_id).await?; @@ -40,7 +42,14 @@ async fn close_from( .map_err(|err| anyhow::anyhow!("{err}"))?; info!(number = record.number, owner = %record.owner, repo = %record.repo, "Closed pull request"); - println!("Closed #{} ({})", record.number, record.html_url); + if globals.json { + print_json_pretty(&serde_json::json!({ + "number": record.number, + "html_url": record.html_url, + }))?; + } else { + println!("Closed #{} ({})", record.number, record.html_url); + } Ok(()) } diff --git a/lib/crates/fabro-cli/src/commands/pr/create.rs b/lib/crates/fabro-cli/src/commands/pr/create.rs index e7726bf8c..873630cba 100644 --- a/lib/crates/fabro-cli/src/commands/pr/create.rs +++ b/lib/crates/fabro-cli/src/commands/pr/create.rs @@ -13,6 +13,7 @@ use fabro_workflow::run_lookup::{resolve_run_combined, runs_base}; use tracing::info; use crate::args::{GlobalArgs, PrCreateArgs}; +use crate::shared::print_json_pretty; use crate::store; use crate::user_config::load_user_settings_with_globals; @@ -23,13 +24,14 @@ pub(super) async fn create_command( ) -> Result<()> { let cli_settings = load_user_settings_with_globals(globals)?; let base = runs_base(&cli_settings.storage_dir()); - create_from(&base, args, github_app).await + create_from(&base, args, github_app, globals).await } async fn create_from( base: &Path, args: PrCreateArgs, github_app: Option, + globals: &GlobalArgs, ) -> Result<()> { let storage_dir = base.parent().unwrap_or(base); let store = store::build_store(storage_dir)?; @@ -149,10 +151,18 @@ async fn create_from( if let Err(err) = record.save(&run_dir.join("pull_request.json")) { tracing::warn!(error = %err, "Failed to save pull_request.json"); } - println!("{}", record.html_url); + if globals.json { + print_json_pretty(&record)?; + } else { + println!("{}", record.html_url); + } } None => { - println!("No pull request created (empty diff)."); + if globals.json { + print_json_pretty(&serde_json::Value::Null)?; + } else { + println!("No pull request created (empty diff)."); + } } } diff --git a/lib/crates/fabro-cli/src/commands/pr/list.rs b/lib/crates/fabro-cli/src/commands/pr/list.rs index 1f1ba0985..63a62020d 100644 --- a/lib/crates/fabro-cli/src/commands/pr/list.rs +++ b/lib/crates/fabro-cli/src/commands/pr/list.rs @@ -5,12 +5,23 @@ use fabro_config::FabroSettingsExt; use fabro_workflow::pull_request::PullRequestRecord; use fabro_workflow::run_lookup::{runs_base, scan_runs_combined}; use futures::future::join_all; +use serde::Serialize; use tracing::info; use crate::args::{GlobalArgs, PrListArgs}; +use crate::shared::print_json_pretty; use crate::store; use crate::user_config::load_user_settings_with_globals; +#[derive(Serialize)] +struct PrRow { + run_id: String, + number: u64, + state: String, + title: String, + url: String, +} + pub(super) async fn list_command( args: PrListArgs, github_app: Option, @@ -19,7 +30,7 @@ pub(super) async fn list_command( let cli_settings = load_user_settings_with_globals(globals)?; let base = runs_base(&cli_settings.storage_dir()); let store = store::build_store(&cli_settings.storage_dir())?; - list_from(store.as_ref(), &base, args, github_app).await + list_from(store.as_ref(), &base, args, github_app, globals).await } async fn list_from( @@ -27,15 +38,8 @@ async fn list_from( base: &Path, args: PrListArgs, github_app: Option, + globals: &GlobalArgs, ) -> Result<()> { - struct PrRow { - run_id: String, - number: u64, - state: String, - title: String, - url: String, - } - let creds = github_app.context( "GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id", )?; @@ -55,6 +59,10 @@ async fn list_from( } if entries.is_empty() { + if globals.json { + print_json_pretty(&Vec::::new())?; + return Ok(()); + } println!("No pull requests found."); return Ok(()); } @@ -111,6 +119,11 @@ async fn list_from( .collect() }; + if globals.json { + print_json_pretty(&rows)?; + return Ok(()); + } + if rows.is_empty() { println!("No open pull requests found. Use --all to include closed/merged."); return Ok(()); diff --git a/lib/crates/fabro-cli/src/commands/pr/merge.rs b/lib/crates/fabro-cli/src/commands/pr/merge.rs index 956b72cbe..e9438b34a 100644 --- a/lib/crates/fabro-cli/src/commands/pr/merge.rs +++ b/lib/crates/fabro-cli/src/commands/pr/merge.rs @@ -7,6 +7,7 @@ use tracing::info; use fabro_workflow::run_lookup::runs_base; use crate::args::{GlobalArgs, PrMergeArgs}; +use crate::shared::print_json_pretty; use crate::user_config::load_user_settings_with_globals; pub(super) async fn merge_command( @@ -16,13 +17,14 @@ pub(super) async fn merge_command( ) -> Result<()> { let cli_settings = load_user_settings_with_globals(globals)?; let base = runs_base(&cli_settings.storage_dir()); - merge_from(&base, args, github_app).await + merge_from(&base, args, github_app, globals).await } async fn merge_from( base: &Path, args: PrMergeArgs, github_app: Option, + globals: &GlobalArgs, ) -> Result<()> { let (record, _run_dir) = super::load_pr_record(base, &args.run_id).await?; @@ -42,7 +44,15 @@ async fn merge_from( .map_err(|err| anyhow::anyhow!("{err}"))?; info!(number = record.number, owner = %record.owner, repo = %record.repo, method = %args.method, "Merged pull request"); - println!("Merged #{} ({})", record.number, record.html_url); + if globals.json { + print_json_pretty(&serde_json::json!({ + "number": record.number, + "html_url": record.html_url, + "method": args.method, + }))?; + } else { + println!("Merged #{} ({})", record.number, record.html_url); + } Ok(()) } diff --git a/lib/crates/fabro-cli/src/commands/pr/view.rs b/lib/crates/fabro-cli/src/commands/pr/view.rs index a69338a74..bb0d0bc23 100644 --- a/lib/crates/fabro-cli/src/commands/pr/view.rs +++ b/lib/crates/fabro-cli/src/commands/pr/view.rs @@ -7,6 +7,7 @@ use tracing::info; use fabro_workflow::run_lookup::runs_base; use crate::args::{GlobalArgs, PrViewArgs}; +use crate::shared::print_json_pretty; use crate::user_config::load_user_settings_with_globals; pub(super) async fn view_command( @@ -16,13 +17,14 @@ pub(super) async fn view_command( ) -> Result<()> { let cli_settings = load_user_settings_with_globals(globals)?; let base = runs_base(&cli_settings.storage_dir()); - view_from(&base, args, github_app).await + view_from(&base, args, github_app, globals).await } async fn view_from( base: &Path, args: PrViewArgs, github_app: Option, + globals: &GlobalArgs, ) -> Result<()> { let (record, _run_dir) = super::load_pr_record(base, &args.run_id).await?; @@ -42,6 +44,11 @@ async fn view_from( info!(number = detail.number, owner = %record.owner, repo = %record.repo, "Viewing pull request"); + if globals.json { + print_json_pretty(&detail)?; + return Ok(()); + } + println!("#{} {}", detail.number, detail.title); let state_display = if detail.draft { "draft" } else { &detail.state }; println!("State: {state_display}"); diff --git a/lib/crates/fabro-cli/src/commands/preflight.rs b/lib/crates/fabro-cli/src/commands/preflight.rs index a72beb26a..9b34d81f9 100644 --- a/lib/crates/fabro-cli/src/commands/preflight.rs +++ b/lib/crates/fabro-cli/src/commands/preflight.rs @@ -15,6 +15,7 @@ use fabro_workflow::operations::{ValidateInput, WorkflowInput, validate}; use crate::args::{GlobalArgs, PreflightArgs}; use crate::shared::github::build_github_app_credentials; +use crate::shared::print_json_pretty; use crate::user_config::{load_user_settings_with_globals, user_layer_with_globals}; pub(crate) async fn execute(mut args: PreflightArgs, globals: &GlobalArgs) -> anyhow::Result<()> { @@ -50,12 +51,14 @@ pub(crate) async fn execute(mut args: PreflightArgs, globals: &GlobalArgs) -> an cwd, custom_transforms: Vec::new(), })?; - super::run::output::print_workflow_report(&validated, Some(&resolution.dot_path), styles); - if validated.has_errors() { - bail!("Validation failed"); + if !globals.json { + super::run::output::print_workflow_report(&validated, Some(&resolution.dot_path), styles); + if validated.has_errors() { + bail!("Validation failed"); + } } - run_preflight( + let (report, preflight_ok) = run_preflight( validated.graph(), &settings, args.model.as_deref(), @@ -66,8 +69,36 @@ pub(crate) async fn execute(mut args: PreflightArgs, globals: &GlobalArgs) -> an styles, github_app, origin_url.as_deref(), + !globals.json, ) - .await + .await?; + + if globals.json { + print_json_pretty(&serde_json::json!({ + "workflow": { + "name": validated.graph().name, + "graph_path": resolution.dot_path, + "nodes": validated.graph().nodes.len(), + "edges": validated.graph().edges.len(), + "goal": validated.graph().goal(), + "diagnostics": validated.diagnostics(), + }, + "checks": report, + }))?; + } else { + let term_width = console::Term::stderr().size().1; + print!("{}", report.render(styles, true, None, Some(term_width))); + } + + if validated.has_errors() { + bail!("Validation failed"); + } + + if !preflight_ok { + std::process::exit(1); + } + + Ok(()) } fn resolve_model_provider( @@ -173,19 +204,23 @@ async fn run_preflight( styles: &'static Styles, github_app: Option, origin_url: Option<&str>, -) -> anyhow::Result<()> { + show_progress: bool, +) -> anyhow::Result<(fabro_util::check_report::CheckReport, bool)> { use fabro_util::check_report::{ CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus, }; - let spinner = indicatif::ProgressBar::new_spinner(); - spinner.set_style( - indicatif::ProgressStyle::with_template("{spinner:.cyan} {msg}") - .expect("valid template") - .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", ""]), - ); - spinner.set_message("Running preflight checks..."); - spinner.enable_steady_tick(std::time::Duration::from_millis(80)); + let spinner = show_progress.then(|| { + let spinner = indicatif::ProgressBar::new_spinner(); + spinner.set_style( + indicatif::ProgressStyle::with_template("{spinner:.cyan} {msg}") + .expect("valid template") + .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", ""]), + ); + spinner.set_message("Running preflight checks..."); + spinner.enable_steady_tick(std::time::Duration::from_millis(80)); + spinner + }); let mut checks: Vec = Vec::new(); @@ -431,8 +466,6 @@ async fn run_preflight( } } - spinner.finish_and_clear(); - let report = CheckReport { title: "Run Preflight".into(), sections: vec![CheckSection { @@ -440,13 +473,10 @@ async fn run_preflight( checks, }], }; - - let term_width = console::Term::stderr().size().1; - print!("{}", report.render(styles, true, None, Some(term_width))); - - if sandbox_ok && llm_ok { - Ok(()) - } else { - std::process::exit(1); + if let Some(spinner) = spinner { + spinner.finish_and_clear(); } + let _ = styles; + + Ok((report, sandbox_ok && llm_ok)) } diff --git a/lib/crates/fabro-cli/src/commands/provider/login.rs b/lib/crates/fabro-cli/src/commands/provider/login.rs index bc1d63a99..94a6c44df 100644 --- a/lib/crates/fabro-cli/src/commands/provider/login.rs +++ b/lib/crates/fabro-cli/src/commands/provider/login.rs @@ -3,10 +3,11 @@ use fabro_model::Provider; use fabro_util::terminal::Styles; use tokio::task::spawn_blocking; -use crate::args::ProviderLoginArgs; +use crate::args::{GlobalArgs, ProviderLoginArgs}; use crate::shared::provider_auth; -pub(super) async fn login_command(args: ProviderLoginArgs) -> Result<()> { +pub(super) async fn login_command(args: ProviderLoginArgs, globals: &GlobalArgs) -> Result<()> { + globals.require_no_json()?; let s = Styles::detect_stderr(); let arc_dir = dirs::home_dir() .context("could not determine home directory")? diff --git a/lib/crates/fabro-cli/src/commands/provider/mod.rs b/lib/crates/fabro-cli/src/commands/provider/mod.rs index 7fd780c51..c1fcb9431 100644 --- a/lib/crates/fabro-cli/src/commands/provider/mod.rs +++ b/lib/crates/fabro-cli/src/commands/provider/mod.rs @@ -2,10 +2,10 @@ mod login; use anyhow::Result; -use crate::args::{ProviderCommand, ProviderNamespace}; +use crate::args::{GlobalArgs, ProviderCommand, ProviderNamespace}; -pub(crate) async fn dispatch(ns: ProviderNamespace) -> Result<()> { +pub(crate) async fn dispatch(ns: ProviderNamespace, globals: &GlobalArgs) -> Result<()> { match ns.command { - ProviderCommand::Login(args) => login::login_command(args).await, + ProviderCommand::Login(args) => login::login_command(args, globals).await, } } diff --git a/lib/crates/fabro-cli/src/commands/repo/deinit.rs b/lib/crates/fabro-cli/src/commands/repo/deinit.rs index 49222b2db..ec602b68e 100644 --- a/lib/crates/fabro-cli/src/commands/repo/deinit.rs +++ b/lib/crates/fabro-cli/src/commands/repo/deinit.rs @@ -1,7 +1,10 @@ use anyhow::{Context, Result, bail}; -pub(crate) fn run_deinit() -> Result<()> { +use crate::args::GlobalArgs; + +pub(crate) fn run_deinit(globals: &GlobalArgs) -> Result> { let repo_root = super::init::git_repo_root()?; + let mut removed = Vec::new(); let fabro_toml = repo_root.join("fabro.toml"); @@ -15,29 +18,37 @@ pub(crate) fn run_deinit() -> Result<()> { } Err(e) => bail!("failed to remove {}: {e}", fabro_toml.display()), } - eprintln!( - " {} {}", - green.apply_to("✔"), - dim.apply_to("removed fabro.toml") - ); + removed.push("fabro.toml".to_string()); + if !globals.json { + eprintln!( + " {} {}", + green.apply_to("✔"), + dim.apply_to("removed fabro.toml") + ); + } let fabro_dir = repo_root.join("fabro"); if fabro_dir.exists() { std::fs::remove_dir_all(&fabro_dir) .with_context(|| format!("failed to remove {}", fabro_dir.display()))?; + removed.push("fabro/".to_string()); + if !globals.json { + eprintln!( + " {} {}", + green.apply_to("✔"), + dim.apply_to("removed fabro/") + ); + } + } + + if !globals.json { eprintln!( - " {} {}", - green.apply_to("✔"), - dim.apply_to("removed fabro/") + "\n{}", + console::Style::new() + .bold() + .apply_to("Project deinitialized.") ); } - eprintln!( - "\n{}", - console::Style::new() - .bold() - .apply_to("Project deinitialized.") - ); - - Ok(()) + Ok(removed) } diff --git a/lib/crates/fabro-cli/src/commands/repo/init.rs b/lib/crates/fabro-cli/src/commands/repo/init.rs index 2140d7be3..e38e07965 100644 --- a/lib/crates/fabro-cli/src/commands/repo/init.rs +++ b/lib/crates/fabro-cli/src/commands/repo/init.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use anyhow::{Context, Result, bail}; use tokio::task::spawn_blocking; +use crate::args::GlobalArgs; use crate::shared::github::build_github_app_credentials; use crate::user_config::load_user_settings; @@ -21,8 +22,9 @@ pub(super) fn git_repo_root() -> Result { )) } -pub(crate) async fn run_init() -> Result<()> { +pub(crate) async fn run_init(globals: &GlobalArgs) -> Result> { let repo_root = git_repo_root()?; + let mut created = Vec::new(); let fabro_toml = repo_root.join("fabro.toml"); if fabro_toml.exists() { @@ -55,11 +57,14 @@ draft = true ", ) .with_context(|| format!("failed to write {}", fabro_toml.display()))?; + created.push("fabro.toml".to_string()); let green = console::Style::new().green(); let bold = console::Style::new().bold(); let dim = console::Style::new().dim(); - eprintln!(" {} {}", green.apply_to("✔"), dim.apply_to("fabro.toml")); + if !globals.json { + eprintln!(" {} {}", green.apply_to("✔"), dim.apply_to("fabro.toml")); + } // Create hello workflow directory let workflow_dir = repo_root.join("fabro/workflows/hello"); @@ -84,11 +89,14 @@ draft = true "#, ) .with_context(|| format!("failed to write {}", dot_path.display()))?; - eprintln!( - " {} {}", - green.apply_to("✔"), - dim.apply_to("fabro/workflows/hello/workflow.fabro") - ); + created.push("fabro/workflows/hello/workflow.fabro".to_string()); + if !globals.json { + eprintln!( + " {} {}", + green.apply_to("✔"), + dim.apply_to("fabro/workflows/hello/workflow.fabro") + ); + } // Create workflow.toml let toml_path = workflow_dir.join("workflow.toml"); @@ -97,24 +105,31 @@ draft = true "version = 1\ngraph = \"workflow.fabro\"\n\n[sandbox]\nprovider = \"local\"\n", ) .with_context(|| format!("failed to write {}", toml_path.display()))?; - eprintln!( - " {} {}", - green.apply_to("✔"), - dim.apply_to("fabro/workflows/hello/workflow.toml") - ); + created.push("fabro/workflows/hello/workflow.toml".to_string()); + if !globals.json { + eprintln!( + " {} {}", + green.apply_to("✔"), + dim.apply_to("fabro/workflows/hello/workflow.toml") + ); + } - eprintln!( - "\n{} Run a workflow with:\n\n {}", - bold.apply_to("Project initialized!"), - console::Style::new() - .cyan() - .bold() - .apply_to("fabro run hello") - ); + if !globals.json { + eprintln!( + "\n{} Run a workflow with:\n\n {}", + bold.apply_to("Project initialized!"), + console::Style::new() + .cyan() + .bold() + .apply_to("fabro run hello") + ); + } - check_github_app_installation().await; + if !globals.json { + check_github_app_installation().await; + } - Ok(()) + Ok(created) } async fn check_github_app_installation() { diff --git a/lib/crates/fabro-cli/src/commands/repo/mod.rs b/lib/crates/fabro-cli/src/commands/repo/mod.rs index 03e7bcd48..5835208b5 100644 --- a/lib/crates/fabro-cli/src/commands/repo/mod.rs +++ b/lib/crates/fabro-cli/src/commands/repo/mod.rs @@ -3,18 +3,28 @@ pub(crate) mod init; use anyhow::Result; -use crate::args::{RepoCommand, RepoNamespace}; +use crate::args::{GlobalArgs, RepoCommand, RepoNamespace}; +use crate::shared::print_json_pretty; -pub(crate) async fn dispatch(ns: RepoNamespace) -> Result<()> { +pub(crate) async fn dispatch(ns: RepoNamespace, globals: &GlobalArgs) -> Result<()> { match ns.command { RepoCommand::Init { skill } => { - init::run_init().await?; + let created = init::run_init(globals).await?; if skill { let base = std::env::current_dir()?.join(".claude").join("skills"); super::skill::install_skill_to(&base)?; } + if globals.json { + print_json_pretty(&serde_json::json!({ "created": created }))?; + } + Ok(()) + } + RepoCommand::Deinit => { + let removed = deinit::run_deinit(globals)?; + if globals.json { + print_json_pretty(&serde_json::json!({ "removed": removed }))?; + } Ok(()) } - RepoCommand::Deinit => deinit::run_deinit(), } } diff --git a/lib/crates/fabro-cli/src/commands/run/attach.rs b/lib/crates/fabro-cli/src/commands/run/attach.rs index a2fa31366..89b4c7ed2 100644 --- a/lib/crates/fabro-cli/src/commands/run/attach.rs +++ b/lib/crates/fabro-cli/src/commands/run/attach.rs @@ -38,6 +38,7 @@ pub(crate) async fn attach_run( kill_on_detach: bool, styles: &'static Styles, engine_child: Option, + json_output: bool, ) -> Result { let run_record = RunRecord::load(run_dir).ok(); if let (Some(storage_dir), Some(run_id)) = ( @@ -61,6 +62,7 @@ pub(crate) async fn attach_run( kill_on_detach, styles, engine_child, + json_output, ) .await; } @@ -83,7 +85,7 @@ pub(crate) async fn attach_run( } } - attach_run_files(run_dir, kill_on_detach, styles, engine_child).await + attach_run_files(run_dir, kill_on_detach, styles, engine_child, json_output).await } async fn attach_run_store( @@ -94,6 +96,7 @@ async fn attach_run_store( kill_on_detach: bool, styles: &'static Styles, engine_child: Option, + json_output: bool, ) -> Result { let runtime_state = RuntimeState::new(run_dir); let runtime_interview_paths = InterviewPaths::from_runtime_state(&runtime_state); @@ -117,7 +120,7 @@ async fn attach_run_store( } for line in &existing_events { - progress_ui.handle_json_line(line); + emit_progress_line(&mut progress_ui, line, json_output)?; } let mut stream = run_store @@ -163,7 +166,7 @@ async fn attach_run_store( match time::timeout(Duration::from_millis(100), stream.next()).await { Ok(Some(Ok(event))) => { let line = event_payload_line(&event)?; - progress_ui.handle_json_line(&line); + emit_progress_line(&mut progress_ui, &line, json_output)?; saw_event = true; } Ok(Some(Err(err))) => return Err(err.into()), @@ -183,7 +186,7 @@ async fn attach_run_store( serde_json::from_str::(&request_data) { // Hide progress bars during interview - progress_ui.hide_bars(); + hide_progress(&mut progress_ui, json_output); // Prompt user via ConsoleInterviewer let interviewer = ConsoleInterviewer::new(styles); @@ -191,7 +194,7 @@ async fn attach_run_store( fabro_interview::Interviewer::ask(&interviewer, question).await; // Show progress bars again before any return path. - progress_ui.show_bars(); + show_progress(&mut progress_ui, json_output); if answer_requires_reattach(&answer) { if let Some(guard) = engine_guard.as_mut() { @@ -252,7 +255,7 @@ async fn attach_run_store( } } - progress_ui.finish(); + finish_progress(&mut progress_ui, json_output); Ok(determine_exit_code_with_store(run_store, run_dir).await) } @@ -262,6 +265,7 @@ async fn attach_run_files( kill_on_detach: bool, styles: &'static Styles, engine_child: Option, + json_output: bool, ) -> Result { let progress_path = run_dir.join("progress.jsonl"); let conclusion_path = run_dir.join("conclusion.json"); @@ -293,7 +297,7 @@ async fn attach_run_files( if let Some(record) = read_status_record(&status_path) { if record.status.is_terminal() { - progress_ui.finish(); + finish_progress(&mut progress_ui, json_output); return Ok(determine_exit_code(&conclusion_path, Some(record))); } } @@ -301,7 +305,7 @@ async fn attach_run_files( if let Some(guard) = engine_guard.as_mut() { if let Some(child) = guard.inner() { if matches!(child.try_wait(), Ok(Some(_))) { - progress_ui.finish(); + finish_progress(&mut progress_ui, json_output); return Ok(determine_exit_code( &conclusion_path, read_status_record(&status_path), @@ -312,7 +316,7 @@ async fn attach_run_files( if let Some(pid) = read_launcher_pid(run_dir) { if !process_alive(pid) && wait_count > 5 { - progress_ui.finish(); + finish_progress(&mut progress_ui, json_output); return Ok(determine_exit_code( &conclusion_path, read_status_record(&status_path), @@ -379,7 +383,7 @@ async fn attach_run_files( } let trimmed = line.trim(); if !trimmed.is_empty() { - progress_ui.handle_json_line(trimmed); + emit_progress_line(&mut progress_ui, trimmed, json_output)?; } } @@ -394,13 +398,13 @@ async fn attach_run_files( if let Ok(question) = serde_json::from_str::(&request_data) { - progress_ui.hide_bars(); + hide_progress(&mut progress_ui, json_output); let interviewer = ConsoleInterviewer::new(styles); let answer = fabro_interview::Interviewer::ask(&interviewer, question).await; - progress_ui.show_bars(); + show_progress(&mut progress_ui, json_output); if answer_requires_reattach(&answer) { if let Some(guard) = engine_guard.as_mut() { @@ -433,12 +437,12 @@ async fn attach_run_files( if let Some(child_alive) = child_alive_via_handle { if !child_alive { - drain_remaining(&mut reader, &mut line, &mut progress_ui); + drain_remaining(&mut reader, &mut line, &mut progress_ui, json_output)?; break; } } else { if terminal_status.is_some() { - drain_remaining(&mut reader, &mut line, &mut progress_ui); + drain_remaining(&mut reader, &mut line, &mut progress_ui, json_output)?; break; } @@ -455,7 +459,7 @@ async fn attach_run_files( } }; if !engine_alive { - drain_remaining(&mut reader, &mut line, &mut progress_ui); + drain_remaining(&mut reader, &mut line, &mut progress_ui, json_output)?; break; } } @@ -463,7 +467,7 @@ async fn attach_run_files( sleep(Duration::from_millis(100)).await; } - progress_ui.finish(); + finish_progress(&mut progress_ui, json_output); Ok(determine_exit_code( &conclusion_path, @@ -475,7 +479,8 @@ fn drain_remaining( reader: &mut BufReader, line: &mut String, progress_ui: &mut run_progress::ProgressUI, -) { + json_output: bool, +) -> Result<()> { loop { line.clear(); match reader.read_line(line) { @@ -483,11 +488,45 @@ fn drain_remaining( Ok(_) => { let trimmed = line.trim(); if !trimmed.is_empty() { - progress_ui.handle_json_line(trimmed); + emit_progress_line(progress_ui, trimmed, json_output)?; } } } } + Ok(()) +} + +fn emit_progress_line( + progress_ui: &mut run_progress::ProgressUI, + line: &str, + json_output: bool, +) -> Result<()> { + if json_output { + let stdout = std::io::stdout(); + let mut handle = stdout.lock(); + writeln!(handle, "{line}")?; + } else { + progress_ui.handle_json_line(line); + } + Ok(()) +} + +fn finish_progress(progress_ui: &mut run_progress::ProgressUI, json_output: bool) { + if !json_output { + progress_ui.finish(); + } +} + +fn hide_progress(progress_ui: &mut run_progress::ProgressUI, json_output: bool) { + if !json_output { + progress_ui.hide_bars(); + } +} + +fn show_progress(progress_ui: &mut run_progress::ProgressUI, json_output: bool) { + if !json_output { + progress_ui.show_bars(); + } } fn event_payload_line(event: &EventEnvelope) -> Result { @@ -736,9 +775,16 @@ mod tests { .unwrap(); let started = Instant::now(); - let exit = attach_run(dir.path(), None, false, no_color_styles(), Some(child)) - .await - .unwrap(); + let exit = attach_run( + dir.path(), + None, + false, + no_color_styles(), + Some(child), + false, + ) + .await + .unwrap(); assert_eq!(exit, ExitCode::from(0)); assert!( @@ -757,7 +803,7 @@ mod tests { Some(StatusReason::LaunchFailed), ); - let exit = attach_run(dir.path(), None, false, no_color_styles(), None) + let exit = attach_run(dir.path(), None, false, no_color_styles(), None, false) .await .unwrap(); diff --git a/lib/crates/fabro-cli/src/commands/run/command.rs b/lib/crates/fabro-cli/src/commands/run/command.rs index bd800872b..2936d2a31 100644 --- a/lib/crates/fabro-cli/src/commands/run/command.rs +++ b/lib/crates/fabro-cli/src/commands/run/command.rs @@ -2,6 +2,7 @@ use anyhow::Result; use fabro_util::terminal::Styles; use crate::args::{GlobalArgs, RunArgs}; +use crate::shared::print_json_pretty; use crate::user_config::{self, user_layer_with_globals}; pub(crate) async fn execute(mut args: RunArgs, globals: &GlobalArgs) -> Result<()> { @@ -23,11 +24,24 @@ pub(crate) async fn execute(mut args: RunArgs, globals: &GlobalArgs) -> Result<( let child = super::start::start_run(&run_dir, false)?; if args.detach { - println!("{run_id}"); + if globals.json { + print_json_pretty(&serde_json::json!({ "run_id": run_id }))?; + } else { + println!("{run_id}"); + } } else { - let exit_code = - super::attach::attach_run(&run_dir, Some(&run_id), true, styles, Some(child)).await?; - super::output::print_run_summary(&run_dir, run_id, styles); + let exit_code = super::attach::attach_run( + &run_dir, + Some(&run_id), + true, + styles, + Some(child), + globals.json, + ) + .await?; + if !globals.json { + super::output::print_run_summary(&run_dir, run_id, styles); + } if exit_code != std::process::ExitCode::SUCCESS { std::process::exit(1); } diff --git a/lib/crates/fabro-cli/src/commands/run/cp.rs b/lib/crates/fabro-cli/src/commands/run/cp.rs index 1182bffc9..30c3a76d1 100644 --- a/lib/crates/fabro-cli/src/commands/run/cp.rs +++ b/lib/crates/fabro-cli/src/commands/run/cp.rs @@ -10,7 +10,7 @@ use tokio::fs; use tracing::{debug, info}; use crate::args::{CpArgs, GlobalArgs}; -use crate::shared::split_run_path; +use crate::shared::{print_json_pretty, split_run_path}; use crate::user_config::load_user_settings_with_globals; enum CopyDirection { @@ -39,15 +39,30 @@ pub(crate) async fn cp_command(args: CpArgs, globals: &GlobalArgs) -> Result<()> } => { let sandbox = load_sandbox(&base, &run_prefix).await?; - if args.recursive { - download_recursive(&*sandbox, &remote_path, &local_path).await?; + let file_count = if args.recursive { + Some(download_recursive(&*sandbox, &remote_path, &local_path).await?) } else { debug!(path = %remote_path, "Downloading file from sandbox"); sandbox .download_file_to_local(&remote_path, &local_path) .await .map_err(|err| anyhow::anyhow!("{err}"))?; + None + }; + + if globals.json { + let mut value = serde_json::json!({ + "direction": "download", + "recursive": args.recursive, + "remote_path": remote_path, + "local_path": local_path, + }); + if let Some(count) = file_count { + value["file_count"] = count.into(); + } + print_json_pretty(&value)?; } + info!(direction = "download", path = %remote_path, "Copy complete"); } CopyDirection::Upload { @@ -57,14 +72,28 @@ pub(crate) async fn cp_command(args: CpArgs, globals: &GlobalArgs) -> Result<()> } => { let sandbox = load_sandbox(&base, &run_prefix).await?; - if args.recursive { - upload_recursive(&*sandbox, &local_path, &remote_path).await?; + let file_count = if args.recursive { + Some(upload_recursive(&*sandbox, &local_path, &remote_path).await?) } else { debug!(path = %remote_path, "Uploading file to sandbox"); sandbox .upload_file_from_local(&local_path, &remote_path) .await .map_err(|err| anyhow::anyhow!("{err}"))?; + None + }; + + if globals.json { + let mut value = serde_json::json!({ + "direction": "upload", + "recursive": args.recursive, + "remote_path": remote_path, + "local_path": local_path, + }); + if let Some(count) = file_count { + value["file_count"] = count.into(); + } + print_json_pretty(&value)?; } info!(direction = "upload", path = %remote_path, "Copy complete"); } @@ -111,7 +140,7 @@ async fn download_recursive( sandbox: &dyn Sandbox, remote_path: &str, local_path: &Path, -) -> Result<()> { +) -> Result { let entries = sandbox .list_directory(remote_path, Some(100)) .await @@ -137,14 +166,14 @@ async fn download_recursive( file_count += 1; } debug!(count = file_count, "Recursive download complete"); - Ok(()) + Ok(file_count) } async fn upload_recursive( sandbox: &dyn Sandbox, local_path: &Path, remote_path: &str, -) -> Result<()> { +) -> Result { let mut file_count = 0usize; let mut stack = vec![(local_path.to_path_buf(), remote_path.to_string())]; @@ -171,7 +200,7 @@ async fn upload_recursive( } } debug!(count = file_count, "Recursive upload complete"); - Ok(()) + Ok(file_count) } #[cfg(test)] diff --git a/lib/crates/fabro-cli/src/commands/run/diff.rs b/lib/crates/fabro-cli/src/commands/run/diff.rs index 37e05930d..32e6ca7f0 100644 --- a/lib/crates/fabro-cli/src/commands/run/diff.rs +++ b/lib/crates/fabro-cli/src/commands/run/diff.rs @@ -11,6 +11,7 @@ use fabro_workflow::sandbox_git::GIT_REMOTE; use tracing::{debug, info}; use crate::args::{DiffArgs, GlobalArgs}; +use crate::shared::print_json_pretty; use crate::store; use crate::user_config::load_user_settings_with_globals; @@ -24,6 +25,22 @@ pub(crate) async fn run(args: DiffArgs, globals: &GlobalArgs) -> Result<()> { let patch = resolve_diff(&run.path, run_store.as_deref(), &args).await?; + if globals.json { + let mut value = serde_json::json!({ + "run_id": run.run_id, + "node": args.node, + }); + if args.shortstat { + value["shortstat"] = patch.trim_end().into(); + } else if args.stat { + value["stat"] = patch.trim_end().into(); + } else { + value["diff"] = patch.into(); + } + print_json_pretty(&value)?; + return Ok(()); + } + let is_tty = io::stdout().is_terminal(); let mut stdout = io::stdout().lock(); if is_tty { diff --git a/lib/crates/fabro-cli/src/commands/run/fork.rs b/lib/crates/fabro-cli/src/commands/run/fork.rs index 6de549381..adccaac1a 100644 --- a/lib/crates/fabro-cli/src/commands/run/fork.rs +++ b/lib/crates/fabro-cli/src/commands/run/fork.rs @@ -9,6 +9,7 @@ use fabro_workflow::operations::{ use git2::Repository; use crate::args::{ForkArgs, GlobalArgs}; +use crate::shared::print_json_pretty; use crate::store::{build_store, open_run_reader}; use crate::user_config::load_user_settings_with_globals; @@ -24,6 +25,10 @@ pub(crate) async fn run(args: &ForkArgs, styles: &Styles, globals: &GlobalArgs) let timeline = build_timeline_or_rebuild(&store, run_store.as_deref(), &run_id).await?; if args.list { + if globals.json { + print_json_pretty(&super::rewind::timeline_entries_json(&timeline))?; + return Ok(()); + } super::rewind::print_timeline(&timeline, styles); return Ok(()); } @@ -45,15 +50,24 @@ pub(crate) async fn run(args: &ForkArgs, styles: &Styles, globals: &GlobalArgs) let run_id_string = run_id.to_string(); let new_run_id_string = new_run_id.to_string(); - eprintln!( - "\nForked run {} -> {}", - &run_id_string[..8.min(run_id_string.len())], - &new_run_id_string[..8.min(new_run_id_string.len())] - ); - eprintln!( - "To resume: fabro resume {}", - &new_run_id_string[..8.min(new_run_id_string.len())] - ); + if globals.json { + let target = args.target.clone().unwrap_or_else(|| "latest".to_string()); + print_json_pretty(&serde_json::json!({ + "source_run_id": run_id_string, + "new_run_id": new_run_id_string, + "target": target, + }))?; + } else { + eprintln!( + "\nForked run {} -> {}", + &run_id_string[..8.min(run_id_string.len())], + &new_run_id_string[..8.min(new_run_id_string.len())] + ); + eprintln!( + "To resume: fabro resume {}", + &new_run_id_string[..8.min(new_run_id_string.len())] + ); + } Ok(()) } diff --git a/lib/crates/fabro-cli/src/commands/run/logs.rs b/lib/crates/fabro-cli/src/commands/run/logs.rs index d8920655a..747880d8d 100644 --- a/lib/crates/fabro-cli/src/commands/run/logs.rs +++ b/lib/crates/fabro-cli/src/commands/run/logs.rs @@ -65,9 +65,10 @@ pub(crate) async fn run(args: &LogsArgs, styles: &Styles, globals: &GlobalArgs) let stdout = io::stdout(); let is_tty = stdout.is_terminal(); let mut out = stdout.lock(); + let pretty = args.pretty && !globals.json; for line in &filtered { - if args.pretty { + if pretty { if let Some(formatted) = format_event_pretty(line, styles) { writeln!(out, "{formatted}")?; } @@ -82,7 +83,7 @@ pub(crate) async fn run(args: &LogsArgs, styles: &Styles, globals: &GlobalArgs) match follow_store_logs( run_store.as_ref(), if last_seq == 0 { 1 } else { last_seq + 1 }, - args.pretty, + pretty, styles, is_tty, ) @@ -103,7 +104,7 @@ pub(crate) async fn run(args: &LogsArgs, styles: &Styles, globals: &GlobalArgs) &progress_path, &run.path, lines_seen, - args.pretty, + pretty, styles, is_tty, )?; @@ -117,7 +118,7 @@ pub(crate) async fn run(args: &LogsArgs, styles: &Styles, globals: &GlobalArgs) &progress_path, &run.path, all_lines.len(), - args.pretty, + pretty, styles, is_tty, )?; diff --git a/lib/crates/fabro-cli/src/commands/run/mod.rs b/lib/crates/fabro-cli/src/commands/run/mod.rs index 734249bf2..e2c1783b9 100644 --- a/lib/crates/fabro-cli/src/commands/run/mod.rs +++ b/lib/crates/fabro-cli/src/commands/run/mod.rs @@ -4,6 +4,7 @@ use fabro_util::terminal::Styles; use fabro_workflow::run_lookup::{resolve_run_combined, runs_base}; use crate::args::{GlobalArgs, RunCommands}; +use crate::shared::print_json_pretty; use crate::store; use crate::user_config::{load_user_settings_with_globals, user_layer_with_globals}; @@ -37,7 +38,11 @@ pub(crate) async fn dispatch(cmd: RunCommands, globals: &GlobalArgs) -> Result<( let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); let cli = user_layer_with_globals(globals)?; let (run_id, _run_dir) = create::create_run(&args, cli, styles, true)?; - println!("{run_id}"); + if globals.json { + print_json_pretty(&serde_json::json!({ "run_id": run_id }))?; + } else { + println!("{run_id}"); + } Ok(()) } RunCommands::Start { run } => { @@ -46,7 +51,11 @@ pub(crate) async fn dispatch(cmd: RunCommands, globals: &GlobalArgs) -> Result<( let store = store::build_store(&cli_settings.storage_dir())?; let run_info = resolve_run_combined(store.as_ref(), &base, &run).await?; let child = start::start_run(&run_info.path, false)?; - eprintln!("Started engine process (PID {})", child.id()); + if globals.json { + print_json_pretty(&serde_json::json!({ "run_id": run_info.run_id }))?; + } else { + eprintln!("Started engine process (PID {})", child.id()); + } Ok(()) } RunCommands::Attach { run } => { @@ -55,9 +64,15 @@ pub(crate) async fn dispatch(cmd: RunCommands, globals: &GlobalArgs) -> Result<( let base = runs_base(&cli_settings.storage_dir()); let store = store::build_store(&cli_settings.storage_dir())?; let run_info = resolve_run_combined(store.as_ref(), &base, &run).await?; - let exit_code = - attach::attach_run(&run_info.path, Some(&run_info.run_id), false, styles, None) - .await?; + let exit_code = attach::attach_run( + &run_info.path, + Some(&run_info.run_id), + false, + styles, + None, + globals.json, + ) + .await?; if exit_code != std::process::ExitCode::SUCCESS { std::process::exit(1); } diff --git a/lib/crates/fabro-cli/src/commands/run/preview.rs b/lib/crates/fabro-cli/src/commands/run/preview.rs index b3b1d9bd0..6fe90adea 100644 --- a/lib/crates/fabro-cli/src/commands/run/preview.rs +++ b/lib/crates/fabro-cli/src/commands/run/preview.rs @@ -6,7 +6,7 @@ use fabro_workflow::run_lookup::{resolve_run_combined, runs_base}; use tracing::info; use crate::args::{GlobalArgs, PreviewArgs}; -use crate::shared::validate_daytona_provider; +use crate::shared::{print_json_pretty, validate_daytona_provider}; use crate::store; use crate::user_config::load_user_settings_with_globals; @@ -49,9 +49,13 @@ pub(crate) async fn run(args: PreviewArgs, globals: &GlobalArgs) -> Result<()> { .get_signed_preview_url(args.port, Some(args.ttl)) .await .map_err(|e| anyhow::anyhow!("{e}"))?; - print!("{}", format_signed_output(&signed.url)); + if globals.json { + print_json_pretty(&serde_json::json!({ "url": signed.url }))?; + } else { + print!("{}", format_signed_output(&signed.url)); + } - if args.open { + if args.open && !globals.json { std::process::Command::new("open") .arg(&signed.url) .spawn() @@ -62,7 +66,14 @@ pub(crate) async fn run(args: PreviewArgs, globals: &GlobalArgs) -> Result<()> { .get_preview_link(args.port) .await .map_err(|e| anyhow::anyhow!("{e}"))?; - print!("{}", format_standard_output(&preview.url, &preview.token)); + if globals.json { + print_json_pretty(&serde_json::json!({ + "url": preview.url, + "token": preview.token, + }))?; + } else { + print!("{}", format_standard_output(&preview.url, &preview.token)); + } } Ok(()) diff --git a/lib/crates/fabro-cli/src/commands/run/resume.rs b/lib/crates/fabro-cli/src/commands/run/resume.rs index db85ef22f..5bf104fdc 100644 --- a/lib/crates/fabro-cli/src/commands/run/resume.rs +++ b/lib/crates/fabro-cli/src/commands/run/resume.rs @@ -5,6 +5,7 @@ use fabro_workflow::records::{RunRecord, RunRecordExt}; use fabro_workflow::run_lookup::{resolve_run_combined, runs_base}; use crate::args::{GlobalArgs, ResumeArgs}; +use crate::shared::print_json_pretty; use crate::store; use crate::user_config::load_user_settings_with_globals; @@ -37,11 +38,24 @@ pub(crate) async fn resume_command( let child = super::start::start_run(&run_dir, true)?; if args.detach { - println!("{run_id}"); + if globals.json { + print_json_pretty(&serde_json::json!({ "run_id": run_id }))?; + } else { + println!("{run_id}"); + } } else { - let exit_code = - super::attach::attach_run(&run_dir, Some(&run_id), true, styles, Some(child)).await?; - super::output::print_run_summary(&run_dir, run_id, styles); + let exit_code = super::attach::attach_run( + &run_dir, + Some(&run_id), + true, + styles, + Some(child), + globals.json, + ) + .await?; + if !globals.json { + super::output::print_run_summary(&run_dir, run_id, styles); + } if exit_code != std::process::ExitCode::SUCCESS { std::process::exit(1); } diff --git a/lib/crates/fabro-cli/src/commands/run/rewind.rs b/lib/crates/fabro-cli/src/commands/run/rewind.rs index 604b6af56..ed2257182 100644 --- a/lib/crates/fabro-cli/src/commands/run/rewind.rs +++ b/lib/crates/fabro-cli/src/commands/run/rewind.rs @@ -14,12 +14,21 @@ use fabro_workflow::records::CheckpointExt; use fabro_workflow::run_lookup::{resolve_run_combined, runs_base}; use fabro_workflow::run_status::{self, RunStatus}; use git2::Repository; +use serde::Serialize; use crate::args::{GlobalArgs, RewindArgs}; -use crate::shared::color_if; +use crate::shared::{color_if, print_json_pretty}; use crate::store::{build_store, open_run_reader}; use crate::user_config::load_user_settings_with_globals; +#[derive(Serialize)] +pub(crate) struct TimelineEntryJson { + ordinal: usize, + node_name: String, + visit: usize, + run_commit_sha: Option, +} + pub(crate) async fn run(args: &RewindArgs, styles: &Styles, globals: &GlobalArgs) -> Result<()> { let repo = Repository::discover(".").context("not in a git repository")?; let cli_settings = load_user_settings_with_globals(globals)?; @@ -39,6 +48,10 @@ pub(crate) async fn run(args: &RewindArgs, styles: &Styles, globals: &GlobalArgs let timeline = build_timeline_or_rebuild(&store, run_store.as_deref(), &run_id).await?; if args.list || args.target.is_none() { + if globals.json { + print_json_pretty(&timeline_entries_json(&timeline))?; + return Ok(()); + } print_timeline(&timeline, styles); return Ok(()); } @@ -59,14 +72,34 @@ pub(crate) async fn run(args: &RewindArgs, styles: &Styles, globals: &GlobalArgs let run_id_string = run_id.to_string(); - eprintln!( - "\nTo resume: fabro resume {}", - &run_id_string[..8.min(run_id_string.len())] - ); + if globals.json { + print_json_pretty(&serde_json::json!({ + "run_id": run_id_string, + "target": args.target.as_deref().unwrap(), + }))?; + } else { + eprintln!( + "\nTo resume: fabro resume {}", + &run_id_string[..8.min(run_id_string.len())] + ); + } Ok(()) } +pub(crate) fn timeline_entries_json(timeline: &RunTimeline) -> Vec { + timeline + .entries + .iter() + .map(|entry| TimelineEntryJson { + ordinal: entry.ordinal, + node_name: entry.node_name.clone(), + visit: entry.visit, + run_commit_sha: entry.run_commit_sha.clone(), + }) + .collect() +} + async fn reset_rewound_run_state( git_store: &Store, durable_store: &dyn fabro_store::Store, diff --git a/lib/crates/fabro-cli/src/commands/run/ssh.rs b/lib/crates/fabro-cli/src/commands/run/ssh.rs index d993b8e84..5d27d2a8e 100644 --- a/lib/crates/fabro-cli/src/commands/run/ssh.rs +++ b/lib/crates/fabro-cli/src/commands/run/ssh.rs @@ -6,11 +6,15 @@ use fabro_workflow::run_lookup::{resolve_run_combined, runs_base}; use tracing::info; use crate::args::{GlobalArgs, SshArgs}; -use crate::shared::validate_daytona_provider; +use crate::shared::{print_json_pretty, validate_daytona_provider}; use crate::store; use crate::user_config::load_user_settings_with_globals; pub(crate) async fn run(args: SshArgs, globals: &GlobalArgs) -> Result<()> { + if globals.json && !args.print { + globals.require_no_json()?; + } + let cli_settings = load_user_settings_with_globals(globals)?; let base = runs_base(&cli_settings.storage_dir()); let store = store::build_store(&cli_settings.storage_dir())?; @@ -50,7 +54,11 @@ pub(crate) async fn run(args: SshArgs, globals: &GlobalArgs) -> Result<()> { .map_err(|e| anyhow::anyhow!("{e}"))?; if args.print { - print!("{}", format_output(&ssh_cmd)); + if globals.json { + print_json_pretty(&serde_json::json!({ "command": ssh_cmd }))?; + } else { + print!("{}", format_output(&ssh_cmd)); + } } else { exec_ssh(&ssh_cmd)?; } diff --git a/lib/crates/fabro-cli/src/commands/run/start.rs b/lib/crates/fabro-cli/src/commands/run/start.rs index 0913d8fea..c454b0909 100644 --- a/lib/crates/fabro-cli/src/commands/run/start.rs +++ b/lib/crates/fabro-cli/src/commands/run/start.rs @@ -44,6 +44,7 @@ pub(crate) fn start_run(run_dir: &Path, resume: bool) -> Result Conclusion::load(&conclusion_path).ok(), }; - if args.json { + if globals.json { let json_value = build_json_output(final_status, &run_info.run_id, conclusion.as_ref()); let mut out = std::io::stdout().lock(); serde_json::to_writer_pretty(&mut out, &json_value)?; diff --git a/lib/crates/fabro-cli/src/commands/runs/list.rs b/lib/crates/fabro-cli/src/commands/runs/list.rs index 2af86662f..62ac9922c 100644 --- a/lib/crates/fabro-cli/src/commands/runs/list.rs +++ b/lib/crates/fabro-cli/src/commands/runs/list.rs @@ -42,6 +42,11 @@ pub(crate) async fn list_command( }, ); + if globals.json { + println!("{}", serde_json::to_string_pretty(&filtered)?); + return Ok(()); + } + if args.quiet { for run in &filtered { println!("{}", run.run_id); @@ -49,11 +54,6 @@ pub(crate) async fn list_command( return Ok(()); } - if args.json { - println!("{}", serde_json::to_string_pretty(&filtered)?); - return Ok(()); - } - if filtered.is_empty() { if args.all { eprintln!("No runs found."); diff --git a/lib/crates/fabro-cli/src/commands/runs/rm.rs b/lib/crates/fabro-cli/src/commands/runs/rm.rs index 7269d8fc6..aa1de879d 100644 --- a/lib/crates/fabro-cli/src/commands/runs/rm.rs +++ b/lib/crates/fabro-cli/src/commands/runs/rm.rs @@ -11,6 +11,7 @@ use fabro_workflow::run_lookup::{resolve_run_combined, runs_base}; use fabro_workflow::run_status::{RunStatus, RunStatusRecord, write_run_status}; use crate::args::{GlobalArgs, RunsRemoveArgs}; +use crate::shared::print_json_pretty; use crate::store; use crate::user_config::load_user_settings_with_globals; @@ -20,17 +21,30 @@ pub(crate) async fn remove_command(args: &RunsRemoveArgs, globals: &GlobalArgs) let cli_settings = load_user_settings_with_globals(globals)?; let base = runs_base(&cli_settings.storage_dir()); let store = store::build_store(&cli_settings.storage_dir())?; - remove_from(args, store.as_ref(), &base).await + remove_from(args, store.as_ref(), &base, globals).await } -async fn remove_from(args: &RunsRemoveArgs, store: &dyn Store, base: &Path) -> Result<()> { +async fn remove_from( + args: &RunsRemoveArgs, + store: &dyn Store, + base: &Path, + globals: &GlobalArgs, +) -> Result<()> { let mut had_errors = false; + let mut removed = Vec::new(); + let mut errors = Vec::new(); for identifier in &args.runs { let run = match resolve_run_combined(store, base, identifier).await { Ok(run) => run, Err(err) => { - eprintln!("error: {identifier}: {err}"); + if !globals.json { + eprintln!("error: {identifier}: {err}"); + } + errors.push(serde_json::json!({ + "identifier": identifier, + "error": err.to_string(), + })); had_errors = true; continue; } @@ -38,11 +52,18 @@ async fn remove_from(args: &RunsRemoveArgs, store: &dyn Store, base: &Path) -> R if run.status.is_active() && !args.force { let run_id = run.run_id.to_string(); - eprintln!( + let error = format!( "cannot remove active run {} (status: {}, use -f to force)", short_run_id(&run_id), run.status ); + if !globals.json { + eprintln!("{error}"); + } + errors.push(serde_json::json!({ + "identifier": identifier, + "error": error, + })); had_errors = true; continue; } @@ -77,14 +98,46 @@ async fn remove_from(args: &RunsRemoveArgs, store: &dyn Store, base: &Path) -> R } } - std::fs::remove_dir_all(&run.path) - .with_context(|| format!("failed to delete {}", run.path.display()))?; - store + let run_id = run.run_id.to_string(); + if let Err(err) = std::fs::remove_dir_all(&run.path) + .with_context(|| format!("failed to delete {}", run.path.display())) + { + if !globals.json { + eprintln!("error: {identifier}: {err}"); + } + errors.push(serde_json::json!({ + "identifier": identifier, + "error": err.to_string(), + })); + had_errors = true; + continue; + } + if let Err(err) = store .delete_run(&run.run_id) .await - .with_context(|| format!("failed to delete store state for {}", run.run_id))?; - let run_id = run.run_id.to_string(); - eprintln!("{}", short_run_id(&run_id)); + .with_context(|| format!("failed to delete store state for {}", run.run_id)) + { + if !globals.json { + eprintln!("error: {identifier}: {err}"); + } + errors.push(serde_json::json!({ + "identifier": identifier, + "error": err.to_string(), + })); + had_errors = true; + continue; + } + removed.push(run_id.clone()); + if !globals.json { + eprintln!("{}", short_run_id(&run_id)); + } + } + + if globals.json { + print_json_pretty(&serde_json::json!({ + "removed": removed, + "errors": errors, + }))?; } if had_errors { diff --git a/lib/crates/fabro-cli/src/commands/secret/get.rs b/lib/crates/fabro-cli/src/commands/secret/get.rs index ac8fc41f1..a70accce3 100644 --- a/lib/crates/fabro-cli/src/commands/secret/get.rs +++ b/lib/crates/fabro-cli/src/commands/secret/get.rs @@ -1,13 +1,21 @@ use anyhow::{Result, bail}; -use crate::args::SecretGetArgs; +use crate::args::{GlobalArgs, SecretGetArgs}; +use crate::shared::print_json_pretty; use fabro_config::dotenv; -pub(super) fn get_command(args: &SecretGetArgs) -> Result<()> { +pub(super) fn get_command(args: &SecretGetArgs, globals: &GlobalArgs) -> Result<()> { let path = dotenv::env_file_path()?; match dotenv::get_env_value(&path, &args.key)? { Some(value) => { - println!("{value}"); + if globals.json { + print_json_pretty(&serde_json::json!({ + "key": args.key, + "value": value, + }))?; + } else { + println!("{value}"); + } Ok(()) } None => bail!("secret not found: {}", args.key), diff --git a/lib/crates/fabro-cli/src/commands/secret/list.rs b/lib/crates/fabro-cli/src/commands/secret/list.rs index de3893c4b..df1a7ef2c 100644 --- a/lib/crates/fabro-cli/src/commands/secret/list.rs +++ b/lib/crates/fabro-cli/src/commands/secret/list.rs @@ -1,16 +1,36 @@ use anyhow::{Result, bail}; -use crate::args::SecretListArgs; +use crate::args::{GlobalArgs, SecretListArgs}; +use crate::shared::print_json_pretty; use fabro_config::dotenv; -pub(super) fn list_command(args: &SecretListArgs) -> Result<()> { +pub(super) fn list_command(args: &SecretListArgs, globals: &GlobalArgs) -> Result<()> { let path = dotenv::env_file_path()?; let contents = match std::fs::read_to_string(&path) { Ok(c) => c, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + if globals.json { + print_json_pretty(&Vec::::new())?; + } + return Ok(()); + } Err(e) => bail!("failed to read {}: {e}", path.display()), }; let pairs = dotenv::parse_env(&contents); + if globals.json { + let values = pairs + .into_iter() + .map(|(key, value)| { + if args.show_values { + serde_json::json!({ "key": key, "value": value }) + } else { + serde_json::json!({ "key": key }) + } + }) + .collect::>(); + print_json_pretty(&values)?; + return Ok(()); + } for (key, value) in pairs { if args.show_values { println!("{key}={value}"); diff --git a/lib/crates/fabro-cli/src/commands/secret/mod.rs b/lib/crates/fabro-cli/src/commands/secret/mod.rs index e9e399f75..4c1103e2a 100644 --- a/lib/crates/fabro-cli/src/commands/secret/mod.rs +++ b/lib/crates/fabro-cli/src/commands/secret/mod.rs @@ -5,13 +5,13 @@ mod set; use anyhow::Result; -use crate::args::{SecretCommand, SecretNamespace}; +use crate::args::{GlobalArgs, SecretCommand, SecretNamespace}; -pub(crate) fn dispatch(ns: SecretNamespace) -> Result<()> { +pub(crate) fn dispatch(ns: SecretNamespace, globals: &GlobalArgs) -> Result<()> { match ns.command { - SecretCommand::Get(args) => get::get_command(&args), - SecretCommand::List(args) => list::list_command(&args), - SecretCommand::Rm(args) => rm::rm_command(&args), - SecretCommand::Set(args) => set::set_command(&args), + SecretCommand::Get(args) => get::get_command(&args, globals), + SecretCommand::List(args) => list::list_command(&args, globals), + SecretCommand::Rm(args) => rm::rm_command(&args, globals), + SecretCommand::Set(args) => set::set_command(&args, globals), } } diff --git a/lib/crates/fabro-cli/src/commands/secret/rm.rs b/lib/crates/fabro-cli/src/commands/secret/rm.rs index e7fbfb00d..ebead9acb 100644 --- a/lib/crates/fabro-cli/src/commands/secret/rm.rs +++ b/lib/crates/fabro-cli/src/commands/secret/rm.rs @@ -1,9 +1,10 @@ use anyhow::{Result, bail}; -use crate::args::SecretRmArgs; +use crate::args::{GlobalArgs, SecretRmArgs}; +use crate::shared::print_json_pretty; use fabro_config::dotenv; -pub(super) fn rm_command(args: &SecretRmArgs) -> Result<()> { +pub(super) fn rm_command(args: &SecretRmArgs, globals: &GlobalArgs) -> Result<()> { let path = dotenv::env_file_path()?; let contents = match std::fs::read_to_string(&path) { Ok(c) => c, @@ -16,7 +17,11 @@ pub(super) fn rm_command(args: &SecretRmArgs) -> Result<()> { match updated { Some(new_contents) => { dotenv::write_env_file(&path, &new_contents)?; - eprintln!("Removed {}", args.key); + if globals.json { + print_json_pretty(&serde_json::json!({ "key": args.key }))?; + } else { + eprintln!("Removed {}", args.key); + } Ok(()) } None => bail!("secret not found: {}", args.key), diff --git a/lib/crates/fabro-cli/src/commands/secret/set.rs b/lib/crates/fabro-cli/src/commands/secret/set.rs index 6bf5b1883..30442864f 100644 --- a/lib/crates/fabro-cli/src/commands/secret/set.rs +++ b/lib/crates/fabro-cli/src/commands/secret/set.rs @@ -1,13 +1,18 @@ use anyhow::Result; -use crate::args::SecretSetArgs; +use crate::args::{GlobalArgs, SecretSetArgs}; +use crate::shared::print_json_pretty; use fabro_config::dotenv; -pub(super) fn set_command(args: &SecretSetArgs) -> Result<()> { +pub(super) fn set_command(args: &SecretSetArgs, globals: &GlobalArgs) -> Result<()> { let path = dotenv::env_file_path()?; let existing = std::fs::read_to_string(&path).unwrap_or_default(); let merged = dotenv::merge_env(&existing, &[(&args.key, &args.value)]); dotenv::write_env_file(&path, &merged)?; - eprintln!("Set {}", args.key); + if globals.json { + print_json_pretty(&serde_json::json!({ "key": args.key }))?; + } else { + eprintln!("Set {}", args.key); + } Ok(()) } diff --git a/lib/crates/fabro-cli/src/commands/skill/install.rs b/lib/crates/fabro-cli/src/commands/skill/install.rs index 9780f9f6d..1f0fa818d 100644 --- a/lib/crates/fabro-cli/src/commands/skill/install.rs +++ b/lib/crates/fabro-cli/src/commands/skill/install.rs @@ -3,7 +3,8 @@ use std::path::Path; use anyhow::{Result, bail}; use tracing::{debug, info}; -use crate::args::{SkillDir, SkillInstallArgs, SkillScope}; +use crate::args::{GlobalArgs, SkillDir, SkillInstallArgs, SkillScope}; +use crate::shared::{absolute_or_current, print_json_pretty}; const SKILL_MD: &str = include_str!("../../../../../../skills/fabro-create-workflow/SKILL.md"); const REF_DOT_LANGUAGE: &str = @@ -37,10 +38,14 @@ pub(crate) fn install_skill_to(base_dir: &Path) -> Result<()> { Ok(()) } -pub(super) fn run_skill_install(args: &SkillInstallArgs) -> Result<()> { +pub(super) fn run_skill_install(args: &SkillInstallArgs, globals: &GlobalArgs) -> Result<()> { let base_dir = resolve_base_dir(&args.scope, &args.dir)?; let skill_dir = base_dir.join("fabro-create-workflow"); + if globals.json && skill_dir.exists() && !args.force { + globals.require_no_json()?; + } + if skill_dir.exists() && !args.force { let confirm = dialoguer::Confirm::new() .with_prompt(format!( @@ -55,7 +60,17 @@ pub(super) fn run_skill_install(args: &SkillInstallArgs) -> Result<()> { } } - install_skill_to(&base_dir) + install_skill_to(&base_dir)?; + + if globals.json { + print_json_pretty(&serde_json::json!({ + "skill": "fabro-create-workflow", + "path": absolute_or_current(&skill_dir), + "files": SKILL_FILES.iter().map(|(rel_path, _)| (*rel_path).to_string()).collect::>(), + }))?; + } + + Ok(()) } fn resolve_base_dir(scope: &SkillScope, dir: &SkillDir) -> Result { diff --git a/lib/crates/fabro-cli/src/commands/skill/mod.rs b/lib/crates/fabro-cli/src/commands/skill/mod.rs index b537cc6b1..1b2bdc696 100644 --- a/lib/crates/fabro-cli/src/commands/skill/mod.rs +++ b/lib/crates/fabro-cli/src/commands/skill/mod.rs @@ -2,12 +2,12 @@ mod install; use anyhow::Result; -use crate::args::{SkillCommand, SkillNamespace}; +use crate::args::{GlobalArgs, SkillCommand, SkillNamespace}; pub(crate) use install::install_skill_to; -pub(crate) fn dispatch(ns: SkillNamespace) -> Result<()> { +pub(crate) fn dispatch(ns: SkillNamespace, globals: &GlobalArgs) -> Result<()> { match ns.command { - SkillCommand::Install(args) => install::run_skill_install(&args), + SkillCommand::Install(args) => install::run_skill_install(&args, globals), } } diff --git a/lib/crates/fabro-cli/src/commands/store/dump.rs b/lib/crates/fabro-cli/src/commands/store/dump.rs index 52ffbe9d5..e1dd9b592 100644 --- a/lib/crates/fabro-cli/src/commands/store/dump.rs +++ b/lib/crates/fabro-cli/src/commands/store/dump.rs @@ -10,6 +10,7 @@ use serde::Serialize; use serde::de::DeserializeOwned; use crate::args::{GlobalArgs, StoreDumpArgs}; +use crate::shared::{absolute_or_current, print_json_pretty}; use crate::store; use crate::user_config::load_user_settings_with_globals; @@ -28,11 +29,19 @@ pub(crate) async fn dump_command(args: &StoreDumpArgs, globals: &GlobalArgs) -> })?; let file_count = export_run(run_store.as_ref(), &args.output).await?; - println!( - "Exported {file_count} files for run {} to {}", - run.run_id, - args.output.display() - ); + if globals.json { + print_json_pretty(&serde_json::json!({ + "run_id": run.run_id, + "output_dir": absolute_or_current(&args.output), + "file_count": file_count, + }))?; + } else { + println!( + "Exported {file_count} files for run {} to {}", + run.run_id, + args.output.display() + ); + } Ok(()) } diff --git a/lib/crates/fabro-cli/src/commands/system/df.rs b/lib/crates/fabro-cli/src/commands/system/df.rs index d810beacb..dfc6c3e80 100644 --- a/lib/crates/fabro-cli/src/commands/system/df.rs +++ b/lib/crates/fabro-cli/src/commands/system/df.rs @@ -5,15 +5,46 @@ use chrono::{DateTime, Utc}; use cli_table::format::{Border, Justify, Separator}; use cli_table::{Cell, CellStruct, Style, Table}; use fabro_config::FabroSettingsExt; +use serde::Serialize; use fabro_workflow::run_lookup::{logs_base, runs_base, scan_runs_combined}; use fabro_workflow::run_status::RunStatus; use crate::args::{DfArgs, GlobalArgs}; -use crate::shared::format_size; +use crate::shared::{format_size, print_json_pretty}; use crate::store; use crate::user_config::load_user_settings_with_globals; +#[derive(Serialize)] +struct SummaryRow { + r#type: String, + count: u64, + #[serde(skip_serializing_if = "Option::is_none")] + active: Option, + size_bytes: u64, + #[serde(skip_serializing_if = "Option::is_none")] + reclaimable_bytes: Option, +} + +#[derive(Serialize)] +struct RunSizeRow { + run_id: String, + workflow_name: String, + status: RunStatus, + start_time: String, + size_bytes: u64, + reclaimable: bool, +} + +#[derive(Serialize)] +struct DfOutput { + summary: Vec, + total_size_bytes: u64, + total_reclaimable_bytes: u64, + #[serde(skip_serializing_if = "Option::is_none")] + runs: Option>, +} + pub(super) async fn df_command(args: &DfArgs, globals: &GlobalArgs) -> Result<()> { let cli_settings = load_user_settings_with_globals(globals)?; let data_dir = cli_settings.storage_dir(); @@ -26,6 +57,7 @@ pub(super) async fn df_command(args: &DfArgs, globals: &GlobalArgs) -> Result<() &data_dir, &runs_base_dir, &logs_base_dir, + globals, ) .await } @@ -37,11 +69,13 @@ async fn df_from( data_dir: &Path, runs_base: &Path, logs_base: &Path, + globals: &GlobalArgs, ) -> Result<()> { struct RunSizeInfo { run_id: String, workflow_name: String, status: RunStatus, + start_time: String, start_time_dt: Option>, size: u64, } @@ -65,6 +99,7 @@ async fn df_from( run_id: run.run_id.to_string(), workflow_name: run.workflow_name.clone(), status: run.status, + start_time: run.start_time.clone(), start_time_dt: run.start_time_dt, size, }); @@ -122,6 +157,52 @@ async fn df_from( }; let log_reclaim_pct = if total_log_size > 0 { 100 } else { 0 }; + if globals.json { + let summary = vec![ + SummaryRow { + r#type: "runs".to_string(), + count: runs.len().try_into().unwrap(), + active: Some(active_count), + size_bytes: total_run_size, + reclaimable_bytes: Some(reclaimable_run_size), + }, + SummaryRow { + r#type: "logs".to_string(), + count: log_count, + active: None, + size_bytes: total_log_size, + reclaimable_bytes: Some(total_log_size), + }, + SummaryRow { + r#type: "databases".to_string(), + count: db_count, + active: None, + size_bytes: total_db_size, + reclaimable_bytes: Some(0), + }, + ]; + let runs = args.verbose.then(|| { + run_details + .iter() + .map(|detail| RunSizeRow { + run_id: detail.run_id.clone(), + workflow_name: detail.workflow_name.clone(), + status: detail.status, + start_time: detail.start_time.clone(), + size_bytes: detail.size, + reclaimable: !detail.status.is_active(), + }) + .collect::>() + }); + print_json_pretty(&DfOutput { + summary, + total_size_bytes: total_run_size + total_log_size + total_db_size, + total_reclaimable_bytes: reclaimable_run_size + total_log_size, + runs, + })?; + return Ok(()); + } + let use_color = console::colors_enabled(); let color_choice = if use_color { cli_table::ColorChoice::Auto diff --git a/lib/crates/fabro-cli/src/commands/system/prune.rs b/lib/crates/fabro-cli/src/commands/system/prune.rs index 34dcee37a..d0148fc5f 100644 --- a/lib/crates/fabro-cli/src/commands/system/prune.rs +++ b/lib/crates/fabro-cli/src/commands/system/prune.rs @@ -4,20 +4,29 @@ use anyhow::{Context, Result, bail}; use chrono::Utc; use fabro_config::FabroSettingsExt; use fabro_store::Store; +use serde::Serialize; use tracing::{debug, info}; use fabro_workflow::run_lookup::{StatusFilter, filter_runs, runs_base, scan_runs_combined}; use crate::args::{GlobalArgs, RunsPruneArgs}; -use crate::shared::format_size; +use crate::shared::{format_size, print_json_pretty}; use crate::store; use crate::user_config::load_user_settings_with_globals; +#[derive(Serialize)] +struct PruneRunRow { + run_id: String, + dir_name: String, + workflow_name: String, + size_bytes: u64, +} + pub(super) async fn prune_command(args: &RunsPruneArgs, globals: &GlobalArgs) -> Result<()> { let cli_settings = load_user_settings_with_globals(globals)?; let base = runs_base(&cli_settings.storage_dir()); let store = store::build_store(&cli_settings.storage_dir())?; - prune_from(args, store.as_ref(), &base).await + prune_from(args, store.as_ref(), &base, globals).await } pub(crate) fn parse_duration(s: &str) -> Result { @@ -36,7 +45,12 @@ pub(crate) fn parse_duration(s: &str) -> Result { } } -async fn prune_from(args: &RunsPruneArgs, store: &dyn Store, base: &Path) -> Result<()> { +async fn prune_from( + args: &RunsPruneArgs, + store: &dyn Store, + base: &Path, + globals: &GlobalArgs, +) -> Result<()> { let runs = scan_runs_combined(store, base).await?; let label_filters = parse_label_filters(&args.filter.label); let mut filtered = filter_runs( @@ -70,11 +84,37 @@ async fn prune_from(args: &RunsPruneArgs, store: &dyn Store, base: &Path) -> Res filtered.retain(|run| !run.status.is_active()); if filtered.is_empty() { - eprintln!("No matching runs to prune."); + if globals.json { + if args.yes { + print_json_pretty(&serde_json::json!({ + "dry_run": false, + "deleted_count": 0, + "freed_bytes": 0, + }))?; + } else { + print_json_pretty(&serde_json::json!({ + "dry_run": true, + "runs": Vec::::new(), + "total_count": 0, + "total_size_bytes": 0, + }))?; + } + } else { + eprintln!("No matching runs to prune."); + } return Ok(()); } - let total_bytes: u64 = filtered.iter().map(|run| dir_size(&run.path)).sum(); + let rows: Vec = filtered + .iter() + .map(|run| PruneRunRow { + run_id: run.run_id.to_string(), + dir_name: run.dir_name.clone(), + workflow_name: run.workflow_name.clone(), + size_bytes: dir_size(&run.path), + }) + .collect(); + let total_bytes: u64 = rows.iter().map(|row| row.size_bytes).sum(); info!(count = filtered.len(), bytes = total_bytes, "pruning runs"); if args.yes { @@ -86,11 +126,29 @@ async fn prune_from(args: &RunsPruneArgs, store: &dyn Store, base: &Path) -> Res .await .with_context(|| format!("failed to delete store state for {}", run.run_id))?; } - eprintln!( - "{} run(s) deleted ({} freed).", - filtered.len(), - format_size(total_bytes) - ); + if globals.json { + print_json_pretty(&serde_json::json!({ + "dry_run": false, + "deleted_count": filtered.len(), + "freed_bytes": total_bytes, + }))?; + } else { + eprintln!( + "{} run(s) deleted ({} freed).", + filtered.len(), + format_size(total_bytes) + ); + } + return Ok(()); + } + + if globals.json { + print_json_pretty(&serde_json::json!({ + "dry_run": true, + "runs": rows, + "total_count": filtered.len(), + "total_size_bytes": total_bytes, + }))?; return Ok(()); } diff --git a/lib/crates/fabro-cli/src/commands/upgrade.rs b/lib/crates/fabro-cli/src/commands/upgrade.rs index 7e054ad51..116ea81e5 100644 --- a/lib/crates/fabro-cli/src/commands/upgrade.rs +++ b/lib/crates/fabro-cli/src/commands/upgrade.rs @@ -10,7 +10,8 @@ use tracing::debug; use tokio::process::Command as TokioCommand; use tokio::task::JoinHandle; -use crate::args::UpgradeArgs; +use crate::args::{GlobalArgs, UpgradeArgs}; +use crate::shared::print_json_pretty; // ── Download backend abstraction ─────────────────────────────────────────── @@ -223,7 +224,7 @@ impl UpgradeCheckState { // ── Main upgrade command ─────────────────────────────────────────────────── -pub(crate) async fn run_upgrade(args: UpgradeArgs) -> Result<()> { +pub(crate) async fn run_upgrade(args: UpgradeArgs, globals: &GlobalArgs) -> Result<()> { let backend = select_backend().await; let current = @@ -263,16 +264,31 @@ pub(crate) async fn run_upgrade(args: UpgradeArgs) -> Result<()> { } } std::cmp::Ordering::Equal if !args.force => { - eprintln!("Already on version {current}"); + if globals.json { + print_json_pretty(&serde_json::json!({ + "previous_version": current.to_string(), + "installed_version": current.to_string(), + }))?; + } else { + eprintln!("Already on version {current}"); + } return Ok(()); } _ => {} } if args.dry_run { - eprintln!("Would upgrade fabro from {current} to {target}"); - eprintln!(" tag: {tag}"); - eprintln!(" target: {}", detect_target()?); + if globals.json { + print_json_pretty(&serde_json::json!({ + "previous_version": current.to_string(), + "installed_version": target.to_string(), + "dry_run": true, + }))?; + } else { + eprintln!("Would upgrade fabro from {current} to {target}"); + eprintln!(" tag: {tag}"); + eprintln!(" target: {}", detect_target()?); + } return Ok(()); } @@ -337,7 +353,14 @@ pub(crate) async fn run_upgrade(args: UpgradeArgs) -> Result<()> { let _ = fs::set_permissions(¤t_exe, fs::Permissions::from_mode(0o755)); } - eprintln!("Upgraded fabro to {target}"); + if globals.json { + print_json_pretty(&serde_json::json!({ + "previous_version": current.to_string(), + "installed_version": target.to_string(), + }))?; + } else { + eprintln!("Upgraded fabro to {target}"); + } Ok(()) } diff --git a/lib/crates/fabro-cli/src/commands/validate.rs b/lib/crates/fabro-cli/src/commands/validate.rs index 913594f91..08aa26211 100644 --- a/lib/crates/fabro-cli/src/commands/validate.rs +++ b/lib/crates/fabro-cli/src/commands/validate.rs @@ -5,10 +5,14 @@ use fabro_util::terminal::Styles; use fabro_validate::Severity; use fabro_workflow::operations::{ValidateInput, WorkflowInput, validate}; -use crate::args::ValidateArgs; -use crate::shared::{print_diagnostics, relative_path}; +use crate::args::{GlobalArgs, ValidateArgs}; +use crate::shared::{print_diagnostics, print_json_pretty, relative_path}; -pub(crate) fn run(args: &ValidateArgs, styles: &Styles) -> anyhow::Result<()> { +pub(crate) fn run( + args: &ValidateArgs, + styles: &Styles, + globals: &GlobalArgs, +) -> anyhow::Result<()> { let cwd = std::env::current_dir()?; let settings = ConfigLayer::for_workflow(&args.workflow, &cwd)? .combine(ConfigLayer::user()?) @@ -23,6 +27,21 @@ pub(crate) fn run(args: &ValidateArgs, styles: &Styles) -> anyhow::Result<()> { let graph = validated.graph(); let diagnostics = validated.diagnostics(); + if globals.json { + print_json_pretty(&serde_json::json!({ + "workflow_name": graph.name, + "nodes": graph.nodes.len(), + "edges": graph.edges.len(), + "valid": !diagnostics.iter().any(|d| d.severity == Severity::Error), + "diagnostics": diagnostics, + }))?; + + if diagnostics.iter().any(|d| d.severity == Severity::Error) { + bail!("Validation failed"); + } + return Ok(()); + } + eprintln!( "{} ({} nodes, {} edges)", styles.bold.apply_to(format!("Workflow: {}", graph.name)), diff --git a/lib/crates/fabro-cli/src/commands/workflow/create.rs b/lib/crates/fabro-cli/src/commands/workflow/create.rs index 99ecd8a4c..91ca7eef6 100644 --- a/lib/crates/fabro-cli/src/commands/workflow/create.rs +++ b/lib/crates/fabro-cli/src/commands/workflow/create.rs @@ -4,10 +4,10 @@ use anyhow::{Context, Result, bail}; use fabro_config::project::{discover_project_config, resolve_fabro_root}; -use crate::args::WorkflowCreateArgs; -use crate::shared::relative_path; +use crate::args::{GlobalArgs, WorkflowCreateArgs}; +use crate::shared::{print_json_pretty, relative_path}; -pub(super) fn create_command(args: &WorkflowCreateArgs) -> Result<()> { +pub(super) fn create_command(args: &WorkflowCreateArgs, globals: &GlobalArgs) -> Result<()> { let cwd = std::env::current_dir()?; let Some((config_path, config)) = discover_project_config(&cwd)? else { @@ -18,7 +18,15 @@ pub(super) fn create_command(args: &WorkflowCreateArgs) -> Result<()> { }; let fabro_root = resolve_fabro_root(&config_path, &config); - write_workflow_scaffold(args, &fabro_root)?; + let created = write_workflow_scaffold(args, &fabro_root)?; + + if globals.json { + print_json_pretty(&serde_json::json!({ + "name": args.name, + "created": created, + }))?; + return Ok(()); + } let workflows_dir = fabro_root.join("workflows").join(&args.name); let green = console::Style::new().green(); @@ -55,7 +63,7 @@ pub(super) fn create_command(args: &WorkflowCreateArgs) -> Result<()> { Ok(()) } -fn write_workflow_scaffold(args: &WorkflowCreateArgs, fabro_root: &Path) -> Result<()> { +fn write_workflow_scaffold(args: &WorkflowCreateArgs, fabro_root: &Path) -> Result> { let workflows_dir = fabro_root.join("workflows").join(&args.name); if workflows_dir.exists() { @@ -95,7 +103,10 @@ fn write_workflow_scaffold(args: &WorkflowCreateArgs, fabro_root: &Path) -> Resu std::fs::write(&toml_path, "version = 1\n") .with_context(|| format!("failed to write {}", toml_path.display()))?; - Ok(()) + Ok(vec![ + format!("fabro/workflows/{}/workflow.fabro", args.name), + format!("fabro/workflows/{}/workflow.toml", args.name), + ]) } fn to_pascal_case(s: &str) -> String { diff --git a/lib/crates/fabro-cli/src/commands/workflow/list.rs b/lib/crates/fabro-cli/src/commands/workflow/list.rs index 9d40faaf0..afe8eefc4 100644 --- a/lib/crates/fabro-cli/src/commands/workflow/list.rs +++ b/lib/crates/fabro-cli/src/commands/workflow/list.rs @@ -6,12 +6,12 @@ use fabro_config::project::{ resolve_fabro_root, }; -use crate::args::WorkflowListArgs; -use crate::shared::relative_path; +use crate::args::{GlobalArgs, WorkflowListArgs}; +use crate::shared::{print_json_pretty, relative_path}; const GOAL_MAX_LEN: usize = 60; -pub(super) fn list_command(_args: &WorkflowListArgs) -> Result<()> { +pub(super) fn list_command(_args: &WorkflowListArgs, globals: &GlobalArgs) -> Result<()> { let styles = Styles::detect_stderr(); let cwd = std::env::current_dir()?; @@ -28,6 +28,11 @@ pub(super) fn list_command(_args: &WorkflowListArgs) -> Result<()> { let workflows = list_workflows_detailed(Some(&project_wf_dir), user_wf_dir.as_deref()); + if globals.json { + print_json_pretty(&workflows)?; + return Ok(()); + } + let project: Vec<_> = workflows .iter() .filter(|w| w.source == WorkflowSource::Project) diff --git a/lib/crates/fabro-cli/src/commands/workflow/mod.rs b/lib/crates/fabro-cli/src/commands/workflow/mod.rs index 56ff98f38..07598d263 100644 --- a/lib/crates/fabro-cli/src/commands/workflow/mod.rs +++ b/lib/crates/fabro-cli/src/commands/workflow/mod.rs @@ -3,11 +3,11 @@ mod list; use anyhow::Result; -use crate::args::{WorkflowCommand, WorkflowNamespace}; +use crate::args::{GlobalArgs, WorkflowCommand, WorkflowNamespace}; -pub(crate) fn dispatch(ns: WorkflowNamespace) -> Result<()> { +pub(crate) fn dispatch(ns: WorkflowNamespace, globals: &GlobalArgs) -> Result<()> { match ns.command { - WorkflowCommand::List(args) => list::list_command(&args), - WorkflowCommand::Create(args) => create::create_command(&args), + WorkflowCommand::List(args) => list::list_command(&args, globals), + WorkflowCommand::Create(args) => create::create_command(&args, globals), } } diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index d1ad7e77a..a539e6be1 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -173,14 +173,14 @@ async fn main_inner() -> (String, Result<()>) { Commands::Preflight(args) => commands::preflight::execute(args, &globals).await?, Commands::Validate(args) => { let styles = Styles::detect_stderr(); - commands::validate::run(&args, &styles)?; + commands::validate::run(&args, &styles, &globals)?; } Commands::Graph(args) => { let styles = Styles::detect_stderr(); - commands::graph::run(&args, &styles)?; + commands::graph::run(&args, &styles, &globals)?; } Commands::Parse(args) => { - commands::parse::run(&args)?; + commands::parse::run(&args, &globals)?; } Commands::Asset(ns) => commands::asset::dispatch(ns, &globals)?, Commands::Store(ns) => commands::store::dispatch(ns, &globals).await?, @@ -196,31 +196,44 @@ async fn main_inner() -> (String, Result<()>) { Commands::Doctor { verbose, dry_run } => { let cli_settings = user_config::load_user_settings()?; let verbose = verbose || cli_settings.verbose_enabled(); - let exit_code = commands::doctor::run_doctor(verbose, !dry_run).await; + let exit_code = commands::doctor::run_doctor(verbose, !dry_run, &globals).await?; std::process::exit(exit_code); } Commands::Discord => { - open::that("https://fabro.sh/discord")?; + if globals.json { + crate::shared::print_json_pretty(&serde_json::json!({ + "url": "https://fabro.sh/discord", + }))?; + } else { + open::that("https://fabro.sh/discord")?; + } } Commands::Docs => { - open::that("https://docs.fabro.sh/")?; + if globals.json { + crate::shared::print_json_pretty(&serde_json::json!({ + "url": "https://docs.fabro.sh/", + }))?; + } else { + open::that("https://docs.fabro.sh/")?; + } } - Commands::Repo(ns) => commands::repo::dispatch(ns).await?, + Commands::Repo(ns) => commands::repo::dispatch(ns, &globals).await?, Commands::Install { web_url } => { - commands::install::run_install(&web_url).await?; + commands::install::run_install(&web_url, &globals).await?; } Commands::Pr(ns) => commands::pr::dispatch(ns, &globals).await?, - Commands::Secret(ns) => commands::secret::dispatch(ns)?, + Commands::Secret(ns) => commands::secret::dispatch(ns, &globals)?, Commands::Settings(args) => commands::config::execute(&args, &globals)?, - Commands::Workflow(ns) => commands::workflow::dispatch(ns)?, - Commands::Skill(ns) => commands::skill::dispatch(ns)?, + Commands::Workflow(ns) => commands::workflow::dispatch(ns, &globals)?, + Commands::Skill(ns) => commands::skill::dispatch(ns, &globals)?, Commands::Upgrade(args) => { - commands::upgrade::run_upgrade(args).await?; + commands::upgrade::run_upgrade(args, &globals).await?; } - Commands::Provider(ns) => commands::provider::dispatch(ns).await?, + Commands::Provider(ns) => commands::provider::dispatch(ns, &globals).await?, Commands::Sandbox { command } => commands::sandbox::dispatch(command, &globals).await?, Commands::System(ns) => commands::system::dispatch(ns, &globals).await?, Commands::Completion(args) => { + globals.require_no_json()?; let mut cmd = Cli::command(); let shell = args.shell; let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { diff --git a/lib/crates/fabro-cli/src/shared/utilities.rs b/lib/crates/fabro-cli/src/shared/utilities.rs index 9c128401d..029f95b5f 100644 --- a/lib/crates/fabro-cli/src/shared/utilities.rs +++ b/lib/crates/fabro-cli/src/shared/utilities.rs @@ -1,16 +1,29 @@ use std::path::Path; use std::time::Duration; +use std::{io::Write, path::PathBuf}; use anyhow::{Result, bail}; use cli_table::Color; use fabro_util::terminal::Styles; use fabro_validate::{Diagnostic, Severity}; +use serde::Serialize; pub(crate) fn read_workflow_file(path: &Path) -> anyhow::Result { std::fs::read_to_string(path) .map_err(|e| anyhow::anyhow!("Failed to read {}: {e}", path.display())) } +pub(crate) fn print_json_pretty(value: &T) -> anyhow::Result<()> +where + T: Serialize + ?Sized, +{ + let stdout = std::io::stdout(); + let mut handle = stdout.lock(); + serde_json::to_writer_pretty(&mut handle, value)?; + writeln!(handle)?; + Ok(()) +} + pub(crate) fn print_diagnostics(diagnostics: &[Diagnostic], styles: &Styles) { for d in diagnostics { let location = match (&d.node_id, &d.edge) { @@ -69,6 +82,16 @@ pub(crate) fn tilde_path(path: &Path) -> String { path.display().to_string() } +pub(crate) fn absolute_or_current(path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else if let Ok(cwd) = std::env::current_dir() { + cwd.join(path) + } else { + path.to_path_buf() + } +} + pub(crate) fn color_if(use_color: bool, color: Color) -> Option { if use_color { Some(color) } else { None } } diff --git a/lib/crates/fabro-cli/tests/it/cmd/asset.rs b/lib/crates/fabro-cli/tests/it/cmd/asset.rs index a9ee18b9f..2adb0236d 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/asset.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/asset.rs @@ -19,6 +19,7 @@ fn help() { help Print this message or the help of the given subcommand(s) Options: + --json Output as JSON [env: FABRO_JSON=] --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] --quiet Suppress non-essential output [env: FABRO_QUIET=] diff --git a/lib/crates/fabro-cli/tests/it/cmd/asset_cp.rs b/lib/crates/fabro-cli/tests/it/cmd/asset_cp.rs index ea21e5fd8..27f62b9dd 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/asset_cp.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/asset_cp.rs @@ -20,12 +20,13 @@ fn help() { [DEST] Destination directory (defaults to current directory) [default: .] Options: - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --json Output as JSON [env: FABRO_JSON=] --node Filter to assets from a specific node - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] --retry Filter to assets from a specific retry attempt - --quiet Suppress non-essential output [env: FABRO_QUIET=] + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] --tree Preserve {node_slug}/retry_{N}/ directory structure + --quiet Suppress non-essential output [env: FABRO_QUIET=] --verbose Enable verbose output [env: FABRO_VERBOSE=] --storage-dir Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] -h, --help Print help diff --git a/lib/crates/fabro-cli/tests/it/cmd/asset_list.rs b/lib/crates/fabro-cli/tests/it/cmd/asset_list.rs index 376ada4b5..32d21d600 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/asset_list.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/asset_list.rs @@ -19,11 +19,11 @@ fn help() { Run ID (or prefix) Options: - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --json Output as JSON [env: FABRO_JSON=] --node Filter to assets from a specific node - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] --retry Filter to assets from a specific retry attempt - --json Output as JSON + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] --quiet Suppress non-essential output [env: FABRO_QUIET=] --verbose Enable verbose output [env: FABRO_VERBOSE=] --storage-dir Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] diff --git a/lib/crates/fabro-cli/tests/it/cmd/attach.rs b/lib/crates/fabro-cli/tests/it/cmd/attach.rs index a2172cac8..f349d1cba 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/attach.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/attach.rs @@ -21,6 +21,7 @@ fn help() { Run ID prefix or workflow name Options: + --json Output as JSON [env: FABRO_JSON=] --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] --quiet Suppress non-essential output [env: FABRO_QUIET=] diff --git a/lib/crates/fabro-cli/tests/it/cmd/completion.rs b/lib/crates/fabro-cli/tests/it/cmd/completion.rs index adeb15708..c3c819aed 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/completion.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/completion.rs @@ -17,6 +17,7 @@ fn help() { Shell to generate completions for [possible values: bash, elvish, fish, powershell, zsh] Options: + --json Output as JSON [env: FABRO_JSON=] --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] --quiet Suppress non-essential output [env: FABRO_QUIET=] diff --git a/lib/crates/fabro-cli/tests/it/cmd/config.rs b/lib/crates/fabro-cli/tests/it/cmd/config.rs index 469bed21f..c9dc6d4a7 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/config.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/config.rs @@ -24,6 +24,7 @@ fn help() { [WORKFLOW] Optional workflow name, .fabro path, or .toml run config to overlay Options: + --json Output as JSON [env: FABRO_JSON=] --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] --quiet Suppress non-essential output [env: FABRO_QUIET=] diff --git a/lib/crates/fabro-cli/tests/it/cmd/create.rs b/lib/crates/fabro-cli/tests/it/cmd/create.rs index 0016a23d2..b073c56db 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/create.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/create.rs @@ -24,16 +24,17 @@ fn help() { Path to a .fabro workflow file or .toml task config Options: - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] --dry-run Execute with simulated LLM backend + --json Output as JSON [env: FABRO_JSON=] --auto-approve Auto-approve all human gates - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] --goal Override the workflow goal (exposed as $goal in prompts) - --quiet Suppress non-essential output [env: FABRO_QUIET=] + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] --goal-file Read the workflow goal from a file + --quiet Suppress non-essential output [env: FABRO_QUIET=] --model Override default LLM model - --storage-dir Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] --provider Override default LLM provider + --storage-dir Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] -v, --verbose Enable verbose output --sandbox Sandbox for agent tools [possible values: local, docker, daytona] --label Attach a label to this run (repeatable, format: KEY=VALUE) diff --git a/lib/crates/fabro-cli/tests/it/cmd/detached.rs b/lib/crates/fabro-cli/tests/it/cmd/detached.rs index 0b622823b..a71bf51b2 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/detached.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/detached.rs @@ -16,12 +16,13 @@ fn help() { Usage: fabro __detached [OPTIONS] --run-dir --launcher-path Options: - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --json Output as JSON [env: FABRO_JSON=] --run-dir Run directory + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] --launcher-path Launcher metadata path --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --quiet Suppress non-essential output [env: FABRO_QUIET=] --resume Resume from checkpoint instead of fresh start + --quiet Suppress non-essential output [env: FABRO_QUIET=] --verbose Enable verbose output [env: FABRO_VERBOSE=] --storage-dir Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] -h, --help Print help diff --git a/lib/crates/fabro-cli/tests/it/cmd/diff.rs b/lib/crates/fabro-cli/tests/it/cmd/diff.rs index 5c45c7836..9a59fa499 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/diff.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/diff.rs @@ -19,12 +19,13 @@ fn help() { Run ID or prefix Options: - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --json Output as JSON [env: FABRO_JSON=] --node Show diff for a specific node - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] --stat Show diffstat instead of full patch (live diffs only) - --quiet Suppress non-essential output [env: FABRO_QUIET=] + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] --shortstat Show only files-changed/insertions/deletions summary (live diffs only) + --quiet Suppress non-essential output [env: FABRO_QUIET=] --verbose Enable verbose output [env: FABRO_VERBOSE=] --storage-dir Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] -h, --help Print help diff --git a/lib/crates/fabro-cli/tests/it/cmd/discord.rs b/lib/crates/fabro-cli/tests/it/cmd/discord.rs index 1cc3d6819..4bb3195dd 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/discord.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/discord.rs @@ -14,6 +14,7 @@ fn help() { Usage: fabro discord [OPTIONS] Options: + --json Output as JSON [env: FABRO_JSON=] --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] --quiet Suppress non-essential output [env: FABRO_QUIET=] diff --git a/lib/crates/fabro-cli/tests/it/cmd/docs.rs b/lib/crates/fabro-cli/tests/it/cmd/docs.rs index d528cfcd2..d1af75144 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/docs.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/docs.rs @@ -14,6 +14,7 @@ fn help() { Usage: fabro docs [OPTIONS] Options: + --json Output as JSON [env: FABRO_JSON=] --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] --quiet Suppress non-essential output [env: FABRO_QUIET=] diff --git a/lib/crates/fabro-cli/tests/it/cmd/doctor.rs b/lib/crates/fabro-cli/tests/it/cmd/doctor.rs index 6e694850c..90880b824 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/doctor.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/doctor.rs @@ -15,8 +15,9 @@ fn help() { Usage: fabro doctor [OPTIONS] Options: - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --json Output as JSON [env: FABRO_JSON=] -v, --verbose Show detailed information for each check + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] --dry-run Skip live service probes (LLM, sandbox, API, web, Brave Search) --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] --quiet Suppress non-essential output [env: FABRO_QUIET=] diff --git a/lib/crates/fabro-cli/tests/it/cmd/exec.rs b/lib/crates/fabro-cli/tests/it/cmd/exec.rs index 1505968ee..8d8166ccd 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/exec.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/exec.rs @@ -17,12 +17,13 @@ fn help() { Task prompt Options: + --json Output as JSON [env: FABRO_JSON=] --provider LLM provider (anthropic, openai, gemini, kimi, zai, minimax, inception) --model Model name (defaults per provider) --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] --permissions Permission level for tool execution [possible values: read-only, read-write, full] - --quiet Suppress non-essential output [env: FABRO_QUIET=] --auto-approve Skip interactive prompts; deny tools outside permission level + --quiet Suppress non-essential output [env: FABRO_QUIET=] --debug Print LLM request/response debug info to stderr --storage-dir Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] --verbose Print full LLM request/response JSON to stderr diff --git a/lib/crates/fabro-cli/tests/it/cmd/fabro.rs b/lib/crates/fabro-cli/tests/it/cmd/fabro.rs index 82d873394..3077a8bb9 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/fabro.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/fabro.rs @@ -46,6 +46,7 @@ fn help() { help Print this message or the help of the given subcommand(s) Options: + --json Output as JSON [env: FABRO_JSON=] --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] --quiet Suppress non-essential output [env: FABRO_QUIET=] diff --git a/lib/crates/fabro-cli/tests/it/cmd/fork.rs b/lib/crates/fabro-cli/tests/it/cmd/fork.rs index bb4f5cfa3..4947aab15 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/fork.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/fork.rs @@ -25,8 +25,9 @@ fn help() { [TARGET] Target checkpoint: node name, node@visit, or @ordinal (omit to fork from latest) Options: - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --json Output as JSON [env: FABRO_JSON=] --list Show the checkpoint timeline instead of forking + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] --no-push Skip pushing new branches to the remote --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] --quiet Suppress non-essential output [env: FABRO_QUIET=] diff --git a/lib/crates/fabro-cli/tests/it/cmd/graph.rs b/lib/crates/fabro-cli/tests/it/cmd/graph.rs index 569454c26..af4fa665c 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/graph.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/graph.rs @@ -20,21 +20,21 @@ fn help() { Path to the .fabro workflow file, .toml task config, or project workflow name Options: - --debug - Enable DEBUG-level logging (default is INFO) - - [env: FABRO_DEBUG=] - --format Output format [default: svg] [possible values: svg, png] - --no-upgrade-check - Disable automatic upgrade check + --json + Output as JSON - [env: FABRO_NO_UPGRADE_CHECK=true] + [env: FABRO_JSON=] + + --debug + Enable DEBUG-level logging (default is INFO) + + [env: FABRO_DEBUG=] -o, --output Output file path (defaults to stdout) @@ -46,6 +46,11 @@ fn help() { - lr: Left to right - tb: Top to bottom + --no-upgrade-check + Disable automatic upgrade check + + [env: FABRO_NO_UPGRADE_CHECK=true] + --quiet Suppress non-essential output diff --git a/lib/crates/fabro-cli/tests/it/cmd/inspect.rs b/lib/crates/fabro-cli/tests/it/cmd/inspect.rs index a4932b01d..1c3c86fa8 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/inspect.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/inspect.rs @@ -24,6 +24,7 @@ fn help() { Run ID prefix or workflow name (most recent run) Options: + --json Output as JSON [env: FABRO_JSON=] --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] --quiet Suppress non-essential output [env: FABRO_QUIET=] diff --git a/lib/crates/fabro-cli/tests/it/cmd/install.rs b/lib/crates/fabro-cli/tests/it/cmd/install.rs index 04988166a..e0699f83e 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/install.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/install.rs @@ -14,8 +14,9 @@ fn help() { Usage: fabro install [OPTIONS] Options: - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --json Output as JSON [env: FABRO_JSON=] --web-url Base URL for the web UI (used for OAuth callback URLs) [default: http://localhost:5173] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] --quiet Suppress non-essential output [env: FABRO_QUIET=] --verbose Enable verbose output [env: FABRO_VERBOSE=] @@ -24,3 +25,17 @@ fn help() { ----- stderr ----- "); } + +#[test] +fn install_rejects_json() { + let context = test_context!(); + let output = context + .command() + .args(["--json", "install"]) + .output() + .expect("command should run"); + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("--json is not supported for this command")); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/json_global.rs b/lib/crates/fabro-cli/tests/it/cmd/json_global.rs new file mode 100644 index 000000000..e3516c2a4 --- /dev/null +++ b/lib/crates/fabro-cli/tests/it/cmd/json_global.rs @@ -0,0 +1,153 @@ +use std::process::Command; + +use fabro_test::test_context; +use serde_json::Value; + +use super::support::{fixture, output_stderr, output_stdout, setup_completed_dry_run}; + +fn dot_is_available() -> bool { + Command::new("dot") + .arg("-V") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok() +} + +#[test] +fn completion_rejects_json() { + let context = test_context!(); + let output = context + .command() + .args(["--json", "completion", "zsh"]) + .output() + .expect("command should run"); + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("--json is not supported for this command")); +} + +#[test] +fn settings_json_outputs_parseable_json() { + let context = test_context!(); + let output = context + .settings() + .arg("--json") + .output() + .expect("command should run"); + + assert!(output.status.success()); + let value: Value = + serde_json::from_slice(&output.stdout).expect("settings --json should parse"); + assert!(value.is_object()); +} + +#[test] +fn ps_supports_global_flag_and_env_var() { + let context = test_context!(); + setup_completed_dry_run(&context); + + let global_output = context + .command() + .args(["--json", "ps", "-a"]) + .output() + .expect("command should run"); + assert!(global_output.status.success()); + let global_runs: Value = + serde_json::from_slice(&global_output.stdout).expect("global --json should parse"); + assert!(global_runs.as_array().is_some_and(|runs| !runs.is_empty())); + + let env_output = context + .command() + .env("FABRO_JSON", "1") + .args(["ps", "-a"]) + .output() + .expect("command should run"); + assert!(env_output.status.success()); + let env_runs: Value = + serde_json::from_slice(&env_output.stdout).expect("FABRO_JSON output should parse"); + assert_eq!(global_runs, env_runs); +} + +#[test] +fn logs_json_wins_over_pretty() { + let context = test_context!(); + let run = setup_completed_dry_run(&context); + + let output = context + .command() + .args(["--json", "logs", "--pretty", &run.run_id]) + .output() + .expect("command should run"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + let first_line = stdout.lines().find(|line| !line.is_empty()).unwrap(); + let value: Value = serde_json::from_str(first_line).expect("logs output should remain JSONL"); + assert!(value.get("event").is_some()); +} + +#[test] +fn graph_json_without_output_is_rejected() { + let context = test_context!(); + let workflow = fixture("simple.fabro"); + + let output = context + .command() + .args(["--json", "graph", workflow.to_str().unwrap()]) + .output() + .expect("command should run"); + + assert!(!output.status.success()); + let stderr = output_stderr(&output); + assert!(stderr.contains("--json is not supported for this command")); +} + +#[test] +fn graph_json_with_output_reports_file() { + if !dot_is_available() { + return; + } + + let context = test_context!(); + let output_path = context.temp_dir.join("graph.svg"); + let workflow = fixture("simple.fabro"); + + let output = context + .command() + .args([ + "--json", + "graph", + workflow.to_str().unwrap(), + "--output", + output_path.to_str().unwrap(), + ]) + .output() + .expect("command should run"); + + assert!( + output.status.success(), + "stdout:\n{}\nstderr:\n{}", + output_stdout(&output), + output_stderr(&output) + ); + let value: Value = serde_json::from_slice(&output.stdout).expect("graph JSON should parse"); + assert_eq!(value["format"], "svg"); + assert_eq!(value["path"], output_path.to_string_lossy().to_string()); + assert!(output_path.exists()); +} + +#[test] +fn secret_list_json_missing_env_is_empty_array() { + let context = test_context!(); + let output = context + .command() + .args(["--json", "secret", "list"]) + .output() + .expect("command should run"); + + assert!(output.status.success()); + let value: Value = serde_json::from_slice(&output.stdout).expect("secret list should parse"); + assert_eq!(value, Value::Array(vec![])); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/llm_prompt.rs b/lib/crates/fabro-cli/tests/it/cmd/llm_prompt.rs index c1eb16169..f5b3fa977 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/llm_prompt.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/llm_prompt.rs @@ -17,17 +17,18 @@ fn help() { [PROMPT] The prompt text (also accepts stdin) Options: - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --json Output as JSON [env: FABRO_JSON=] -m, --model Model to use - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] -s, --system System prompt --no-stream Do not stream output + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] --quiet Suppress non-essential output [env: FABRO_QUIET=] -u, --usage Show token usage - --verbose Enable verbose output [env: FABRO_VERBOSE=] -S, --schema JSON schema for structured output (inline JSON string) - --storage-dir Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] + --verbose Enable verbose output [env: FABRO_VERBOSE=] -o, --option