diff --git a/lib/crates/fabro-cli/src/args.rs b/lib/crates/fabro-cli/src/args.rs new file mode 100644 index 000000000..fcb324783 --- /dev/null +++ b/lib/crates/fabro-cli/src/args.rs @@ -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, + + /// Server URL (overrides server.base_url from cli.toml) + #[cfg(feature = "server")] + #[arg(long, global = true)] + pub server_url: Option, +} + +#[cfg(feature = "server")] +pub(crate) fn parse_execution_mode(s: &str) -> Result { + 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 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 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, + + /// Run output directory + #[arg(long)] + pub(crate) run_dir: Option, + + /// 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, + + /// Read the workflow goal from a file + #[arg(long, conflicts_with = "goal")] + pub(crate) goal_file: Option, + + /// Override default LLM model + #[arg(long)] + pub(crate) model: Option, + + /// Override default LLM provider + #[arg(long)] + pub(crate) provider: Option, + + /// Enable verbose output + #[arg(short, long)] + pub(crate) verbose: bool, + + /// Sandbox for agent tools + #[arg(long, value_enum)] + pub(crate) sandbox: Option, + + /// Attach a label to this run (repeatable, format: KEY=VALUE) + #[arg(long = "label", value_name = "KEY=VALUE")] + pub(crate) label: Vec, + + /// 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, +} + +#[derive(Args)] +pub(crate) struct RunFilterArgs { + /// Only include runs started before this date (YYYY-MM-DD prefix match) + #[arg(long)] + pub(crate) before: Option, + + /// Filter by workflow name (substring match) + #[arg(long)] + pub(crate) workflow: Option, + + /// Filter by label (KEY=VALUE, repeatable, AND semantics) + #[arg(long = "label", value_name = "KEY=VALUE")] + pub(crate) label: Vec, + + /// 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, + + /// 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, + /// Lines from end (default: all) + #[arg(short = 'n', long)] + pub(crate) tail: Option, + /// 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 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, + + /// Graph layout direction (overrides the DOT file's rankdir) + #[arg(short = 'd', long)] + pub(crate) direction: Option, +} + +#[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, + + /// 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, + + /// Preserve {node_slug}/retry_{N}/ directory structure + #[arg(long)] + pub(crate) tree: bool, +} + +#[derive(Args)] +pub(crate) struct CpArgs { + /// Source: : or local path + pub(crate) src: String, + /// Destination: : 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, + /// 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, + + /// 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, + + /// 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, + + /// 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, +} + +#[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, + + /// 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, +} + +#[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, + + /// 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, + }, + /// 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), +} diff --git a/lib/crates/fabro-cli/src/cli_config.rs b/lib/crates/fabro-cli/src/cli_config.rs index 9c1bc47b0..b3e8d2fde 100644 --- a/lib/crates/fabro-cli/src/cli_config.rs +++ b/lib/crates/fabro-cli/src/cli_config.rs @@ -1,5 +1,7 @@ pub use fabro_config::cli::*; +#[cfg(feature = "server")] +use fabro_config::FabroConfig; #[cfg(feature = "server")] use tracing::debug; diff --git a/lib/crates/fabro-cli/src/commands/asset.rs b/lib/crates/fabro-cli/src/commands/asset/cp.rs similarity index 66% rename from lib/crates/fabro-cli/src/commands/asset.rs rename to lib/crates/fabro-cli/src/commands/asset/cp.rs index ec6629fe1..5b3a26641 100644 --- a/lib/crates/fabro-cli/src/commands/asset.rs +++ b/lib/crates/fabro-cli/src/commands/asset/cp.rs @@ -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, - - /// 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, - - /// 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!( - "{:retry_width$} {:>size_width$} PATH", - "NODE", "RETRY", "SIZE" - ); - let total_size: u64 = entries.iter().map(|entry| entry.size).sum(); - for entry in &entries { - println!( - "{: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::*; diff --git a/lib/crates/fabro-cli/src/commands/asset/list.rs b/lib/crates/fabro-cli/src/commands/asset/list.rs new file mode 100644 index 000000000..d8a21fb90 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/asset/list.rs @@ -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!( + "{:retry_width$} {:>size_width$} PATH", + "NODE", "RETRY", "SIZE" + ); + let total_size: u64 = entries.iter().map(|entry| entry.size).sum(); + for entry in &entries { + println!( + "{: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(()) +} diff --git a/lib/crates/fabro-cli/src/commands/asset/mod.rs b/lib/crates/fabro-cli/src/commands/asset/mod.rs new file mode 100644 index 000000000..b21ce1d1a --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/asset/mod.rs @@ -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), + } +} diff --git a/lib/crates/fabro-cli/src/commands/cp.rs b/lib/crates/fabro-cli/src/commands/cp.rs index 48e66c06d..7b90bcf4e 100644 --- a/lib/crates/fabro-cli/src/commands/cp.rs +++ b/lib/crates/fabro-cli/src/commands/cp.rs @@ -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: : or local path - pub src: String, - /// Destination: : 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 { diff --git a/lib/crates/fabro-cli/src/commands/create.rs b/lib/crates/fabro-cli/src/commands/create.rs index 731a08e47..6c3e1bea5 100644 --- a/lib/crates/fabro-cli/src/commands/create.rs +++ b/lib/crates/fabro-cli/src/commands/create.rs @@ -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; diff --git a/lib/crates/fabro-cli/src/commands/diff.rs b/lib/crates/fabro-cli/src/commands/diff.rs index 4cba7a0d1..9e61c10f3 100644 --- a/lib/crates/fabro-cli/src/commands/diff.rs +++ b/lib/crates/fabro-cli/src/commands/diff.rs @@ -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, - /// 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"); diff --git a/lib/crates/fabro-cli/src/doctor.rs b/lib/crates/fabro-cli/src/commands/doctor.rs similarity index 98% rename from lib/crates/fabro-cli/src/doctor.rs rename to lib/crates/fabro-cli/src/commands/doctor.rs index 9720190e6..44b8e3200 100644 --- a/lib/crates/fabro-cli/src/doctor.rs +++ b/lib/crates/fabro-cli/src/commands/doctor.rs @@ -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 { 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); diff --git a/lib/crates/fabro-cli/src/commands/exec.rs b/lib/crates/fabro-cli/src/commands/exec.rs new file mode 100644 index 000000000..7d63bcfa7 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/exec.rs @@ -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 = 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(()) +} diff --git a/lib/crates/fabro-cli/src/commands/fork.rs b/lib/crates/fabro-cli/src/commands/fork.rs index 313992a0d..3eca30139 100644 --- a/lib/crates/fabro-cli/src/commands/fork.rs +++ b/lib/crates/fabro-cli/src/commands/fork.rs @@ -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, - - /// 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")?; diff --git a/lib/crates/fabro-cli/src/commands/graph.rs b/lib/crates/fabro-cli/src/commands/graph.rs index 2f5907c19..5e9811141 100644 --- a/lib/crates/fabro-cli/src/commands/graph.rs +++ b/lib/crates/fabro-cli/src/commands/graph.rs @@ -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, - - /// Graph layout direction (overrides the DOT file's rankdir) - #[arg(short = 'd', long)] - pub direction: Option, -} +use crate::args::{GraphArgs, GraphDirection}; +use crate::shared::{print_diagnostics, read_workflow_file, relative_path}; static RANKDIR_RE: LazyLock = 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 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) -> Cow<'a, str> { match direction { Some(dir) => { diff --git a/lib/crates/fabro-cli/src/commands/inspect.rs b/lib/crates/fabro-cli/src/commands/inspect.rs index af1494e7e..c686e7a13 100644 --- a/lib/crates/fabro-cli/src/commands/inspect.rs +++ b/lib/crates/fabro-cli/src/commands/inspect.rs @@ -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 { diff --git a/lib/crates/fabro-cli/src/install.rs b/lib/crates/fabro-cli/src/commands/install.rs similarity index 99% rename from lib/crates/fabro-cli/src/install.rs rename to lib/crates/fabro-cli/src/commands/install.rs index 589081db9..84bae5128 100644 --- a/lib/crates/fabro-cli/src/install.rs +++ b/lib/crates/fabro-cli/src/commands/install.rs @@ -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, }; diff --git a/lib/crates/fabro-cli/src/commands/llm/chat.rs b/lib/crates/fabro-cli/src/commands/llm/chat.rs new file mode 100644 index 000000000..10dddeead --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/llm/chat.rs @@ -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(()) +} diff --git a/lib/crates/fabro-cli/src/commands/llm/mod.rs b/lib/crates/fabro-cli/src/commands/llm/mod.rs new file mode 100644 index 000000000..a4ebf8247 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/llm/mod.rs @@ -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, + } +} diff --git a/lib/crates/fabro-cli/src/commands/llm/prompt.rs b/lib/crates/fabro-cli/src/commands/llm/prompt.rs new file mode 100644 index 000000000..7a3ffd7b0 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/llm/prompt.rs @@ -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(()) +} diff --git a/lib/crates/fabro-cli/src/commands/logs.rs b/lib/crates/fabro-cli/src/commands/logs.rs index 681bc571f..8b8029df3 100644 --- a/lib/crates/fabro-cli/src/commands/logs.rs +++ b/lib/crates/fabro-cli/src/commands/logs.rs @@ -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, - /// Lines from end (default: all) - #[arg(short = 'n', long)] - pub tail: Option, - /// 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(); diff --git a/lib/crates/fabro-cli/src/commands/mod.rs b/lib/crates/fabro-cli/src/commands/mod.rs index e5cd4cacc..2cb5edea7 100644 --- a/lib/crates/fabro-cli/src/commands/mod.rs +++ b/lib/crates/fabro-cli/src/commands/mod.rs @@ -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; diff --git a/lib/crates/fabro-cli/src/commands/model.rs b/lib/crates/fabro-cli/src/commands/model.rs new file mode 100644 index 000000000..61ff875b1 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/model.rs @@ -0,0 +1,39 @@ +use anyhow::Result; + +use crate::args::GlobalArgs; +#[cfg(feature = "server")] +use crate::cli_config; + +pub async fn execute( + command: Option, + 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 +} diff --git a/lib/crates/fabro-cli/src/commands/parse.rs b/lib/crates/fabro-cli/src/commands/parse.rs index 0b6e54dce..3827f422f 100644 --- a/lib/crates/fabro-cli/src/commands/parse.rs +++ b/lib/crates/fabro-cli/src/commands/parse.rs @@ -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(); diff --git a/lib/crates/fabro-cli/src/commands/pr.rs b/lib/crates/fabro-cli/src/commands/pr.rs deleted file mode 100644 index 02fc35d57..000000000 --- a/lib/crates/fabro-cli/src/commands/pr.rs +++ /dev/null @@ -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, -} - -#[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, -) -> 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, -) -> 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::(&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, -) -> 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, -) -> 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, -) -> 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, -) -> 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, -) -> 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, -) -> 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, -) -> 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, -) -> 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(()) -} diff --git a/lib/crates/fabro-cli/src/commands/pr/close.rs b/lib/crates/fabro-cli/src/commands/pr/close.rs new file mode 100644 index 000000000..b3f0d7172 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/pr/close.rs @@ -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, +) -> 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, +) -> 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(()) +} diff --git a/lib/crates/fabro-cli/src/commands/pr/create.rs b/lib/crates/fabro-cli/src/commands/pr/create.rs new file mode 100644 index 000000000..41e2332da --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/pr/create.rs @@ -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, +) -> 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, +) -> 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(()) +} diff --git a/lib/crates/fabro-cli/src/commands/pr/list.rs b/lib/crates/fabro-cli/src/commands/pr/list.rs new file mode 100644 index 000000000..344246008 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/pr/list.rs @@ -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, +) -> 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, +) -> 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::(&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(()) +} diff --git a/lib/crates/fabro-cli/src/commands/pr/merge.rs b/lib/crates/fabro-cli/src/commands/pr/merge.rs new file mode 100644 index 000000000..1f8c28716 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/pr/merge.rs @@ -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, +) -> 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, +) -> 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(()) +} diff --git a/lib/crates/fabro-cli/src/commands/pr/mod.rs b/lib/crates/fabro-cli/src/commands/pr/mod.rs new file mode 100644 index 000000000..e199a5757 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/pr/mod.rs @@ -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)) +} diff --git a/lib/crates/fabro-cli/src/commands/pr/view.rs b/lib/crates/fabro-cli/src/commands/pr/view.rs new file mode 100644 index 000000000..08fe0fb60 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/pr/view.rs @@ -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, +) -> 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, +) -> 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(()) +} diff --git a/lib/crates/fabro-cli/src/commands/preview.rs b/lib/crates/fabro-cli/src/commands/preview.rs index 9288adde8..70534e617 100644 --- a/lib/crates/fabro-cli/src/commands/preview.rs +++ b/lib/crates/fabro-cli/src/commands/preview.rs @@ -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 diff --git a/lib/crates/fabro-cli/src/commands/provider.rs b/lib/crates/fabro-cli/src/commands/provider/login.rs similarity index 83% rename from lib/crates/fabro-cli/src/commands/provider.rs rename to lib/crates/fabro-cli/src/commands/provider/login.rs index 3d89fc23e..2ffa34aa5 100644 --- a/lib/crates/fabro-cli/src/commands/provider.rs +++ b/lib/crates/fabro-cli/src/commands/provider/login.rs @@ -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(); diff --git a/lib/crates/fabro-cli/src/commands/provider/mod.rs b/lib/crates/fabro-cli/src/commands/provider/mod.rs new file mode 100644 index 000000000..c906b596c --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/provider/mod.rs @@ -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, + } +} diff --git a/lib/crates/fabro-cli/src/commands/repo/deinit.rs b/lib/crates/fabro-cli/src/commands/repo/deinit.rs new file mode 100644 index 000000000..e4cb6f70f --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/repo/deinit.rs @@ -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(()) +} diff --git a/lib/crates/fabro-cli/src/init.rs b/lib/crates/fabro-cli/src/commands/repo/init.rs similarity index 88% rename from lib/crates/fabro-cli/src/init.rs rename to lib/crates/fabro-cli/src/commands/repo/init.rs index 552bffe60..a322666ca 100644 --- a/lib/crates/fabro-cli/src/init.rs +++ b/lib/crates/fabro-cli/src/commands/repo/init.rs @@ -1,7 +1,8 @@ -use anyhow::{bail, Context, Result}; use std::path::PathBuf; -fn git_repo_root() -> Result { +use anyhow::{bail, Context, Result}; + +pub(super) fn git_repo_root() -> Result { 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!( diff --git a/lib/crates/fabro-cli/src/commands/repo/mod.rs b/lib/crates/fabro-cli/src/commands/repo/mod.rs new file mode 100644 index 000000000..a6d2409bc --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/repo/mod.rs @@ -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(), + } +} diff --git a/lib/crates/fabro-cli/src/commands/resume.rs b/lib/crates/fabro-cli/src/commands/resume.rs index f6487b04d..31d3c8e8b 100644 --- a/lib/crates/fabro-cli/src/commands/resume.rs +++ b/lib/crates/fabro-cli/src/commands/resume.rs @@ -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. /// diff --git a/lib/crates/fabro-cli/src/commands/rewind.rs b/lib/crates/fabro-cli/src/commands/rewind.rs index 2e6d22ab6..0bef4f084 100644 --- a/lib/crates/fabro-cli/src/commands/rewind.rs +++ b/lib/crates/fabro-cli/src/commands/rewind.rs @@ -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, - - /// 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")?; diff --git a/lib/crates/fabro-cli/src/commands/run.rs b/lib/crates/fabro-cli/src/commands/run.rs index bb822a2e9..98f0b7c0f 100644 --- a/lib/crates/fabro-cli/src/commands/run.rs +++ b/lib/crates/fabro-cli/src/commands/run.rs @@ -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 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 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, - - /// Run output directory - #[arg(long)] - pub run_dir: Option, - - /// 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, - - /// Read the workflow goal from a file - #[arg(long, conflicts_with = "goal")] - pub goal_file: Option, - - /// Override default LLM model - #[arg(long)] - pub model: Option, - - /// Override default LLM provider - #[arg(long)] - pub provider: Option, - - /// Enable verbose output - #[arg(short, long)] - pub verbose: bool, - - /// Sandbox for agent tools - #[arg(long, value_enum)] - pub sandbox: Option, - - /// Attach a label to this run (repeatable, format: KEY=VALUE) - #[arg(long = "label", value_name = "KEY=VALUE")] - pub label: Vec, - - /// 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, -} - /// Resolve goal from `--goal` string or `--goal-file` path. pub(crate) fn resolve_cli_goal( goal: &Option, @@ -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, diff --git a/lib/crates/fabro-cli/src/commands/run_engine.rs b/lib/crates/fabro-cli/src/commands/run_engine.rs new file mode 100644 index 000000000..111beed09 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/run_engine.rs @@ -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) + } + } +} diff --git a/lib/crates/fabro-cli/src/commands/run_progress.rs b/lib/crates/fabro-cli/src/commands/run_progress.rs index 058fdf483..cb62efed4 100644 --- a/lib/crates/fabro-cli/src/commands/run_progress.rs +++ b/lib/crates/fabro-cli/src/commands/run_progress.rs @@ -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 ─────────────────────────────────────────────────────── diff --git a/lib/crates/fabro-cli/src/commands/runs.rs b/lib/crates/fabro-cli/src/commands/runs.rs index a5febe22b..877c5dc87 100644 --- a/lib/crates/fabro-cli/src/commands/runs.rs +++ b/lib/crates/fabro-cli/src/commands/runs.rs @@ -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, - - /// Filter by workflow name (substring match) - #[arg(long)] - pub workflow: Option, - - /// Filter by label (KEY=VALUE, repeatable, AND semantics) - #[arg(long = "label", value_name = "KEY=VALUE")] - pub label: Vec, - - /// 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, - - /// 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, - - /// 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>, - 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![ - 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> = 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 { - 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() { diff --git a/lib/crates/fabro-cli/src/commands/secret.rs b/lib/crates/fabro-cli/src/commands/secret.rs deleted file mode 100644 index b35419b34..000000000 --- a/lib/crates/fabro-cli/src/commands/secret.rs +++ /dev/null @@ -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(()) -} diff --git a/lib/crates/fabro-cli/src/commands/secret/get.rs b/lib/crates/fabro-cli/src/commands/secret/get.rs new file mode 100644 index 000000000..c9b5b96c0 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/secret/get.rs @@ -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), + } +} diff --git a/lib/crates/fabro-cli/src/commands/secret/list.rs b/lib/crates/fabro-cli/src/commands/secret/list.rs new file mode 100644 index 000000000..06197ac2d --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/secret/list.rs @@ -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(()) +} diff --git a/lib/crates/fabro-cli/src/commands/secret/mod.rs b/lib/crates/fabro-cli/src/commands/secret/mod.rs new file mode 100644 index 000000000..cdcdc6889 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/secret/mod.rs @@ -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), + } +} diff --git a/lib/crates/fabro-cli/src/commands/secret/rm.rs b/lib/crates/fabro-cli/src/commands/secret/rm.rs new file mode 100644 index 000000000..bb6f7fca2 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/secret/rm.rs @@ -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), + } +} diff --git a/lib/crates/fabro-cli/src/commands/secret/set.rs b/lib/crates/fabro-cli/src/commands/secret/set.rs new file mode 100644 index 000000000..a14f104b9 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/secret/set.rs @@ -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(()) +} diff --git a/lib/crates/fabro-cli/src/skill.rs b/lib/crates/fabro-cli/src/commands/skill/install.rs similarity index 78% rename from lib/crates/fabro-cli/src/skill.rs rename to lib/crates/fabro-cli/src/commands/skill/install.rs index 8b2e0615e..cc6ae52da 100644 --- a/lib/crates/fabro-cli/src/skill.rs +++ b/lib/crates/fabro-cli/src/commands/skill/install.rs @@ -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(); diff --git a/lib/crates/fabro-cli/src/commands/skill/mod.rs b/lib/crates/fabro-cli/src/commands/skill/mod.rs new file mode 100644 index 000000000..27086ae54 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/skill/mod.rs @@ -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), + } +} diff --git a/lib/crates/fabro-cli/src/commands/ssh.rs b/lib/crates/fabro-cli/src/commands/ssh.rs index f9ed907da..6b3eb262e 100644 --- a/lib/crates/fabro-cli/src/commands/ssh.rs +++ b/lib/crates/fabro-cli/src/commands/ssh.rs @@ -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(); diff --git a/lib/crates/fabro-cli/src/commands/system/df.rs b/lib/crates/fabro-cli/src/commands/system/df.rs new file mode 100644 index 000000000..29940d007 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/system/df.rs @@ -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>, + 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![ + 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> = 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() +} diff --git a/lib/crates/fabro-cli/src/commands/system/mod.rs b/lib/crates/fabro-cli/src/commands/system/mod.rs new file mode 100644 index 000000000..4e1bfe0e6 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/system/mod.rs @@ -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), + } +} diff --git a/lib/crates/fabro-cli/src/commands/system/prune.rs b/lib/crates/fabro-cli/src/commands/system/prune.rs new file mode 100644 index 000000000..ef5882189 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/system/prune.rs @@ -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 { + 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() +} diff --git a/lib/crates/fabro-cli/src/upgrade.rs b/lib/crates/fabro-cli/src/commands/upgrade.rs similarity index 97% rename from lib/crates/fabro-cli/src/upgrade.rs rename to lib/crates/fabro-cli/src/commands/upgrade.rs index 6d492dedd..8984f2e23 100644 --- a/lib/crates/fabro-cli/src/upgrade.rs +++ b/lib/crates/fabro-cli/src/commands/upgrade.rs @@ -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, - - /// 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 ─────────────────────────────────────────── diff --git a/lib/crates/fabro-cli/src/commands/validate.rs b/lib/crates/fabro-cli/src/commands/validate.rs index 9a58d9c53..a2b205b07 100644 --- a/lib/crates/fabro-cli/src/commands/validate.rs +++ b/lib/crates/fabro-cli/src/commands/validate.rs @@ -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)?; diff --git a/lib/crates/fabro-cli/src/commands/wait.rs b/lib/crates/fabro-cli/src/commands/wait.rs index 563300cf5..62bf4d661 100644 --- a/lib/crates/fabro-cli/src/commands/wait.rs +++ b/lib/crates/fabro-cli/src/commands/wait.rs @@ -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, - - /// 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(); diff --git a/lib/crates/fabro-cli/src/commands/workflow.rs b/lib/crates/fabro-cli/src/commands/workflow.rs deleted file mode 100644 index ebe0abc00..000000000 --- a/lib/crates/fabro-cli/src/commands/workflow.rs +++ /dev/null @@ -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, -} - -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!( - " {: 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]) - } -} diff --git a/lib/crates/fabro-cli/src/commands/workflow/create.rs b/lib/crates/fabro-cli/src/commands/workflow/create.rs new file mode 100644 index 000000000..96c59180d --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/workflow/create.rs @@ -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() +} diff --git a/lib/crates/fabro-cli/src/commands/workflow/list.rs b/lib/crates/fabro-cli/src/commands/workflow/list.rs new file mode 100644 index 000000000..338621cd9 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/workflow/list.rs @@ -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!( + " {: 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]) + } +} diff --git a/lib/crates/fabro-cli/src/commands/workflow/mod.rs b/lib/crates/fabro-cli/src/commands/workflow/mod.rs new file mode 100644 index 000000000..d236638f3 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/workflow/mod.rs @@ -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), + } +} diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index f71c5fa28..f59709695 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -1,398 +1,22 @@ +mod args; mod cli_config; mod commands; -mod doctor; -mod init; -mod install; mod logging; -mod provider_auth; -mod skill; -mod upgrade; - -use std::path::PathBuf; +mod shared; use anyhow::Result; -use clap::{Parser, Subcommand}; +use args::*; +use clap::Parser; use tracing::debug; -const LONG_VERSION: &str = concat!( - env!("CARGO_PKG_VERSION"), - " (", - env!("FABRO_GIT_SHA"), - " ", - env!("FABRO_BUILD_DATE"), - ")" -); - #[derive(Parser)] #[command(name = "fabro", version, long_version = LONG_VERSION)] struct Cli { - /// Enable DEBUG-level logging (default is INFO) - #[arg(long, global = true)] - debug: bool, - - /// Disable automatic upgrade check - #[arg(long, global = true)] - 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)] - mode: Option, - - /// Server URL (overrides server.base_url from cli.toml) - #[cfg(feature = "server")] - #[arg(long, global = true)] - server_url: Option, + #[command(flatten)] + globals: GlobalArgs, #[command(subcommand)] - command: Command, -} - -#[cfg(feature = "server")] -fn parse_execution_mode(s: &str) -> Result { - match s { - "standalone" => Ok(cli_config::ExecutionMode::Standalone), - "server" => Ok(cli_config::ExecutionMode::Server), - _ => Err(format!( - "invalid mode '{s}', expected 'standalone' or 'server'" - )), - } -} - -#[derive(Subcommand)] -enum Command { - /// LLM prompt operations - #[command(hide = true)] - Llm { - #[command(subcommand)] - command: LlmCommand, - }, - /// Run an agentic coding session - #[command(hide = true)] - Exec(fabro_agent::cli::AgentArgs), - /// Launch a workflow run - Run(commands::run::RunArgs), - /// Create a workflow run (allocate run dir, persist spec) - Create(commands::run::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(commands::validate::ValidateArgs), - /// Render a workflow graph as SVG or PNG - Graph(commands::graph::GraphArgs), - /// Parse a DOT file and print its AST - #[command(hide = true)] - Parse(commands::parse::ParseArgs), - /// Inspect and copy run assets (screenshots, reports, traces) - Asset { - #[command(subcommand)] - command: AssetCommand, - }, - /// Copy files to/from a run's sandbox - Cp(commands::cp::CpArgs), - /// Get a preview URL for a port on a run's sandbox - Preview(commands::preview::PreviewArgs), - /// SSH into a run's Daytona sandbox - Ssh(commands::ssh::SshArgs), - /// Show the diff of changes from a workflow run - #[command(hide = true)] - Diff(commands::diff::DiffArgs), - /// View the event log of a workflow run - Logs(commands::logs::LogsArgs), - /// Show detailed information about a workflow run - Inspect(commands::inspect::InspectArgs), - /// List and test LLM models - Model { - #[command(subcommand)] - command: Option, - }, - /// 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(commands::runs::RunsListArgs), - /// Remove one or more workflow runs - Rm(commands::runs::RunsRemoveArgs), - /// Pull request operations - Pr { - #[command(subcommand)] - command: PrCommand, - }, - /// Skill management - #[command(hide = true)] - Skill { - #[command(subcommand)] - command: SkillCommand, - }, - /// Manage secrets in ~/.fabro/.env - Secret { - #[command(subcommand)] - command: SecretCommand, - }, - /// Resume an interrupted workflow run - Resume(commands::resume::ResumeArgs), - /// Rewind a workflow run to an earlier checkpoint - Rewind(commands::rewind::RewindArgs), - /// Fork a workflow run from an earlier checkpoint into a new run - Fork(commands::fork::ForkArgs), - /// Block until a workflow run completes - Wait(commands::wait::WaitArgs), - /// Workflow operations - Workflow { - #[command(subcommand)] - command: WorkflowCommand, - }, - /// Open the Discord community in the browser - Discord, - /// Open the docs website in the browser - Docs, - /// Upgrade fabro to the latest version - Upgrade(upgrade::UpgradeArgs), - /// Repository commands - Repo { - #[command(subcommand)] - command: RepoCommand, - }, - /// Provider operations - Provider { - #[command(subcommand)] - command: ProviderCommand, - }, - /// System maintenance commands - System { - #[command(subcommand)] - command: SystemCommand, - }, - /// 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, - }, -} - -#[derive(Subcommand)] -enum PrCommand { - /// Create a pull request from a completed run - Create(commands::pr::PrCreateArgs), - /// List pull requests from workflow runs - List(commands::pr::PrListArgs), - /// View pull request details - View(commands::pr::PrViewArgs), - /// Merge a pull request - Merge(commands::pr::PrMergeArgs), - /// Close a pull request - Close(commands::pr::PrCloseArgs), -} - -#[derive(Subcommand)] -enum SystemCommand { - /// Delete old workflow runs - Prune(commands::runs::RunsPruneArgs), - /// Show disk usage - Df(commands::runs::DfArgs), -} - -#[derive(Subcommand)] -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(Subcommand)] -enum SecretCommand { - /// Get a secret value - Get(commands::secret::SecretGetArgs), - /// List secret names - #[command(alias = "ls")] - List(commands::secret::SecretListArgs), - /// Remove a secret - Rm(commands::secret::SecretRmArgs), - /// Set a secret value - Set(commands::secret::SecretSetArgs), -} - -#[derive(Subcommand)] -enum SkillCommand { - /// Install a built-in skill - Install(skill::SkillInstallArgs), -} - -#[derive(Subcommand)] -enum WorkflowCommand { - /// List available workflows - List(commands::workflow::WorkflowListArgs), - /// Create a new workflow - Create(commands::workflow::WorkflowCreateArgs), -} - -#[derive(Subcommand)] -enum ProviderCommand { - /// Log in to an LLM provider - Login(commands::provider::ProviderLoginArgs), -} - -#[derive(Subcommand)] -enum AssetCommand { - /// List assets for a workflow run - List(commands::asset::AssetListArgs), - /// Copy assets from a workflow run - Cp(commands::asset::AssetCpArgs), -} - -#[derive(Subcommand)] -enum LlmCommand { - /// Execute a prompt - Prompt(fabro_llm::cli::PromptArgs), - /// Interactive multi-turn chat - Chat(fabro_llm::cli::ChatArgs), -} - -pub(crate) fn build_github_app_credentials( - app_id: Option<&str>, -) -> Option { - 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, - }) -} - -async fn run_engine_entrypoint( - run_dir: PathBuf, - resume: bool, - styles: &'static fabro_util::terminal::Styles, -) -> Result<()> { - let cli_config = cli_config::load_cli_config(None)?; - let github_app = 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 _ = commands::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 _ = commands::detached_support::persist_detached_failure( - &run_dir, - "bootstrap", - fabro_workflows::run_status::StatusReason::BootstrapFailed, - &err, - ); - return Err(err); - } - - let result = if resume { - commands::run::resume_from_record( - persisted, - run_dir.clone(), - cli_config, - styles, - github_app, - git_author, - ) - .await - } else { - commands::run::run_from_record( - persisted, - run_dir.clone(), - cli_config, - styles, - github_app, - git_author, - ) - .await - }; - - match result { - Ok(()) => Ok(()), - Err(err) => { - let _ = commands::detached_support::persist_detached_failure( - &run_dir, - "bootstrap", - fabro_workflows::run_status::StatusReason::SandboxInitFailed, - &err, - ); - Err(err) - } - } + command: Box, } #[tokio::main] @@ -466,90 +90,13 @@ async fn main_inner() -> (String, Result<()>) { } } - let command_name = match &cli.command { - Command::Llm { command } => match command { - LlmCommand::Prompt(_) => "llm prompt", - LlmCommand::Chat(_) => "llm chat", - }, - Command::Asset { command } => match command { - AssetCommand::List(_) => "asset list", - AssetCommand::Cp(_) => "asset cp", - }, - Command::Exec(_) => "exec", - Command::Run(_) => "run", - Command::Create(_) => "create", - Command::Start { .. } => "start", - Command::Attach { .. } => "attach", - Command::RunEngine { .. } => "_run_engine", - Command::Validate(_) => "validate", - Command::Graph(_) => "graph", - Command::Parse(_) => "parse", - Command::Cp(_) => "cp", - Command::Preview(_) => "preview", - Command::Ssh(_) => "ssh", - Command::Diff(_) => "diff", - Command::Logs(_) => "logs", - Command::Inspect(_) => "inspect", - Command::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")] - Command::Serve(_) => "serve", - Command::Doctor { .. } => "doctor", - Command::Repo { command } => match command { - RepoCommand::Init { .. } => "repo init", - RepoCommand::Deinit => "repo deinit", - }, - Command::Init => "init", - Command::Install { .. } => "install", - Command::Ps(_) => "ps", - Command::Rm(_) => "rm", - Command::Pr { command } => match command { - PrCommand::Create(_) => "pr create", - PrCommand::List(_) => "pr list", - PrCommand::View(_) => "pr view", - PrCommand::Merge(_) => "pr merge", - PrCommand::Close(_) => "pr close", - }, - Command::Secret { command } => match command { - SecretCommand::Get(_) => "secret get", - SecretCommand::List(_) => "secret list", - SecretCommand::Rm(_) => "secret rm", - SecretCommand::Set(_) => "secret set", - }, - Command::Resume(_) => "resume", - Command::Rewind(_) => "rewind", - Command::Fork(_) => "fork", - Command::Wait(_) => "wait", - Command::Workflow { command } => match command { - WorkflowCommand::List(_) => "workflow list", - WorkflowCommand::Create(_) => "workflow create", - }, - Command::Skill { command } => match command { - SkillCommand::Install(_) => "skill install", - }, - Command::Discord => "discord", - Command::Docs => "docs", - Command::Upgrade(_) => "upgrade", - Command::Provider { command } => match command { - ProviderCommand::Login(_) => "provider login", - }, - Command::System { command } => match command { - SystemCommand::Prune(_) => "system prune", - SystemCommand::Df(_) => "system df", - }, - Command::SendAnalytics { .. } => "__send_analytics", - Command::SendPanic { .. } => "__send_panic", - }; - - let command_name = command_name.to_string(); + let Cli { globals, command } = cli; + let command_name = command.name().to_string(); let (config_log_level, upgrade_check_enabled) = { #[cfg(feature = "server")] { - if let Command::Serve(ref args) = cli.command { + if let Commands::Serve(args) = command.as_ref() { match fabro_config::server::load_server_config(args.config.as_deref()) { Ok(server_config) => ( server_config.log.as_ref().and_then(|l| l.level.clone()), @@ -584,205 +131,33 @@ async fn main_inner() -> (String, Result<()>) { } else { "cli" }; - if let Err(err) = logging::init_tracing(cli.debug, config_log_level.as_deref(), log_prefix) { + if let Err(err) = logging::init_tracing(globals.debug, config_log_level.as_deref(), log_prefix) + { eprintln!("Warning: failed to initialize logging: {err:#}"); } debug!(command = %command_name, "CLI command started"); let upgrade_handle = if matches!( - cli.command, - Command::Run(_) - | Command::Create(_) - | Command::Exec(_) - | Command::Repo { .. } - | Command::Init - | Command::Install { .. } + command.as_ref(), + Commands::Run(_) + | Commands::Create(_) + | Commands::Exec(_) + | Commands::Repo(_) + | Commands::Init + | Commands::Install { .. } ) { - upgrade::spawn_upgrade_check(cli.no_upgrade_check, upgrade_check_enabled) + commands::upgrade::spawn_upgrade_check(globals.no_upgrade_check, upgrade_check_enabled) } else { None }; - let result = async { - match cli.command { - Command::Llm { command } => { - let cli_config = cli_config::load_cli_config(None)?; - let llm_defaults = cli_config.llm.as_ref(); - match command { - LlmCommand::Prompt(mut args) => { - if args.model.is_none() { - args.model = llm_defaults.and_then(|l| l.model.clone()); - } - #[cfg(feature = "server")] - { - let resolved = cli_config::resolve_mode( - cli.mode, - cli.server_url.as_deref(), - &cli_config, - ); - match resolved.mode { - cli_config::ExecutionMode::Server => { - let client = - 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? - } - cli_config::ExecutionMode::Standalone => { - fabro_llm::cli::run_prompt(args).await? - } - } - } - #[cfg(not(feature = "server"))] - { - fabro_llm::cli::run_prompt(args).await? - } - } - LlmCommand::Chat(mut args) => { - if args.model.is_none() { - args.model = llm_defaults.and_then(|l| l.model.clone()); - } - #[cfg(feature = "server")] - { - let resolved = cli_config::resolve_mode( - cli.mode, - cli.server_url.as_deref(), - &cli_config, - ); - match resolved.mode { - cli_config::ExecutionMode::Server => { - let client = - 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? - } - cli_config::ExecutionMode::Standalone => { - fabro_llm::cli::run_chat(args).await? - } - } - } - #[cfg(not(feature = "server"))] - { - fabro_llm::cli::run_chat(args).await? - } - } - } - } - Command::Exec(mut args) => { - 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(cli.mode, cli.server_url.as_deref(), &cli_config); - let mcp_servers: Vec = 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"))] - { - tracing::info!(mode = "standalone", "Agent session starting"); - fabro_agent::cli::run_with_args(args, mcp_servers).await? - } - } - Command::Run(mut args) => { - 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 { - // Preflight validates config without creating a run dir. - // Needs github_app for token validation, runs in-process. - let github_app = 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()), - ); - commands::run::run_command(args, cli_config, styles, github_app, git_author) - .await?; - } else { - // Unified path: create + start (+ attach for foreground) - let quiet = args.detach; - let _prevent_idle_sleep = cli_config.prevent_idle_sleep_enabled(); - let (run_id, run_dir) = - commands::create::create_run(&args, cli_config, styles, quiet).await?; - - #[cfg(feature = "sleep_inhibitor")] - let _sleep_guard = fabro_beastie::guard(_prevent_idle_sleep); - - let child = commands::start::start_run(&run_dir, false)?; - - if args.detach { - println!("{run_id}"); - } else { - let exit_code = - commands::attach::attach_run(&run_dir, true, styles, Some(child)) - .await?; - commands::run::print_run_summary(&run_dir, &run_id, styles); - if exit_code != std::process::ExitCode::SUCCESS { - std::process::exit(1); - } - } - } - } - Command::Create(args) => { + let result = async move { + match *command { + Commands::Llm(ns) => commands::llm::dispatch(ns, &globals).await?, + Commands::Exec(args) => commands::exec::execute(args, &globals).await?, + Commands::Run(args) => commands::run::execute(args, &globals).await?, + Commands::Create(args) => { 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)?; @@ -790,13 +165,13 @@ async fn main_inner() -> (String, Result<()>) { commands::create::create_run(&args, cli_config, styles, true).await?; println!("{run_id}"); } - Command::Start { run } => { + Commands::Start { run } => { let base = fabro_workflows::run_lookup::default_runs_base(); let run_info = fabro_workflows::run_lookup::resolve_run(&base, &run)?; let child = commands::start::start_run(&run_info.path, false)?; eprintln!("Started engine process (PID {})", child.id()); } - Command::Attach { run } => { + Commands::Attach { run } => { let styles: &'static fabro_util::terminal::Styles = Box::leak(Box::new(fabro_util::terminal::Styles::detect_stderr())); let base = fabro_workflows::run_lookup::default_runs_base(); @@ -807,161 +182,80 @@ async fn main_inner() -> (String, Result<()>) { std::process::exit(1); } } - Command::RunEngine { run_dir, resume } => { - let styles: &'static fabro_util::terminal::Styles = - Box::leak(Box::new(fabro_util::terminal::Styles::detect_stderr())); - run_engine_entrypoint(run_dir, resume, styles).await?; + Commands::RunEngine { run_dir, resume } => { + commands::run_engine::execute(run_dir, resume).await?; } - Command::Validate(args) => { + Commands::Validate(args) => { let styles = fabro_util::terminal::Styles::detect_stderr(); commands::validate::run(&args, &styles)?; } - Command::Graph(args) => { + Commands::Graph(args) => { let styles = fabro_util::terminal::Styles::detect_stderr(); commands::graph::run(&args, &styles)?; } - Command::Parse(args) => { + Commands::Parse(args) => { commands::parse::run(&args)?; } - Command::Asset { command } => match command { - AssetCommand::List(args) => { - commands::asset::list_command(&args)?; - } - AssetCommand::Cp(args) => { - commands::asset::cp_command(&args)?; - } - }, - Command::Cp(args) => { + Commands::Asset(ns) => commands::asset::dispatch(ns)?, + Commands::Cp(args) => { commands::cp::cp_command(args).await?; } - Command::Preview(args) => { + Commands::Preview(args) => { commands::preview::run(args).await?; } - Command::Ssh(args) => { + Commands::Ssh(args) => { commands::ssh::run(args).await?; } - Command::Diff(args) => { + Commands::Diff(args) => { commands::diff::run(args).await?; } - Command::Logs(args) => { + Commands::Logs(args) => { let styles = fabro_util::terminal::Styles::detect_stdout(); commands::logs::run(args, &styles)?; } - Command::Inspect(args) => { + Commands::Inspect(args) => { commands::inspect::run(&args)?; } - Command::Model { command } => { - let server = { - #[cfg(feature = "server")] - { - let cli_config = cli_config::load_cli_config(None)?; - let resolved = cli_config::resolve_mode( - cli.mode, - cli.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"))] - { - None - } - }; - fabro_llm::cli::run_models(command, server).await? - } + Commands::Model { command } => commands::model::execute(command, &globals).await?, #[cfg(feature = "server")] - Command::Serve(args) => { + Commands::Serve(args) => { let styles: &'static fabro_util::terminal::Styles = Box::leak(Box::new(fabro_util::terminal::Styles::detect_stderr())); fabro_api::serve::serve_command(args, styles).await?; } - Command::Doctor { verbose, dry_run } => { + Commands::Doctor { verbose, dry_run } => { let cli_config = cli_config::load_cli_config(None)?; let verbose = verbose || cli_config.verbose_enabled(); - let exit_code = doctor::run_doctor(verbose, !dry_run).await; + let exit_code = commands::doctor::run_doctor(verbose, !dry_run).await; std::process::exit(exit_code); } - Command::Discord => { + Commands::Discord => { open::that("https://fabro.sh/discord")?; } - Command::Docs => { + Commands::Docs => { open::that("https://docs.fabro.sh/")?; } - Command::Repo { command } => match command { - RepoCommand::Init { skill } => { - init::run_init().await?; - if skill { - let base = std::env::current_dir()?.join(".claude").join("skills"); - skill::install_skill_to(&base)?; - } - } - RepoCommand::Deinit => { - init::run_deinit()?; - } - }, - Command::Init => { + Commands::Repo(ns) => commands::repo::dispatch(ns).await?, + Commands::Init => { eprintln!( "{} `fabro init` is deprecated, use `fabro repo init` instead", console::Style::new().yellow().apply_to("warning:") ); - init::run_init().await?; + commands::repo::init::run_init().await?; } - Command::Install { web_url } => { - install::run_install(&web_url).await?; + Commands::Install { web_url } => { + commands::install::run_install(&web_url).await?; } - Command::Ps(args) => { + Commands::Ps(args) => { let styles = fabro_util::terminal::Styles::detect_stdout(); commands::runs::list_command(&args, &styles)?; } - Command::Rm(args) => { + Commands::Rm(args) => { commands::runs::remove_command(&args).await?; } - Command::Pr { command } => { - let cli_config = cli_config::load_cli_config(None)?; - let github_app = build_github_app_credentials(cli_config.app_id()); - match command { - PrCommand::Create(args) => { - commands::pr::create_command(args, github_app).await?; - } - PrCommand::List(args) => { - commands::pr::list_command(args, github_app).await?; - } - PrCommand::View(args) => { - commands::pr::view_command(args, github_app).await?; - } - PrCommand::Merge(args) => { - commands::pr::merge_command(args, github_app).await?; - } - PrCommand::Close(args) => { - commands::pr::close_command(args, github_app).await?; - } - } - } - Command::Secret { command } => match command { - SecretCommand::Get(args) => { - commands::secret::get_command(&args)?; - } - SecretCommand::List(args) => { - commands::secret::list_command(&args)?; - } - SecretCommand::Rm(args) => { - commands::secret::rm_command(&args)?; - } - SecretCommand::Set(args) => { - commands::secret::set_command(&args)?; - } - }, - Command::Resume(args) => { + Commands::Pr(ns) => commands::pr::dispatch(ns).await?, + Commands::Secret(ns) => commands::secret::dispatch(ns)?, + Commands::Resume(args) => { let styles: &'static fabro_util::terminal::Styles = Box::leak(Box::new(fabro_util::terminal::Styles::detect_stderr())); #[cfg(feature = "sleep_inhibitor")] @@ -971,53 +265,31 @@ async fn main_inner() -> (String, Result<()>) { }; commands::resume::resume_command(args, styles).await?; } - Command::Rewind(args) => { + Commands::Rewind(args) => { let styles = fabro_util::terminal::Styles::detect_stderr(); commands::rewind::run(&args, &styles)?; } - Command::Fork(args) => { + Commands::Fork(args) => { let styles = fabro_util::terminal::Styles::detect_stderr(); commands::fork::run(&args, &styles)?; } - Command::Wait(args) => { + Commands::Wait(args) => { let styles = fabro_util::terminal::Styles::detect_stderr(); commands::wait::run(args, &styles)?; } - Command::Workflow { command } => match command { - WorkflowCommand::List(args) => { - commands::workflow::list_command(&args)?; - } - WorkflowCommand::Create(args) => { - commands::workflow::create_command(&args)?; - } - }, - Command::Skill { command } => match command { - SkillCommand::Install(args) => { - skill::run_skill_install(&args)?; - } - }, - Command::Upgrade(args) => { - upgrade::run_upgrade(args).await?; + Commands::Workflow(ns) => commands::workflow::dispatch(ns)?, + Commands::Skill(ns) => commands::skill::dispatch(ns)?, + Commands::Upgrade(args) => { + commands::upgrade::run_upgrade(args).await?; } - Command::Provider { command } => match command { - ProviderCommand::Login(args) => { - commands::provider::login_command(args).await?; - } - }, - Command::System { command } => match command { - SystemCommand::Prune(args) => { - commands::runs::prune_command(&args)?; - } - SystemCommand::Df(args) => { - commands::runs::df_command(&args)?; - } - }, - Command::SendAnalytics { path } => { + Commands::Provider(ns) => commands::provider::dispatch(ns).await?, + Commands::System(ns) => commands::system::dispatch(ns)?, + Commands::SendAnalytics { path } => { let result = fabro_telemetry::sender::upload(&path).await; let _ = std::fs::remove_file(&path); result?; } - Command::SendPanic { path } => { + Commands::SendPanic { path } => { let result = fabro_telemetry::panic::capture(&path).await; let _ = std::fs::remove_file(&path); result?; @@ -1045,10 +317,10 @@ mod tests { fn parse_provider_login_openai() { let cli = Cli::try_parse_from(["fabro", "provider", "login", "--provider", "openai"]) .expect("should parse"); - match cli.command { - Command::Provider { + match *cli.command { + Commands::Provider(ProviderNamespace { command: ProviderCommand::Login(args), - } => { + }) => { assert_eq!(args.provider, fabro_model::Provider::OpenAi); } _ => panic!("unexpected command variant"), @@ -1059,10 +331,10 @@ mod tests { fn parse_provider_login_anthropic() { let cli = Cli::try_parse_from(["fabro", "provider", "login", "--provider", "anthropic"]) .expect("should parse"); - match cli.command { - Command::Provider { + match *cli.command { + Commands::Provider(ProviderNamespace { command: ProviderCommand::Login(args), - } => { + }) => { assert_eq!(args.provider, fabro_model::Provider::Anthropic); } _ => panic!("unexpected command variant"), @@ -1085,8 +357,8 @@ mod tests { fn parse_create_command() { let cli = Cli::try_parse_from(["fabro", "create", "my-workflow.toml", "--goal", "test"]) .expect("should parse"); - match cli.command { - Command::Create(args) => { + match *cli.command { + Commands::Create(args) => { assert_eq!( args.workflow.as_deref(), Some(std::path::Path::new("my-workflow.toml")) @@ -1100,8 +372,8 @@ mod tests { #[test] fn parse_start_command() { let cli = Cli::try_parse_from(["fabro", "start", "ABC123"]).expect("should parse"); - match cli.command { - Command::Start { run } => { + match *cli.command { + Commands::Start { run } => { assert_eq!(run, "ABC123"); } _ => panic!("unexpected command variant"), @@ -1111,8 +383,8 @@ mod tests { #[test] fn parse_attach_command() { let cli = Cli::try_parse_from(["fabro", "attach", "ABC123"]).expect("should parse"); - match cli.command { - Command::Attach { run } => { + match *cli.command { + Commands::Attach { run } => { assert_eq!(run, "ABC123"); } _ => panic!("unexpected command variant"), @@ -1123,8 +395,8 @@ mod tests { fn parse_run_engine_command() { let cli = Cli::try_parse_from(["fabro", "_run_engine", "--run-dir", "/tmp/runs/test"]) .expect("should parse"); - match cli.command { - Command::RunEngine { run_dir, resume } => { + match *cli.command { + Commands::RunEngine { run_dir, resume } => { assert_eq!(run_dir, std::path::PathBuf::from("/tmp/runs/test")); assert!(!resume); } @@ -1142,8 +414,8 @@ mod tests { "--resume", ]) .expect("should parse"); - match cli.command { - Command::RunEngine { run_dir, resume } => { + match *cli.command { + Commands::RunEngine { run_dir, resume } => { assert_eq!(run_dir, std::path::PathBuf::from("/tmp/runs/test")); assert!(resume); } diff --git a/lib/crates/fabro-cli/src/shared/github.rs b/lib/crates/fabro-cli/src/shared/github.rs new file mode 100644 index 000000000..be9fc546f --- /dev/null +++ b/lib/crates/fabro-cli/src/shared/github.rs @@ -0,0 +1,17 @@ +pub(crate) fn build_github_app_credentials( + app_id: Option<&str>, +) -> Option { + 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, + }) +} diff --git a/lib/crates/fabro-cli/src/shared/mod.rs b/lib/crates/fabro-cli/src/shared/mod.rs new file mode 100644 index 000000000..5166958a4 --- /dev/null +++ b/lib/crates/fabro-cli/src/shared/mod.rs @@ -0,0 +1,5 @@ +pub(crate) mod github; +pub(crate) mod provider_auth; +mod utilities; + +pub(crate) use utilities::*; diff --git a/lib/crates/fabro-cli/src/provider_auth.rs b/lib/crates/fabro-cli/src/shared/provider_auth.rs similarity index 99% rename from lib/crates/fabro-cli/src/provider_auth.rs rename to lib/crates/fabro-cli/src/shared/provider_auth.rs index 57f95d41d..933caa345 100644 --- a/lib/crates/fabro-cli/src/provider_auth.rs +++ b/lib/crates/fabro-cli/src/shared/provider_auth.rs @@ -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 diff --git a/lib/crates/fabro-cli/src/commands/shared.rs b/lib/crates/fabro-cli/src/shared/utilities.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/shared.rs rename to lib/crates/fabro-cli/src/shared/utilities.rs diff --git a/lib/crates/fabro-cli/tests/cmd/doctor/dry-run-flag.toml b/lib/crates/fabro-cli/tests/cmd/doctor/dry-run-flag.toml index 42757fac8..1cdddd1d8 100644 --- a/lib/crates/fabro-cli/tests/cmd/doctor/dry-run-flag.toml +++ b/lib/crates/fabro-cli/tests/cmd/doctor/dry-run-flag.toml @@ -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" diff --git a/lib/crates/fabro-cli/tests/snapshots/cli__serve_help.snap b/lib/crates/fabro-cli/tests/snapshots/cli__serve_help.snap new file mode 100644 index 000000000..fcb3f5b30 --- /dev/null +++ b/lib/crates/fabro-cli/tests/snapshots/cli__serve_help.snap @@ -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 to listen on [default: 3000] + --host + Host address to bind to [default: 127.0.0.1] + --no-upgrade-check + Disable automatic upgrade check + --mode + Execution mode: standalone (in-process) or server (delegate to API) + --model + Override default LLM model + --provider + Override default LLM provider + --server-url + Server URL (overrides server.base_url from cli.toml) + --dry-run + Execute with simulated LLM backend + --sandbox + Sandbox for agent tools + --max-concurrent-runs + Maximum number of concurrent run executions + --config + Path to server config file (default: ~/.fabro/server.toml) + -h, --help + Print help