refactor(fabro-cli): slim down main

This commit is contained in:
Bryan Helmkamp 2026-03-27 09:57:26 -04:00
parent 4dcd7f49da
commit cece073052
66 changed files with 2766 additions and 2486 deletions

View file

@ -0,0 +1,944 @@
use std::fmt;
use std::path::PathBuf;
use clap::{Args, Subcommand, ValueEnum};
#[cfg(feature = "server")]
use crate::cli_config;
pub(crate) const LONG_VERSION: &str = concat!(
env!("CARGO_PKG_VERSION"),
" (",
env!("FABRO_GIT_SHA"),
" ",
env!("FABRO_BUILD_DATE"),
")"
);
#[derive(Args)]
pub(crate) struct GlobalArgs {
/// Enable DEBUG-level logging (default is INFO)
#[arg(long, global = true)]
pub debug: bool,
/// Disable automatic upgrade check
#[arg(long, global = true)]
pub no_upgrade_check: bool,
/// Execution mode: standalone (in-process) or server (delegate to API)
#[cfg(feature = "server")]
#[arg(long, global = true, value_parser = parse_execution_mode)]
pub mode: Option<cli_config::ExecutionMode>,
/// Server URL (overrides server.base_url from cli.toml)
#[cfg(feature = "server")]
#[arg(long, global = true)]
pub server_url: Option<String>,
}
#[cfg(feature = "server")]
pub(crate) fn parse_execution_mode(s: &str) -> Result<cli_config::ExecutionMode, String> {
match s {
"standalone" => Ok(cli_config::ExecutionMode::Standalone),
"server" => Ok(cli_config::ExecutionMode::Server),
_ => Err(format!(
"invalid mode '{s}', expected 'standalone' or 'server'"
)),
}
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub(crate) enum CliSandboxProvider {
Local,
Docker,
Daytona,
#[cfg(feature = "exedev")]
Exe,
Ssh,
}
impl From<CliSandboxProvider> for fabro_sandbox::SandboxProvider {
fn from(value: CliSandboxProvider) -> Self {
match value {
CliSandboxProvider::Local => Self::Local,
CliSandboxProvider::Docker => Self::Docker,
CliSandboxProvider::Daytona => Self::Daytona,
#[cfg(feature = "exedev")]
CliSandboxProvider::Exe => Self::Exe,
CliSandboxProvider::Ssh => Self::Ssh,
}
}
}
impl From<fabro_sandbox::SandboxProvider> for CliSandboxProvider {
fn from(value: fabro_sandbox::SandboxProvider) -> Self {
match value {
fabro_sandbox::SandboxProvider::Local => Self::Local,
fabro_sandbox::SandboxProvider::Docker => Self::Docker,
fabro_sandbox::SandboxProvider::Daytona => Self::Daytona,
#[cfg(feature = "exedev")]
fabro_sandbox::SandboxProvider::Exe => Self::Exe,
#[cfg(not(feature = "exedev"))]
fabro_sandbox::SandboxProvider::Exe => Self::Local,
fabro_sandbox::SandboxProvider::Ssh => Self::Ssh,
}
}
}
#[derive(Args)]
pub(crate) struct RunArgs {
/// Path to a .fabro workflow file or .toml task config
#[arg(required = true)]
pub(crate) workflow: Option<PathBuf>,
/// Run output directory
#[arg(long)]
pub(crate) run_dir: Option<PathBuf>,
/// Execute with simulated LLM backend
#[arg(long)]
pub(crate) dry_run: bool,
/// Validate run configuration without executing
#[arg(long, conflicts_with = "dry_run")]
pub(crate) preflight: bool,
/// Auto-approve all human gates
#[arg(long)]
pub(crate) auto_approve: bool,
/// Override the workflow goal (exposed as $goal in prompts)
#[arg(long)]
pub(crate) goal: Option<String>,
/// Read the workflow goal from a file
#[arg(long, conflicts_with = "goal")]
pub(crate) goal_file: Option<PathBuf>,
/// Override default LLM model
#[arg(long)]
pub(crate) model: Option<String>,
/// Override default LLM provider
#[arg(long)]
pub(crate) provider: Option<String>,
/// Enable verbose output
#[arg(short, long)]
pub(crate) verbose: bool,
/// Sandbox for agent tools
#[arg(long, value_enum)]
pub(crate) sandbox: Option<CliSandboxProvider>,
/// Attach a label to this run (repeatable, format: KEY=VALUE)
#[arg(long = "label", value_name = "KEY=VALUE")]
pub(crate) label: Vec<String>,
/// Skip retro generation after the run
#[arg(long)]
pub(crate) no_retro: bool,
/// Keep the sandbox alive after the run finishes (for debugging)
#[arg(long)]
pub(crate) preserve_sandbox: bool,
/// Run the workflow in the background and print the run ID
#[arg(short = 'd', long, conflicts_with = "preflight")]
pub(crate) detach: bool,
/// Pre-generated run ID (used internally by --detach)
#[arg(long, hide = true)]
pub(crate) run_id: Option<String>,
}
#[derive(Args)]
pub(crate) struct RunFilterArgs {
/// Only include runs started before this date (YYYY-MM-DD prefix match)
#[arg(long)]
pub(crate) before: Option<String>,
/// Filter by workflow name (substring match)
#[arg(long)]
pub(crate) workflow: Option<String>,
/// Filter by label (KEY=VALUE, repeatable, AND semantics)
#[arg(long = "label", value_name = "KEY=VALUE")]
pub(crate) label: Vec<String>,
/// Include orphan directories (no run.json)
#[arg(long)]
pub(crate) orphans: bool,
}
#[derive(Args)]
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,
/// Only display run IDs
#[arg(short = 'q', long)]
pub(crate) quiet: bool,
}
#[derive(Args)]
pub(crate) struct RunsRemoveArgs {
/// Run IDs or workflow names to remove
#[arg(required = true)]
pub(crate) runs: Vec<String>,
/// Force removal of active runs
#[arg(short, long)]
pub(crate) force: bool,
}
#[derive(Args)]
pub(crate) struct LogsArgs {
/// Run ID prefix or workflow name (most recent run)
pub(crate) run: String,
/// Follow log output
#[arg(short, long)]
pub(crate) follow: bool,
/// Logs since timestamp or relative (e.g. "42m", "2h", "2026-01-02T13:00:00Z")
#[arg(long)]
pub(crate) since: Option<String>,
/// Lines from end (default: all)
#[arg(short = 'n', long)]
pub(crate) tail: Option<usize>,
/// Formatted colored output with rendered assistant text
#[arg(short = 'p', long)]
pub(crate) pretty: bool,
}
#[derive(Args)]
pub(crate) struct ValidateArgs {
/// Path to the .fabro workflow file
pub(crate) workflow: PathBuf,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub(crate) enum GraphDirection {
/// Left to right
Lr,
/// Top to bottom
Tb,
}
impl fmt::Display for GraphDirection {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Lr => write!(f, "LR"),
Self::Tb => write!(f, "TB"),
}
}
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub(crate) enum GraphOutputFormat {
Svg,
Png,
}
impl From<GraphOutputFormat> for fabro_graphviz::render::GraphFormat {
fn from(value: GraphOutputFormat) -> Self {
match value {
GraphOutputFormat::Svg => Self::Svg,
GraphOutputFormat::Png => Self::Png,
}
}
}
impl fmt::Display for GraphOutputFormat {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Svg => write!(f, "svg"),
Self::Png => write!(f, "png"),
}
}
}
#[derive(Args)]
pub(crate) struct GraphArgs {
/// Path to the .fabro workflow file, .toml task config, or project workflow name
pub(crate) workflow: PathBuf,
/// Output format
#[arg(long, value_enum, default_value_t = GraphOutputFormat::Svg)]
pub(crate) format: GraphOutputFormat,
/// Output file path (defaults to stdout)
#[arg(short, long)]
pub(crate) output: Option<PathBuf>,
/// Graph layout direction (overrides the DOT file's rankdir)
#[arg(short = 'd', long)]
pub(crate) direction: Option<GraphDirection>,
}
#[derive(Args)]
pub(crate) struct ParseArgs {
/// Path to the .fabro workflow file
pub(crate) workflow: PathBuf,
}
#[derive(Args)]
pub(crate) struct AssetListArgs {
/// Run ID (or prefix)
pub(crate) run_id: String,
/// Filter to assets from a specific node
#[arg(long)]
pub(crate) node: Option<String>,
/// Output as JSON
#[arg(long)]
pub(crate) json: bool,
}
#[derive(Args)]
pub(crate) struct AssetCpArgs {
/// Source: RUN_ID (all assets) or RUN_ID:path (specific asset)
pub(crate) source: String,
/// Destination directory (defaults to current directory)
#[arg(default_value = ".")]
pub(crate) dest: PathBuf,
/// Filter to assets from a specific node
#[arg(long)]
pub(crate) node: Option<String>,
/// Preserve {node_slug}/retry_{N}/ directory structure
#[arg(long)]
pub(crate) tree: bool,
}
#[derive(Args)]
pub(crate) struct CpArgs {
/// Source: <run-id>:<path> or local path
pub(crate) src: String,
/// Destination: <run-id>:<path> or local path
pub(crate) dst: String,
/// Recurse into directories
#[arg(short, long)]
pub(crate) recursive: bool,
}
#[derive(Args)]
pub(crate) struct PreviewArgs {
/// Run ID or prefix
pub(crate) run: String,
/// Port number
pub(crate) port: u16,
/// Generate a signed URL (embeds auth token, no headers needed)
#[arg(long)]
pub(crate) signed: bool,
/// Signed URL expiry in seconds (default 3600, requires --signed)
#[arg(long, default_value = "3600", requires = "signed")]
pub(crate) ttl: i32,
/// Open URL in browser (implies --signed)
#[arg(long)]
pub(crate) open: bool,
}
#[derive(Args)]
pub(crate) struct SshArgs {
/// Run ID or prefix
pub(crate) run: String,
/// SSH access expiry in minutes (default 60)
#[arg(long, default_value = "60")]
pub(crate) ttl: f64,
/// Print the SSH command instead of connecting
#[arg(long)]
pub(crate) print: bool,
}
#[derive(Args)]
pub(crate) struct DiffArgs {
/// Run ID or prefix
pub(crate) run: String,
/// Show diff for a specific node
#[arg(long)]
pub(crate) node: Option<String>,
/// Show diffstat instead of full patch (live diffs only)
#[arg(long)]
pub(crate) stat: bool,
/// Show only files-changed/insertions/deletions summary (live diffs only)
#[arg(long)]
pub(crate) shortstat: bool,
}
#[derive(Args)]
pub(crate) struct InspectArgs {
/// Run ID prefix or workflow name (most recent run)
pub(crate) run: String,
}
#[derive(Args)]
pub(crate) struct SecretGetArgs {
/// Name of the secret
pub(crate) key: String,
}
#[derive(Args)]
pub(crate) struct SecretListArgs {
/// Show values alongside keys
#[arg(long)]
pub(crate) show_values: bool,
}
#[derive(Args)]
pub(crate) struct SecretRmArgs {
/// Name of the secret to remove
pub(crate) key: String,
}
#[derive(Args)]
pub(crate) struct SecretSetArgs {
/// Name of the secret
pub(crate) key: String,
/// Value to store
pub(crate) value: String,
}
#[derive(Debug, Args)]
pub(crate) struct ResumeArgs {
/// Run ID or unambiguous prefix
pub(crate) run: String,
/// Run in the background and print the run ID
#[arg(short = 'd', long)]
pub(crate) detach: bool,
}
#[derive(Debug, Args)]
pub(crate) struct RewindArgs {
/// Run ID (or unambiguous prefix)
pub(crate) run_id: String,
/// Target checkpoint: node name, node@visit, or @ordinal (omit with --list)
pub(crate) target: Option<String>,
/// Show the checkpoint timeline instead of rewinding
#[arg(long)]
pub(crate) list: bool,
/// Skip force-pushing rewound refs to the remote
#[arg(long)]
pub(crate) no_push: bool,
}
#[derive(Debug, Args)]
pub(crate) struct ForkArgs {
/// Run ID (or unambiguous prefix)
pub(crate) run_id: String,
/// Target checkpoint: node name, node@visit, or @ordinal (omit to fork from latest)
pub(crate) target: Option<String>,
/// Show the checkpoint timeline instead of forking
#[arg(long)]
pub(crate) list: bool,
/// Skip pushing new branches to the remote
#[arg(long)]
pub(crate) no_push: bool,
}
#[derive(Args)]
pub(crate) struct WaitArgs {
/// Run ID prefix or workflow name (most recent run)
pub(crate) run: String,
/// Maximum time to wait in seconds
#[arg(long, value_name = "SECONDS")]
pub(crate) timeout: Option<u64>,
/// 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)]
pub(crate) struct WorkflowListArgs {}
#[derive(Args)]
pub(crate) struct WorkflowCreateArgs {
/// Name of the workflow
pub(crate) name: String,
/// Goal description for the workflow
#[arg(short, long)]
pub(crate) goal: Option<String>,
}
#[derive(Args)]
pub(crate) struct ProviderLoginArgs {
/// LLM provider to authenticate with
#[arg(long)]
pub(crate) provider: fabro_model::Provider,
}
#[derive(Args)]
pub(crate) struct RunsPruneArgs {
#[command(flatten)]
pub(crate) filter: RunFilterArgs,
/// Only prune runs older than this duration (e.g. 24h, 7d). Default: 24h when no explicit filters are set.
#[arg(
long,
value_name = "DURATION",
value_parser = crate::commands::system::parse_duration
)]
pub(crate) older_than: Option<chrono::Duration>,
/// Actually delete (default is dry-run)
#[arg(long)]
pub(crate) yes: bool,
}
#[derive(Args)]
pub(crate) struct DfArgs {
/// Show per-run breakdown
#[arg(short, long)]
pub(crate) verbose: bool,
}
#[derive(Clone, ValueEnum)]
pub(crate) enum SkillDir {
Claude,
Agents,
}
#[derive(Clone, ValueEnum)]
pub(crate) enum SkillScope {
User,
Project,
}
#[derive(Args)]
pub(crate) struct SkillInstallArgs {
/// Where to install: user-level or project-level
#[arg(long = "for", default_value = "user")]
pub(crate) scope: SkillScope,
/// Target directory convention
#[arg(long)]
pub(crate) dir: SkillDir,
/// Overwrite existing skill without prompting
#[arg(long)]
pub(crate) force: bool,
}
#[derive(Args)]
pub(crate) struct PrCreateArgs {
/// Run ID or prefix
pub(crate) run_id: String,
/// LLM model for generating PR description
#[arg(long)]
pub(crate) model: Option<String>,
}
#[derive(Args)]
pub(crate) struct PrListArgs {
/// Show all PRs (including closed/merged), not just open
#[arg(long)]
pub(crate) all: bool,
}
#[derive(Args)]
pub(crate) struct PrViewArgs {
/// Run ID or prefix
pub(crate) run_id: String,
}
#[derive(Args)]
pub(crate) struct PrMergeArgs {
/// Run ID or prefix
pub(crate) run_id: String,
/// Merge method: merge, squash, or rebase
#[arg(long, default_value = "squash")]
pub(crate) method: String,
}
#[derive(Args)]
pub(crate) struct PrCloseArgs {
/// Run ID or prefix
pub(crate) run_id: String,
}
#[derive(Args)]
pub(crate) struct UpgradeArgs {
/// Target version (e.g. "0.5.0" or "v0.5.0")
#[arg(long)]
pub(crate) version: Option<String>,
/// Upgrade even if already on the target version
#[arg(long)]
pub(crate) force: bool,
/// Preview what would happen without making changes
#[arg(long)]
pub(crate) dry_run: bool,
}
#[derive(Subcommand)]
pub(crate) enum Commands {
/// LLM prompt operations
#[command(hide = true)]
Llm(LlmNamespace),
/// Run an agentic coding session
#[command(hide = true)]
Exec(fabro_agent::cli::AgentArgs),
/// Launch a workflow run
Run(RunArgs),
/// Create a workflow run (allocate run dir, persist spec)
Create(RunArgs),
/// Start a created workflow run (spawn engine process)
Start {
/// Run ID prefix or workflow name
run: String,
},
/// Attach to a running or finished workflow run
Attach {
/// Run ID prefix or workflow name
run: String,
},
/// Internal: run the engine process (reads run.json from run dir)
#[command(name = "_run_engine", hide = true)]
RunEngine {
/// Path to the run directory
#[arg(long)]
run_dir: PathBuf,
/// Resume from checkpoint instead of fresh start
#[arg(long)]
resume: bool,
},
/// Validate a workflow
Validate(ValidateArgs),
/// Render a workflow graph as SVG or PNG
Graph(GraphArgs),
/// Parse a DOT file and print its AST
#[command(hide = true)]
Parse(ParseArgs),
/// Inspect and copy run assets (screenshots, reports, traces)
Asset(AssetNamespace),
/// Copy files to/from a run's sandbox
Cp(CpArgs),
/// Get a preview URL for a port on a run's sandbox
Preview(PreviewArgs),
/// SSH into a run's Daytona sandbox
Ssh(SshArgs),
/// Show the diff of changes from a workflow run
#[command(hide = true)]
Diff(DiffArgs),
/// View the event log of a workflow run
Logs(LogsArgs),
/// Show detailed information about a workflow run
Inspect(InspectArgs),
/// List and test LLM models
Model {
#[command(subcommand)]
command: Option<fabro_llm::cli::ModelsCommand>,
},
/// Start the HTTP API server
#[cfg(feature = "server")]
Serve(fabro_api::serve::ServeArgs),
/// Check environment and integration health
Doctor {
/// Show detailed information for each check
#[arg(short, long)]
verbose: bool,
/// Skip live service probes (LLM, sandbox, API, web, Brave Search)
#[arg(long)]
dry_run: bool,
},
/// Initialize a new project (deprecated: use `repo init`)
#[command(hide = true)]
Init,
/// Set up the Fabro environment (LLMs, certs, GitHub)
Install {
/// Base URL for the web UI (used for OAuth callback URLs)
#[arg(long, default_value = "http://localhost:5173")]
web_url: String,
},
/// List workflow runs
#[command(hide = true)]
Ps(RunsListArgs),
/// Remove one or more workflow runs
Rm(RunsRemoveArgs),
/// Pull request operations
Pr(PrNamespace),
/// Skill management
#[command(hide = true)]
Skill(SkillNamespace),
/// Manage secrets in ~/.fabro/.env
Secret(SecretNamespace),
/// Resume an interrupted workflow run
Resume(ResumeArgs),
/// Rewind a workflow run to an earlier checkpoint
Rewind(RewindArgs),
/// Fork a workflow run from an earlier checkpoint into a new run
Fork(ForkArgs),
/// Block until a workflow run completes
Wait(WaitArgs),
/// Workflow operations
Workflow(WorkflowNamespace),
/// Open the Discord community in the browser
Discord,
/// Open the docs website in the browser
Docs,
/// Upgrade fabro to the latest version
Upgrade(UpgradeArgs),
/// Repository commands
Repo(RepoNamespace),
/// Provider operations
Provider(ProviderNamespace),
/// System maintenance commands
System(SystemNamespace),
/// Send a queued analytics event (internal)
#[command(name = "__send_analytics", hide = true)]
SendAnalytics {
/// Path to the JSON event file
path: PathBuf,
},
/// Send a queued panic event to Sentry (internal)
#[command(name = "__send_panic", hide = true)]
SendPanic {
/// Path to the JSON event file
path: PathBuf,
},
}
impl Commands {
pub(crate) fn name(&self) -> &'static str {
match self {
Self::Llm(ns) => match &ns.command {
LlmCommand::Prompt(_) => "llm prompt",
LlmCommand::Chat(_) => "llm chat",
},
Self::Asset(ns) => match &ns.command {
AssetCommand::List(_) => "asset list",
AssetCommand::Cp(_) => "asset cp",
},
Self::Exec(_) => "exec",
Self::Run(_) => "run",
Self::Create(_) => "create",
Self::Start { .. } => "start",
Self::Attach { .. } => "attach",
Self::RunEngine { .. } => "_run_engine",
Self::Validate(_) => "validate",
Self::Graph(_) => "graph",
Self::Parse(_) => "parse",
Self::Cp(_) => "cp",
Self::Preview(_) => "preview",
Self::Ssh(_) => "ssh",
Self::Diff(_) => "diff",
Self::Logs(_) => "logs",
Self::Inspect(_) => "inspect",
Self::Model { command } => match command {
Some(fabro_llm::cli::ModelsCommand::List { .. }) => "model list",
Some(fabro_llm::cli::ModelsCommand::Test { .. }) => "model test",
None => "model",
},
#[cfg(feature = "server")]
Self::Serve(_) => "serve",
Self::Doctor { .. } => "doctor",
Self::Repo(ns) => match &ns.command {
RepoCommand::Init { .. } => "repo init",
RepoCommand::Deinit => "repo deinit",
},
Self::Init => "init",
Self::Install { .. } => "install",
Self::Ps(_) => "ps",
Self::Rm(_) => "rm",
Self::Pr(ns) => match &ns.command {
PrCommand::Create(_) => "pr create",
PrCommand::List(_) => "pr list",
PrCommand::View(_) => "pr view",
PrCommand::Merge(_) => "pr merge",
PrCommand::Close(_) => "pr close",
},
Self::Secret(ns) => match &ns.command {
SecretCommand::Get(_) => "secret get",
SecretCommand::List(_) => "secret list",
SecretCommand::Rm(_) => "secret rm",
SecretCommand::Set(_) => "secret set",
},
Self::Resume(_) => "resume",
Self::Rewind(_) => "rewind",
Self::Fork(_) => "fork",
Self::Wait(_) => "wait",
Self::Workflow(ns) => match &ns.command {
WorkflowCommand::List(_) => "workflow list",
WorkflowCommand::Create(_) => "workflow create",
},
Self::Skill(ns) => match &ns.command {
SkillCommand::Install(_) => "skill install",
},
Self::Discord => "discord",
Self::Docs => "docs",
Self::Upgrade(_) => "upgrade",
Self::Provider(ns) => match &ns.command {
ProviderCommand::Login(_) => "provider login",
},
Self::System(ns) => match &ns.command {
SystemCommand::Prune(_) => "system prune",
SystemCommand::Df(_) => "system df",
},
Self::SendAnalytics { .. } => "__send_analytics",
Self::SendPanic { .. } => "__send_panic",
}
}
}
#[derive(Args)]
pub(crate) struct PrNamespace {
#[command(subcommand)]
pub(crate) command: PrCommand,
}
#[derive(Subcommand)]
pub(crate) enum PrCommand {
/// Create a pull request from a completed run
Create(PrCreateArgs),
/// List pull requests from workflow runs
List(PrListArgs),
/// View pull request details
View(PrViewArgs),
/// Merge a pull request
Merge(PrMergeArgs),
/// Close a pull request
Close(PrCloseArgs),
}
#[derive(Args)]
pub(crate) struct AssetNamespace {
#[command(subcommand)]
pub(crate) command: AssetCommand,
}
#[derive(Subcommand)]
pub(crate) enum AssetCommand {
/// List assets for a workflow run
List(AssetListArgs),
/// Copy assets from a workflow run
Cp(AssetCpArgs),
}
#[derive(Args)]
pub(crate) struct SecretNamespace {
#[command(subcommand)]
pub(crate) command: SecretCommand,
}
#[derive(Subcommand)]
pub(crate) enum SecretCommand {
/// Get a secret value
Get(SecretGetArgs),
/// List secret names
#[command(alias = "ls")]
List(SecretListArgs),
/// Remove a secret
Rm(SecretRmArgs),
/// Set a secret value
Set(SecretSetArgs),
}
#[derive(Args)]
pub(crate) struct SystemNamespace {
#[command(subcommand)]
pub(crate) command: SystemCommand,
}
#[derive(Subcommand)]
pub(crate) enum SystemCommand {
/// Delete old workflow runs
Prune(RunsPruneArgs),
/// Show disk usage
Df(DfArgs),
}
#[derive(Args)]
pub(crate) struct WorkflowNamespace {
#[command(subcommand)]
pub(crate) command: WorkflowCommand,
}
#[derive(Subcommand)]
pub(crate) enum WorkflowCommand {
/// List available workflows
List(WorkflowListArgs),
/// Create a new workflow
Create(WorkflowCreateArgs),
}
#[derive(Args)]
pub(crate) struct RepoNamespace {
#[command(subcommand)]
pub(crate) command: RepoCommand,
}
#[derive(Subcommand)]
pub(crate) enum RepoCommand {
/// Initialize a new project
Init {
/// Also install the fabro-create-workflow skill
#[arg(long, hide = true)]
skill: bool,
},
/// Remove fabro.toml and fabro/ directory
Deinit,
}
#[derive(Args)]
pub(crate) struct ProviderNamespace {
#[command(subcommand)]
pub(crate) command: ProviderCommand,
}
#[derive(Subcommand)]
pub(crate) enum ProviderCommand {
/// Log in to an LLM provider
Login(ProviderLoginArgs),
}
#[derive(Args)]
pub(crate) struct LlmNamespace {
#[command(subcommand)]
pub(crate) command: LlmCommand,
}
#[derive(Subcommand)]
pub(crate) enum LlmCommand {
/// Execute a prompt
Prompt(fabro_llm::cli::PromptArgs),
/// Interactive multi-turn chat
Chat(fabro_llm::cli::ChatArgs),
}
#[derive(Args)]
pub(crate) struct SkillNamespace {
#[command(subcommand)]
pub(crate) command: SkillCommand,
}
#[derive(Subcommand)]
pub(crate) enum SkillCommand {
/// Install a built-in skill
Install(SkillInstallArgs),
}

View file

@ -1,5 +1,7 @@
pub use fabro_config::cli::*;
#[cfg(feature = "server")]
use fabro_config::FabroConfig;
#[cfg(feature = "server")]
use tracing::debug;

View file

@ -1,94 +1,9 @@
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use clap::Args;
use super::shared::split_run_path;
#[derive(Args)]
pub struct AssetListArgs {
/// Run ID (or prefix)
pub run_id: String,
/// Filter to assets from a specific node
#[arg(long)]
pub node: Option<String>,
/// Output as JSON
#[arg(long)]
pub json: bool,
}
#[derive(Args)]
pub struct AssetCpArgs {
/// Source: RUN_ID (all assets) or RUN_ID:path (specific asset)
pub source: String,
/// Destination directory (defaults to current directory)
#[arg(default_value = ".")]
pub dest: PathBuf,
/// Filter to assets from a specific node
#[arg(long)]
pub node: Option<String>,
/// Preserve {node_slug}/retry_{N}/ directory structure
#[arg(long)]
pub tree: bool,
}
pub fn list_command(args: &AssetListArgs) -> Result<()> {
let base = fabro_workflows::run_lookup::default_runs_base();
let run = fabro_workflows::run_lookup::resolve_run(&base, &args.run_id)?;
let entries = fabro_workflows::assets::scan_assets(&run.path, args.node.as_deref())?;
if args.json {
println!("{}", serde_json::to_string_pretty(&entries)?);
return Ok(());
}
if entries.is_empty() {
println!("No assets found for this run.");
return Ok(());
}
let node_width = entries
.iter()
.map(|entry| entry.node_slug.len())
.max()
.unwrap_or(4)
.max(4);
let retry_width = 5;
let size_width = entries
.iter()
.map(|entry| format_size(entry.size).len())
.max()
.unwrap_or(4)
.max(4);
println!(
"{:<node_width$} {:>retry_width$} {:>size_width$} PATH",
"NODE", "RETRY", "SIZE"
);
let total_size: u64 = entries.iter().map(|entry| entry.size).sum();
for entry in &entries {
println!(
"{:<node_width$} {:>retry_width$} {:>size_width$} {}",
entry.node_slug,
entry.retry,
format_size(entry.size),
entry.relative_path
);
}
println!();
println!(
"{} asset(s), {} total",
entries.len(),
format_size(total_size)
);
Ok(())
}
use crate::args::AssetCpArgs;
use crate::shared::split_run_path;
pub fn cp_command(args: &AssetCpArgs) -> Result<()> {
let base = fabro_workflows::run_lookup::default_runs_base();
@ -209,22 +124,6 @@ fn parse_source(source: &str) -> (&str, Option<&str>) {
}
}
fn format_size(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = 1024 * KB;
const GB: u64 = 1024 * MB;
if bytes >= GB {
format!("{:.1} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.1} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.1} KB", bytes as f64 / KB as f64)
} else {
format!("{bytes} B")
}
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -0,0 +1,57 @@
use anyhow::Result;
use crate::args::AssetListArgs;
use crate::shared::format_size;
pub fn list_command(args: &AssetListArgs) -> Result<()> {
let base = fabro_workflows::run_lookup::default_runs_base();
let run = fabro_workflows::run_lookup::resolve_run(&base, &args.run_id)?;
let entries = fabro_workflows::assets::scan_assets(&run.path, args.node.as_deref())?;
if args.json {
println!("{}", serde_json::to_string_pretty(&entries)?);
return Ok(());
}
if entries.is_empty() {
println!("No assets found for this run.");
return Ok(());
}
let node_width = entries
.iter()
.map(|entry| entry.node_slug.len())
.max()
.unwrap_or(4)
.max(4);
let retry_width = 5;
let size_width = entries
.iter()
.map(|entry| format_size(entry.size).len())
.max()
.unwrap_or(4)
.max(4);
println!(
"{:<node_width$} {:>retry_width$} {:>size_width$} PATH",
"NODE", "RETRY", "SIZE"
);
let total_size: u64 = entries.iter().map(|entry| entry.size).sum();
for entry in &entries {
println!(
"{:<node_width$} {:>retry_width$} {:>size_width$} {}",
entry.node_slug,
entry.retry,
format_size(entry.size),
entry.relative_path
);
}
println!();
println!(
"{} asset(s), {} total",
entries.len(),
format_size(total_size)
);
Ok(())
}

View file

@ -0,0 +1,13 @@
mod cp;
mod list;
use anyhow::Result;
use crate::args::{AssetCommand, AssetNamespace};
pub fn dispatch(ns: AssetNamespace) -> Result<()> {
match ns.command {
AssetCommand::List(args) => list::list_command(&args),
AssetCommand::Cp(args) => cp::cp_command(&args),
}
}

View file

@ -1,21 +1,10 @@
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use clap::Args;
use tracing::{debug, info};
use super::shared::split_run_path;
#[derive(Args)]
pub struct CpArgs {
/// Source: <run-id>:<path> or local path
pub src: String,
/// Destination: <run-id>:<path> or local path
pub dst: String,
/// Recurse into directories
#[arg(short, long)]
pub recursive: bool,
}
use crate::args::CpArgs;
use crate::shared::split_run_path;
enum CopyDirection {
Download {

View file

@ -3,10 +3,12 @@ use std::path::PathBuf;
use fabro_config::config::FabroConfig;
use fabro_sandbox::SandboxProvider;
use crate::args::RunArgs;
use super::run::{
apply_execution_overrides, cached_graph_path, default_run_dir, load_workflow_source_input,
parse_labels, print_diagnostics_from_error, print_workflow_report_from_persisted,
resolve_sandbox_provider, write_run_config_snapshot, ExecutionOverrides, RunArgs,
resolve_sandbox_provider, write_run_config_snapshot, ExecutionOverrides,
};
use fabro_util::terminal::Styles;

View file

@ -2,23 +2,9 @@ use std::io::{self, IsTerminal, Write};
use std::path::Path;
use anyhow::{bail, Context, Result};
use clap::Args;
use tracing::{debug, info};
#[derive(Args)]
pub struct DiffArgs {
/// Run ID or prefix
pub run: String,
/// Show diff for a specific node
#[arg(long)]
pub node: Option<String>,
/// Show diffstat instead of full patch (live diffs only)
#[arg(long)]
pub stat: bool,
/// Show only files-changed/insertions/deletions summary (live diffs only)
#[arg(long)]
pub shortstat: bool,
}
use crate::args::DiffArgs;
pub async fn run(args: DiffArgs) -> Result<()> {
info!(run_id = %args.run, "Showing diff");

View file

@ -780,7 +780,7 @@ pub fn check_crypto(input: &CryptoInput) -> CheckResult {
let result = input
.jwt_public_key
.as_deref()
.ok_or_else(|| "JWT configured but ARC_JWT_PUBLIC_KEY not set".to_string())
.ok_or_else(|| "JWT configured but FABRO_JWT_PUBLIC_KEY not set".to_string())
.and_then(|raw| decode_pem_value("FABRO_JWT_PUBLIC_KEY", raw))
.and_then(|pem| {
jsonwebtoken::DecodingKey::from_ed_pem(pem.as_bytes())
@ -947,7 +947,7 @@ pub async fn run_doctor(verbose: bool, live: bool) -> i32 {
#[cfg(feature = "server")]
let api_status = {
let api = server_config.api.unwrap_or_default();
let api = server_config.api.clone().unwrap_or_default();
ApiStatus {
base_url: api.base_url.clone(),
authentication_strategies: api.authentication_strategies.clone(),
@ -956,7 +956,7 @@ pub async fn run_doctor(verbose: bool, live: bool) -> i32 {
#[cfg(feature = "server")]
let web_status = {
let web = server_config.web.unwrap_or_default();
let web = server_config.web.clone().unwrap_or_default();
WebStatus {
url: web.url.clone(),
auth_provider: web.auth.provider.clone(),
@ -964,6 +964,15 @@ pub async fn run_doctor(verbose: bool, live: bool) -> i32 {
}
};
#[cfg(feature = "server")]
let server_git = server_config.git.clone().unwrap_or_default();
#[cfg(feature = "server")]
let server_api = server_config.api.clone().unwrap_or_default();
#[cfg(feature = "server")]
let server_web = server_config.web.clone().unwrap_or_default();
let git_app_id = cli_config.app_id().map(str::to_owned);
let private_key_raw = std::env::var("GITHUB_APP_PRIVATE_KEY").ok();
let sign_result = match (&git_app_id, &private_key_raw) {
@ -995,7 +1004,7 @@ pub async fn run_doctor(verbose: bool, live: bool) -> i32 {
private_key_set: private_key_raw.is_some(),
sign_result,
#[cfg(feature = "server")]
client_id: server_config.git.client_id.is_some(),
client_id: server_git.client_id.is_some(),
#[cfg(feature = "server")]
client_secret: std::env::var("GITHUB_APP_CLIENT_SECRET").is_ok(),
#[cfg(feature = "server")]
@ -1004,12 +1013,11 @@ pub async fn run_doctor(verbose: bool, live: bool) -> i32 {
#[cfg(feature = "server")]
let crypto_input = {
let has_mtls = server_config
.api
let has_mtls = server_api
.authentication_strategies
.contains(&ApiAuthStrategy::Mtls);
let tls_files = if has_mtls {
server_config.api.tls.as_ref().map(|tls| {
server_api.tls.as_ref().map(|tls| {
let read = |p: &std::path::Path| -> Result<String, String> {
let expanded = fabro_config::expand_tilde(p);
std::fs::read_to_string(&expanded)
@ -1025,7 +1033,7 @@ pub async fn run_doctor(verbose: bool, live: bool) -> i32 {
None
};
CryptoInput {
auth_strategies: server_config.api.authentication_strategies.clone(),
auth_strategies: server_api.authentication_strategies.clone(),
tls_files,
jwt_public_key: std::env::var("FABRO_JWT_PUBLIC_KEY").ok(),
jwt_private_key: std::env::var("FABRO_JWT_PRIVATE_KEY").ok(),
@ -1081,9 +1089,9 @@ pub async fn run_doctor(verbose: bool, live: bool) -> i32 {
#[cfg(feature = "server")]
{
let api_url = format!("{}/runs", server_config.api.base_url);
let api_url = format!("{}/runs", server_api.base_url);
let api_fut = probe_url(&http, &api_url);
let web_fut = probe_url(&http, &server_config.web.url);
let web_fut = probe_url(&http, &server_web.url);
let (sandbox, llm, brave, api, web) =
tokio::join!(sandbox_fut, llm_fut, brave_fut, api_fut, web_fut);

View file

@ -0,0 +1,65 @@
use anyhow::Result;
use crate::args::GlobalArgs;
use crate::cli_config;
pub async fn execute(mut args: fabro_agent::cli::AgentArgs, globals: &GlobalArgs) -> Result<()> {
let cli_config = cli_config::load_cli_config(None)?;
#[cfg(feature = "sleep_inhibitor")]
let _sleep_guard = fabro_beastie::guard(cli_config.prevent_idle_sleep_enabled());
let exec_defaults = cli_config.exec.as_ref();
args.apply_cli_defaults(
exec_defaults.and_then(|a| a.provider.as_deref()),
exec_defaults.and_then(|a| a.model.as_deref()),
exec_defaults.and_then(|a| a.permissions),
exec_defaults.and_then(|a| a.output_format),
);
#[cfg(feature = "server")]
let resolved = cli_config::resolve_mode(
globals.mode.clone(),
globals.server_url.as_deref(),
&cli_config,
);
let mcp_servers: Vec<fabro_mcp::config::McpServerConfig> = cli_config
.mcp_servers
.into_iter()
.map(|(name, entry): (String, fabro_config::mcp::McpServerEntry)| entry.into_config(name))
.collect();
#[cfg(feature = "server")]
{
match resolved.mode {
cli_config::ExecutionMode::Server => {
tracing::info!(mode = "server", "Agent session starting");
let http_client = cli_config::build_server_client(resolved.tls.as_ref())?;
let provider_name = args
.provider
.clone()
.unwrap_or_else(|| "anthropic".to_string());
let adapter = std::sync::Arc::new(fabro_llm::providers::FabroServerAdapter::new(
http_client,
&resolved.server_base_url,
&provider_name,
));
let mut client =
fabro_llm::client::Client::new(std::collections::HashMap::new(), None, vec![]);
client
.register_provider(adapter)
.await
.map_err(|e| anyhow::anyhow!("Failed to register fabro server adapter: {e}"))?;
fabro_agent::cli::run_with_args_and_client(args, Some(client), mcp_servers).await?
}
cli_config::ExecutionMode::Standalone => {
tracing::info!(mode = "standalone", "Agent session starting");
fabro_agent::cli::run_with_args(args, mcp_servers).await?
}
}
}
#[cfg(not(feature = "server"))]
{
let _ = globals;
tracing::info!(mode = "standalone", "Agent session starting");
fabro_agent::cli::run_with_args(args, mcp_servers).await?
}
Ok(())
}

View file

@ -1,26 +1,10 @@
use anyhow::Context;
use anyhow::Result;
use clap::Args;
use fabro_git_storage::gitobj::Store;
use fabro_util::terminal::Styles;
use git2::Repository;
#[derive(Debug, Args)]
pub struct ForkArgs {
/// Run ID (or unambiguous prefix)
pub run_id: String,
/// Target checkpoint: node name, node@visit, or @ordinal (omit to fork from latest)
pub target: Option<String>,
/// Show the checkpoint timeline instead of forking
#[arg(long)]
pub list: bool,
/// Skip pushing new branches to the remote
#[arg(long)]
pub no_push: bool,
}
use crate::args::ForkArgs;
pub fn run(args: &ForkArgs, styles: &Styles) -> Result<()> {
let repo = Repository::discover(".").context("not in a git repository")?;

View file

@ -1,55 +1,14 @@
use std::borrow::Cow;
use std::fmt;
use std::io::Write;
use std::path::PathBuf;
use std::sync::LazyLock;
use anyhow::bail;
use clap::{Args, ValueEnum};
use fabro_util::terminal::Styles;
use fabro_validate::Severity;
use tracing::debug;
use crate::commands::shared::{print_diagnostics, read_workflow_file, relative_path};
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum GraphDirection {
/// Left to right
Lr,
/// Top to bottom
Tb,
}
impl fmt::Display for GraphDirection {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Lr => write!(f, "LR"),
Self::Tb => write!(f, "TB"),
}
}
}
#[derive(Args)]
pub struct GraphArgs {
/// Path to the .fabro workflow file, .toml task config, or project workflow name
pub workflow: PathBuf,
/// Output format
#[arg(
long,
value_enum,
default_value_t = GraphOutputFormat::Svg
)]
pub format: GraphOutputFormat,
/// Output file path (defaults to stdout)
#[arg(short, long)]
pub output: Option<PathBuf>,
/// Graph layout direction (overrides the DOT file's rankdir)
#[arg(short = 'd', long)]
pub direction: Option<GraphDirection>,
}
use crate::args::{GraphArgs, GraphDirection};
use crate::shared::{print_diagnostics, read_workflow_file, relative_path};
static RANKDIR_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"rankdir\s*=\s*\w+").unwrap());
@ -85,30 +44,6 @@ pub fn run(args: &GraphArgs, styles: &Styles) -> anyhow::Result<()> {
Ok(())
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum GraphOutputFormat {
Svg,
Png,
}
impl From<GraphOutputFormat> for fabro_graphviz::render::GraphFormat {
fn from(value: GraphOutputFormat) -> Self {
match value {
GraphOutputFormat::Svg => Self::Svg,
GraphOutputFormat::Png => Self::Png,
}
}
}
impl fmt::Display for GraphOutputFormat {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Svg => write!(f, "svg"),
Self::Png => write!(f, "png"),
}
}
}
fn apply_direction<'a>(source: &'a str, direction: Option<GraphDirection>) -> Cow<'a, str> {
match direction {
Some(dir) => {

View file

@ -1,14 +1,9 @@
use std::path::{Path, PathBuf};
use anyhow::Result;
use clap::Args;
use serde::Serialize;
#[derive(Args)]
pub struct InspectArgs {
/// Run ID prefix or workflow name (most recent run)
pub run: String,
}
use crate::args::InspectArgs;
#[derive(Debug, Serialize)]
pub struct InspectOutput {

View file

@ -15,8 +15,8 @@ use rand::Rng;
use tokio::net::TcpListener;
use tokio::sync::oneshot;
use crate::doctor;
use crate::provider_auth::{
use super::doctor;
use crate::shared::provider_auth::{
prompt_and_validate_key, prompt_confirm, provider_display_name, run_openai_oauth_or_api_key,
write_env_file,
};

View file

@ -0,0 +1,45 @@
use anyhow::Result;
use fabro_config::FabroConfig;
use crate::args::GlobalArgs;
pub async fn execute(
mut args: fabro_llm::cli::ChatArgs,
cli_config: &FabroConfig,
globals: &GlobalArgs,
) -> Result<()> {
let llm_defaults = cli_config.llm.as_ref();
if args.model.is_none() {
args.model = llm_defaults.and_then(|l| l.model.clone());
}
#[cfg(feature = "server")]
{
let resolved = crate::cli_config::resolve_mode(
globals.mode.clone(),
globals.server_url.as_deref(),
cli_config,
);
match resolved.mode {
crate::cli_config::ExecutionMode::Server => {
let client = crate::cli_config::build_server_client(resolved.tls.as_ref())?;
let server = fabro_llm::cli::ServerConnection {
client,
base_url: resolved.server_base_url,
};
fabro_llm::cli::run_chat_via_server(args, &server).await?;
}
crate::cli_config::ExecutionMode::Standalone => {
fabro_llm::cli::run_chat(args).await?;
}
}
}
#[cfg(not(feature = "server"))]
{
let _ = globals;
fabro_llm::cli::run_chat(args).await?;
}
Ok(())
}

View file

@ -0,0 +1,15 @@
mod chat;
mod prompt;
use anyhow::Result;
use crate::args::{GlobalArgs, LlmCommand, LlmNamespace};
pub async fn dispatch(ns: LlmNamespace, globals: &GlobalArgs) -> Result<()> {
let cli_config = crate::cli_config::load_cli_config(None)?;
match ns.command {
LlmCommand::Prompt(args) => prompt::execute(args, &cli_config, globals).await,
LlmCommand::Chat(args) => chat::execute(args, &cli_config, globals).await,
}
}

View file

@ -0,0 +1,45 @@
use anyhow::Result;
use fabro_config::FabroConfig;
use crate::args::GlobalArgs;
pub async fn execute(
mut args: fabro_llm::cli::PromptArgs,
cli_config: &FabroConfig,
globals: &GlobalArgs,
) -> Result<()> {
let llm_defaults = cli_config.llm.as_ref();
if args.model.is_none() {
args.model = llm_defaults.and_then(|l| l.model.clone());
}
#[cfg(feature = "server")]
{
let resolved = crate::cli_config::resolve_mode(
globals.mode.clone(),
globals.server_url.as_deref(),
cli_config,
);
match resolved.mode {
crate::cli_config::ExecutionMode::Server => {
let client = crate::cli_config::build_server_client(resolved.tls.as_ref())?;
let server = fabro_llm::cli::ServerConnection {
client,
base_url: resolved.server_base_url,
};
fabro_llm::cli::run_prompt_via_server(args, &server).await?;
}
crate::cli_config::ExecutionMode::Standalone => {
fabro_llm::cli::run_prompt(args).await?;
}
}
}
#[cfg(not(feature = "server"))]
{
let _ = globals;
fabro_llm::cli::run_prompt(args).await?;
}
Ok(())
}

View file

@ -3,27 +3,10 @@ use std::path::Path;
use anyhow::{bail, Context, Result};
use chrono::{DateTime, Utc};
use clap::Args;
use fabro_util::terminal::Styles;
use tracing::{debug, info};
#[derive(Args)]
pub struct LogsArgs {
/// Run ID prefix or workflow name (most recent run)
pub run: String,
/// Follow log output
#[arg(short, long)]
pub follow: bool,
/// Logs since timestamp or relative (e.g. "42m", "2h", "2026-01-02T13:00:00Z")
#[arg(long)]
pub since: Option<String>,
/// Lines from end (default: all)
#[arg(short = 'n', long)]
pub tail: Option<usize>,
/// Formatted colored output with rendered assistant text
#[arg(short = 'p', long)]
pub pretty: bool,
}
use crate::args::LogsArgs;
pub fn run(args: LogsArgs, styles: &Styles) -> Result<()> {
let base = fabro_workflows::run_lookup::default_runs_base();

View file

@ -4,23 +4,32 @@ pub mod cp;
pub mod create;
pub(crate) mod detached_support;
pub mod diff;
pub mod doctor;
pub mod exec;
pub mod fork;
pub mod graph;
pub mod inspect;
pub mod install;
pub mod llm;
pub mod logs;
pub mod model;
pub mod parse;
pub mod pr;
pub mod preview;
pub mod provider;
pub mod repo;
pub mod resume;
pub mod rewind;
pub mod run;
pub mod run_engine;
pub(crate) mod run_progress;
pub mod runs;
pub mod secret;
pub(crate) mod shared;
pub mod skill;
pub mod ssh;
pub mod start;
pub mod system;
pub mod upgrade;
pub mod validate;
pub mod wait;
pub mod workflow;

View file

@ -0,0 +1,39 @@
use anyhow::Result;
use crate::args::GlobalArgs;
#[cfg(feature = "server")]
use crate::cli_config;
pub async fn execute(
command: Option<fabro_llm::cli::ModelsCommand>,
globals: &GlobalArgs,
) -> Result<()> {
let server = {
#[cfg(feature = "server")]
{
let cli_config = cli_config::load_cli_config(None)?;
let resolved = cli_config::resolve_mode(
globals.mode.clone(),
globals.server_url.as_deref(),
&cli_config,
);
match resolved.mode {
cli_config::ExecutionMode::Server => {
let client = cli_config::build_server_client(resolved.tls.as_ref())?;
Some(fabro_llm::cli::ServerConnection {
client,
base_url: resolved.server_base_url,
})
}
cli_config::ExecutionMode::Standalone => None,
}
}
#[cfg(not(feature = "server"))]
{
let _ = globals;
None
}
};
fabro_llm::cli::run_models(command, server).await
}

View file

@ -1,15 +1,6 @@
use crate::args::ParseArgs;
use crate::shared::read_workflow_file;
use std::io::Write;
use std::path::PathBuf;
use clap::Args;
use crate::commands::shared::read_workflow_file;
#[derive(Args)]
pub struct ParseArgs {
/// Path to the .fabro workflow file
pub workflow: PathBuf,
}
pub fn run(args: &ParseArgs) -> anyhow::Result<()> {
let stdout = std::io::stdout();

View file

@ -1,423 +0,0 @@
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use clap::Args;
use fabro_model::Catalog;
use tracing::info;
#[derive(Args)]
pub struct PrCreateArgs {
/// Run ID or prefix
pub run_id: String,
/// LLM model for generating PR description
#[arg(long)]
pub model: Option<String>,
}
#[derive(Args)]
pub struct PrListArgs {
/// Show all PRs (including closed/merged), not just open
#[arg(long)]
pub all: bool,
}
#[derive(Args)]
pub struct PrViewArgs {
/// Run ID or prefix
pub run_id: String,
}
#[derive(Args)]
pub struct PrMergeArgs {
/// Run ID or prefix
pub run_id: String,
/// Merge method: merge, squash, or rebase
#[arg(long, default_value = "squash")]
pub method: String,
}
#[derive(Args)]
pub struct PrCloseArgs {
/// Run ID or prefix
pub run_id: String,
}
fn load_pr_record(
base: &Path,
run_id: &str,
) -> Result<(fabro_workflows::pull_request::PullRequestRecord, PathBuf)> {
let run_dir = fabro_workflows::run_lookup::resolve_run(base, run_id)?.path;
let pr_path = run_dir.join("pull_request.json");
let content = std::fs::read_to_string(&pr_path).with_context(|| {
format!(
"No pull_request.json found in run directory. \
Create one first with: fabro pr create {run_id}"
)
})?;
let record: fabro_workflows::pull_request::PullRequestRecord =
serde_json::from_str(&content).context("Failed to parse pull_request.json")?;
Ok((record, run_dir))
}
pub async fn list_command(
args: PrListArgs,
github_app: Option<fabro_github::GitHubAppCredentials>,
) -> Result<()> {
let base = fabro_workflows::run_lookup::default_runs_base();
list_from(&base, args, github_app).await
}
async fn list_from(
base: &Path,
args: PrListArgs,
github_app: Option<fabro_github::GitHubAppCredentials>,
) -> Result<()> {
let creds = github_app.context(
"GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id",
)?;
let runs = fabro_workflows::run_lookup::scan_runs(base).context("Failed to scan runs")?;
let mut entries: Vec<(String, fabro_workflows::pull_request::PullRequestRecord)> = Vec::new();
for run in &runs {
let pr_path = run.path.join("pull_request.json");
if let Ok(content) = std::fs::read_to_string(&pr_path) {
if let Ok(record) =
serde_json::from_str::<fabro_workflows::pull_request::PullRequestRecord>(&content)
{
entries.push((run.run_id.clone(), record));
}
}
}
if entries.is_empty() {
println!("No pull requests found.");
return Ok(());
}
struct PrRow {
run_id: String,
number: u64,
state: String,
title: String,
url: String,
}
let futures: Vec<_> = entries
.iter()
.map(|(run_id, record)| {
let creds = creds.clone();
let run_id = run_id.clone();
let record = record.clone();
async move {
match fabro_github::get_pull_request(
&creds,
&record.owner,
&record.repo,
record.number,
fabro_github::GITHUB_API_BASE_URL,
)
.await
{
Ok(detail) => PrRow {
run_id,
number: detail.number,
state: if detail.draft {
"draft".to_string()
} else {
detail.state
},
title: detail.title,
url: detail.html_url,
},
Err(err) => {
tracing::warn!(run_id, error = %err, "Failed to fetch PR state");
PrRow {
run_id,
number: record.number,
state: "unknown".to_string(),
title: record.title,
url: record.html_url,
}
}
}
}
})
.collect();
let all_rows = futures::future::join_all(futures).await;
let rows: Vec<_> = if args.all {
all_rows
} else {
all_rows
.into_iter()
.filter(|row| row.state == "open" || row.state == "draft" || row.state == "unknown")
.collect()
};
if rows.is_empty() {
println!("No open pull requests found. Use --all to include closed/merged.");
return Ok(());
}
println!(
"{:<12} {:<6} {:<8} {:<50} URL",
"RUN", "#", "STATE", "TITLE"
);
for row in &rows {
let short_id = if row.run_id.len() > 12 {
&row.run_id[..12]
} else {
&row.run_id
};
let short_title = if row.title.len() > 50 {
format!("{}", &row.title[..row.title.floor_char_boundary(49)])
} else {
row.title.clone()
};
println!(
"{:<12} {:<6} {:<8} {:<50} {}",
short_id, row.number, row.state, short_title, row.url
);
}
info!(count = rows.len(), "Listed pull requests");
Ok(())
}
pub async fn view_command(
args: PrViewArgs,
github_app: Option<fabro_github::GitHubAppCredentials>,
) -> Result<()> {
let base = fabro_workflows::run_lookup::default_runs_base();
view_from(&base, args, github_app).await
}
async fn view_from(
base: &Path,
args: PrViewArgs,
github_app: Option<fabro_github::GitHubAppCredentials>,
) -> Result<()> {
let (record, _run_dir) = load_pr_record(base, &args.run_id)?;
let creds = github_app.context(
"GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id",
)?;
let detail = fabro_github::get_pull_request(
&creds,
&record.owner,
&record.repo,
record.number,
fabro_github::GITHUB_API_BASE_URL,
)
.await
.map_err(|err| anyhow::anyhow!("{err}"))?;
info!(number = detail.number, owner = %record.owner, repo = %record.repo, "Viewing pull request");
println!("#{} {}", detail.number, detail.title);
let state_display = if detail.draft { "draft" } else { &detail.state };
println!("State: {state_display}");
println!("URL: {}", detail.html_url);
println!(
"Branch: {} -> {}",
detail.head.ref_name, detail.base.ref_name
);
println!("Author: {}", detail.user.login);
println!(
"Changes: +{} -{} ({} files)",
detail.additions, detail.deletions, detail.changed_files
);
if let Some(body) = &detail.body {
if !body.is_empty() {
println!();
println!("{body}");
}
}
Ok(())
}
pub async fn merge_command(
args: PrMergeArgs,
github_app: Option<fabro_github::GitHubAppCredentials>,
) -> Result<()> {
let base = fabro_workflows::run_lookup::default_runs_base();
merge_from(&base, args, github_app).await
}
async fn merge_from(
base: &Path,
args: PrMergeArgs,
github_app: Option<fabro_github::GitHubAppCredentials>,
) -> Result<()> {
let (record, _run_dir) = load_pr_record(base, &args.run_id)?;
let creds = github_app.context(
"GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id",
)?;
fabro_github::merge_pull_request(
&creds,
&record.owner,
&record.repo,
record.number,
&args.method,
fabro_github::GITHUB_API_BASE_URL,
)
.await
.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);
Ok(())
}
pub async fn close_command(
args: PrCloseArgs,
github_app: Option<fabro_github::GitHubAppCredentials>,
) -> Result<()> {
let base = fabro_workflows::run_lookup::default_runs_base();
close_from(&base, args, github_app).await
}
async fn close_from(
base: &Path,
args: PrCloseArgs,
github_app: Option<fabro_github::GitHubAppCredentials>,
) -> Result<()> {
let (record, _run_dir) = load_pr_record(base, &args.run_id)?;
let creds = github_app.context(
"GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id",
)?;
fabro_github::close_pull_request(
&creds,
&record.owner,
&record.repo,
record.number,
fabro_github::GITHUB_API_BASE_URL,
)
.await
.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);
Ok(())
}
pub async fn create_command(
args: PrCreateArgs,
github_app: Option<fabro_github::GitHubAppCredentials>,
) -> Result<()> {
let base = fabro_workflows::run_lookup::default_runs_base();
create_from(&base, args, github_app).await
}
async fn create_from(
base: &Path,
args: PrCreateArgs,
github_app: Option<fabro_github::GitHubAppCredentials>,
) -> Result<()> {
let run_dir = fabro_workflows::run_lookup::resolve_run(base, &args.run_id)?.path;
let record =
fabro_workflows::records::RunRecord::load(&run_dir).context("Failed to load run.json")?;
let start = fabro_workflows::records::StartRecord::load(&run_dir)
.context("Failed to load start.json")?;
let conclusion = fabro_workflows::records::Conclusion::load(&run_dir.join("conclusion.json"))
.context("Failed to load conclusion.json — is the run finished?")?;
match conclusion.status {
fabro_workflows::outcome::StageStatus::Success
| fabro_workflows::outcome::StageStatus::PartialSuccess => {}
status => bail!("Run status is '{status}', expected success or partial_success"),
}
let run_branch = start
.run_branch
.as_deref()
.context("Run has no run_branch — was it run with git push enabled?")?;
let diff = std::fs::read_to_string(run_dir.join("final.patch"))
.context("Failed to read final.patch — no diff available")?;
if diff.trim().is_empty() {
bail!("final.patch is empty — nothing to create a PR for");
}
let cwd = std::env::current_dir().context("Failed to get current directory")?;
let (origin_url, detected_branch) =
fabro_sandbox::daytona::detect_repo_info(&cwd).map_err(|err| anyhow::anyhow!("{err}"))?;
let base_branch = record
.base_branch
.as_deref()
.or(detected_branch.as_deref())
.unwrap_or("main");
let https_url = fabro_github::ssh_url_to_https(&origin_url);
let (owner, repo) = fabro_github::parse_github_owner_repo(&https_url)
.map_err(|err| anyhow::anyhow!("{err}"))?;
let creds = github_app.context(
"GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id",
)?;
let branch_found = fabro_github::branch_exists(
&creds,
&owner,
&repo,
run_branch,
fabro_github::GITHUB_API_BASE_URL,
)
.await
.map_err(|err| anyhow::anyhow!("{err}"))?;
if !branch_found {
bail!(
"Branch '{run_branch}' not found on GitHub. \
Was it pushed? Try: git push origin {run_branch}"
);
}
let model = args
.model
.unwrap_or_else(|| Catalog::builtin().default_from_env().id.clone());
let record = fabro_workflows::pull_request::maybe_open_pull_request(
&creds,
&origin_url,
base_branch,
run_branch,
record.goal(),
&diff,
&model,
true,
None,
&run_dir,
None,
)
.await
.map_err(|err| anyhow::anyhow!("{err}"))?;
match record {
Some(record) => {
info!(pr_url = %record.html_url, "Pull request created");
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);
}
None => {
println!("No pull request created (empty diff).");
}
}
Ok(())
}

View file

@ -0,0 +1,41 @@
use std::path::Path;
use anyhow::{Context, Result};
use tracing::info;
use crate::args::PrCloseArgs;
pub async fn close_command(
args: PrCloseArgs,
github_app: Option<fabro_github::GitHubAppCredentials>,
) -> Result<()> {
let base = fabro_workflows::run_lookup::default_runs_base();
close_from(&base, args, github_app).await
}
async fn close_from(
base: &Path,
args: PrCloseArgs,
github_app: Option<fabro_github::GitHubAppCredentials>,
) -> Result<()> {
let (record, _run_dir) = super::load_pr_record(base, &args.run_id)?;
let creds = github_app.context(
"GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id",
)?;
fabro_github::close_pull_request(
&creds,
&record.owner,
&record.repo,
record.number,
fabro_github::GITHUB_API_BASE_URL,
)
.await
.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);
Ok(())
}

View file

@ -0,0 +1,119 @@
use std::path::Path;
use anyhow::{bail, Context, Result};
use fabro_model::Catalog;
use tracing::info;
use crate::args::PrCreateArgs;
pub async fn create_command(
args: PrCreateArgs,
github_app: Option<fabro_github::GitHubAppCredentials>,
) -> Result<()> {
let base = fabro_workflows::run_lookup::default_runs_base();
create_from(&base, args, github_app).await
}
async fn create_from(
base: &Path,
args: PrCreateArgs,
github_app: Option<fabro_github::GitHubAppCredentials>,
) -> Result<()> {
let run_dir = fabro_workflows::run_lookup::resolve_run(base, &args.run_id)?.path;
let record =
fabro_workflows::records::RunRecord::load(&run_dir).context("Failed to load run.json")?;
let start = fabro_workflows::records::StartRecord::load(&run_dir)
.context("Failed to load start.json")?;
let conclusion = fabro_workflows::records::Conclusion::load(&run_dir.join("conclusion.json"))
.context("Failed to load conclusion.json — is the run finished?")?;
match conclusion.status {
fabro_workflows::outcome::StageStatus::Success
| fabro_workflows::outcome::StageStatus::PartialSuccess => {}
status => bail!("Run status is '{status}', expected success or partial_success"),
}
let run_branch = start
.run_branch
.as_deref()
.context("Run has no run_branch — was it run with git push enabled?")?;
let diff = std::fs::read_to_string(run_dir.join("final.patch"))
.context("Failed to read final.patch — no diff available")?;
if diff.trim().is_empty() {
bail!("final.patch is empty — nothing to create a PR for");
}
let cwd = std::env::current_dir().context("Failed to get current directory")?;
let (origin_url, detected_branch) =
fabro_sandbox::daytona::detect_repo_info(&cwd).map_err(|err| anyhow::anyhow!("{err}"))?;
let base_branch = record
.base_branch
.as_deref()
.or(detected_branch.as_deref())
.unwrap_or("main");
let https_url = fabro_github::ssh_url_to_https(&origin_url);
let (owner, repo) = fabro_github::parse_github_owner_repo(&https_url)
.map_err(|err| anyhow::anyhow!("{err}"))?;
let creds = github_app.context(
"GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id",
)?;
let branch_found = fabro_github::branch_exists(
&creds,
&owner,
&repo,
run_branch,
fabro_github::GITHUB_API_BASE_URL,
)
.await
.map_err(|err| anyhow::anyhow!("{err}"))?;
if !branch_found {
bail!(
"Branch '{run_branch}' not found on GitHub. \
Was it pushed? Try: git push origin {run_branch}"
);
}
let model = args
.model
.unwrap_or_else(|| Catalog::builtin().default_from_env().id.clone());
let record = fabro_workflows::pull_request::maybe_open_pull_request(
&creds,
&origin_url,
base_branch,
run_branch,
record.goal(),
&diff,
&model,
true,
None,
&run_dir,
None,
)
.await
.map_err(|err| anyhow::anyhow!("{err}"))?;
match record {
Some(record) => {
info!(pr_url = %record.html_url, "Pull request created");
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);
}
None => {
println!("No pull request created (empty diff).");
}
}
Ok(())
}

View file

@ -0,0 +1,132 @@
use std::path::Path;
use anyhow::{Context, Result};
use tracing::info;
use crate::args::PrListArgs;
pub async fn list_command(
args: PrListArgs,
github_app: Option<fabro_github::GitHubAppCredentials>,
) -> Result<()> {
let base = fabro_workflows::run_lookup::default_runs_base();
list_from(&base, args, github_app).await
}
async fn list_from(
base: &Path,
args: PrListArgs,
github_app: Option<fabro_github::GitHubAppCredentials>,
) -> Result<()> {
let creds = github_app.context(
"GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id",
)?;
let runs = fabro_workflows::run_lookup::scan_runs(base).context("Failed to scan runs")?;
let mut entries: Vec<(String, fabro_workflows::pull_request::PullRequestRecord)> = Vec::new();
for run in &runs {
let pr_path = run.path.join("pull_request.json");
if let Ok(content) = std::fs::read_to_string(&pr_path) {
if let Ok(record) =
serde_json::from_str::<fabro_workflows::pull_request::PullRequestRecord>(&content)
{
entries.push((run.run_id.clone(), record));
}
}
}
if entries.is_empty() {
println!("No pull requests found.");
return Ok(());
}
struct PrRow {
run_id: String,
number: u64,
state: String,
title: String,
url: String,
}
let futures: Vec<_> = entries
.iter()
.map(|(run_id, record)| {
let creds = creds.clone();
let run_id = run_id.clone();
let record = record.clone();
async move {
match fabro_github::get_pull_request(
&creds,
&record.owner,
&record.repo,
record.number,
fabro_github::GITHUB_API_BASE_URL,
)
.await
{
Ok(detail) => PrRow {
run_id,
number: detail.number,
state: if detail.draft {
"draft".to_string()
} else {
detail.state
},
title: detail.title,
url: detail.html_url,
},
Err(err) => {
tracing::warn!(run_id, error = %err, "Failed to fetch PR state");
PrRow {
run_id,
number: record.number,
state: "unknown".to_string(),
title: record.title,
url: record.html_url,
}
}
}
}
})
.collect();
let all_rows = futures::future::join_all(futures).await;
let rows: Vec<_> = if args.all {
all_rows
} else {
all_rows
.into_iter()
.filter(|row| row.state == "open" || row.state == "draft" || row.state == "unknown")
.collect()
};
if rows.is_empty() {
println!("No open pull requests found. Use --all to include closed/merged.");
return Ok(());
}
println!(
"{:<12} {:<6} {:<8} {:<50} URL",
"RUN", "#", "STATE", "TITLE"
);
for row in &rows {
let short_id = if row.run_id.len() > 12 {
&row.run_id[..12]
} else {
&row.run_id
};
let short_title = if row.title.len() > 50 {
format!("{}", &row.title[..row.title.floor_char_boundary(49)])
} else {
row.title.clone()
};
println!(
"{:<12} {:<6} {:<8} {:<50} {}",
short_id, row.number, row.state, short_title, row.url
);
}
info!(count = rows.len(), "Listed pull requests");
Ok(())
}

View file

@ -0,0 +1,42 @@
use std::path::Path;
use anyhow::{Context, Result};
use tracing::info;
use crate::args::PrMergeArgs;
pub async fn merge_command(
args: PrMergeArgs,
github_app: Option<fabro_github::GitHubAppCredentials>,
) -> Result<()> {
let base = fabro_workflows::run_lookup::default_runs_base();
merge_from(&base, args, github_app).await
}
async fn merge_from(
base: &Path,
args: PrMergeArgs,
github_app: Option<fabro_github::GitHubAppCredentials>,
) -> Result<()> {
let (record, _run_dir) = super::load_pr_record(base, &args.run_id)?;
let creds = github_app.context(
"GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id",
)?;
fabro_github::merge_pull_request(
&creds,
&record.owner,
&record.repo,
record.number,
&args.method,
fabro_github::GITHUB_API_BASE_URL,
)
.await
.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);
Ok(())
}

View file

@ -0,0 +1,41 @@
mod close;
mod create;
mod list;
mod merge;
mod view;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crate::args::{PrCommand, PrNamespace};
pub async fn dispatch(ns: PrNamespace) -> Result<()> {
let cli_config = crate::cli_config::load_cli_config(None)?;
let github_app = crate::shared::github::build_github_app_credentials(cli_config.app_id());
match ns.command {
PrCommand::Create(args) => create::create_command(args, github_app).await,
PrCommand::List(args) => list::list_command(args, github_app).await,
PrCommand::View(args) => view::view_command(args, github_app).await,
PrCommand::Merge(args) => merge::merge_command(args, github_app).await,
PrCommand::Close(args) => close::close_command(args, github_app).await,
}
}
pub(crate) fn load_pr_record(
base: &Path,
run_id: &str,
) -> Result<(fabro_workflows::pull_request::PullRequestRecord, PathBuf)> {
let run_dir = fabro_workflows::run_lookup::resolve_run(base, run_id)?.path;
let pr_path = run_dir.join("pull_request.json");
let content = std::fs::read_to_string(&pr_path).with_context(|| {
format!(
"No pull_request.json found in run directory. \
Create one first with: fabro pr create {run_id}"
)
})?;
let record: fabro_workflows::pull_request::PullRequestRecord =
serde_json::from_str(&content).context("Failed to parse pull_request.json")?;
Ok((record, run_dir))
}

View file

@ -0,0 +1,60 @@
use std::path::Path;
use anyhow::{Context, Result};
use tracing::info;
use crate::args::PrViewArgs;
pub async fn view_command(
args: PrViewArgs,
github_app: Option<fabro_github::GitHubAppCredentials>,
) -> Result<()> {
let base = fabro_workflows::run_lookup::default_runs_base();
view_from(&base, args, github_app).await
}
async fn view_from(
base: &Path,
args: PrViewArgs,
github_app: Option<fabro_github::GitHubAppCredentials>,
) -> Result<()> {
let (record, _run_dir) = super::load_pr_record(base, &args.run_id)?;
let creds = github_app.context(
"GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id",
)?;
let detail = fabro_github::get_pull_request(
&creds,
&record.owner,
&record.repo,
record.number,
fabro_github::GITHUB_API_BASE_URL,
)
.await
.map_err(|err| anyhow::anyhow!("{err}"))?;
info!(number = detail.number, owner = %record.owner, repo = %record.repo, "Viewing pull request");
println!("#{} {}", detail.number, detail.title);
let state_display = if detail.draft { "draft" } else { &detail.state };
println!("State: {state_display}");
println!("URL: {}", detail.html_url);
println!(
"Branch: {} -> {}",
detail.head.ref_name, detail.base.ref_name
);
println!("Author: {}", detail.user.login);
println!(
"Changes: +{} -{} ({} files)",
detail.additions, detail.deletions, detail.changed_files
);
if let Some(body) = &detail.body {
if !body.is_empty() {
println!();
println!("{body}");
}
}
Ok(())
}

View file

@ -1,31 +1,8 @@
use anyhow::{Context, Result};
use clap::Args;
use tracing::info;
use super::shared::validate_daytona_provider;
#[derive(Args)]
pub struct PreviewArgs {
/// Run ID or prefix
pub run: String,
/// Port number
pub port: u16,
/// Generate a signed URL (embeds auth token, no headers needed)
#[arg(long)]
pub signed: bool,
/// Signed URL expiry in seconds (default 3600, requires --signed)
#[arg(long, default_value = "3600", requires = "signed")]
pub ttl: i32,
/// Open URL in browser (implies --signed)
#[arg(long)]
pub open: bool,
}
impl PreviewArgs {
fn use_signed(&self) -> bool {
self.signed || self.open
}
}
use crate::args::PreviewArgs;
use crate::shared::validate_daytona_provider;
pub async fn run(args: PreviewArgs) -> Result<()> {
let base = fabro_workflows::run_lookup::default_runs_base();
@ -48,7 +25,7 @@ pub async fn run(args: PreviewArgs) -> Result<()> {
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
if args.use_signed() {
if args.signed || args.open {
let signed = daytona
.get_signed_preview_url(args.port, Some(args.ttl))
.await

View file

@ -1,16 +1,9 @@
use anyhow::{Context, Result};
use clap::Args;
use fabro_model::Provider;
use fabro_util::terminal::Styles;
use crate::provider_auth;
#[derive(Args)]
pub struct ProviderLoginArgs {
/// LLM provider to authenticate with
#[arg(long)]
pub provider: Provider,
}
use crate::args::ProviderLoginArgs;
use crate::shared::provider_auth;
pub async fn login_command(args: ProviderLoginArgs) -> Result<()> {
let s = Styles::detect_stderr();

View file

@ -0,0 +1,11 @@
mod login;
use anyhow::Result;
use crate::args::{ProviderCommand, ProviderNamespace};
pub async fn dispatch(ns: ProviderNamespace) -> Result<()> {
match ns.command {
ProviderCommand::Login(args) => login::login_command(args).await,
}
}

View file

@ -0,0 +1,43 @@
use anyhow::{bail, Context, Result};
pub fn run_deinit() -> Result<()> {
let repo_root = super::init::git_repo_root()?;
let fabro_toml = repo_root.join("fabro.toml");
let green = console::Style::new().green();
let dim = console::Style::new().dim();
match std::fs::remove_file(&fabro_toml) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
bail!("not initialized — fabro.toml not found");
}
Err(e) => bail!("failed to remove {}: {e}", fabro_toml.display()),
}
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()))?;
eprintln!(
" {} {}",
green.apply_to(""),
dim.apply_to("removed fabro/")
);
}
eprintln!(
"\n{}",
console::Style::new()
.bold()
.apply_to("Project deinitialized.")
);
Ok(())
}

View file

@ -1,7 +1,8 @@
use anyhow::{bail, Context, Result};
use std::path::PathBuf;
fn git_repo_root() -> Result<PathBuf> {
use anyhow::{bail, Context, Result};
pub(super) fn git_repo_root() -> Result<PathBuf> {
let output = std::process::Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.output()
@ -112,48 +113,6 @@ draft = true
Ok(())
}
pub fn run_deinit() -> Result<()> {
let repo_root = git_repo_root()?;
let fabro_toml = repo_root.join("fabro.toml");
let green = console::Style::new().green();
let dim = console::Style::new().dim();
match std::fs::remove_file(&fabro_toml) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
bail!("not initialized — fabro.toml not found");
}
Err(e) => bail!("failed to remove {}: {e}", fabro_toml.display()),
}
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()))?;
eprintln!(
" {} {}",
green.apply_to(""),
dim.apply_to("removed fabro/")
);
}
eprintln!(
"\n{}",
console::Style::new()
.bold()
.apply_to("Project deinitialized.")
);
Ok(())
}
async fn check_github_app_installation() {
// Get the git remote origin URL
let output = match std::process::Command::new("git")
@ -213,7 +172,7 @@ async fn check_github_app_installation() {
let slug = cli_config.slug().map(String::from);
// Build GitHub App credentials
let creds = match crate::build_github_app_credentials(Some(&app_id)) {
let creds = match crate::shared::github::build_github_app_credentials(Some(&app_id)) {
Some(c) => c,
None => {
eprintln!(

View file

@ -0,0 +1,20 @@
pub mod deinit;
pub mod init;
use anyhow::Result;
use crate::args::{RepoCommand, RepoNamespace};
pub async fn dispatch(ns: RepoNamespace) -> Result<()> {
match ns.command {
RepoCommand::Init { skill } => {
init::run_init().await?;
if skill {
let base = std::env::current_dir()?.join(".claude").join("skills");
super::skill::install_skill_to(&base)?;
}
Ok(())
}
RepoCommand::Deinit => deinit::run_deinit(),
}
}

View file

@ -1,18 +1,9 @@
use anyhow::bail;
use clap::Args;
use fabro_util::terminal::Styles;
use fabro_workflows::records::{Checkpoint, RunRecord};
use fabro_workflows::run_status::{RunStatus, RunStatusRecord};
#[derive(Debug, Args)]
pub struct ResumeArgs {
/// Run ID or unambiguous prefix
pub run: String,
/// Run in the background and print the run ID
#[arg(short = 'd', long)]
pub detach: bool,
}
use crate::args::ResumeArgs;
/// Resume an interrupted workflow run.
///

View file

@ -1,30 +1,13 @@
use anyhow::Context;
use anyhow::Result;
use clap::Args;
use cli_table::format::{Border, Separator};
use cli_table::{print_stderr, Cell, CellStruct, Color, Style, Table};
use fabro_git_storage::gitobj::Store;
use fabro_util::terminal::Styles;
use git2::Repository;
use super::shared::color_if;
#[derive(Debug, Args)]
pub struct RewindArgs {
/// Run ID (or unambiguous prefix)
pub run_id: String,
/// Target checkpoint: node name, node@visit, or @ordinal (omit with --list)
pub target: Option<String>,
/// Show the checkpoint timeline instead of rewinding
#[arg(long)]
pub list: bool,
/// Skip force-pushing rewound refs to the remote
#[arg(long)]
pub no_push: bool,
}
use crate::args::RewindArgs;
use crate::shared::color_if;
pub fn run(args: &RewindArgs, styles: &Styles) -> Result<()> {
let repo = Repository::discover(".").context("not in a git repository")?;

View file

@ -6,7 +6,6 @@ use std::time::Instant;
use anyhow::{bail, Context};
use chrono::Local;
use clap::{Args, ValueEnum};
use fabro_agent::{DockerSandbox, DockerSandboxConfig, LocalSandbox, Sandbox};
use fabro_config::config::FabroConfig;
use fabro_config::{project as project_config, run as run_config, sandbox as sandbox_config};
@ -33,115 +32,12 @@ use tracing::debug;
use super::detached_support::{DetachedRunBootstrapGuard, DetachedRunCompletionGuard};
use super::run_progress;
use crate::commands::shared::{
use crate::args::{CliSandboxProvider, GlobalArgs, RunArgs};
use crate::cli_config;
use crate::shared::{
format_tokens_human, print_diagnostics, read_workflow_file, relative_path, tilde_path,
};
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum CliSandboxProvider {
Local,
Docker,
Daytona,
#[cfg(feature = "exedev")]
Exe,
Ssh,
}
impl From<CliSandboxProvider> for SandboxProvider {
fn from(value: CliSandboxProvider) -> Self {
match value {
CliSandboxProvider::Local => Self::Local,
CliSandboxProvider::Docker => Self::Docker,
CliSandboxProvider::Daytona => Self::Daytona,
#[cfg(feature = "exedev")]
CliSandboxProvider::Exe => Self::Exe,
CliSandboxProvider::Ssh => Self::Ssh,
}
}
}
impl From<SandboxProvider> for CliSandboxProvider {
fn from(value: SandboxProvider) -> Self {
match value {
SandboxProvider::Local => Self::Local,
SandboxProvider::Docker => Self::Docker,
SandboxProvider::Daytona => Self::Daytona,
#[cfg(feature = "exedev")]
SandboxProvider::Exe => Self::Exe,
#[cfg(not(feature = "exedev"))]
SandboxProvider::Exe => Self::Local,
SandboxProvider::Ssh => Self::Ssh,
}
}
}
#[derive(Args)]
pub struct RunArgs {
/// Path to a .fabro workflow file or .toml task config
#[arg(required = true)]
pub workflow: Option<PathBuf>,
/// Run output directory
#[arg(long)]
pub run_dir: Option<PathBuf>,
/// Execute with simulated LLM backend
#[arg(long)]
pub dry_run: bool,
/// Validate run configuration without executing
#[arg(long, conflicts_with = "dry_run")]
pub preflight: bool,
/// Auto-approve all human gates
#[arg(long)]
pub auto_approve: bool,
/// Override the workflow goal (exposed as $goal in prompts)
#[arg(long)]
pub goal: Option<String>,
/// Read the workflow goal from a file
#[arg(long, conflicts_with = "goal")]
pub goal_file: Option<PathBuf>,
/// Override default LLM model
#[arg(long)]
pub model: Option<String>,
/// Override default LLM provider
#[arg(long)]
pub provider: Option<String>,
/// Enable verbose output
#[arg(short, long)]
pub verbose: bool,
/// Sandbox for agent tools
#[arg(long, value_enum)]
pub sandbox: Option<CliSandboxProvider>,
/// Attach a label to this run (repeatable, format: KEY=VALUE)
#[arg(long = "label", value_name = "KEY=VALUE")]
pub label: Vec<String>,
/// Skip retro generation after the run
#[arg(long)]
pub no_retro: bool,
/// Keep the sandbox alive after the run finishes (for debugging)
#[arg(long)]
pub preserve_sandbox: bool,
/// Run the workflow in the background and print the run ID
#[arg(short = 'd', long, conflicts_with = "preflight")]
pub detach: bool,
/// Pre-generated run ID (used internally by --detach)
#[arg(long, hide = true)]
pub run_id: Option<String>,
}
/// Resolve goal from `--goal` string or `--goal-file` path.
pub(crate) fn resolve_cli_goal(
goal: &Option<String>,
@ -836,6 +732,43 @@ fn ensure_resume_target_is_not_already_successful(run_dir: &Path) -> anyhow::Res
/// # Errors
///
/// Returns an error if the workflow cannot be read, parsed, validated, or executed.
pub async fn execute(mut args: RunArgs, _globals: &GlobalArgs) -> anyhow::Result<()> {
let styles: &'static fabro_util::terminal::Styles =
Box::leak(Box::new(fabro_util::terminal::Styles::detect_stderr()));
let cli_config = cli_config::load_cli_config(None)?;
args.verbose = args.verbose || cli_config.verbose_enabled();
if args.preflight {
let github_app = crate::shared::github::build_github_app_credentials(cli_config.app_id());
let git_author = fabro_workflows::git::GitAuthor::from_options(
cli_config.git_author().and_then(|a| a.name.clone()),
cli_config.git_author().and_then(|a| a.email.clone()),
);
run_command(args, cli_config, styles, github_app, git_author).await?;
} else {
let quiet = args.detach;
let _prevent_idle_sleep = cli_config.prevent_idle_sleep_enabled();
let (run_id, run_dir) = super::create::create_run(&args, cli_config, styles, quiet).await?;
#[cfg(feature = "sleep_inhibitor")]
let _sleep_guard = fabro_beastie::guard(_prevent_idle_sleep);
let child = super::start::start_run(&run_dir, false)?;
if args.detach {
println!("{run_id}");
} else {
let exit_code = super::attach::attach_run(&run_dir, true, styles, Some(child)).await?;
print_run_summary(&run_dir, &run_id, styles);
if exit_code != std::process::ExitCode::SUCCESS {
std::process::exit(1);
}
}
}
Ok(())
}
pub async fn run_command(
args: RunArgs,
run_defaults: FabroConfig,

View file

@ -0,0 +1,83 @@
use std::path::PathBuf;
use anyhow::Result;
use crate::cli_config;
use crate::shared;
pub async fn execute(run_dir: PathBuf, resume: bool) -> Result<()> {
let styles: &'static fabro_util::terminal::Styles =
Box::leak(Box::new(fabro_util::terminal::Styles::detect_stderr()));
let cli_config = cli_config::load_cli_config(None)?;
let github_app = shared::github::build_github_app_credentials(cli_config.app_id());
let git_author = fabro_workflows::git::GitAuthor::from_options(
cli_config.git_author().and_then(|a| a.name.clone()),
cli_config.git_author().and_then(|a| a.email.clone()),
);
let persisted = match fabro_workflows::pipeline::Persisted::load(&run_dir) {
Ok(persisted) => persisted,
Err(err) => {
let anyhow_err: anyhow::Error = anyhow::anyhow!("Failed to load persisted run: {err}");
let _ = super::detached_support::persist_detached_failure(
&run_dir,
"bootstrap",
fabro_workflows::run_status::StatusReason::BootstrapFailed,
&anyhow_err,
);
return Err(anyhow_err);
}
};
if let Err(err) =
std::env::set_current_dir(&persisted.run_record().working_directory).map_err(|e| {
anyhow::anyhow!(
"Failed to set working directory to {}: {e}",
persisted.run_record().working_directory.display()
)
})
{
let _ = super::detached_support::persist_detached_failure(
&run_dir,
"bootstrap",
fabro_workflows::run_status::StatusReason::BootstrapFailed,
&err,
);
return Err(err);
}
let result = if resume {
super::run::resume_from_record(
persisted,
run_dir.clone(),
cli_config,
styles,
github_app,
git_author,
)
.await
} else {
super::run::run_from_record(
persisted,
run_dir.clone(),
cli_config,
styles,
github_app,
git_author,
)
.await
};
match result {
Ok(()) => Ok(()),
Err(err) => {
let _ = super::detached_support::persist_detached_failure(
&run_dir,
"bootstrap",
fabro_workflows::run_status::StatusReason::SandboxInitFailed,
&err,
);
Err(err)
}
}
}

View file

@ -12,7 +12,7 @@ use fabro_interview::{Answer, ConsoleInterviewer, Interviewer, Question};
use fabro_workflows::event::{EventEmitter, RunNoticeLevel, WorkflowRunEvent};
use fabro_workflows::outcome::StageStatus;
use crate::commands::shared::{format_duration_ms, format_tokens_human, tilde_path};
use crate::shared::{format_duration_ms, format_tokens_human, tilde_path};
use fabro_workflows::outcome::{compute_stage_cost, format_cost};
// ── Cached styles ───────────────────────────────────────────────────────

View file

@ -1,83 +1,14 @@
use std::path::Path;
use anyhow::{bail, Context, Result};
use chrono::{DateTime, Utc};
use clap::Args;
use cli_table::format::{Border, Justify, Separator};
use chrono::Utc;
use cli_table::format::{Border, Separator};
use cli_table::{print_stdout, Cell, CellStruct, Color, Style, Table};
use fabro_util::terminal::Styles;
use tracing::{debug, info, warn};
use tracing::warn;
use super::shared::{color_if, format_duration_ms, format_size, tilde_path};
#[derive(Args)]
pub struct RunFilterArgs {
/// Only include runs started before this date (YYYY-MM-DD prefix match)
#[arg(long)]
pub before: Option<String>,
/// Filter by workflow name (substring match)
#[arg(long)]
pub workflow: Option<String>,
/// Filter by label (KEY=VALUE, repeatable, AND semantics)
#[arg(long = "label", value_name = "KEY=VALUE")]
pub label: Vec<String>,
/// Include orphan directories (no run.json)
#[arg(long)]
pub orphans: bool,
}
#[derive(Args)]
pub struct RunsListArgs {
#[command(flatten)]
pub filter: RunFilterArgs,
/// Output as JSON
#[arg(long)]
pub json: bool,
/// Show all runs, not just running (like docker ps -a)
#[arg(short = 'a', long)]
pub all: bool,
/// Only display run IDs
#[arg(short = 'q', long)]
pub quiet: bool,
}
#[derive(Args)]
pub struct RunsPruneArgs {
#[command(flatten)]
pub filter: RunFilterArgs,
/// Only prune runs older than this duration (e.g. 24h, 7d). Default: 24h when no explicit filters are set.
#[arg(long, value_name = "DURATION", value_parser = parse_duration)]
pub older_than: Option<chrono::Duration>,
/// Actually delete (default is dry-run)
#[arg(long)]
pub yes: bool,
}
#[derive(Args)]
pub struct RunsRemoveArgs {
/// Run IDs or workflow names to remove
#[arg(required = true)]
pub runs: Vec<String>,
/// Force removal of active runs
#[arg(short, long)]
pub force: bool,
}
#[derive(Args)]
pub struct DfArgs {
/// Show per-run breakdown
#[arg(short, long)]
pub verbose: bool,
}
use crate::args::{RunsListArgs, RunsRemoveArgs};
use crate::shared::{color_if, format_duration_ms, tilde_path};
pub fn list_command(args: &RunsListArgs, styles: &Styles) -> Result<()> {
let base = fabro_workflows::run_lookup::default_runs_base();
@ -176,18 +107,6 @@ pub fn list_command(args: &RunsListArgs, styles: &Styles) -> Result<()> {
Ok(())
}
pub fn df_command(args: &DfArgs) -> Result<()> {
let data_dir = fabro_workflows::run_lookup::default_data_dir();
let runs_base = fabro_workflows::run_lookup::default_runs_base();
let logs_base = fabro_workflows::run_lookup::default_logs_base();
df_from(args, &data_dir, &runs_base, &logs_base)
}
pub fn prune_command(args: &RunsPruneArgs) -> Result<()> {
let base = fabro_workflows::run_lookup::default_runs_base();
prune_from(args, &base)
}
pub async fn remove_command(args: &RunsRemoveArgs) -> Result<()> {
let base = fabro_workflows::run_lookup::default_runs_base();
remove_from(args, &base).await
@ -239,276 +158,6 @@ fn truncate_str(s: &str, max_len: usize) -> String {
format!("{truncated}...")
}
fn dir_size(path: &Path) -> u64 {
walkdir::WalkDir::new(path)
.into_iter()
.filter_map(|entry| entry.ok())
.filter_map(|entry| entry.metadata().ok())
.filter(|metadata| metadata.is_file())
.map(|metadata| metadata.len())
.sum()
}
fn df_from(args: &DfArgs, data_dir: &Path, runs_base: &Path, logs_base: &Path) -> Result<()> {
let runs = fabro_workflows::run_lookup::scan_runs(runs_base)?;
let mut active_count = 0u64;
let mut total_run_size = 0u64;
let mut reclaimable_run_size = 0u64;
struct RunSizeInfo {
run_id: String,
workflow_name: String,
status: fabro_workflows::run_status::RunStatus,
start_time_dt: Option<DateTime<Utc>>,
size: u64,
}
let mut run_details = Vec::new();
for run in &runs {
let size = dir_size(&run.path);
total_run_size += size;
if run.status.is_active() {
active_count += 1;
} else {
reclaimable_run_size += size;
}
if args.verbose {
run_details.push(RunSizeInfo {
run_id: run.run_id.clone(),
workflow_name: run.workflow_name.clone(),
status: run.status,
start_time_dt: run.start_time_dt,
size,
});
}
}
let mut log_count = 0u64;
let mut total_log_size = 0u64;
if let Ok(entries) = std::fs::read_dir(logs_base) {
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
if path.extension().is_some_and(|ext| ext == "log") {
if let Ok(meta) = path.metadata() {
log_count += 1;
total_log_size += meta.len();
}
}
}
}
let mut db_count = 0u64;
let mut total_db_size = 0u64;
if let Ok(entries) = std::fs::read_dir(data_dir) {
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
if name.ends_with(".db") || name.ends_with(".db-wal") || name.ends_with(".db-shm") {
if let Ok(meta) = path.metadata() {
db_count += 1;
total_db_size += meta.len();
}
}
}
}
let run_reclaim_pct = if total_run_size > 0 {
(reclaimable_run_size as f64 / total_run_size as f64 * 100.0) as u64
} else {
0
};
let log_reclaim_pct = if total_log_size > 0 { 100 } else { 0 };
let summary_title = vec![
"TYPE".cell().bold(true),
"COUNT".cell().bold(true).justify(Justify::Right),
"ACTIVE".cell().bold(true).justify(Justify::Right),
"SIZE".cell().bold(true).justify(Justify::Right),
"RECLAIMABLE".cell().bold(true).justify(Justify::Right),
];
let summary_rows: Vec<Vec<CellStruct>> = vec![
vec![
"Runs".cell(),
runs.len().cell().justify(Justify::Right),
active_count.cell().justify(Justify::Right),
format_size(total_run_size).cell().justify(Justify::Right),
format!("{} ({run_reclaim_pct}%)", format_size(reclaimable_run_size))
.cell()
.justify(Justify::Right),
],
vec![
"Logs".cell(),
log_count.cell().justify(Justify::Right),
"-".cell().justify(Justify::Right),
format_size(total_log_size).cell().justify(Justify::Right),
format!("{} ({log_reclaim_pct}%)", format_size(total_log_size))
.cell()
.justify(Justify::Right),
],
vec![
"Databases".cell(),
db_count.cell().justify(Justify::Right),
"-".cell().justify(Justify::Right),
format_size(total_db_size).cell().justify(Justify::Right),
format!("{} (0%)", format_size(0))
.cell()
.justify(Justify::Right),
],
];
let summary_table = summary_rows
.table()
.title(summary_title)
.border(Border::builder().build())
.separator(Separator::builder().build());
print_stdout(summary_table)?;
println!();
println!("Data directory: {}", data_dir.display());
if !args.verbose {
return Ok(());
}
println!();
let verbose_title = vec![
"RUN ID".cell().bold(true),
"WORKFLOW".cell().bold(true),
"STATUS".cell().bold(true),
"AGE".cell().bold(true).justify(Justify::Right),
"SIZE".cell().bold(true).justify(Justify::Right),
];
let now = Utc::now();
let verbose_rows: Vec<Vec<CellStruct>> = run_details
.iter()
.map(|detail| {
let age = if let Some(dt) = detail.start_time_dt {
let dur = now.signed_duration_since(dt);
if dur.num_days() > 0 {
format!("{}d", dur.num_days())
} else if dur.num_hours() > 0 {
format!("{}h", dur.num_hours())
} else {
format!("{}m", dur.num_minutes().max(1))
}
} else {
"-".to_string()
};
let size_display = if detail.status.is_active() {
format_size(detail.size)
} else {
format!("{} *", format_size(detail.size))
};
vec![
short_run_id(&detail.run_id).cell(),
truncate_str(&detail.workflow_name, 16).cell(),
detail.status.to_string().cell(),
age.cell().justify(Justify::Right),
size_display.cell().justify(Justify::Right),
]
})
.collect();
let verbose_table = verbose_rows
.table()
.title(verbose_title)
.border(Border::builder().build())
.separator(Separator::builder().build());
print_stdout(verbose_table)?;
println!();
println!("* = reclaimable");
Ok(())
}
fn parse_duration(s: &str) -> Result<chrono::Duration> {
let s = s.trim();
if s.is_empty() {
bail!("empty duration string");
}
let (num_str, unit) = s.split_at(s.len() - 1);
let num: u64 = num_str
.parse()
.with_context(|| format!("invalid duration: {s}"))?;
match unit {
"h" => Ok(chrono::Duration::hours(num as i64)),
"d" => Ok(chrono::Duration::days(num as i64)),
_ => bail!("invalid duration unit '{unit}' in '{s}' (expected 'h' or 'd')"),
}
}
fn prune_from(args: &RunsPruneArgs, base: &Path) -> Result<()> {
let runs = fabro_workflows::run_lookup::scan_runs(base)?;
let label_filters = parse_label_filters(&args.filter.label);
let mut filtered = fabro_workflows::run_lookup::filter_runs(
&runs,
args.filter.before.as_deref(),
args.filter.workflow.as_deref(),
&label_filters,
args.filter.orphans,
fabro_workflows::run_lookup::StatusFilter::All,
);
let has_explicit_filters =
args.filter.before.is_some() || args.filter.workflow.is_some() || !label_filters.is_empty();
let staleness_threshold = if let Some(duration) = args.older_than {
Some(duration)
} else if !has_explicit_filters {
Some(chrono::Duration::hours(24))
} else {
None
};
if let Some(threshold) = staleness_threshold {
let cutoff = Utc::now() - threshold;
filtered.retain(|run| {
if run.status.is_active() {
return false;
}
run.end_time
.or(run.start_time_dt)
.is_some_and(|time| time < cutoff)
});
}
if filtered.is_empty() {
eprintln!("No matching runs to prune.");
return Ok(());
}
let total_bytes: u64 = filtered.iter().map(|run| dir_size(&run.path)).sum();
info!(count = filtered.len(), bytes = total_bytes, "pruning runs");
if args.yes {
for run in &filtered {
info!(run_id = %run.run_id, path = %run.path.display(), "deleting run");
std::fs::remove_dir_all(&run.path)?;
}
eprintln!(
"{} run(s) deleted ({} freed).",
filtered.len(),
format_size(total_bytes)
);
return Ok(());
}
for run in &filtered {
debug!(run_id = %run.run_id, "would delete run (dry-run)");
println!("would delete: {} ({})", run.dir_name, run.workflow_name);
}
eprintln!(
"\n{} run(s) would be deleted ({} freed). Pass --yes to confirm.",
filtered.len(),
format_size(total_bytes)
);
Ok(())
}
async fn remove_from(args: &RunsRemoveArgs, base: &Path) -> Result<()> {
let mut had_errors = false;
@ -568,6 +217,8 @@ async fn remove_from(args: &RunsRemoveArgs, base: &Path) -> Result<()> {
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::system::parse_duration;
use crate::shared::format_size;
#[test]
fn parse_duration_hours() {

View file

@ -1,89 +0,0 @@
use anyhow::{bail, Result};
use clap::Args;
use fabro_config::dotenv;
#[derive(Args)]
pub struct SecretGetArgs {
/// Name of the secret
pub key: String,
}
#[derive(Args)]
pub struct SecretListArgs {
/// Show values alongside keys
#[arg(long)]
pub show_values: bool,
}
#[derive(Args)]
pub struct SecretRmArgs {
/// Name of the secret to remove
pub key: String,
}
#[derive(Args)]
pub struct SecretSetArgs {
/// Name of the secret
pub key: String,
/// Value to store
pub value: String,
}
pub fn get_command(args: &SecretGetArgs) -> Result<()> {
let path = dotenv::env_file_path()?;
match dotenv::get_env_value(&path, &args.key)? {
Some(value) => {
println!("{value}");
Ok(())
}
None => bail!("secret not found: {}", args.key),
}
}
pub fn list_command(args: &SecretListArgs) -> 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) => bail!("failed to read {}: {e}", path.display()),
};
let pairs = dotenv::parse_env(&contents);
for (key, value) in pairs {
if args.show_values {
println!("{key}={value}");
} else {
println!("{key}");
}
}
Ok(())
}
pub fn rm_command(args: &SecretRmArgs) -> 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 => {
bail!("secret not found: {}", args.key)
}
Err(e) => bail!("failed to read {}: {e}", path.display()),
};
let updated = dotenv::remove_env_key(&contents, &args.key);
match updated {
Some(new_contents) => {
dotenv::write_env_file(&path, &new_contents)?;
eprintln!("Removed {}", args.key);
Ok(())
}
None => bail!("secret not found: {}", args.key),
}
}
pub fn set_command(args: &SecretSetArgs) -> 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);
Ok(())
}

View file

@ -0,0 +1,15 @@
use anyhow::{bail, Result};
use crate::args::SecretGetArgs;
use fabro_config::dotenv;
pub fn get_command(args: &SecretGetArgs) -> Result<()> {
let path = dotenv::env_file_path()?;
match dotenv::get_env_value(&path, &args.key)? {
Some(value) => {
println!("{value}");
Ok(())
}
None => bail!("secret not found: {}", args.key),
}
}

View file

@ -0,0 +1,22 @@
use anyhow::{bail, Result};
use crate::args::SecretListArgs;
use fabro_config::dotenv;
pub fn list_command(args: &SecretListArgs) -> 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) => bail!("failed to read {}: {e}", path.display()),
};
let pairs = dotenv::parse_env(&contents);
for (key, value) in pairs {
if args.show_values {
println!("{key}={value}");
} else {
println!("{key}");
}
}
Ok(())
}

View file

@ -0,0 +1,17 @@
mod get;
mod list;
mod rm;
mod set;
use anyhow::Result;
use crate::args::{SecretCommand, SecretNamespace};
pub fn dispatch(ns: SecretNamespace) -> 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),
}
}

View file

@ -0,0 +1,24 @@
use anyhow::{bail, Result};
use crate::args::SecretRmArgs;
use fabro_config::dotenv;
pub fn rm_command(args: &SecretRmArgs) -> 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 => {
bail!("secret not found: {}", args.key)
}
Err(e) => bail!("failed to read {}: {e}", path.display()),
};
let updated = dotenv::remove_env_key(&contents, &args.key);
match updated {
Some(new_contents) => {
dotenv::write_env_file(&path, &new_contents)?;
eprintln!("Removed {}", args.key);
Ok(())
}
None => bail!("secret not found: {}", args.key),
}
}

View file

@ -0,0 +1,13 @@
use anyhow::Result;
use crate::args::SecretSetArgs;
use fabro_config::dotenv;
pub fn set_command(args: &SecretSetArgs) -> 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);
Ok(())
}

View file

@ -1,16 +1,17 @@
use std::path::Path;
use anyhow::{bail, Result};
use clap::{Args, ValueEnum};
use tracing::{debug, info};
const SKILL_MD: &str = include_str!("../../../../skills/fabro-create-workflow/SKILL.md");
use crate::args::{SkillDir, SkillInstallArgs, SkillScope};
const SKILL_MD: &str = include_str!("../../../../../../skills/fabro-create-workflow/SKILL.md");
const REF_DOT_LANGUAGE: &str =
include_str!("../../../../skills/fabro-create-workflow/references/dot-language.md");
include_str!("../../../../../../skills/fabro-create-workflow/references/dot-language.md");
const REF_EXAMPLE_WORKFLOWS: &str =
include_str!("../../../../skills/fabro-create-workflow/references/example-workflows.md");
include_str!("../../../../../../skills/fabro-create-workflow/references/example-workflows.md");
const REF_RUN_CONFIGURATION: &str =
include_str!("../../../../skills/fabro-create-workflow/references/run-configuration.md");
include_str!("../../../../../../skills/fabro-create-workflow/references/run-configuration.md");
const SKILL_FILES: &[(&str, &str)] = &[
("SKILL.md", SKILL_MD),
@ -19,33 +20,6 @@ const SKILL_FILES: &[(&str, &str)] = &[
("references/run-configuration.md", REF_RUN_CONFIGURATION),
];
#[derive(Clone, ValueEnum)]
pub enum SkillDir {
Claude,
Agents,
}
#[derive(Args)]
pub struct SkillInstallArgs {
/// Where to install: user-level or project-level
#[arg(long = "for", default_value = "user")]
scope: SkillScope,
/// Target directory convention
#[arg(long)]
dir: SkillDir,
/// Overwrite existing skill without prompting
#[arg(long)]
force: bool,
}
#[derive(Clone, ValueEnum)]
pub enum SkillScope {
User,
Project,
}
/// Install all skill files under `base_dir/fabro-create-workflow/`.
pub fn install_skill_to(base_dir: &Path) -> Result<()> {
let skill_dir = base_dir.join("fabro-create-workflow");
@ -134,11 +108,9 @@ mod tests {
install_skill_to(&base).unwrap();
// Write a sentinel to one file
let sentinel_path = base.join("fabro-create-workflow/SKILL.md");
std::fs::write(&sentinel_path, "old content").unwrap();
// Re-install should overwrite
install_skill_to(&base).unwrap();
let content = std::fs::read_to_string(&sentinel_path).unwrap();

View file

@ -0,0 +1,13 @@
mod install;
use anyhow::Result;
use crate::args::{SkillCommand, SkillNamespace};
pub use install::install_skill_to;
pub fn dispatch(ns: SkillNamespace) -> Result<()> {
match ns.command {
SkillCommand::Install(args) => install::run_skill_install(&args),
}
}

View file

@ -1,20 +1,8 @@
use anyhow::{bail, Context, Result};
use clap::Args;
use tracing::info;
use super::shared::validate_daytona_provider;
#[derive(Args)]
pub struct SshArgs {
/// Run ID or prefix
pub run: String,
/// SSH access expiry in minutes (default 60)
#[arg(long, default_value = "60")]
pub ttl: f64,
/// Print the SSH command instead of connecting
#[arg(long)]
pub print: bool,
}
use crate::args::SshArgs;
use crate::shared::validate_daytona_provider;
pub async fn run(args: SshArgs) -> Result<()> {
let base = fabro_workflows::run_lookup::default_runs_base();

View file

@ -0,0 +1,220 @@
use std::path::Path;
use anyhow::Result;
use chrono::{DateTime, Utc};
use cli_table::format::{Border, Justify, Separator};
use cli_table::{print_stdout, Cell, CellStruct, Style, Table};
use crate::args::DfArgs;
use crate::shared::format_size;
pub fn df_command(args: &DfArgs) -> Result<()> {
let data_dir = fabro_workflows::run_lookup::default_data_dir();
let runs_base = fabro_workflows::run_lookup::default_runs_base();
let logs_base = fabro_workflows::run_lookup::default_logs_base();
df_from(args, &data_dir, &runs_base, &logs_base)
}
fn df_from(args: &DfArgs, data_dir: &Path, runs_base: &Path, logs_base: &Path) -> Result<()> {
let runs = fabro_workflows::run_lookup::scan_runs(runs_base)?;
let mut active_count = 0u64;
let mut total_run_size = 0u64;
let mut reclaimable_run_size = 0u64;
struct RunSizeInfo {
run_id: String,
workflow_name: String,
status: fabro_workflows::run_status::RunStatus,
start_time_dt: Option<DateTime<Utc>>,
size: u64,
}
let mut run_details = Vec::new();
for run in &runs {
let size = dir_size(&run.path);
total_run_size += size;
if run.status.is_active() {
active_count += 1;
} else {
reclaimable_run_size += size;
}
if args.verbose {
run_details.push(RunSizeInfo {
run_id: run.run_id.clone(),
workflow_name: run.workflow_name.clone(),
status: run.status,
start_time_dt: run.start_time_dt,
size,
});
}
}
let mut log_count = 0u64;
let mut total_log_size = 0u64;
if let Ok(entries) = std::fs::read_dir(logs_base) {
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
if path.extension().is_some_and(|ext| ext == "log") {
if let Ok(meta) = path.metadata() {
log_count += 1;
total_log_size += meta.len();
}
}
}
}
let mut db_count = 0u64;
let mut total_db_size = 0u64;
if let Ok(entries) = std::fs::read_dir(data_dir) {
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
if name.ends_with(".db") || name.ends_with(".db-wal") || name.ends_with(".db-shm") {
if let Ok(meta) = path.metadata() {
db_count += 1;
total_db_size += meta.len();
}
}
}
}
let run_reclaim_pct = if total_run_size > 0 {
(reclaimable_run_size as f64 / total_run_size as f64 * 100.0) as u64
} else {
0
};
let log_reclaim_pct = if total_log_size > 0 { 100 } else { 0 };
let summary_title = vec![
"TYPE".cell().bold(true),
"COUNT".cell().bold(true).justify(Justify::Right),
"ACTIVE".cell().bold(true).justify(Justify::Right),
"SIZE".cell().bold(true).justify(Justify::Right),
"RECLAIMABLE".cell().bold(true).justify(Justify::Right),
];
let summary_rows: Vec<Vec<CellStruct>> = vec![
vec![
"Runs".cell(),
runs.len().cell().justify(Justify::Right),
active_count.cell().justify(Justify::Right),
format_size(total_run_size).cell().justify(Justify::Right),
format!("{} ({run_reclaim_pct}%)", format_size(reclaimable_run_size))
.cell()
.justify(Justify::Right),
],
vec![
"Logs".cell(),
log_count.cell().justify(Justify::Right),
"-".cell().justify(Justify::Right),
format_size(total_log_size).cell().justify(Justify::Right),
format!("{} ({log_reclaim_pct}%)", format_size(total_log_size))
.cell()
.justify(Justify::Right),
],
vec![
"Databases".cell(),
db_count.cell().justify(Justify::Right),
"-".cell().justify(Justify::Right),
format_size(total_db_size).cell().justify(Justify::Right),
format!("{} (0%)", format_size(0))
.cell()
.justify(Justify::Right),
],
];
let summary_table = summary_rows
.table()
.title(summary_title)
.border(Border::builder().build())
.separator(Separator::builder().build());
print_stdout(summary_table)?;
println!();
println!("Data directory: {}", data_dir.display());
if !args.verbose {
return Ok(());
}
println!();
let verbose_title = vec![
"RUN ID".cell().bold(true),
"WORKFLOW".cell().bold(true),
"STATUS".cell().bold(true),
"AGE".cell().bold(true).justify(Justify::Right),
"SIZE".cell().bold(true).justify(Justify::Right),
];
let now = Utc::now();
let verbose_rows: Vec<Vec<CellStruct>> = run_details
.iter()
.map(|detail| {
let age = if let Some(dt) = detail.start_time_dt {
let dur = now.signed_duration_since(dt);
if dur.num_days() > 0 {
format!("{}d", dur.num_days())
} else if dur.num_hours() > 0 {
format!("{}h", dur.num_hours())
} else {
format!("{}m", dur.num_minutes().max(1))
}
} else {
"-".to_string()
};
let size_display = if detail.status.is_active() {
format_size(detail.size)
} else {
format!("{} *", format_size(detail.size))
};
vec![
short_run_id(&detail.run_id).cell(),
truncate_str(&detail.workflow_name, 16).cell(),
detail.status.to_string().cell(),
age.cell().justify(Justify::Right),
size_display.cell().justify(Justify::Right),
]
})
.collect();
let verbose_table = verbose_rows
.table()
.title(verbose_title)
.border(Border::builder().build())
.separator(Separator::builder().build());
print_stdout(verbose_table)?;
println!();
println!("* = reclaimable");
Ok(())
}
fn short_run_id(id: &str) -> &str {
if id.len() > 12 {
&id[..12]
} else {
id
}
}
fn truncate_str(s: &str, max_len: usize) -> String {
let char_count = s.chars().count();
if char_count <= max_len {
return s.to_string();
}
let truncated: String = s.chars().take(max_len - 3).collect();
format!("{truncated}...")
}
fn dir_size(path: &Path) -> u64 {
walkdir::WalkDir::new(path)
.into_iter()
.filter_map(|entry| entry.ok())
.filter_map(|entry| entry.metadata().ok())
.filter(|metadata| metadata.is_file())
.map(|metadata| metadata.len())
.sum()
}

View file

@ -0,0 +1,15 @@
mod df;
mod prune;
use anyhow::Result;
use crate::args::{SystemCommand, SystemNamespace};
pub(crate) use prune::parse_duration;
pub fn dispatch(ns: SystemNamespace) -> Result<()> {
match ns.command {
SystemCommand::Prune(args) => prune::prune_command(&args),
SystemCommand::Df(args) => df::df_command(&args),
}
}

View file

@ -0,0 +1,114 @@
use std::path::Path;
use anyhow::{bail, Context, Result};
use chrono::Utc;
use tracing::{debug, info};
use crate::args::RunsPruneArgs;
use crate::shared::format_size;
pub fn prune_command(args: &RunsPruneArgs) -> Result<()> {
let base = fabro_workflows::run_lookup::default_runs_base();
prune_from(args, &base)
}
pub(crate) fn parse_duration(s: &str) -> Result<chrono::Duration> {
let s = s.trim();
if s.is_empty() {
bail!("empty duration string");
}
let (num_str, unit) = s.split_at(s.len() - 1);
let num: u64 = num_str
.parse()
.with_context(|| format!("invalid duration: {s}"))?;
match unit {
"h" => Ok(chrono::Duration::hours(num as i64)),
"d" => Ok(chrono::Duration::days(num as i64)),
_ => bail!("invalid duration unit '{unit}' in '{s}' (expected 'h' or 'd')"),
}
}
fn prune_from(args: &RunsPruneArgs, base: &Path) -> Result<()> {
let runs = fabro_workflows::run_lookup::scan_runs(base)?;
let label_filters = parse_label_filters(&args.filter.label);
let mut filtered = fabro_workflows::run_lookup::filter_runs(
&runs,
args.filter.before.as_deref(),
args.filter.workflow.as_deref(),
&label_filters,
args.filter.orphans,
fabro_workflows::run_lookup::StatusFilter::All,
);
let has_explicit_filters =
args.filter.before.is_some() || args.filter.workflow.is_some() || !label_filters.is_empty();
let staleness_threshold = if let Some(duration) = args.older_than {
Some(duration)
} else if !has_explicit_filters {
Some(chrono::Duration::hours(24))
} else {
None
};
if let Some(threshold) = staleness_threshold {
let cutoff = Utc::now() - threshold;
filtered.retain(|run| {
if run.status.is_active() {
return false;
}
run.end_time
.or(run.start_time_dt)
.is_some_and(|time| time < cutoff)
});
}
if filtered.is_empty() {
eprintln!("No matching runs to prune.");
return Ok(());
}
let total_bytes: u64 = filtered.iter().map(|run| dir_size(&run.path)).sum();
info!(count = filtered.len(), bytes = total_bytes, "pruning runs");
if args.yes {
for run in &filtered {
info!(run_id = %run.run_id, path = %run.path.display(), "deleting run");
std::fs::remove_dir_all(&run.path)?;
}
eprintln!(
"{} run(s) deleted ({} freed).",
filtered.len(),
format_size(total_bytes)
);
return Ok(());
}
for run in &filtered {
debug!(run_id = %run.run_id, "would delete run (dry-run)");
println!("would delete: {} ({})", run.dir_name, run.workflow_name);
}
eprintln!(
"\n{} run(s) would be deleted ({} freed). Pass --yes to confirm.",
filtered.len(),
format_size(total_bytes)
);
Ok(())
}
fn parse_label_filters(label_args: &[String]) -> Vec<(String, String)> {
label_args
.iter()
.filter_map(|s| s.split_once('='))
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
fn dir_size(path: &Path) -> u64 {
walkdir::WalkDir::new(path)
.into_iter()
.filter_map(|entry| entry.ok())
.filter_map(|entry| entry.metadata().ok())
.filter(|metadata| metadata.is_file())
.map(|metadata| metadata.len())
.sum()
}

View file

@ -7,22 +7,7 @@ use semver::Version;
use sha2::{Digest, Sha256};
use tracing::debug;
// ── Clap args ──────────────────────────────────────────────────────────────
#[derive(clap::Args)]
pub struct UpgradeArgs {
/// Target version (e.g. "0.5.0" or "v0.5.0")
#[arg(long)]
version: Option<String>,
/// Upgrade even if already on the target version
#[arg(long)]
force: bool,
/// Preview what would happen without making changes
#[arg(long)]
dry_run: bool,
}
use crate::args::UpgradeArgs;
// ── Download backend abstraction ───────────────────────────────────────────

View file

@ -1,17 +1,9 @@
use std::path::PathBuf;
use anyhow::bail;
use clap::Args;
use fabro_util::terminal::Styles;
use fabro_validate::Severity;
use crate::commands::shared::{print_diagnostics, relative_path};
#[derive(Args)]
pub struct ValidateArgs {
/// Path to the .fabro workflow file
pub workflow: PathBuf,
}
use crate::args::ValidateArgs;
use crate::shared::{print_diagnostics, relative_path};
pub fn run(args: &ValidateArgs, styles: &Styles) -> anyhow::Result<()> {
let (dot_path, _cfg) = fabro_config::project::resolve_workflow(&args.workflow)?;

View file

@ -1,30 +1,12 @@
use std::io::Write;
use anyhow::{bail, Result};
use clap::Args;
use fabro_util::terminal::Styles;
use fabro_workflows::run_status::{RunStatus, RunStatusRecord};
use tracing::info;
use super::shared::format_duration_ms;
#[derive(Args)]
pub struct WaitArgs {
/// Run ID prefix or workflow name (most recent run)
pub run: String,
/// Maximum time to wait in seconds
#[arg(long, value_name = "SECONDS")]
pub timeout: Option<u64>,
/// Poll interval in milliseconds
#[arg(long, value_name = "MS", default_value = "1000")]
pub interval: u64,
/// Output conclusion as JSON
#[arg(long)]
pub json: bool,
}
use crate::args::WaitArgs;
use crate::shared::format_duration_ms;
pub fn run(args: WaitArgs, styles: &Styles) -> Result<()> {
let base = fabro_workflows::run_lookup::default_runs_base();

View file

@ -1,231 +0,0 @@
use std::path::Path;
use anyhow::{bail, Context};
use clap::Args;
use fabro_util::terminal::Styles;
use crate::commands::shared::relative_path;
const GOAL_MAX_LEN: usize = 60;
#[derive(Args)]
pub struct WorkflowListArgs {}
pub fn list_command(_args: &WorkflowListArgs) -> anyhow::Result<()> {
let styles = Styles::detect_stderr();
let cwd = std::env::current_dir()?;
let (config_path, config) = match fabro_config::project::discover_project_config(&cwd)? {
Some(found) => found,
None => bail!(
"No fabro.toml found in {cwd} or any parent directory",
cwd = cwd.display()
),
};
let fabro_root = fabro_config::project::resolve_fabro_root(&config_path, &config);
let project_wf_dir = fabro_root.join("workflows");
let user_wf_dir = dirs::home_dir().map(|h| h.join(".fabro").join("workflows"));
let workflows = fabro_config::project::list_workflows_detailed(
Some(&project_wf_dir),
user_wf_dir.as_deref(),
);
let project: Vec<_> = workflows
.iter()
.filter(|w| w.source == fabro_config::project::WorkflowSource::Project)
.collect();
let user: Vec<_> = workflows
.iter()
.filter(|w| w.source == fabro_config::project::WorkflowSource::User)
.collect();
let name_width = workflows.iter().map(|w| w.name.len()).max().unwrap_or(0);
eprintln!(
"{} workflow(s) found\n",
styles.bold.apply_to(workflows.len())
);
let user_path = user_wf_dir
.as_deref()
.map(relative_path)
.unwrap_or_else(|| "~/.fabro/workflows".to_string());
print_section("User Workflows", &user_path, &user, name_width, &styles);
eprintln!();
print_section(
"Project Workflows",
&relative_path(&project_wf_dir),
&project,
name_width,
&styles,
);
Ok(())
}
#[derive(Args)]
pub struct WorkflowCreateArgs {
/// Name of the workflow
pub name: String,
/// Goal description for the workflow
#[arg(short, long)]
goal: Option<String>,
}
pub fn create_command(args: &WorkflowCreateArgs) -> anyhow::Result<()> {
let cwd = std::env::current_dir()?;
let (config_path, config) = match fabro_config::project::discover_project_config(&cwd)? {
Some(found) => found,
None => bail!(
"No fabro.toml found in {cwd} or any parent directory",
cwd = cwd.display()
),
};
let fabro_root = fabro_config::project::resolve_fabro_root(&config_path, &config);
write_workflow_scaffold(args, &fabro_root)?;
let workflows_dir = fabro_root.join("workflows").join(&args.name);
let green = console::Style::new().green();
let bold = console::Style::new().bold();
let cyan_bold = console::Style::new().cyan().bold();
let dim = console::Style::new().dim();
let rel_dir = relative_path(&workflows_dir);
eprintln!(
" {} {}",
green.apply_to(""),
dim.apply_to(format!("{rel_dir}/workflow.fabro"))
);
eprintln!(
" {} {}",
green.apply_to(""),
dim.apply_to(format!("{rel_dir}/workflow.toml"))
);
eprintln!("\n{} Next steps:\n", bold.apply_to("Workflow created!"));
eprintln!(
" 1. Edit the graph: {}",
cyan_bold.apply_to(format!("{rel_dir}/workflow.fabro"))
);
eprintln!(
" 2. Validate: {}",
cyan_bold.apply_to(format!("fabro validate {}", args.name))
);
eprintln!(
" 3. Run: {}",
cyan_bold.apply_to(format!("fabro run {}", args.name))
);
Ok(())
}
fn write_workflow_scaffold(args: &WorkflowCreateArgs, fabro_root: &Path) -> anyhow::Result<()> {
let workflows_dir = fabro_root.join("workflows").join(&args.name);
if workflows_dir.exists() {
bail!(
"Workflow '{}' already exists at {}",
args.name,
workflows_dir.display()
);
}
std::fs::create_dir_all(&workflows_dir)
.with_context(|| format!("failed to create {}", workflows_dir.display()))?;
let goal = args.goal.as_deref().unwrap_or("TODO: describe the goal");
let digraph_name = to_pascal_case(&args.name);
let fabro_content = format!(
r#"digraph {digraph_name} {{
graph [goal="{goal}"]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
main [label="Main", prompt="TODO: describe what this agent should do"]
start -> main -> exit
}}
"#
);
let dot_path = workflows_dir.join("workflow.fabro");
std::fs::write(&dot_path, &fabro_content)
.with_context(|| format!("failed to write {}", dot_path.display()))?;
let toml_path = workflows_dir.join("workflow.toml");
std::fs::write(&toml_path, "version = 1\n")
.with_context(|| format!("failed to write {}", toml_path.display()))?;
Ok(())
}
fn to_pascal_case(s: &str) -> String {
s.split(['-', '_'])
.filter(|part| !part.is_empty())
.map(|part| {
let mut chars = part.chars();
match chars.next() {
Some(first) => {
let upper: String = first.to_uppercase().collect();
format!("{upper}{rest}", rest = chars.as_str())
}
None => String::new(),
}
})
.collect()
}
fn print_section(
title: &str,
path: &str,
workflows: &[&fabro_config::project::WorkflowInfo],
name_width: usize,
styles: &Styles,
) {
eprintln!(
"{} {}",
styles.bold.apply_to(title),
styles.dim.apply_to(format!("({path})")),
);
if workflows.is_empty() {
eprintln!(" {}", styles.dim.apply_to("(none)"));
return;
}
eprintln!();
eprintln!(
" {:<name_width$} {}",
styles.bold_dim.apply_to("NAME"),
styles.bold_dim.apply_to("DESCRIPTION"),
);
for w in workflows {
let goal_str = w
.goal
.as_deref()
.map(|g| truncate_str(g, GOAL_MAX_LEN))
.unwrap_or_default();
eprintln!(
" {:<name_width$} {}",
styles.cyan.apply_to(&w.name),
styles.dim.apply_to(goal_str),
);
}
}
fn truncate_str(s: &str, max: usize) -> String {
let first_line = s.lines().next().unwrap_or(s);
if first_line.len() <= max {
first_line.to_string()
} else {
format!("{}...", &first_line[..max - 3])
}
}

View file

@ -0,0 +1,114 @@
use std::path::Path;
use anyhow::{bail, Context, Result};
use crate::args::WorkflowCreateArgs;
use crate::shared::relative_path;
pub fn create_command(args: &WorkflowCreateArgs) -> Result<()> {
let cwd = std::env::current_dir()?;
let (config_path, config) = match fabro_config::project::discover_project_config(&cwd)? {
Some(found) => found,
None => bail!(
"No fabro.toml found in {cwd} or any parent directory",
cwd = cwd.display()
),
};
let fabro_root = fabro_config::project::resolve_fabro_root(&config_path, &config);
write_workflow_scaffold(args, &fabro_root)?;
let workflows_dir = fabro_root.join("workflows").join(&args.name);
let green = console::Style::new().green();
let bold = console::Style::new().bold();
let cyan_bold = console::Style::new().cyan().bold();
let dim = console::Style::new().dim();
let rel_dir = relative_path(&workflows_dir);
eprintln!(
" {} {}",
green.apply_to(""),
dim.apply_to(format!("{rel_dir}/workflow.fabro"))
);
eprintln!(
" {} {}",
green.apply_to(""),
dim.apply_to(format!("{rel_dir}/workflow.toml"))
);
eprintln!("\n{} Next steps:\n", bold.apply_to("Workflow created!"));
eprintln!(
" 1. Edit the graph: {}",
cyan_bold.apply_to(format!("{rel_dir}/workflow.fabro"))
);
eprintln!(
" 2. Validate: {}",
cyan_bold.apply_to(format!("fabro validate {}", args.name))
);
eprintln!(
" 3. Run: {}",
cyan_bold.apply_to(format!("fabro run {}", args.name))
);
Ok(())
}
fn write_workflow_scaffold(args: &WorkflowCreateArgs, fabro_root: &Path) -> Result<()> {
let workflows_dir = fabro_root.join("workflows").join(&args.name);
if workflows_dir.exists() {
bail!(
"Workflow '{}' already exists at {}",
args.name,
workflows_dir.display()
);
}
std::fs::create_dir_all(&workflows_dir)
.with_context(|| format!("failed to create {}", workflows_dir.display()))?;
let goal = args.goal.as_deref().unwrap_or("TODO: describe the goal");
let digraph_name = to_pascal_case(&args.name);
let fabro_content = format!(
r#"digraph {digraph_name} {{
graph [goal="{goal}"]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
main [label="Main", prompt="TODO: describe what this agent should do"]
start -> main -> exit
}}
"#
);
let dot_path = workflows_dir.join("workflow.fabro");
std::fs::write(&dot_path, &fabro_content)
.with_context(|| format!("failed to write {}", dot_path.display()))?;
let toml_path = workflows_dir.join("workflow.toml");
std::fs::write(&toml_path, "version = 1\n")
.with_context(|| format!("failed to write {}", toml_path.display()))?;
Ok(())
}
fn to_pascal_case(s: &str) -> String {
s.split(['-', '_'])
.filter(|part| !part.is_empty())
.map(|part| {
let mut chars = part.chars();
match chars.next() {
Some(first) => {
let upper: String = first.to_uppercase().collect();
format!("{upper}{rest}", rest = chars.as_str())
}
None => String::new(),
}
})
.collect()
}

View file

@ -0,0 +1,108 @@
use anyhow::{bail, Result};
use fabro_util::terminal::Styles;
use crate::args::WorkflowListArgs;
use crate::shared::relative_path;
const GOAL_MAX_LEN: usize = 60;
pub fn list_command(_args: &WorkflowListArgs) -> Result<()> {
let styles = Styles::detect_stderr();
let cwd = std::env::current_dir()?;
let (config_path, config) = match fabro_config::project::discover_project_config(&cwd)? {
Some(found) => found,
None => bail!(
"No fabro.toml found in {cwd} or any parent directory",
cwd = cwd.display()
),
};
let fabro_root = fabro_config::project::resolve_fabro_root(&config_path, &config);
let project_wf_dir = fabro_root.join("workflows");
let user_wf_dir = dirs::home_dir().map(|h| h.join(".fabro").join("workflows"));
let workflows = fabro_config::project::list_workflows_detailed(
Some(&project_wf_dir),
user_wf_dir.as_deref(),
);
let project: Vec<_> = workflows
.iter()
.filter(|w| w.source == fabro_config::project::WorkflowSource::Project)
.collect();
let user: Vec<_> = workflows
.iter()
.filter(|w| w.source == fabro_config::project::WorkflowSource::User)
.collect();
let name_width = workflows.iter().map(|w| w.name.len()).max().unwrap_or(0);
eprintln!(
"{} workflow(s) found\n",
styles.bold.apply_to(workflows.len())
);
let user_path = user_wf_dir
.as_deref()
.map(relative_path)
.unwrap_or_else(|| "~/.fabro/workflows".to_string());
print_section("User Workflows", &user_path, &user, name_width, &styles);
eprintln!();
print_section(
"Project Workflows",
&relative_path(&project_wf_dir),
&project,
name_width,
&styles,
);
Ok(())
}
fn print_section(
title: &str,
path: &str,
workflows: &[&fabro_config::project::WorkflowInfo],
name_width: usize,
styles: &Styles,
) {
eprintln!(
"{} {}",
styles.bold.apply_to(title),
styles.dim.apply_to(format!("({path})")),
);
if workflows.is_empty() {
eprintln!(" {}", styles.dim.apply_to("(none)"));
return;
}
eprintln!();
eprintln!(
" {:<name_width$} {}",
styles.bold_dim.apply_to("NAME"),
styles.bold_dim.apply_to("DESCRIPTION"),
);
for w in workflows {
let goal_str = w
.goal
.as_deref()
.map(|g| truncate_str(g, GOAL_MAX_LEN))
.unwrap_or_default();
eprintln!(
" {:<name_width$} {}",
styles.cyan.apply_to(&w.name),
styles.dim.apply_to(goal_str),
);
}
}
fn truncate_str(s: &str, max: usize) -> String {
let first_line = s.lines().next().unwrap_or(s);
if first_line.len() <= max {
first_line.to_string()
} else {
format!("{}...", &first_line[..max - 3])
}
}

View file

@ -0,0 +1,13 @@
mod create;
mod list;
use anyhow::Result;
use crate::args::{WorkflowCommand, WorkflowNamespace};
pub fn dispatch(ns: WorkflowNamespace) -> Result<()> {
match ns.command {
WorkflowCommand::List(args) => list::list_command(&args),
WorkflowCommand::Create(args) => create::create_command(&args),
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,17 @@
pub(crate) fn build_github_app_credentials(
app_id: Option<&str>,
) -> Option<fabro_github::GitHubAppCredentials> {
let app_id = app_id?;
let raw = std::env::var("GITHUB_APP_PRIVATE_KEY").ok()?;
let private_key_pem = if raw.starts_with("-----") {
raw
} else {
let pem_bytes =
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &raw).ok()?;
String::from_utf8(pem_bytes).ok()?
};
Some(fabro_github::GitHubAppCredentials {
app_id: app_id.to_string(),
private_key_pem,
})
}

View file

@ -0,0 +1,5 @@
pub(crate) mod github;
pub(crate) mod provider_auth;
mod utilities;
pub(crate) use utilities::*;

View file

@ -5,7 +5,7 @@ use dialoguer::{Confirm, Password};
use fabro_model::Provider;
use fabro_util::terminal::Styles;
use crate::doctor;
use crate::commands::doctor;
// ---------------------------------------------------------------------------
// Provider key URLs

View file

@ -5,5 +5,6 @@ args = ["doctor", "--dry-run"]
inherit = false
[env.add]
PATH = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
HOME = "/tmp/fabro-trycmd-nonexistent"
ANTHROPIC_API_KEY = "sk-test-dummy"

View file

@ -0,0 +1,36 @@
---
source: lib/crates/fabro-cli/tests/cli.rs
assertion_line: 349
expression: stdout
---
Start the HTTP API server
Usage: fabro serve [OPTIONS]
Options:
--debug
Enable DEBUG-level logging (default is INFO)
--port <PORT>
Port to listen on [default: 3000]
--host <HOST>
Host address to bind to [default: 127.0.0.1]
--no-upgrade-check
Disable automatic upgrade check
--mode <MODE>
Execution mode: standalone (in-process) or server (delegate to API)
--model <MODEL>
Override default LLM model
--provider <PROVIDER>
Override default LLM provider
--server-url <SERVER_URL>
Server URL (overrides server.base_url from cli.toml)
--dry-run
Execute with simulated LLM backend
--sandbox <SANDBOX>
Sandbox for agent tools
--max-concurrent-runs <MAX_CONCURRENT_RUNS>
Maximum number of concurrent run executions
--config <CONFIG>
Path to server config file (default: ~/.fabro/server.toml)
-h, --help
Print help