mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-07 08:27:12 +00:00
refactor(fabro-cli): slim down main
This commit is contained in:
parent
4dcd7f49da
commit
cece073052
66 changed files with 2766 additions and 2486 deletions
944
lib/crates/fabro-cli/src/args.rs
Normal file
944
lib/crates/fabro-cli/src/args.rs
Normal file
|
|
@ -0,0 +1,944 @@
|
|||
use std::fmt;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::{Args, Subcommand, ValueEnum};
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
use crate::cli_config;
|
||||
|
||||
pub(crate) const LONG_VERSION: &str = concat!(
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
" (",
|
||||
env!("FABRO_GIT_SHA"),
|
||||
" ",
|
||||
env!("FABRO_BUILD_DATE"),
|
||||
")"
|
||||
);
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct GlobalArgs {
|
||||
/// Enable DEBUG-level logging (default is INFO)
|
||||
#[arg(long, global = true)]
|
||||
pub debug: bool,
|
||||
|
||||
/// Disable automatic upgrade check
|
||||
#[arg(long, global = true)]
|
||||
pub no_upgrade_check: bool,
|
||||
|
||||
/// Execution mode: standalone (in-process) or server (delegate to API)
|
||||
#[cfg(feature = "server")]
|
||||
#[arg(long, global = true, value_parser = parse_execution_mode)]
|
||||
pub mode: Option<cli_config::ExecutionMode>,
|
||||
|
||||
/// Server URL (overrides server.base_url from cli.toml)
|
||||
#[cfg(feature = "server")]
|
||||
#[arg(long, global = true)]
|
||||
pub server_url: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub(crate) fn parse_execution_mode(s: &str) -> Result<cli_config::ExecutionMode, String> {
|
||||
match s {
|
||||
"standalone" => Ok(cli_config::ExecutionMode::Standalone),
|
||||
"server" => Ok(cli_config::ExecutionMode::Server),
|
||||
_ => Err(format!(
|
||||
"invalid mode '{s}', expected 'standalone' or 'server'"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||
pub(crate) enum CliSandboxProvider {
|
||||
Local,
|
||||
Docker,
|
||||
Daytona,
|
||||
#[cfg(feature = "exedev")]
|
||||
Exe,
|
||||
Ssh,
|
||||
}
|
||||
|
||||
impl From<CliSandboxProvider> for fabro_sandbox::SandboxProvider {
|
||||
fn from(value: CliSandboxProvider) -> Self {
|
||||
match value {
|
||||
CliSandboxProvider::Local => Self::Local,
|
||||
CliSandboxProvider::Docker => Self::Docker,
|
||||
CliSandboxProvider::Daytona => Self::Daytona,
|
||||
#[cfg(feature = "exedev")]
|
||||
CliSandboxProvider::Exe => Self::Exe,
|
||||
CliSandboxProvider::Ssh => Self::Ssh,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<fabro_sandbox::SandboxProvider> for CliSandboxProvider {
|
||||
fn from(value: fabro_sandbox::SandboxProvider) -> Self {
|
||||
match value {
|
||||
fabro_sandbox::SandboxProvider::Local => Self::Local,
|
||||
fabro_sandbox::SandboxProvider::Docker => Self::Docker,
|
||||
fabro_sandbox::SandboxProvider::Daytona => Self::Daytona,
|
||||
#[cfg(feature = "exedev")]
|
||||
fabro_sandbox::SandboxProvider::Exe => Self::Exe,
|
||||
#[cfg(not(feature = "exedev"))]
|
||||
fabro_sandbox::SandboxProvider::Exe => Self::Local,
|
||||
fabro_sandbox::SandboxProvider::Ssh => Self::Ssh,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct RunArgs {
|
||||
/// Path to a .fabro workflow file or .toml task config
|
||||
#[arg(required = true)]
|
||||
pub(crate) workflow: Option<PathBuf>,
|
||||
|
||||
/// Run output directory
|
||||
#[arg(long)]
|
||||
pub(crate) run_dir: Option<PathBuf>,
|
||||
|
||||
/// Execute with simulated LLM backend
|
||||
#[arg(long)]
|
||||
pub(crate) dry_run: bool,
|
||||
|
||||
/// Validate run configuration without executing
|
||||
#[arg(long, conflicts_with = "dry_run")]
|
||||
pub(crate) preflight: bool,
|
||||
|
||||
/// Auto-approve all human gates
|
||||
#[arg(long)]
|
||||
pub(crate) auto_approve: bool,
|
||||
|
||||
/// Override the workflow goal (exposed as $goal in prompts)
|
||||
#[arg(long)]
|
||||
pub(crate) goal: Option<String>,
|
||||
|
||||
/// Read the workflow goal from a file
|
||||
#[arg(long, conflicts_with = "goal")]
|
||||
pub(crate) goal_file: Option<PathBuf>,
|
||||
|
||||
/// Override default LLM model
|
||||
#[arg(long)]
|
||||
pub(crate) model: Option<String>,
|
||||
|
||||
/// Override default LLM provider
|
||||
#[arg(long)]
|
||||
pub(crate) provider: Option<String>,
|
||||
|
||||
/// Enable verbose output
|
||||
#[arg(short, long)]
|
||||
pub(crate) verbose: bool,
|
||||
|
||||
/// Sandbox for agent tools
|
||||
#[arg(long, value_enum)]
|
||||
pub(crate) sandbox: Option<CliSandboxProvider>,
|
||||
|
||||
/// Attach a label to this run (repeatable, format: KEY=VALUE)
|
||||
#[arg(long = "label", value_name = "KEY=VALUE")]
|
||||
pub(crate) label: Vec<String>,
|
||||
|
||||
/// Skip retro generation after the run
|
||||
#[arg(long)]
|
||||
pub(crate) no_retro: bool,
|
||||
|
||||
/// Keep the sandbox alive after the run finishes (for debugging)
|
||||
#[arg(long)]
|
||||
pub(crate) preserve_sandbox: bool,
|
||||
|
||||
/// Run the workflow in the background and print the run ID
|
||||
#[arg(short = 'd', long, conflicts_with = "preflight")]
|
||||
pub(crate) detach: bool,
|
||||
|
||||
/// Pre-generated run ID (used internally by --detach)
|
||||
#[arg(long, hide = true)]
|
||||
pub(crate) run_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct RunFilterArgs {
|
||||
/// Only include runs started before this date (YYYY-MM-DD prefix match)
|
||||
#[arg(long)]
|
||||
pub(crate) before: Option<String>,
|
||||
|
||||
/// Filter by workflow name (substring match)
|
||||
#[arg(long)]
|
||||
pub(crate) workflow: Option<String>,
|
||||
|
||||
/// Filter by label (KEY=VALUE, repeatable, AND semantics)
|
||||
#[arg(long = "label", value_name = "KEY=VALUE")]
|
||||
pub(crate) label: Vec<String>,
|
||||
|
||||
/// Include orphan directories (no run.json)
|
||||
#[arg(long)]
|
||||
pub(crate) orphans: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct RunsListArgs {
|
||||
#[command(flatten)]
|
||||
pub(crate) filter: RunFilterArgs,
|
||||
|
||||
/// Output as JSON
|
||||
#[arg(long)]
|
||||
pub(crate) json: bool,
|
||||
|
||||
/// Show all runs, not just running (like docker ps -a)
|
||||
#[arg(short = 'a', long)]
|
||||
pub(crate) all: bool,
|
||||
|
||||
/// Only display run IDs
|
||||
#[arg(short = 'q', long)]
|
||||
pub(crate) quiet: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct RunsRemoveArgs {
|
||||
/// Run IDs or workflow names to remove
|
||||
#[arg(required = true)]
|
||||
pub(crate) runs: Vec<String>,
|
||||
|
||||
/// Force removal of active runs
|
||||
#[arg(short, long)]
|
||||
pub(crate) force: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct LogsArgs {
|
||||
/// Run ID prefix or workflow name (most recent run)
|
||||
pub(crate) run: String,
|
||||
/// Follow log output
|
||||
#[arg(short, long)]
|
||||
pub(crate) follow: bool,
|
||||
/// Logs since timestamp or relative (e.g. "42m", "2h", "2026-01-02T13:00:00Z")
|
||||
#[arg(long)]
|
||||
pub(crate) since: Option<String>,
|
||||
/// Lines from end (default: all)
|
||||
#[arg(short = 'n', long)]
|
||||
pub(crate) tail: Option<usize>,
|
||||
/// Formatted colored output with rendered assistant text
|
||||
#[arg(short = 'p', long)]
|
||||
pub(crate) pretty: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct ValidateArgs {
|
||||
/// Path to the .fabro workflow file
|
||||
pub(crate) workflow: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||
pub(crate) enum GraphDirection {
|
||||
/// Left to right
|
||||
Lr,
|
||||
/// Top to bottom
|
||||
Tb,
|
||||
}
|
||||
|
||||
impl fmt::Display for GraphDirection {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Lr => write!(f, "LR"),
|
||||
Self::Tb => write!(f, "TB"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||
pub(crate) enum GraphOutputFormat {
|
||||
Svg,
|
||||
Png,
|
||||
}
|
||||
|
||||
impl From<GraphOutputFormat> for fabro_graphviz::render::GraphFormat {
|
||||
fn from(value: GraphOutputFormat) -> Self {
|
||||
match value {
|
||||
GraphOutputFormat::Svg => Self::Svg,
|
||||
GraphOutputFormat::Png => Self::Png,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for GraphOutputFormat {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Svg => write!(f, "svg"),
|
||||
Self::Png => write!(f, "png"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct GraphArgs {
|
||||
/// Path to the .fabro workflow file, .toml task config, or project workflow name
|
||||
pub(crate) workflow: PathBuf,
|
||||
|
||||
/// Output format
|
||||
#[arg(long, value_enum, default_value_t = GraphOutputFormat::Svg)]
|
||||
pub(crate) format: GraphOutputFormat,
|
||||
|
||||
/// Output file path (defaults to stdout)
|
||||
#[arg(short, long)]
|
||||
pub(crate) output: Option<PathBuf>,
|
||||
|
||||
/// Graph layout direction (overrides the DOT file's rankdir)
|
||||
#[arg(short = 'd', long)]
|
||||
pub(crate) direction: Option<GraphDirection>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct ParseArgs {
|
||||
/// Path to the .fabro workflow file
|
||||
pub(crate) workflow: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct AssetListArgs {
|
||||
/// Run ID (or prefix)
|
||||
pub(crate) run_id: String,
|
||||
|
||||
/// Filter to assets from a specific node
|
||||
#[arg(long)]
|
||||
pub(crate) node: Option<String>,
|
||||
|
||||
/// Output as JSON
|
||||
#[arg(long)]
|
||||
pub(crate) json: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct AssetCpArgs {
|
||||
/// Source: RUN_ID (all assets) or RUN_ID:path (specific asset)
|
||||
pub(crate) source: String,
|
||||
|
||||
/// Destination directory (defaults to current directory)
|
||||
#[arg(default_value = ".")]
|
||||
pub(crate) dest: PathBuf,
|
||||
|
||||
/// Filter to assets from a specific node
|
||||
#[arg(long)]
|
||||
pub(crate) node: Option<String>,
|
||||
|
||||
/// Preserve {node_slug}/retry_{N}/ directory structure
|
||||
#[arg(long)]
|
||||
pub(crate) tree: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct CpArgs {
|
||||
/// Source: <run-id>:<path> or local path
|
||||
pub(crate) src: String,
|
||||
/// Destination: <run-id>:<path> or local path
|
||||
pub(crate) dst: String,
|
||||
/// Recurse into directories
|
||||
#[arg(short, long)]
|
||||
pub(crate) recursive: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct PreviewArgs {
|
||||
/// Run ID or prefix
|
||||
pub(crate) run: String,
|
||||
/// Port number
|
||||
pub(crate) port: u16,
|
||||
/// Generate a signed URL (embeds auth token, no headers needed)
|
||||
#[arg(long)]
|
||||
pub(crate) signed: bool,
|
||||
/// Signed URL expiry in seconds (default 3600, requires --signed)
|
||||
#[arg(long, default_value = "3600", requires = "signed")]
|
||||
pub(crate) ttl: i32,
|
||||
/// Open URL in browser (implies --signed)
|
||||
#[arg(long)]
|
||||
pub(crate) open: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct SshArgs {
|
||||
/// Run ID or prefix
|
||||
pub(crate) run: String,
|
||||
/// SSH access expiry in minutes (default 60)
|
||||
#[arg(long, default_value = "60")]
|
||||
pub(crate) ttl: f64,
|
||||
/// Print the SSH command instead of connecting
|
||||
#[arg(long)]
|
||||
pub(crate) print: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct DiffArgs {
|
||||
/// Run ID or prefix
|
||||
pub(crate) run: String,
|
||||
/// Show diff for a specific node
|
||||
#[arg(long)]
|
||||
pub(crate) node: Option<String>,
|
||||
/// Show diffstat instead of full patch (live diffs only)
|
||||
#[arg(long)]
|
||||
pub(crate) stat: bool,
|
||||
/// Show only files-changed/insertions/deletions summary (live diffs only)
|
||||
#[arg(long)]
|
||||
pub(crate) shortstat: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct InspectArgs {
|
||||
/// Run ID prefix or workflow name (most recent run)
|
||||
pub(crate) run: String,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct SecretGetArgs {
|
||||
/// Name of the secret
|
||||
pub(crate) key: String,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct SecretListArgs {
|
||||
/// Show values alongside keys
|
||||
#[arg(long)]
|
||||
pub(crate) show_values: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct SecretRmArgs {
|
||||
/// Name of the secret to remove
|
||||
pub(crate) key: String,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct SecretSetArgs {
|
||||
/// Name of the secret
|
||||
pub(crate) key: String,
|
||||
/// Value to store
|
||||
pub(crate) value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub(crate) struct ResumeArgs {
|
||||
/// Run ID or unambiguous prefix
|
||||
pub(crate) run: String,
|
||||
|
||||
/// Run in the background and print the run ID
|
||||
#[arg(short = 'd', long)]
|
||||
pub(crate) detach: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub(crate) struct RewindArgs {
|
||||
/// Run ID (or unambiguous prefix)
|
||||
pub(crate) run_id: String,
|
||||
|
||||
/// Target checkpoint: node name, node@visit, or @ordinal (omit with --list)
|
||||
pub(crate) target: Option<String>,
|
||||
|
||||
/// Show the checkpoint timeline instead of rewinding
|
||||
#[arg(long)]
|
||||
pub(crate) list: bool,
|
||||
|
||||
/// Skip force-pushing rewound refs to the remote
|
||||
#[arg(long)]
|
||||
pub(crate) no_push: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub(crate) struct ForkArgs {
|
||||
/// Run ID (or unambiguous prefix)
|
||||
pub(crate) run_id: String,
|
||||
|
||||
/// Target checkpoint: node name, node@visit, or @ordinal (omit to fork from latest)
|
||||
pub(crate) target: Option<String>,
|
||||
|
||||
/// Show the checkpoint timeline instead of forking
|
||||
#[arg(long)]
|
||||
pub(crate) list: bool,
|
||||
|
||||
/// Skip pushing new branches to the remote
|
||||
#[arg(long)]
|
||||
pub(crate) no_push: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct WaitArgs {
|
||||
/// Run ID prefix or workflow name (most recent run)
|
||||
pub(crate) run: String,
|
||||
|
||||
/// Maximum time to wait in seconds
|
||||
#[arg(long, value_name = "SECONDS")]
|
||||
pub(crate) timeout: Option<u64>,
|
||||
|
||||
/// Poll interval in milliseconds
|
||||
#[arg(long, value_name = "MS", default_value = "1000")]
|
||||
pub(crate) interval: u64,
|
||||
|
||||
/// Output conclusion as JSON
|
||||
#[arg(long)]
|
||||
pub(crate) json: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct WorkflowListArgs {}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct WorkflowCreateArgs {
|
||||
/// Name of the workflow
|
||||
pub(crate) name: String,
|
||||
|
||||
/// Goal description for the workflow
|
||||
#[arg(short, long)]
|
||||
pub(crate) goal: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct ProviderLoginArgs {
|
||||
/// LLM provider to authenticate with
|
||||
#[arg(long)]
|
||||
pub(crate) provider: fabro_model::Provider,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct RunsPruneArgs {
|
||||
#[command(flatten)]
|
||||
pub(crate) filter: RunFilterArgs,
|
||||
|
||||
/// Only prune runs older than this duration (e.g. 24h, 7d). Default: 24h when no explicit filters are set.
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "DURATION",
|
||||
value_parser = crate::commands::system::parse_duration
|
||||
)]
|
||||
pub(crate) older_than: Option<chrono::Duration>,
|
||||
|
||||
/// Actually delete (default is dry-run)
|
||||
#[arg(long)]
|
||||
pub(crate) yes: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct DfArgs {
|
||||
/// Show per-run breakdown
|
||||
#[arg(short, long)]
|
||||
pub(crate) verbose: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, ValueEnum)]
|
||||
pub(crate) enum SkillDir {
|
||||
Claude,
|
||||
Agents,
|
||||
}
|
||||
|
||||
#[derive(Clone, ValueEnum)]
|
||||
pub(crate) enum SkillScope {
|
||||
User,
|
||||
Project,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct SkillInstallArgs {
|
||||
/// Where to install: user-level or project-level
|
||||
#[arg(long = "for", default_value = "user")]
|
||||
pub(crate) scope: SkillScope,
|
||||
|
||||
/// Target directory convention
|
||||
#[arg(long)]
|
||||
pub(crate) dir: SkillDir,
|
||||
|
||||
/// Overwrite existing skill without prompting
|
||||
#[arg(long)]
|
||||
pub(crate) force: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct PrCreateArgs {
|
||||
/// Run ID or prefix
|
||||
pub(crate) run_id: String,
|
||||
/// LLM model for generating PR description
|
||||
#[arg(long)]
|
||||
pub(crate) model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct PrListArgs {
|
||||
/// Show all PRs (including closed/merged), not just open
|
||||
#[arg(long)]
|
||||
pub(crate) all: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct PrViewArgs {
|
||||
/// Run ID or prefix
|
||||
pub(crate) run_id: String,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct PrMergeArgs {
|
||||
/// Run ID or prefix
|
||||
pub(crate) run_id: String,
|
||||
/// Merge method: merge, squash, or rebase
|
||||
#[arg(long, default_value = "squash")]
|
||||
pub(crate) method: String,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct PrCloseArgs {
|
||||
/// Run ID or prefix
|
||||
pub(crate) run_id: String,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct UpgradeArgs {
|
||||
/// Target version (e.g. "0.5.0" or "v0.5.0")
|
||||
#[arg(long)]
|
||||
pub(crate) version: Option<String>,
|
||||
|
||||
/// Upgrade even if already on the target version
|
||||
#[arg(long)]
|
||||
pub(crate) force: bool,
|
||||
|
||||
/// Preview what would happen without making changes
|
||||
#[arg(long)]
|
||||
pub(crate) dry_run: bool,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub(crate) enum Commands {
|
||||
/// LLM prompt operations
|
||||
#[command(hide = true)]
|
||||
Llm(LlmNamespace),
|
||||
/// Run an agentic coding session
|
||||
#[command(hide = true)]
|
||||
Exec(fabro_agent::cli::AgentArgs),
|
||||
/// Launch a workflow run
|
||||
Run(RunArgs),
|
||||
/// Create a workflow run (allocate run dir, persist spec)
|
||||
Create(RunArgs),
|
||||
/// Start a created workflow run (spawn engine process)
|
||||
Start {
|
||||
/// Run ID prefix or workflow name
|
||||
run: String,
|
||||
},
|
||||
/// Attach to a running or finished workflow run
|
||||
Attach {
|
||||
/// Run ID prefix or workflow name
|
||||
run: String,
|
||||
},
|
||||
/// Internal: run the engine process (reads run.json from run dir)
|
||||
#[command(name = "_run_engine", hide = true)]
|
||||
RunEngine {
|
||||
/// Path to the run directory
|
||||
#[arg(long)]
|
||||
run_dir: PathBuf,
|
||||
/// Resume from checkpoint instead of fresh start
|
||||
#[arg(long)]
|
||||
resume: bool,
|
||||
},
|
||||
/// Validate a workflow
|
||||
Validate(ValidateArgs),
|
||||
/// Render a workflow graph as SVG or PNG
|
||||
Graph(GraphArgs),
|
||||
/// Parse a DOT file and print its AST
|
||||
#[command(hide = true)]
|
||||
Parse(ParseArgs),
|
||||
/// Inspect and copy run assets (screenshots, reports, traces)
|
||||
Asset(AssetNamespace),
|
||||
/// Copy files to/from a run's sandbox
|
||||
Cp(CpArgs),
|
||||
/// Get a preview URL for a port on a run's sandbox
|
||||
Preview(PreviewArgs),
|
||||
/// SSH into a run's Daytona sandbox
|
||||
Ssh(SshArgs),
|
||||
/// Show the diff of changes from a workflow run
|
||||
#[command(hide = true)]
|
||||
Diff(DiffArgs),
|
||||
/// View the event log of a workflow run
|
||||
Logs(LogsArgs),
|
||||
/// Show detailed information about a workflow run
|
||||
Inspect(InspectArgs),
|
||||
/// List and test LLM models
|
||||
Model {
|
||||
#[command(subcommand)]
|
||||
command: Option<fabro_llm::cli::ModelsCommand>,
|
||||
},
|
||||
/// Start the HTTP API server
|
||||
#[cfg(feature = "server")]
|
||||
Serve(fabro_api::serve::ServeArgs),
|
||||
/// Check environment and integration health
|
||||
Doctor {
|
||||
/// Show detailed information for each check
|
||||
#[arg(short, long)]
|
||||
verbose: bool,
|
||||
|
||||
/// Skip live service probes (LLM, sandbox, API, web, Brave Search)
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
},
|
||||
/// Initialize a new project (deprecated: use `repo init`)
|
||||
#[command(hide = true)]
|
||||
Init,
|
||||
/// Set up the Fabro environment (LLMs, certs, GitHub)
|
||||
Install {
|
||||
/// Base URL for the web UI (used for OAuth callback URLs)
|
||||
#[arg(long, default_value = "http://localhost:5173")]
|
||||
web_url: String,
|
||||
},
|
||||
/// List workflow runs
|
||||
#[command(hide = true)]
|
||||
Ps(RunsListArgs),
|
||||
/// Remove one or more workflow runs
|
||||
Rm(RunsRemoveArgs),
|
||||
/// Pull request operations
|
||||
Pr(PrNamespace),
|
||||
/// Skill management
|
||||
#[command(hide = true)]
|
||||
Skill(SkillNamespace),
|
||||
/// Manage secrets in ~/.fabro/.env
|
||||
Secret(SecretNamespace),
|
||||
/// Resume an interrupted workflow run
|
||||
Resume(ResumeArgs),
|
||||
/// Rewind a workflow run to an earlier checkpoint
|
||||
Rewind(RewindArgs),
|
||||
/// Fork a workflow run from an earlier checkpoint into a new run
|
||||
Fork(ForkArgs),
|
||||
/// Block until a workflow run completes
|
||||
Wait(WaitArgs),
|
||||
/// Workflow operations
|
||||
Workflow(WorkflowNamespace),
|
||||
/// Open the Discord community in the browser
|
||||
Discord,
|
||||
/// Open the docs website in the browser
|
||||
Docs,
|
||||
/// Upgrade fabro to the latest version
|
||||
Upgrade(UpgradeArgs),
|
||||
/// Repository commands
|
||||
Repo(RepoNamespace),
|
||||
/// Provider operations
|
||||
Provider(ProviderNamespace),
|
||||
/// System maintenance commands
|
||||
System(SystemNamespace),
|
||||
/// Send a queued analytics event (internal)
|
||||
#[command(name = "__send_analytics", hide = true)]
|
||||
SendAnalytics {
|
||||
/// Path to the JSON event file
|
||||
path: PathBuf,
|
||||
},
|
||||
/// Send a queued panic event to Sentry (internal)
|
||||
#[command(name = "__send_panic", hide = true)]
|
||||
SendPanic {
|
||||
/// Path to the JSON event file
|
||||
path: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
impl Commands {
|
||||
pub(crate) fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Llm(ns) => match &ns.command {
|
||||
LlmCommand::Prompt(_) => "llm prompt",
|
||||
LlmCommand::Chat(_) => "llm chat",
|
||||
},
|
||||
Self::Asset(ns) => match &ns.command {
|
||||
AssetCommand::List(_) => "asset list",
|
||||
AssetCommand::Cp(_) => "asset cp",
|
||||
},
|
||||
Self::Exec(_) => "exec",
|
||||
Self::Run(_) => "run",
|
||||
Self::Create(_) => "create",
|
||||
Self::Start { .. } => "start",
|
||||
Self::Attach { .. } => "attach",
|
||||
Self::RunEngine { .. } => "_run_engine",
|
||||
Self::Validate(_) => "validate",
|
||||
Self::Graph(_) => "graph",
|
||||
Self::Parse(_) => "parse",
|
||||
Self::Cp(_) => "cp",
|
||||
Self::Preview(_) => "preview",
|
||||
Self::Ssh(_) => "ssh",
|
||||
Self::Diff(_) => "diff",
|
||||
Self::Logs(_) => "logs",
|
||||
Self::Inspect(_) => "inspect",
|
||||
Self::Model { command } => match command {
|
||||
Some(fabro_llm::cli::ModelsCommand::List { .. }) => "model list",
|
||||
Some(fabro_llm::cli::ModelsCommand::Test { .. }) => "model test",
|
||||
None => "model",
|
||||
},
|
||||
#[cfg(feature = "server")]
|
||||
Self::Serve(_) => "serve",
|
||||
Self::Doctor { .. } => "doctor",
|
||||
Self::Repo(ns) => match &ns.command {
|
||||
RepoCommand::Init { .. } => "repo init",
|
||||
RepoCommand::Deinit => "repo deinit",
|
||||
},
|
||||
Self::Init => "init",
|
||||
Self::Install { .. } => "install",
|
||||
Self::Ps(_) => "ps",
|
||||
Self::Rm(_) => "rm",
|
||||
Self::Pr(ns) => match &ns.command {
|
||||
PrCommand::Create(_) => "pr create",
|
||||
PrCommand::List(_) => "pr list",
|
||||
PrCommand::View(_) => "pr view",
|
||||
PrCommand::Merge(_) => "pr merge",
|
||||
PrCommand::Close(_) => "pr close",
|
||||
},
|
||||
Self::Secret(ns) => match &ns.command {
|
||||
SecretCommand::Get(_) => "secret get",
|
||||
SecretCommand::List(_) => "secret list",
|
||||
SecretCommand::Rm(_) => "secret rm",
|
||||
SecretCommand::Set(_) => "secret set",
|
||||
},
|
||||
Self::Resume(_) => "resume",
|
||||
Self::Rewind(_) => "rewind",
|
||||
Self::Fork(_) => "fork",
|
||||
Self::Wait(_) => "wait",
|
||||
Self::Workflow(ns) => match &ns.command {
|
||||
WorkflowCommand::List(_) => "workflow list",
|
||||
WorkflowCommand::Create(_) => "workflow create",
|
||||
},
|
||||
Self::Skill(ns) => match &ns.command {
|
||||
SkillCommand::Install(_) => "skill install",
|
||||
},
|
||||
Self::Discord => "discord",
|
||||
Self::Docs => "docs",
|
||||
Self::Upgrade(_) => "upgrade",
|
||||
Self::Provider(ns) => match &ns.command {
|
||||
ProviderCommand::Login(_) => "provider login",
|
||||
},
|
||||
Self::System(ns) => match &ns.command {
|
||||
SystemCommand::Prune(_) => "system prune",
|
||||
SystemCommand::Df(_) => "system df",
|
||||
},
|
||||
Self::SendAnalytics { .. } => "__send_analytics",
|
||||
Self::SendPanic { .. } => "__send_panic",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct PrNamespace {
|
||||
#[command(subcommand)]
|
||||
pub(crate) command: PrCommand,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub(crate) enum PrCommand {
|
||||
/// Create a pull request from a completed run
|
||||
Create(PrCreateArgs),
|
||||
/// List pull requests from workflow runs
|
||||
List(PrListArgs),
|
||||
/// View pull request details
|
||||
View(PrViewArgs),
|
||||
/// Merge a pull request
|
||||
Merge(PrMergeArgs),
|
||||
/// Close a pull request
|
||||
Close(PrCloseArgs),
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct AssetNamespace {
|
||||
#[command(subcommand)]
|
||||
pub(crate) command: AssetCommand,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub(crate) enum AssetCommand {
|
||||
/// List assets for a workflow run
|
||||
List(AssetListArgs),
|
||||
/// Copy assets from a workflow run
|
||||
Cp(AssetCpArgs),
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct SecretNamespace {
|
||||
#[command(subcommand)]
|
||||
pub(crate) command: SecretCommand,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub(crate) enum SecretCommand {
|
||||
/// Get a secret value
|
||||
Get(SecretGetArgs),
|
||||
/// List secret names
|
||||
#[command(alias = "ls")]
|
||||
List(SecretListArgs),
|
||||
/// Remove a secret
|
||||
Rm(SecretRmArgs),
|
||||
/// Set a secret value
|
||||
Set(SecretSetArgs),
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct SystemNamespace {
|
||||
#[command(subcommand)]
|
||||
pub(crate) command: SystemCommand,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub(crate) enum SystemCommand {
|
||||
/// Delete old workflow runs
|
||||
Prune(RunsPruneArgs),
|
||||
/// Show disk usage
|
||||
Df(DfArgs),
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct WorkflowNamespace {
|
||||
#[command(subcommand)]
|
||||
pub(crate) command: WorkflowCommand,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub(crate) enum WorkflowCommand {
|
||||
/// List available workflows
|
||||
List(WorkflowListArgs),
|
||||
/// Create a new workflow
|
||||
Create(WorkflowCreateArgs),
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct RepoNamespace {
|
||||
#[command(subcommand)]
|
||||
pub(crate) command: RepoCommand,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub(crate) enum RepoCommand {
|
||||
/// Initialize a new project
|
||||
Init {
|
||||
/// Also install the fabro-create-workflow skill
|
||||
#[arg(long, hide = true)]
|
||||
skill: bool,
|
||||
},
|
||||
/// Remove fabro.toml and fabro/ directory
|
||||
Deinit,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct ProviderNamespace {
|
||||
#[command(subcommand)]
|
||||
pub(crate) command: ProviderCommand,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub(crate) enum ProviderCommand {
|
||||
/// Log in to an LLM provider
|
||||
Login(ProviderLoginArgs),
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct LlmNamespace {
|
||||
#[command(subcommand)]
|
||||
pub(crate) command: LlmCommand,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub(crate) enum LlmCommand {
|
||||
/// Execute a prompt
|
||||
Prompt(fabro_llm::cli::PromptArgs),
|
||||
/// Interactive multi-turn chat
|
||||
Chat(fabro_llm::cli::ChatArgs),
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct SkillNamespace {
|
||||
#[command(subcommand)]
|
||||
pub(crate) command: SkillCommand,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub(crate) enum SkillCommand {
|
||||
/// Install a built-in skill
|
||||
Install(SkillInstallArgs),
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
pub use fabro_config::cli::*;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
use fabro_config::FabroConfig;
|
||||
#[cfg(feature = "server")]
|
||||
use tracing::debug;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,94 +1,9 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use clap::Args;
|
||||
|
||||
use super::shared::split_run_path;
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct AssetListArgs {
|
||||
/// Run ID (or prefix)
|
||||
pub run_id: String,
|
||||
|
||||
/// Filter to assets from a specific node
|
||||
#[arg(long)]
|
||||
pub node: Option<String>,
|
||||
|
||||
/// Output as JSON
|
||||
#[arg(long)]
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct AssetCpArgs {
|
||||
/// Source: RUN_ID (all assets) or RUN_ID:path (specific asset)
|
||||
pub source: String,
|
||||
|
||||
/// Destination directory (defaults to current directory)
|
||||
#[arg(default_value = ".")]
|
||||
pub dest: PathBuf,
|
||||
|
||||
/// Filter to assets from a specific node
|
||||
#[arg(long)]
|
||||
pub node: Option<String>,
|
||||
|
||||
/// Preserve {node_slug}/retry_{N}/ directory structure
|
||||
#[arg(long)]
|
||||
pub tree: bool,
|
||||
}
|
||||
|
||||
pub fn list_command(args: &AssetListArgs) -> Result<()> {
|
||||
let base = fabro_workflows::run_lookup::default_runs_base();
|
||||
let run = fabro_workflows::run_lookup::resolve_run(&base, &args.run_id)?;
|
||||
let entries = fabro_workflows::assets::scan_assets(&run.path, args.node.as_deref())?;
|
||||
|
||||
if args.json {
|
||||
println!("{}", serde_json::to_string_pretty(&entries)?);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if entries.is_empty() {
|
||||
println!("No assets found for this run.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let node_width = entries
|
||||
.iter()
|
||||
.map(|entry| entry.node_slug.len())
|
||||
.max()
|
||||
.unwrap_or(4)
|
||||
.max(4);
|
||||
let retry_width = 5;
|
||||
let size_width = entries
|
||||
.iter()
|
||||
.map(|entry| format_size(entry.size).len())
|
||||
.max()
|
||||
.unwrap_or(4)
|
||||
.max(4);
|
||||
|
||||
println!(
|
||||
"{:<node_width$} {:>retry_width$} {:>size_width$} PATH",
|
||||
"NODE", "RETRY", "SIZE"
|
||||
);
|
||||
let total_size: u64 = entries.iter().map(|entry| entry.size).sum();
|
||||
for entry in &entries {
|
||||
println!(
|
||||
"{:<node_width$} {:>retry_width$} {:>size_width$} {}",
|
||||
entry.node_slug,
|
||||
entry.retry,
|
||||
format_size(entry.size),
|
||||
entry.relative_path
|
||||
);
|
||||
}
|
||||
println!();
|
||||
println!(
|
||||
"{} asset(s), {} total",
|
||||
entries.len(),
|
||||
format_size(total_size)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
use crate::args::AssetCpArgs;
|
||||
use crate::shared::split_run_path;
|
||||
|
||||
pub fn cp_command(args: &AssetCpArgs) -> Result<()> {
|
||||
let base = fabro_workflows::run_lookup::default_runs_base();
|
||||
|
|
@ -209,22 +124,6 @@ fn parse_source(source: &str) -> (&str, Option<&str>) {
|
|||
}
|
||||
}
|
||||
|
||||
fn format_size(bytes: u64) -> String {
|
||||
const KB: u64 = 1024;
|
||||
const MB: u64 = 1024 * KB;
|
||||
const GB: u64 = 1024 * MB;
|
||||
|
||||
if bytes >= GB {
|
||||
format!("{:.1} GB", bytes as f64 / GB as f64)
|
||||
} else if bytes >= MB {
|
||||
format!("{:.1} MB", bytes as f64 / MB as f64)
|
||||
} else if bytes >= KB {
|
||||
format!("{:.1} KB", bytes as f64 / KB as f64)
|
||||
} else {
|
||||
format!("{bytes} B")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
57
lib/crates/fabro-cli/src/commands/asset/list.rs
Normal file
57
lib/crates/fabro-cli/src/commands/asset/list.rs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
use anyhow::Result;
|
||||
|
||||
use crate::args::AssetListArgs;
|
||||
use crate::shared::format_size;
|
||||
|
||||
pub fn list_command(args: &AssetListArgs) -> Result<()> {
|
||||
let base = fabro_workflows::run_lookup::default_runs_base();
|
||||
let run = fabro_workflows::run_lookup::resolve_run(&base, &args.run_id)?;
|
||||
let entries = fabro_workflows::assets::scan_assets(&run.path, args.node.as_deref())?;
|
||||
|
||||
if args.json {
|
||||
println!("{}", serde_json::to_string_pretty(&entries)?);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if entries.is_empty() {
|
||||
println!("No assets found for this run.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let node_width = entries
|
||||
.iter()
|
||||
.map(|entry| entry.node_slug.len())
|
||||
.max()
|
||||
.unwrap_or(4)
|
||||
.max(4);
|
||||
let retry_width = 5;
|
||||
let size_width = entries
|
||||
.iter()
|
||||
.map(|entry| format_size(entry.size).len())
|
||||
.max()
|
||||
.unwrap_or(4)
|
||||
.max(4);
|
||||
|
||||
println!(
|
||||
"{:<node_width$} {:>retry_width$} {:>size_width$} PATH",
|
||||
"NODE", "RETRY", "SIZE"
|
||||
);
|
||||
let total_size: u64 = entries.iter().map(|entry| entry.size).sum();
|
||||
for entry in &entries {
|
||||
println!(
|
||||
"{:<node_width$} {:>retry_width$} {:>size_width$} {}",
|
||||
entry.node_slug,
|
||||
entry.retry,
|
||||
format_size(entry.size),
|
||||
entry.relative_path
|
||||
);
|
||||
}
|
||||
println!();
|
||||
println!(
|
||||
"{} asset(s), {} total",
|
||||
entries.len(),
|
||||
format_size(total_size)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
13
lib/crates/fabro-cli/src/commands/asset/mod.rs
Normal file
13
lib/crates/fabro-cli/src/commands/asset/mod.rs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
mod cp;
|
||||
mod list;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::args::{AssetCommand, AssetNamespace};
|
||||
|
||||
pub fn dispatch(ns: AssetNamespace) -> Result<()> {
|
||||
match ns.command {
|
||||
AssetCommand::List(args) => list::list_command(&args),
|
||||
AssetCommand::Cp(args) => cp::cp_command(&args),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +1,10 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use clap::Args;
|
||||
use tracing::{debug, info};
|
||||
|
||||
use super::shared::split_run_path;
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct CpArgs {
|
||||
/// Source: <run-id>:<path> or local path
|
||||
pub src: String,
|
||||
/// Destination: <run-id>:<path> or local path
|
||||
pub dst: String,
|
||||
/// Recurse into directories
|
||||
#[arg(short, long)]
|
||||
pub recursive: bool,
|
||||
}
|
||||
use crate::args::CpArgs;
|
||||
use crate::shared::split_run_path;
|
||||
|
||||
enum CopyDirection {
|
||||
Download {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -2,23 +2,9 @@ use std::io::{self, IsTerminal, Write};
|
|||
use std::path::Path;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use clap::Args;
|
||||
use tracing::{debug, info};
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct DiffArgs {
|
||||
/// Run ID or prefix
|
||||
pub run: String,
|
||||
/// Show diff for a specific node
|
||||
#[arg(long)]
|
||||
pub node: Option<String>,
|
||||
/// Show diffstat instead of full patch (live diffs only)
|
||||
#[arg(long)]
|
||||
pub stat: bool,
|
||||
/// Show only files-changed/insertions/deletions summary (live diffs only)
|
||||
#[arg(long)]
|
||||
pub shortstat: bool,
|
||||
}
|
||||
use crate::args::DiffArgs;
|
||||
|
||||
pub async fn run(args: DiffArgs) -> Result<()> {
|
||||
info!(run_id = %args.run, "Showing diff");
|
||||
|
|
|
|||
|
|
@ -780,7 +780,7 @@ pub fn check_crypto(input: &CryptoInput) -> CheckResult {
|
|||
let result = input
|
||||
.jwt_public_key
|
||||
.as_deref()
|
||||
.ok_or_else(|| "JWT configured but ARC_JWT_PUBLIC_KEY not set".to_string())
|
||||
.ok_or_else(|| "JWT configured but FABRO_JWT_PUBLIC_KEY not set".to_string())
|
||||
.and_then(|raw| decode_pem_value("FABRO_JWT_PUBLIC_KEY", raw))
|
||||
.and_then(|pem| {
|
||||
jsonwebtoken::DecodingKey::from_ed_pem(pem.as_bytes())
|
||||
|
|
@ -947,7 +947,7 @@ pub async fn run_doctor(verbose: bool, live: bool) -> i32 {
|
|||
|
||||
#[cfg(feature = "server")]
|
||||
let api_status = {
|
||||
let api = server_config.api.unwrap_or_default();
|
||||
let api = server_config.api.clone().unwrap_or_default();
|
||||
ApiStatus {
|
||||
base_url: api.base_url.clone(),
|
||||
authentication_strategies: api.authentication_strategies.clone(),
|
||||
|
|
@ -956,7 +956,7 @@ pub async fn run_doctor(verbose: bool, live: bool) -> i32 {
|
|||
|
||||
#[cfg(feature = "server")]
|
||||
let web_status = {
|
||||
let web = server_config.web.unwrap_or_default();
|
||||
let web = server_config.web.clone().unwrap_or_default();
|
||||
WebStatus {
|
||||
url: web.url.clone(),
|
||||
auth_provider: web.auth.provider.clone(),
|
||||
|
|
@ -964,6 +964,15 @@ pub async fn run_doctor(verbose: bool, live: bool) -> i32 {
|
|||
}
|
||||
};
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
let server_git = server_config.git.clone().unwrap_or_default();
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
let server_api = server_config.api.clone().unwrap_or_default();
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
let server_web = server_config.web.clone().unwrap_or_default();
|
||||
|
||||
let git_app_id = cli_config.app_id().map(str::to_owned);
|
||||
let private_key_raw = std::env::var("GITHUB_APP_PRIVATE_KEY").ok();
|
||||
let sign_result = match (&git_app_id, &private_key_raw) {
|
||||
|
|
@ -995,7 +1004,7 @@ pub async fn run_doctor(verbose: bool, live: bool) -> i32 {
|
|||
private_key_set: private_key_raw.is_some(),
|
||||
sign_result,
|
||||
#[cfg(feature = "server")]
|
||||
client_id: server_config.git.client_id.is_some(),
|
||||
client_id: server_git.client_id.is_some(),
|
||||
#[cfg(feature = "server")]
|
||||
client_secret: std::env::var("GITHUB_APP_CLIENT_SECRET").is_ok(),
|
||||
#[cfg(feature = "server")]
|
||||
|
|
@ -1004,12 +1013,11 @@ pub async fn run_doctor(verbose: bool, live: bool) -> i32 {
|
|||
|
||||
#[cfg(feature = "server")]
|
||||
let crypto_input = {
|
||||
let has_mtls = server_config
|
||||
.api
|
||||
let has_mtls = server_api
|
||||
.authentication_strategies
|
||||
.contains(&ApiAuthStrategy::Mtls);
|
||||
let tls_files = if has_mtls {
|
||||
server_config.api.tls.as_ref().map(|tls| {
|
||||
server_api.tls.as_ref().map(|tls| {
|
||||
let read = |p: &std::path::Path| -> Result<String, String> {
|
||||
let expanded = fabro_config::expand_tilde(p);
|
||||
std::fs::read_to_string(&expanded)
|
||||
|
|
@ -1025,7 +1033,7 @@ pub async fn run_doctor(verbose: bool, live: bool) -> i32 {
|
|||
None
|
||||
};
|
||||
CryptoInput {
|
||||
auth_strategies: server_config.api.authentication_strategies.clone(),
|
||||
auth_strategies: server_api.authentication_strategies.clone(),
|
||||
tls_files,
|
||||
jwt_public_key: std::env::var("FABRO_JWT_PUBLIC_KEY").ok(),
|
||||
jwt_private_key: std::env::var("FABRO_JWT_PRIVATE_KEY").ok(),
|
||||
|
|
@ -1081,9 +1089,9 @@ pub async fn run_doctor(verbose: bool, live: bool) -> i32 {
|
|||
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
let api_url = format!("{}/runs", server_config.api.base_url);
|
||||
let api_url = format!("{}/runs", server_api.base_url);
|
||||
let api_fut = probe_url(&http, &api_url);
|
||||
let web_fut = probe_url(&http, &server_config.web.url);
|
||||
let web_fut = probe_url(&http, &server_web.url);
|
||||
|
||||
let (sandbox, llm, brave, api, web) =
|
||||
tokio::join!(sandbox_fut, llm_fut, brave_fut, api_fut, web_fut);
|
||||
65
lib/crates/fabro-cli/src/commands/exec.rs
Normal file
65
lib/crates/fabro-cli/src/commands/exec.rs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
use anyhow::Result;
|
||||
|
||||
use crate::args::GlobalArgs;
|
||||
use crate::cli_config;
|
||||
|
||||
pub async fn execute(mut args: fabro_agent::cli::AgentArgs, globals: &GlobalArgs) -> Result<()> {
|
||||
let cli_config = cli_config::load_cli_config(None)?;
|
||||
#[cfg(feature = "sleep_inhibitor")]
|
||||
let _sleep_guard = fabro_beastie::guard(cli_config.prevent_idle_sleep_enabled());
|
||||
let exec_defaults = cli_config.exec.as_ref();
|
||||
args.apply_cli_defaults(
|
||||
exec_defaults.and_then(|a| a.provider.as_deref()),
|
||||
exec_defaults.and_then(|a| a.model.as_deref()),
|
||||
exec_defaults.and_then(|a| a.permissions),
|
||||
exec_defaults.and_then(|a| a.output_format),
|
||||
);
|
||||
#[cfg(feature = "server")]
|
||||
let resolved = cli_config::resolve_mode(
|
||||
globals.mode.clone(),
|
||||
globals.server_url.as_deref(),
|
||||
&cli_config,
|
||||
);
|
||||
let mcp_servers: Vec<fabro_mcp::config::McpServerConfig> = cli_config
|
||||
.mcp_servers
|
||||
.into_iter()
|
||||
.map(|(name, entry): (String, fabro_config::mcp::McpServerEntry)| entry.into_config(name))
|
||||
.collect();
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
match resolved.mode {
|
||||
cli_config::ExecutionMode::Server => {
|
||||
tracing::info!(mode = "server", "Agent session starting");
|
||||
let http_client = cli_config::build_server_client(resolved.tls.as_ref())?;
|
||||
let provider_name = args
|
||||
.provider
|
||||
.clone()
|
||||
.unwrap_or_else(|| "anthropic".to_string());
|
||||
let adapter = std::sync::Arc::new(fabro_llm::providers::FabroServerAdapter::new(
|
||||
http_client,
|
||||
&resolved.server_base_url,
|
||||
&provider_name,
|
||||
));
|
||||
let mut client =
|
||||
fabro_llm::client::Client::new(std::collections::HashMap::new(), None, vec![]);
|
||||
client
|
||||
.register_provider(adapter)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to register fabro server adapter: {e}"))?;
|
||||
fabro_agent::cli::run_with_args_and_client(args, Some(client), mcp_servers).await?
|
||||
}
|
||||
cli_config::ExecutionMode::Standalone => {
|
||||
tracing::info!(mode = "standalone", "Agent session starting");
|
||||
fabro_agent::cli::run_with_args(args, mcp_servers).await?
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "server"))]
|
||||
{
|
||||
let _ = globals;
|
||||
tracing::info!(mode = "standalone", "Agent session starting");
|
||||
fabro_agent::cli::run_with_args(args, mcp_servers).await?
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1,26 +1,10 @@
|
|||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use clap::Args;
|
||||
use fabro_git_storage::gitobj::Store;
|
||||
use fabro_util::terminal::Styles;
|
||||
use git2::Repository;
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct ForkArgs {
|
||||
/// Run ID (or unambiguous prefix)
|
||||
pub run_id: String,
|
||||
|
||||
/// Target checkpoint: node name, node@visit, or @ordinal (omit to fork from latest)
|
||||
pub target: Option<String>,
|
||||
|
||||
/// Show the checkpoint timeline instead of forking
|
||||
#[arg(long)]
|
||||
pub list: bool,
|
||||
|
||||
/// Skip pushing new branches to the remote
|
||||
#[arg(long)]
|
||||
pub no_push: bool,
|
||||
}
|
||||
use crate::args::ForkArgs;
|
||||
|
||||
pub fn run(args: &ForkArgs, styles: &Styles) -> Result<()> {
|
||||
let repo = Repository::discover(".").context("not in a git repository")?;
|
||||
|
|
|
|||
|
|
@ -1,55 +1,14 @@
|
|||
use std::borrow::Cow;
|
||||
use std::fmt;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use anyhow::bail;
|
||||
use clap::{Args, ValueEnum};
|
||||
use fabro_util::terminal::Styles;
|
||||
use fabro_validate::Severity;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::commands::shared::{print_diagnostics, read_workflow_file, relative_path};
|
||||
|
||||
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||
pub enum GraphDirection {
|
||||
/// Left to right
|
||||
Lr,
|
||||
/// Top to bottom
|
||||
Tb,
|
||||
}
|
||||
|
||||
impl fmt::Display for GraphDirection {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Lr => write!(f, "LR"),
|
||||
Self::Tb => write!(f, "TB"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct GraphArgs {
|
||||
/// Path to the .fabro workflow file, .toml task config, or project workflow name
|
||||
pub workflow: PathBuf,
|
||||
|
||||
/// Output format
|
||||
#[arg(
|
||||
long,
|
||||
value_enum,
|
||||
default_value_t = GraphOutputFormat::Svg
|
||||
)]
|
||||
pub format: GraphOutputFormat,
|
||||
|
||||
/// Output file path (defaults to stdout)
|
||||
#[arg(short, long)]
|
||||
pub output: Option<PathBuf>,
|
||||
|
||||
/// Graph layout direction (overrides the DOT file's rankdir)
|
||||
#[arg(short = 'd', long)]
|
||||
pub direction: Option<GraphDirection>,
|
||||
}
|
||||
use crate::args::{GraphArgs, GraphDirection};
|
||||
use crate::shared::{print_diagnostics, read_workflow_file, relative_path};
|
||||
|
||||
static RANKDIR_RE: LazyLock<regex::Regex> =
|
||||
LazyLock::new(|| regex::Regex::new(r"rankdir\s*=\s*\w+").unwrap());
|
||||
|
|
@ -85,30 +44,6 @@ pub fn run(args: &GraphArgs, styles: &Styles) -> anyhow::Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||
pub enum GraphOutputFormat {
|
||||
Svg,
|
||||
Png,
|
||||
}
|
||||
|
||||
impl From<GraphOutputFormat> for fabro_graphviz::render::GraphFormat {
|
||||
fn from(value: GraphOutputFormat) -> Self {
|
||||
match value {
|
||||
GraphOutputFormat::Svg => Self::Svg,
|
||||
GraphOutputFormat::Png => Self::Png,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for GraphOutputFormat {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Svg => write!(f, "svg"),
|
||||
Self::Png => write!(f, "png"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_direction<'a>(source: &'a str, direction: Option<GraphDirection>) -> Cow<'a, str> {
|
||||
match direction {
|
||||
Some(dir) => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
45
lib/crates/fabro-cli/src/commands/llm/chat.rs
Normal file
45
lib/crates/fabro-cli/src/commands/llm/chat.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
use anyhow::Result;
|
||||
use fabro_config::FabroConfig;
|
||||
|
||||
use crate::args::GlobalArgs;
|
||||
|
||||
pub async fn execute(
|
||||
mut args: fabro_llm::cli::ChatArgs,
|
||||
cli_config: &FabroConfig,
|
||||
globals: &GlobalArgs,
|
||||
) -> Result<()> {
|
||||
let llm_defaults = cli_config.llm.as_ref();
|
||||
if args.model.is_none() {
|
||||
args.model = llm_defaults.and_then(|l| l.model.clone());
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
let resolved = crate::cli_config::resolve_mode(
|
||||
globals.mode.clone(),
|
||||
globals.server_url.as_deref(),
|
||||
cli_config,
|
||||
);
|
||||
match resolved.mode {
|
||||
crate::cli_config::ExecutionMode::Server => {
|
||||
let client = crate::cli_config::build_server_client(resolved.tls.as_ref())?;
|
||||
let server = fabro_llm::cli::ServerConnection {
|
||||
client,
|
||||
base_url: resolved.server_base_url,
|
||||
};
|
||||
fabro_llm::cli::run_chat_via_server(args, &server).await?;
|
||||
}
|
||||
crate::cli_config::ExecutionMode::Standalone => {
|
||||
fabro_llm::cli::run_chat(args).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "server"))]
|
||||
{
|
||||
let _ = globals;
|
||||
fabro_llm::cli::run_chat(args).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
15
lib/crates/fabro-cli/src/commands/llm/mod.rs
Normal file
15
lib/crates/fabro-cli/src/commands/llm/mod.rs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
mod chat;
|
||||
mod prompt;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::args::{GlobalArgs, LlmCommand, LlmNamespace};
|
||||
|
||||
pub async fn dispatch(ns: LlmNamespace, globals: &GlobalArgs) -> Result<()> {
|
||||
let cli_config = crate::cli_config::load_cli_config(None)?;
|
||||
|
||||
match ns.command {
|
||||
LlmCommand::Prompt(args) => prompt::execute(args, &cli_config, globals).await,
|
||||
LlmCommand::Chat(args) => chat::execute(args, &cli_config, globals).await,
|
||||
}
|
||||
}
|
||||
45
lib/crates/fabro-cli/src/commands/llm/prompt.rs
Normal file
45
lib/crates/fabro-cli/src/commands/llm/prompt.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
use anyhow::Result;
|
||||
use fabro_config::FabroConfig;
|
||||
|
||||
use crate::args::GlobalArgs;
|
||||
|
||||
pub async fn execute(
|
||||
mut args: fabro_llm::cli::PromptArgs,
|
||||
cli_config: &FabroConfig,
|
||||
globals: &GlobalArgs,
|
||||
) -> Result<()> {
|
||||
let llm_defaults = cli_config.llm.as_ref();
|
||||
if args.model.is_none() {
|
||||
args.model = llm_defaults.and_then(|l| l.model.clone());
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
let resolved = crate::cli_config::resolve_mode(
|
||||
globals.mode.clone(),
|
||||
globals.server_url.as_deref(),
|
||||
cli_config,
|
||||
);
|
||||
match resolved.mode {
|
||||
crate::cli_config::ExecutionMode::Server => {
|
||||
let client = crate::cli_config::build_server_client(resolved.tls.as_ref())?;
|
||||
let server = fabro_llm::cli::ServerConnection {
|
||||
client,
|
||||
base_url: resolved.server_base_url,
|
||||
};
|
||||
fabro_llm::cli::run_prompt_via_server(args, &server).await?;
|
||||
}
|
||||
crate::cli_config::ExecutionMode::Standalone => {
|
||||
fabro_llm::cli::run_prompt(args).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "server"))]
|
||||
{
|
||||
let _ = globals;
|
||||
fabro_llm::cli::run_prompt(args).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -3,27 +3,10 @@ use std::path::Path;
|
|||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use clap::Args;
|
||||
use fabro_util::terminal::Styles;
|
||||
use tracing::{debug, info};
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct LogsArgs {
|
||||
/// Run ID prefix or workflow name (most recent run)
|
||||
pub run: String,
|
||||
/// Follow log output
|
||||
#[arg(short, long)]
|
||||
pub follow: bool,
|
||||
/// Logs since timestamp or relative (e.g. "42m", "2h", "2026-01-02T13:00:00Z")
|
||||
#[arg(long)]
|
||||
pub since: Option<String>,
|
||||
/// Lines from end (default: all)
|
||||
#[arg(short = 'n', long)]
|
||||
pub tail: Option<usize>,
|
||||
/// Formatted colored output with rendered assistant text
|
||||
#[arg(short = 'p', long)]
|
||||
pub pretty: bool,
|
||||
}
|
||||
use crate::args::LogsArgs;
|
||||
|
||||
pub fn run(args: LogsArgs, styles: &Styles) -> Result<()> {
|
||||
let base = fabro_workflows::run_lookup::default_runs_base();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
39
lib/crates/fabro-cli/src/commands/model.rs
Normal file
39
lib/crates/fabro-cli/src/commands/model.rs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
use anyhow::Result;
|
||||
|
||||
use crate::args::GlobalArgs;
|
||||
#[cfg(feature = "server")]
|
||||
use crate::cli_config;
|
||||
|
||||
pub async fn execute(
|
||||
command: Option<fabro_llm::cli::ModelsCommand>,
|
||||
globals: &GlobalArgs,
|
||||
) -> Result<()> {
|
||||
let server = {
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
let cli_config = cli_config::load_cli_config(None)?;
|
||||
let resolved = cli_config::resolve_mode(
|
||||
globals.mode.clone(),
|
||||
globals.server_url.as_deref(),
|
||||
&cli_config,
|
||||
);
|
||||
match resolved.mode {
|
||||
cli_config::ExecutionMode::Server => {
|
||||
let client = cli_config::build_server_client(resolved.tls.as_ref())?;
|
||||
Some(fabro_llm::cli::ServerConnection {
|
||||
client,
|
||||
base_url: resolved.server_base_url,
|
||||
})
|
||||
}
|
||||
cli_config::ExecutionMode::Standalone => None,
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "server"))]
|
||||
{
|
||||
let _ = globals;
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
fabro_llm::cli::run_models(command, server).await
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -1,423 +0,0 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use clap::Args;
|
||||
use fabro_model::Catalog;
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct PrCreateArgs {
|
||||
/// Run ID or prefix
|
||||
pub run_id: String,
|
||||
/// LLM model for generating PR description
|
||||
#[arg(long)]
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct PrListArgs {
|
||||
/// Show all PRs (including closed/merged), not just open
|
||||
#[arg(long)]
|
||||
pub all: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct PrViewArgs {
|
||||
/// Run ID or prefix
|
||||
pub run_id: String,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct PrMergeArgs {
|
||||
/// Run ID or prefix
|
||||
pub run_id: String,
|
||||
/// Merge method: merge, squash, or rebase
|
||||
#[arg(long, default_value = "squash")]
|
||||
pub method: String,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct PrCloseArgs {
|
||||
/// Run ID or prefix
|
||||
pub run_id: String,
|
||||
}
|
||||
|
||||
fn load_pr_record(
|
||||
base: &Path,
|
||||
run_id: &str,
|
||||
) -> Result<(fabro_workflows::pull_request::PullRequestRecord, PathBuf)> {
|
||||
let run_dir = fabro_workflows::run_lookup::resolve_run(base, run_id)?.path;
|
||||
let pr_path = run_dir.join("pull_request.json");
|
||||
let content = std::fs::read_to_string(&pr_path).with_context(|| {
|
||||
format!(
|
||||
"No pull_request.json found in run directory. \
|
||||
Create one first with: fabro pr create {run_id}"
|
||||
)
|
||||
})?;
|
||||
let record: fabro_workflows::pull_request::PullRequestRecord =
|
||||
serde_json::from_str(&content).context("Failed to parse pull_request.json")?;
|
||||
Ok((record, run_dir))
|
||||
}
|
||||
|
||||
pub async fn list_command(
|
||||
args: PrListArgs,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) -> Result<()> {
|
||||
let base = fabro_workflows::run_lookup::default_runs_base();
|
||||
list_from(&base, args, github_app).await
|
||||
}
|
||||
|
||||
async fn list_from(
|
||||
base: &Path,
|
||||
args: PrListArgs,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) -> Result<()> {
|
||||
let creds = github_app.context(
|
||||
"GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id",
|
||||
)?;
|
||||
|
||||
let runs = fabro_workflows::run_lookup::scan_runs(base).context("Failed to scan runs")?;
|
||||
|
||||
let mut entries: Vec<(String, fabro_workflows::pull_request::PullRequestRecord)> = Vec::new();
|
||||
for run in &runs {
|
||||
let pr_path = run.path.join("pull_request.json");
|
||||
if let Ok(content) = std::fs::read_to_string(&pr_path) {
|
||||
if let Ok(record) =
|
||||
serde_json::from_str::<fabro_workflows::pull_request::PullRequestRecord>(&content)
|
||||
{
|
||||
entries.push((run.run_id.clone(), record));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if entries.is_empty() {
|
||||
println!("No pull requests found.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
struct PrRow {
|
||||
run_id: String,
|
||||
number: u64,
|
||||
state: String,
|
||||
title: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
let futures: Vec<_> = entries
|
||||
.iter()
|
||||
.map(|(run_id, record)| {
|
||||
let creds = creds.clone();
|
||||
let run_id = run_id.clone();
|
||||
let record = record.clone();
|
||||
async move {
|
||||
match fabro_github::get_pull_request(
|
||||
&creds,
|
||||
&record.owner,
|
||||
&record.repo,
|
||||
record.number,
|
||||
fabro_github::GITHUB_API_BASE_URL,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(detail) => PrRow {
|
||||
run_id,
|
||||
number: detail.number,
|
||||
state: if detail.draft {
|
||||
"draft".to_string()
|
||||
} else {
|
||||
detail.state
|
||||
},
|
||||
title: detail.title,
|
||||
url: detail.html_url,
|
||||
},
|
||||
Err(err) => {
|
||||
tracing::warn!(run_id, error = %err, "Failed to fetch PR state");
|
||||
PrRow {
|
||||
run_id,
|
||||
number: record.number,
|
||||
state: "unknown".to_string(),
|
||||
title: record.title,
|
||||
url: record.html_url,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let all_rows = futures::future::join_all(futures).await;
|
||||
let rows: Vec<_> = if args.all {
|
||||
all_rows
|
||||
} else {
|
||||
all_rows
|
||||
.into_iter()
|
||||
.filter(|row| row.state == "open" || row.state == "draft" || row.state == "unknown")
|
||||
.collect()
|
||||
};
|
||||
|
||||
if rows.is_empty() {
|
||||
println!("No open pull requests found. Use --all to include closed/merged.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!(
|
||||
"{:<12} {:<6} {:<8} {:<50} URL",
|
||||
"RUN", "#", "STATE", "TITLE"
|
||||
);
|
||||
for row in &rows {
|
||||
let short_id = if row.run_id.len() > 12 {
|
||||
&row.run_id[..12]
|
||||
} else {
|
||||
&row.run_id
|
||||
};
|
||||
let short_title = if row.title.len() > 50 {
|
||||
format!("{}…", &row.title[..row.title.floor_char_boundary(49)])
|
||||
} else {
|
||||
row.title.clone()
|
||||
};
|
||||
println!(
|
||||
"{:<12} {:<6} {:<8} {:<50} {}",
|
||||
short_id, row.number, row.state, short_title, row.url
|
||||
);
|
||||
}
|
||||
|
||||
info!(count = rows.len(), "Listed pull requests");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn view_command(
|
||||
args: PrViewArgs,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) -> Result<()> {
|
||||
let base = fabro_workflows::run_lookup::default_runs_base();
|
||||
view_from(&base, args, github_app).await
|
||||
}
|
||||
|
||||
async fn view_from(
|
||||
base: &Path,
|
||||
args: PrViewArgs,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) -> Result<()> {
|
||||
let (record, _run_dir) = load_pr_record(base, &args.run_id)?;
|
||||
|
||||
let creds = github_app.context(
|
||||
"GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id",
|
||||
)?;
|
||||
|
||||
let detail = fabro_github::get_pull_request(
|
||||
&creds,
|
||||
&record.owner,
|
||||
&record.repo,
|
||||
record.number,
|
||||
fabro_github::GITHUB_API_BASE_URL,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("{err}"))?;
|
||||
|
||||
info!(number = detail.number, owner = %record.owner, repo = %record.repo, "Viewing pull request");
|
||||
|
||||
println!("#{} {}", detail.number, detail.title);
|
||||
let state_display = if detail.draft { "draft" } else { &detail.state };
|
||||
println!("State: {state_display}");
|
||||
println!("URL: {}", detail.html_url);
|
||||
println!(
|
||||
"Branch: {} -> {}",
|
||||
detail.head.ref_name, detail.base.ref_name
|
||||
);
|
||||
println!("Author: {}", detail.user.login);
|
||||
println!(
|
||||
"Changes: +{} -{} ({} files)",
|
||||
detail.additions, detail.deletions, detail.changed_files
|
||||
);
|
||||
if let Some(body) = &detail.body {
|
||||
if !body.is_empty() {
|
||||
println!();
|
||||
println!("{body}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn merge_command(
|
||||
args: PrMergeArgs,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) -> Result<()> {
|
||||
let base = fabro_workflows::run_lookup::default_runs_base();
|
||||
merge_from(&base, args, github_app).await
|
||||
}
|
||||
|
||||
async fn merge_from(
|
||||
base: &Path,
|
||||
args: PrMergeArgs,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) -> Result<()> {
|
||||
let (record, _run_dir) = load_pr_record(base, &args.run_id)?;
|
||||
|
||||
let creds = github_app.context(
|
||||
"GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id",
|
||||
)?;
|
||||
|
||||
fabro_github::merge_pull_request(
|
||||
&creds,
|
||||
&record.owner,
|
||||
&record.repo,
|
||||
record.number,
|
||||
&args.method,
|
||||
fabro_github::GITHUB_API_BASE_URL,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("{err}"))?;
|
||||
|
||||
info!(number = record.number, owner = %record.owner, repo = %record.repo, method = %args.method, "Merged pull request");
|
||||
println!("Merged #{} ({})", record.number, record.html_url);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn close_command(
|
||||
args: PrCloseArgs,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) -> Result<()> {
|
||||
let base = fabro_workflows::run_lookup::default_runs_base();
|
||||
close_from(&base, args, github_app).await
|
||||
}
|
||||
|
||||
async fn close_from(
|
||||
base: &Path,
|
||||
args: PrCloseArgs,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) -> Result<()> {
|
||||
let (record, _run_dir) = load_pr_record(base, &args.run_id)?;
|
||||
|
||||
let creds = github_app.context(
|
||||
"GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id",
|
||||
)?;
|
||||
|
||||
fabro_github::close_pull_request(
|
||||
&creds,
|
||||
&record.owner,
|
||||
&record.repo,
|
||||
record.number,
|
||||
fabro_github::GITHUB_API_BASE_URL,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("{err}"))?;
|
||||
|
||||
info!(number = record.number, owner = %record.owner, repo = %record.repo, "Closed pull request");
|
||||
println!("Closed #{} ({})", record.number, record.html_url);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_command(
|
||||
args: PrCreateArgs,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) -> Result<()> {
|
||||
let base = fabro_workflows::run_lookup::default_runs_base();
|
||||
create_from(&base, args, github_app).await
|
||||
}
|
||||
|
||||
async fn create_from(
|
||||
base: &Path,
|
||||
args: PrCreateArgs,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) -> Result<()> {
|
||||
let run_dir = fabro_workflows::run_lookup::resolve_run(base, &args.run_id)?.path;
|
||||
|
||||
let record =
|
||||
fabro_workflows::records::RunRecord::load(&run_dir).context("Failed to load run.json")?;
|
||||
|
||||
let start = fabro_workflows::records::StartRecord::load(&run_dir)
|
||||
.context("Failed to load start.json")?;
|
||||
|
||||
let conclusion = fabro_workflows::records::Conclusion::load(&run_dir.join("conclusion.json"))
|
||||
.context("Failed to load conclusion.json — is the run finished?")?;
|
||||
|
||||
match conclusion.status {
|
||||
fabro_workflows::outcome::StageStatus::Success
|
||||
| fabro_workflows::outcome::StageStatus::PartialSuccess => {}
|
||||
status => bail!("Run status is '{status}', expected success or partial_success"),
|
||||
}
|
||||
|
||||
let run_branch = start
|
||||
.run_branch
|
||||
.as_deref()
|
||||
.context("Run has no run_branch — was it run with git push enabled?")?;
|
||||
|
||||
let diff = std::fs::read_to_string(run_dir.join("final.patch"))
|
||||
.context("Failed to read final.patch — no diff available")?;
|
||||
if diff.trim().is_empty() {
|
||||
bail!("final.patch is empty — nothing to create a PR for");
|
||||
}
|
||||
|
||||
let cwd = std::env::current_dir().context("Failed to get current directory")?;
|
||||
let (origin_url, detected_branch) =
|
||||
fabro_sandbox::daytona::detect_repo_info(&cwd).map_err(|err| anyhow::anyhow!("{err}"))?;
|
||||
|
||||
let base_branch = record
|
||||
.base_branch
|
||||
.as_deref()
|
||||
.or(detected_branch.as_deref())
|
||||
.unwrap_or("main");
|
||||
|
||||
let https_url = fabro_github::ssh_url_to_https(&origin_url);
|
||||
let (owner, repo) = fabro_github::parse_github_owner_repo(&https_url)
|
||||
.map_err(|err| anyhow::anyhow!("{err}"))?;
|
||||
|
||||
let creds = github_app.context(
|
||||
"GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id",
|
||||
)?;
|
||||
|
||||
let branch_found = fabro_github::branch_exists(
|
||||
&creds,
|
||||
&owner,
|
||||
&repo,
|
||||
run_branch,
|
||||
fabro_github::GITHUB_API_BASE_URL,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("{err}"))?;
|
||||
|
||||
if !branch_found {
|
||||
bail!(
|
||||
"Branch '{run_branch}' not found on GitHub. \
|
||||
Was it pushed? Try: git push origin {run_branch}"
|
||||
);
|
||||
}
|
||||
|
||||
let model = args
|
||||
.model
|
||||
.unwrap_or_else(|| Catalog::builtin().default_from_env().id.clone());
|
||||
|
||||
let record = fabro_workflows::pull_request::maybe_open_pull_request(
|
||||
&creds,
|
||||
&origin_url,
|
||||
base_branch,
|
||||
run_branch,
|
||||
record.goal(),
|
||||
&diff,
|
||||
&model,
|
||||
true,
|
||||
None,
|
||||
&run_dir,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("{err}"))?;
|
||||
|
||||
match record {
|
||||
Some(record) => {
|
||||
info!(pr_url = %record.html_url, "Pull request created");
|
||||
if let Err(err) = record.save(&run_dir.join("pull_request.json")) {
|
||||
tracing::warn!(error = %err, "Failed to save pull_request.json");
|
||||
}
|
||||
println!("{}", record.html_url);
|
||||
}
|
||||
None => {
|
||||
println!("No pull request created (empty diff).");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
41
lib/crates/fabro-cli/src/commands/pr/close.rs
Normal file
41
lib/crates/fabro-cli/src/commands/pr/close.rs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::info;
|
||||
|
||||
use crate::args::PrCloseArgs;
|
||||
|
||||
pub async fn close_command(
|
||||
args: PrCloseArgs,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) -> Result<()> {
|
||||
let base = fabro_workflows::run_lookup::default_runs_base();
|
||||
close_from(&base, args, github_app).await
|
||||
}
|
||||
|
||||
async fn close_from(
|
||||
base: &Path,
|
||||
args: PrCloseArgs,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) -> Result<()> {
|
||||
let (record, _run_dir) = super::load_pr_record(base, &args.run_id)?;
|
||||
|
||||
let creds = github_app.context(
|
||||
"GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id",
|
||||
)?;
|
||||
|
||||
fabro_github::close_pull_request(
|
||||
&creds,
|
||||
&record.owner,
|
||||
&record.repo,
|
||||
record.number,
|
||||
fabro_github::GITHUB_API_BASE_URL,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("{err}"))?;
|
||||
|
||||
info!(number = record.number, owner = %record.owner, repo = %record.repo, "Closed pull request");
|
||||
println!("Closed #{} ({})", record.number, record.html_url);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
119
lib/crates/fabro-cli/src/commands/pr/create.rs
Normal file
119
lib/crates/fabro-cli/src/commands/pr/create.rs
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
use std::path::Path;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use fabro_model::Catalog;
|
||||
use tracing::info;
|
||||
|
||||
use crate::args::PrCreateArgs;
|
||||
|
||||
pub async fn create_command(
|
||||
args: PrCreateArgs,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) -> Result<()> {
|
||||
let base = fabro_workflows::run_lookup::default_runs_base();
|
||||
create_from(&base, args, github_app).await
|
||||
}
|
||||
|
||||
async fn create_from(
|
||||
base: &Path,
|
||||
args: PrCreateArgs,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) -> Result<()> {
|
||||
let run_dir = fabro_workflows::run_lookup::resolve_run(base, &args.run_id)?.path;
|
||||
|
||||
let record =
|
||||
fabro_workflows::records::RunRecord::load(&run_dir).context("Failed to load run.json")?;
|
||||
|
||||
let start = fabro_workflows::records::StartRecord::load(&run_dir)
|
||||
.context("Failed to load start.json")?;
|
||||
|
||||
let conclusion = fabro_workflows::records::Conclusion::load(&run_dir.join("conclusion.json"))
|
||||
.context("Failed to load conclusion.json — is the run finished?")?;
|
||||
|
||||
match conclusion.status {
|
||||
fabro_workflows::outcome::StageStatus::Success
|
||||
| fabro_workflows::outcome::StageStatus::PartialSuccess => {}
|
||||
status => bail!("Run status is '{status}', expected success or partial_success"),
|
||||
}
|
||||
|
||||
let run_branch = start
|
||||
.run_branch
|
||||
.as_deref()
|
||||
.context("Run has no run_branch — was it run with git push enabled?")?;
|
||||
|
||||
let diff = std::fs::read_to_string(run_dir.join("final.patch"))
|
||||
.context("Failed to read final.patch — no diff available")?;
|
||||
if diff.trim().is_empty() {
|
||||
bail!("final.patch is empty — nothing to create a PR for");
|
||||
}
|
||||
|
||||
let cwd = std::env::current_dir().context("Failed to get current directory")?;
|
||||
let (origin_url, detected_branch) =
|
||||
fabro_sandbox::daytona::detect_repo_info(&cwd).map_err(|err| anyhow::anyhow!("{err}"))?;
|
||||
|
||||
let base_branch = record
|
||||
.base_branch
|
||||
.as_deref()
|
||||
.or(detected_branch.as_deref())
|
||||
.unwrap_or("main");
|
||||
|
||||
let https_url = fabro_github::ssh_url_to_https(&origin_url);
|
||||
let (owner, repo) = fabro_github::parse_github_owner_repo(&https_url)
|
||||
.map_err(|err| anyhow::anyhow!("{err}"))?;
|
||||
|
||||
let creds = github_app.context(
|
||||
"GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id",
|
||||
)?;
|
||||
|
||||
let branch_found = fabro_github::branch_exists(
|
||||
&creds,
|
||||
&owner,
|
||||
&repo,
|
||||
run_branch,
|
||||
fabro_github::GITHUB_API_BASE_URL,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("{err}"))?;
|
||||
|
||||
if !branch_found {
|
||||
bail!(
|
||||
"Branch '{run_branch}' not found on GitHub. \
|
||||
Was it pushed? Try: git push origin {run_branch}"
|
||||
);
|
||||
}
|
||||
|
||||
let model = args
|
||||
.model
|
||||
.unwrap_or_else(|| Catalog::builtin().default_from_env().id.clone());
|
||||
|
||||
let record = fabro_workflows::pull_request::maybe_open_pull_request(
|
||||
&creds,
|
||||
&origin_url,
|
||||
base_branch,
|
||||
run_branch,
|
||||
record.goal(),
|
||||
&diff,
|
||||
&model,
|
||||
true,
|
||||
None,
|
||||
&run_dir,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("{err}"))?;
|
||||
|
||||
match record {
|
||||
Some(record) => {
|
||||
info!(pr_url = %record.html_url, "Pull request created");
|
||||
if let Err(err) = record.save(&run_dir.join("pull_request.json")) {
|
||||
tracing::warn!(error = %err, "Failed to save pull_request.json");
|
||||
}
|
||||
println!("{}", record.html_url);
|
||||
}
|
||||
None => {
|
||||
println!("No pull request created (empty diff).");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
132
lib/crates/fabro-cli/src/commands/pr/list.rs
Normal file
132
lib/crates/fabro-cli/src/commands/pr/list.rs
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::info;
|
||||
|
||||
use crate::args::PrListArgs;
|
||||
|
||||
pub async fn list_command(
|
||||
args: PrListArgs,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) -> Result<()> {
|
||||
let base = fabro_workflows::run_lookup::default_runs_base();
|
||||
list_from(&base, args, github_app).await
|
||||
}
|
||||
|
||||
async fn list_from(
|
||||
base: &Path,
|
||||
args: PrListArgs,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) -> Result<()> {
|
||||
let creds = github_app.context(
|
||||
"GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id",
|
||||
)?;
|
||||
|
||||
let runs = fabro_workflows::run_lookup::scan_runs(base).context("Failed to scan runs")?;
|
||||
|
||||
let mut entries: Vec<(String, fabro_workflows::pull_request::PullRequestRecord)> = Vec::new();
|
||||
for run in &runs {
|
||||
let pr_path = run.path.join("pull_request.json");
|
||||
if let Ok(content) = std::fs::read_to_string(&pr_path) {
|
||||
if let Ok(record) =
|
||||
serde_json::from_str::<fabro_workflows::pull_request::PullRequestRecord>(&content)
|
||||
{
|
||||
entries.push((run.run_id.clone(), record));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if entries.is_empty() {
|
||||
println!("No pull requests found.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
struct PrRow {
|
||||
run_id: String,
|
||||
number: u64,
|
||||
state: String,
|
||||
title: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
let futures: Vec<_> = entries
|
||||
.iter()
|
||||
.map(|(run_id, record)| {
|
||||
let creds = creds.clone();
|
||||
let run_id = run_id.clone();
|
||||
let record = record.clone();
|
||||
async move {
|
||||
match fabro_github::get_pull_request(
|
||||
&creds,
|
||||
&record.owner,
|
||||
&record.repo,
|
||||
record.number,
|
||||
fabro_github::GITHUB_API_BASE_URL,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(detail) => PrRow {
|
||||
run_id,
|
||||
number: detail.number,
|
||||
state: if detail.draft {
|
||||
"draft".to_string()
|
||||
} else {
|
||||
detail.state
|
||||
},
|
||||
title: detail.title,
|
||||
url: detail.html_url,
|
||||
},
|
||||
Err(err) => {
|
||||
tracing::warn!(run_id, error = %err, "Failed to fetch PR state");
|
||||
PrRow {
|
||||
run_id,
|
||||
number: record.number,
|
||||
state: "unknown".to_string(),
|
||||
title: record.title,
|
||||
url: record.html_url,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let all_rows = futures::future::join_all(futures).await;
|
||||
let rows: Vec<_> = if args.all {
|
||||
all_rows
|
||||
} else {
|
||||
all_rows
|
||||
.into_iter()
|
||||
.filter(|row| row.state == "open" || row.state == "draft" || row.state == "unknown")
|
||||
.collect()
|
||||
};
|
||||
|
||||
if rows.is_empty() {
|
||||
println!("No open pull requests found. Use --all to include closed/merged.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!(
|
||||
"{:<12} {:<6} {:<8} {:<50} URL",
|
||||
"RUN", "#", "STATE", "TITLE"
|
||||
);
|
||||
for row in &rows {
|
||||
let short_id = if row.run_id.len() > 12 {
|
||||
&row.run_id[..12]
|
||||
} else {
|
||||
&row.run_id
|
||||
};
|
||||
let short_title = if row.title.len() > 50 {
|
||||
format!("{}…", &row.title[..row.title.floor_char_boundary(49)])
|
||||
} else {
|
||||
row.title.clone()
|
||||
};
|
||||
println!(
|
||||
"{:<12} {:<6} {:<8} {:<50} {}",
|
||||
short_id, row.number, row.state, short_title, row.url
|
||||
);
|
||||
}
|
||||
|
||||
info!(count = rows.len(), "Listed pull requests");
|
||||
Ok(())
|
||||
}
|
||||
42
lib/crates/fabro-cli/src/commands/pr/merge.rs
Normal file
42
lib/crates/fabro-cli/src/commands/pr/merge.rs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::info;
|
||||
|
||||
use crate::args::PrMergeArgs;
|
||||
|
||||
pub async fn merge_command(
|
||||
args: PrMergeArgs,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) -> Result<()> {
|
||||
let base = fabro_workflows::run_lookup::default_runs_base();
|
||||
merge_from(&base, args, github_app).await
|
||||
}
|
||||
|
||||
async fn merge_from(
|
||||
base: &Path,
|
||||
args: PrMergeArgs,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) -> Result<()> {
|
||||
let (record, _run_dir) = super::load_pr_record(base, &args.run_id)?;
|
||||
|
||||
let creds = github_app.context(
|
||||
"GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id",
|
||||
)?;
|
||||
|
||||
fabro_github::merge_pull_request(
|
||||
&creds,
|
||||
&record.owner,
|
||||
&record.repo,
|
||||
record.number,
|
||||
&args.method,
|
||||
fabro_github::GITHUB_API_BASE_URL,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("{err}"))?;
|
||||
|
||||
info!(number = record.number, owner = %record.owner, repo = %record.repo, method = %args.method, "Merged pull request");
|
||||
println!("Merged #{} ({})", record.number, record.html_url);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
41
lib/crates/fabro-cli/src/commands/pr/mod.rs
Normal file
41
lib/crates/fabro-cli/src/commands/pr/mod.rs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
mod close;
|
||||
mod create;
|
||||
mod list;
|
||||
mod merge;
|
||||
mod view;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::args::{PrCommand, PrNamespace};
|
||||
|
||||
pub async fn dispatch(ns: PrNamespace) -> Result<()> {
|
||||
let cli_config = crate::cli_config::load_cli_config(None)?;
|
||||
let github_app = crate::shared::github::build_github_app_credentials(cli_config.app_id());
|
||||
|
||||
match ns.command {
|
||||
PrCommand::Create(args) => create::create_command(args, github_app).await,
|
||||
PrCommand::List(args) => list::list_command(args, github_app).await,
|
||||
PrCommand::View(args) => view::view_command(args, github_app).await,
|
||||
PrCommand::Merge(args) => merge::merge_command(args, github_app).await,
|
||||
PrCommand::Close(args) => close::close_command(args, github_app).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn load_pr_record(
|
||||
base: &Path,
|
||||
run_id: &str,
|
||||
) -> Result<(fabro_workflows::pull_request::PullRequestRecord, PathBuf)> {
|
||||
let run_dir = fabro_workflows::run_lookup::resolve_run(base, run_id)?.path;
|
||||
let pr_path = run_dir.join("pull_request.json");
|
||||
let content = std::fs::read_to_string(&pr_path).with_context(|| {
|
||||
format!(
|
||||
"No pull_request.json found in run directory. \
|
||||
Create one first with: fabro pr create {run_id}"
|
||||
)
|
||||
})?;
|
||||
let record: fabro_workflows::pull_request::PullRequestRecord =
|
||||
serde_json::from_str(&content).context("Failed to parse pull_request.json")?;
|
||||
Ok((record, run_dir))
|
||||
}
|
||||
60
lib/crates/fabro-cli/src/commands/pr/view.rs
Normal file
60
lib/crates/fabro-cli/src/commands/pr/view.rs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::info;
|
||||
|
||||
use crate::args::PrViewArgs;
|
||||
|
||||
pub async fn view_command(
|
||||
args: PrViewArgs,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) -> Result<()> {
|
||||
let base = fabro_workflows::run_lookup::default_runs_base();
|
||||
view_from(&base, args, github_app).await
|
||||
}
|
||||
|
||||
async fn view_from(
|
||||
base: &Path,
|
||||
args: PrViewArgs,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) -> Result<()> {
|
||||
let (record, _run_dir) = super::load_pr_record(base, &args.run_id)?;
|
||||
|
||||
let creds = github_app.context(
|
||||
"GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id",
|
||||
)?;
|
||||
|
||||
let detail = fabro_github::get_pull_request(
|
||||
&creds,
|
||||
&record.owner,
|
||||
&record.repo,
|
||||
record.number,
|
||||
fabro_github::GITHUB_API_BASE_URL,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("{err}"))?;
|
||||
|
||||
info!(number = detail.number, owner = %record.owner, repo = %record.repo, "Viewing pull request");
|
||||
|
||||
println!("#{} {}", detail.number, detail.title);
|
||||
let state_display = if detail.draft { "draft" } else { &detail.state };
|
||||
println!("State: {state_display}");
|
||||
println!("URL: {}", detail.html_url);
|
||||
println!(
|
||||
"Branch: {} -> {}",
|
||||
detail.head.ref_name, detail.base.ref_name
|
||||
);
|
||||
println!("Author: {}", detail.user.login);
|
||||
println!(
|
||||
"Changes: +{} -{} ({} files)",
|
||||
detail.additions, detail.deletions, detail.changed_files
|
||||
);
|
||||
if let Some(body) = &detail.body {
|
||||
if !body.is_empty() {
|
||||
println!();
|
||||
println!("{body}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
11
lib/crates/fabro-cli/src/commands/provider/mod.rs
Normal file
11
lib/crates/fabro-cli/src/commands/provider/mod.rs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
mod login;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::args::{ProviderCommand, ProviderNamespace};
|
||||
|
||||
pub async fn dispatch(ns: ProviderNamespace) -> Result<()> {
|
||||
match ns.command {
|
||||
ProviderCommand::Login(args) => login::login_command(args).await,
|
||||
}
|
||||
}
|
||||
43
lib/crates/fabro-cli/src/commands/repo/deinit.rs
Normal file
43
lib/crates/fabro-cli/src/commands/repo/deinit.rs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
use anyhow::{bail, Context, Result};
|
||||
|
||||
pub fn run_deinit() -> Result<()> {
|
||||
let repo_root = super::init::git_repo_root()?;
|
||||
|
||||
let fabro_toml = repo_root.join("fabro.toml");
|
||||
|
||||
let green = console::Style::new().green();
|
||||
let dim = console::Style::new().dim();
|
||||
|
||||
match std::fs::remove_file(&fabro_toml) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
bail!("not initialized — fabro.toml not found");
|
||||
}
|
||||
Err(e) => bail!("failed to remove {}: {e}", fabro_toml.display()),
|
||||
}
|
||||
eprintln!(
|
||||
" {} {}",
|
||||
green.apply_to("✔"),
|
||||
dim.apply_to("removed fabro.toml")
|
||||
);
|
||||
|
||||
let fabro_dir = repo_root.join("fabro");
|
||||
if fabro_dir.exists() {
|
||||
std::fs::remove_dir_all(&fabro_dir)
|
||||
.with_context(|| format!("failed to remove {}", fabro_dir.display()))?;
|
||||
eprintln!(
|
||||
" {} {}",
|
||||
green.apply_to("✔"),
|
||||
dim.apply_to("removed fabro/")
|
||||
);
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"\n{}",
|
||||
console::Style::new()
|
||||
.bold()
|
||||
.apply_to("Project deinitialized.")
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
use anyhow::{bail, Context, Result};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn git_repo_root() -> Result<PathBuf> {
|
||||
use anyhow::{bail, Context, Result};
|
||||
|
||||
pub(super) fn git_repo_root() -> Result<PathBuf> {
|
||||
let output = std::process::Command::new("git")
|
||||
.args(["rev-parse", "--show-toplevel"])
|
||||
.output()
|
||||
|
|
@ -112,48 +113,6 @@ draft = true
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run_deinit() -> Result<()> {
|
||||
let repo_root = git_repo_root()?;
|
||||
|
||||
let fabro_toml = repo_root.join("fabro.toml");
|
||||
|
||||
let green = console::Style::new().green();
|
||||
let dim = console::Style::new().dim();
|
||||
|
||||
match std::fs::remove_file(&fabro_toml) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
bail!("not initialized — fabro.toml not found");
|
||||
}
|
||||
Err(e) => bail!("failed to remove {}: {e}", fabro_toml.display()),
|
||||
}
|
||||
eprintln!(
|
||||
" {} {}",
|
||||
green.apply_to("✔"),
|
||||
dim.apply_to("removed fabro.toml")
|
||||
);
|
||||
|
||||
let fabro_dir = repo_root.join("fabro");
|
||||
if fabro_dir.exists() {
|
||||
std::fs::remove_dir_all(&fabro_dir)
|
||||
.with_context(|| format!("failed to remove {}", fabro_dir.display()))?;
|
||||
eprintln!(
|
||||
" {} {}",
|
||||
green.apply_to("✔"),
|
||||
dim.apply_to("removed fabro/")
|
||||
);
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"\n{}",
|
||||
console::Style::new()
|
||||
.bold()
|
||||
.apply_to("Project deinitialized.")
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn check_github_app_installation() {
|
||||
// Get the git remote origin URL
|
||||
let output = match std::process::Command::new("git")
|
||||
|
|
@ -213,7 +172,7 @@ async fn check_github_app_installation() {
|
|||
let slug = cli_config.slug().map(String::from);
|
||||
|
||||
// Build GitHub App credentials
|
||||
let creds = match crate::build_github_app_credentials(Some(&app_id)) {
|
||||
let creds = match crate::shared::github::build_github_app_credentials(Some(&app_id)) {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
eprintln!(
|
||||
20
lib/crates/fabro-cli/src/commands/repo/mod.rs
Normal file
20
lib/crates/fabro-cli/src/commands/repo/mod.rs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
pub mod deinit;
|
||||
pub mod init;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::args::{RepoCommand, RepoNamespace};
|
||||
|
||||
pub async fn dispatch(ns: RepoNamespace) -> Result<()> {
|
||||
match ns.command {
|
||||
RepoCommand::Init { skill } => {
|
||||
init::run_init().await?;
|
||||
if skill {
|
||||
let base = std::env::current_dir()?.join(".claude").join("skills");
|
||||
super::skill::install_skill_to(&base)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
RepoCommand::Deinit => deinit::run_deinit(),
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -1,30 +1,13 @@
|
|||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use clap::Args;
|
||||
use cli_table::format::{Border, Separator};
|
||||
use cli_table::{print_stderr, Cell, CellStruct, Color, Style, Table};
|
||||
use fabro_git_storage::gitobj::Store;
|
||||
use fabro_util::terminal::Styles;
|
||||
use git2::Repository;
|
||||
|
||||
use super::shared::color_if;
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct RewindArgs {
|
||||
/// Run ID (or unambiguous prefix)
|
||||
pub run_id: String,
|
||||
|
||||
/// Target checkpoint: node name, node@visit, or @ordinal (omit with --list)
|
||||
pub target: Option<String>,
|
||||
|
||||
/// Show the checkpoint timeline instead of rewinding
|
||||
#[arg(long)]
|
||||
pub list: bool,
|
||||
|
||||
/// Skip force-pushing rewound refs to the remote
|
||||
#[arg(long)]
|
||||
pub no_push: bool,
|
||||
}
|
||||
use crate::args::RewindArgs;
|
||||
use crate::shared::color_if;
|
||||
|
||||
pub fn run(args: &RewindArgs, styles: &Styles) -> Result<()> {
|
||||
let repo = Repository::discover(".").context("not in a git repository")?;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ use std::time::Instant;
|
|||
|
||||
use anyhow::{bail, Context};
|
||||
use chrono::Local;
|
||||
use clap::{Args, ValueEnum};
|
||||
use fabro_agent::{DockerSandbox, DockerSandboxConfig, LocalSandbox, Sandbox};
|
||||
use fabro_config::config::FabroConfig;
|
||||
use fabro_config::{project as project_config, run as run_config, sandbox as sandbox_config};
|
||||
|
|
@ -33,115 +32,12 @@ use tracing::debug;
|
|||
|
||||
use super::detached_support::{DetachedRunBootstrapGuard, DetachedRunCompletionGuard};
|
||||
use super::run_progress;
|
||||
use crate::commands::shared::{
|
||||
use crate::args::{CliSandboxProvider, GlobalArgs, RunArgs};
|
||||
use crate::cli_config;
|
||||
use crate::shared::{
|
||||
format_tokens_human, print_diagnostics, read_workflow_file, relative_path, tilde_path,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||
pub enum CliSandboxProvider {
|
||||
Local,
|
||||
Docker,
|
||||
Daytona,
|
||||
#[cfg(feature = "exedev")]
|
||||
Exe,
|
||||
Ssh,
|
||||
}
|
||||
|
||||
impl From<CliSandboxProvider> for SandboxProvider {
|
||||
fn from(value: CliSandboxProvider) -> Self {
|
||||
match value {
|
||||
CliSandboxProvider::Local => Self::Local,
|
||||
CliSandboxProvider::Docker => Self::Docker,
|
||||
CliSandboxProvider::Daytona => Self::Daytona,
|
||||
#[cfg(feature = "exedev")]
|
||||
CliSandboxProvider::Exe => Self::Exe,
|
||||
CliSandboxProvider::Ssh => Self::Ssh,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SandboxProvider> for CliSandboxProvider {
|
||||
fn from(value: SandboxProvider) -> Self {
|
||||
match value {
|
||||
SandboxProvider::Local => Self::Local,
|
||||
SandboxProvider::Docker => Self::Docker,
|
||||
SandboxProvider::Daytona => Self::Daytona,
|
||||
#[cfg(feature = "exedev")]
|
||||
SandboxProvider::Exe => Self::Exe,
|
||||
#[cfg(not(feature = "exedev"))]
|
||||
SandboxProvider::Exe => Self::Local,
|
||||
SandboxProvider::Ssh => Self::Ssh,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct RunArgs {
|
||||
/// Path to a .fabro workflow file or .toml task config
|
||||
#[arg(required = true)]
|
||||
pub workflow: Option<PathBuf>,
|
||||
|
||||
/// Run output directory
|
||||
#[arg(long)]
|
||||
pub run_dir: Option<PathBuf>,
|
||||
|
||||
/// Execute with simulated LLM backend
|
||||
#[arg(long)]
|
||||
pub dry_run: bool,
|
||||
|
||||
/// Validate run configuration without executing
|
||||
#[arg(long, conflicts_with = "dry_run")]
|
||||
pub preflight: bool,
|
||||
|
||||
/// Auto-approve all human gates
|
||||
#[arg(long)]
|
||||
pub auto_approve: bool,
|
||||
|
||||
/// Override the workflow goal (exposed as $goal in prompts)
|
||||
#[arg(long)]
|
||||
pub goal: Option<String>,
|
||||
|
||||
/// Read the workflow goal from a file
|
||||
#[arg(long, conflicts_with = "goal")]
|
||||
pub goal_file: Option<PathBuf>,
|
||||
|
||||
/// Override default LLM model
|
||||
#[arg(long)]
|
||||
pub model: Option<String>,
|
||||
|
||||
/// Override default LLM provider
|
||||
#[arg(long)]
|
||||
pub provider: Option<String>,
|
||||
|
||||
/// Enable verbose output
|
||||
#[arg(short, long)]
|
||||
pub verbose: bool,
|
||||
|
||||
/// Sandbox for agent tools
|
||||
#[arg(long, value_enum)]
|
||||
pub sandbox: Option<CliSandboxProvider>,
|
||||
|
||||
/// Attach a label to this run (repeatable, format: KEY=VALUE)
|
||||
#[arg(long = "label", value_name = "KEY=VALUE")]
|
||||
pub label: Vec<String>,
|
||||
|
||||
/// Skip retro generation after the run
|
||||
#[arg(long)]
|
||||
pub no_retro: bool,
|
||||
|
||||
/// Keep the sandbox alive after the run finishes (for debugging)
|
||||
#[arg(long)]
|
||||
pub preserve_sandbox: bool,
|
||||
|
||||
/// Run the workflow in the background and print the run ID
|
||||
#[arg(short = 'd', long, conflicts_with = "preflight")]
|
||||
pub detach: bool,
|
||||
|
||||
/// Pre-generated run ID (used internally by --detach)
|
||||
#[arg(long, hide = true)]
|
||||
pub run_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Resolve goal from `--goal` string or `--goal-file` path.
|
||||
pub(crate) fn resolve_cli_goal(
|
||||
goal: &Option<String>,
|
||||
|
|
@ -836,6 +732,43 @@ fn ensure_resume_target_is_not_already_successful(run_dir: &Path) -> anyhow::Res
|
|||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the workflow cannot be read, parsed, validated, or executed.
|
||||
pub async fn execute(mut args: RunArgs, _globals: &GlobalArgs) -> anyhow::Result<()> {
|
||||
let styles: &'static fabro_util::terminal::Styles =
|
||||
Box::leak(Box::new(fabro_util::terminal::Styles::detect_stderr()));
|
||||
let cli_config = cli_config::load_cli_config(None)?;
|
||||
args.verbose = args.verbose || cli_config.verbose_enabled();
|
||||
|
||||
if args.preflight {
|
||||
let github_app = crate::shared::github::build_github_app_credentials(cli_config.app_id());
|
||||
let git_author = fabro_workflows::git::GitAuthor::from_options(
|
||||
cli_config.git_author().and_then(|a| a.name.clone()),
|
||||
cli_config.git_author().and_then(|a| a.email.clone()),
|
||||
);
|
||||
run_command(args, cli_config, styles, github_app, git_author).await?;
|
||||
} else {
|
||||
let quiet = args.detach;
|
||||
let _prevent_idle_sleep = cli_config.prevent_idle_sleep_enabled();
|
||||
let (run_id, run_dir) = super::create::create_run(&args, cli_config, styles, quiet).await?;
|
||||
|
||||
#[cfg(feature = "sleep_inhibitor")]
|
||||
let _sleep_guard = fabro_beastie::guard(_prevent_idle_sleep);
|
||||
|
||||
let child = super::start::start_run(&run_dir, false)?;
|
||||
|
||||
if args.detach {
|
||||
println!("{run_id}");
|
||||
} else {
|
||||
let exit_code = super::attach::attach_run(&run_dir, true, styles, Some(child)).await?;
|
||||
print_run_summary(&run_dir, &run_id, styles);
|
||||
if exit_code != std::process::ExitCode::SUCCESS {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn run_command(
|
||||
args: RunArgs,
|
||||
run_defaults: FabroConfig,
|
||||
|
|
|
|||
83
lib/crates/fabro-cli/src/commands/run_engine.rs
Normal file
83
lib/crates/fabro-cli/src/commands/run_engine.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::cli_config;
|
||||
use crate::shared;
|
||||
|
||||
pub async fn execute(run_dir: PathBuf, resume: bool) -> Result<()> {
|
||||
let styles: &'static fabro_util::terminal::Styles =
|
||||
Box::leak(Box::new(fabro_util::terminal::Styles::detect_stderr()));
|
||||
let cli_config = cli_config::load_cli_config(None)?;
|
||||
let github_app = shared::github::build_github_app_credentials(cli_config.app_id());
|
||||
let git_author = fabro_workflows::git::GitAuthor::from_options(
|
||||
cli_config.git_author().and_then(|a| a.name.clone()),
|
||||
cli_config.git_author().and_then(|a| a.email.clone()),
|
||||
);
|
||||
|
||||
let persisted = match fabro_workflows::pipeline::Persisted::load(&run_dir) {
|
||||
Ok(persisted) => persisted,
|
||||
Err(err) => {
|
||||
let anyhow_err: anyhow::Error = anyhow::anyhow!("Failed to load persisted run: {err}");
|
||||
let _ = super::detached_support::persist_detached_failure(
|
||||
&run_dir,
|
||||
"bootstrap",
|
||||
fabro_workflows::run_status::StatusReason::BootstrapFailed,
|
||||
&anyhow_err,
|
||||
);
|
||||
return Err(anyhow_err);
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) =
|
||||
std::env::set_current_dir(&persisted.run_record().working_directory).map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"Failed to set working directory to {}: {e}",
|
||||
persisted.run_record().working_directory.display()
|
||||
)
|
||||
})
|
||||
{
|
||||
let _ = super::detached_support::persist_detached_failure(
|
||||
&run_dir,
|
||||
"bootstrap",
|
||||
fabro_workflows::run_status::StatusReason::BootstrapFailed,
|
||||
&err,
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
let result = if resume {
|
||||
super::run::resume_from_record(
|
||||
persisted,
|
||||
run_dir.clone(),
|
||||
cli_config,
|
||||
styles,
|
||||
github_app,
|
||||
git_author,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
super::run::run_from_record(
|
||||
persisted,
|
||||
run_dir.clone(),
|
||||
cli_config,
|
||||
styles,
|
||||
github_app,
|
||||
git_author,
|
||||
)
|
||||
.await
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) => {
|
||||
let _ = super::detached_support::persist_detached_failure(
|
||||
&run_dir,
|
||||
"bootstrap",
|
||||
fabro_workflows::run_status::StatusReason::SandboxInitFailed,
|
||||
&err,
|
||||
);
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ───────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -1,83 +1,14 @@
|
|||
use std::path::Path;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use clap::Args;
|
||||
use cli_table::format::{Border, Justify, Separator};
|
||||
use chrono::Utc;
|
||||
use cli_table::format::{Border, Separator};
|
||||
use cli_table::{print_stdout, Cell, CellStruct, Color, Style, Table};
|
||||
use fabro_util::terminal::Styles;
|
||||
use tracing::{debug, info, warn};
|
||||
use tracing::warn;
|
||||
|
||||
use super::shared::{color_if, format_duration_ms, format_size, tilde_path};
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct RunFilterArgs {
|
||||
/// Only include runs started before this date (YYYY-MM-DD prefix match)
|
||||
#[arg(long)]
|
||||
pub before: Option<String>,
|
||||
|
||||
/// Filter by workflow name (substring match)
|
||||
#[arg(long)]
|
||||
pub workflow: Option<String>,
|
||||
|
||||
/// Filter by label (KEY=VALUE, repeatable, AND semantics)
|
||||
#[arg(long = "label", value_name = "KEY=VALUE")]
|
||||
pub label: Vec<String>,
|
||||
|
||||
/// Include orphan directories (no run.json)
|
||||
#[arg(long)]
|
||||
pub orphans: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct RunsListArgs {
|
||||
#[command(flatten)]
|
||||
pub filter: RunFilterArgs,
|
||||
|
||||
/// Output as JSON
|
||||
#[arg(long)]
|
||||
pub json: bool,
|
||||
|
||||
/// Show all runs, not just running (like docker ps -a)
|
||||
#[arg(short = 'a', long)]
|
||||
pub all: bool,
|
||||
|
||||
/// Only display run IDs
|
||||
#[arg(short = 'q', long)]
|
||||
pub quiet: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct RunsPruneArgs {
|
||||
#[command(flatten)]
|
||||
pub filter: RunFilterArgs,
|
||||
|
||||
/// Only prune runs older than this duration (e.g. 24h, 7d). Default: 24h when no explicit filters are set.
|
||||
#[arg(long, value_name = "DURATION", value_parser = parse_duration)]
|
||||
pub older_than: Option<chrono::Duration>,
|
||||
|
||||
/// Actually delete (default is dry-run)
|
||||
#[arg(long)]
|
||||
pub yes: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct RunsRemoveArgs {
|
||||
/// Run IDs or workflow names to remove
|
||||
#[arg(required = true)]
|
||||
pub runs: Vec<String>,
|
||||
|
||||
/// Force removal of active runs
|
||||
#[arg(short, long)]
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct DfArgs {
|
||||
/// Show per-run breakdown
|
||||
#[arg(short, long)]
|
||||
pub verbose: bool,
|
||||
}
|
||||
use crate::args::{RunsListArgs, RunsRemoveArgs};
|
||||
use crate::shared::{color_if, format_duration_ms, tilde_path};
|
||||
|
||||
pub fn list_command(args: &RunsListArgs, styles: &Styles) -> Result<()> {
|
||||
let base = fabro_workflows::run_lookup::default_runs_base();
|
||||
|
|
@ -176,18 +107,6 @@ pub fn list_command(args: &RunsListArgs, styles: &Styles) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub fn df_command(args: &DfArgs) -> Result<()> {
|
||||
let data_dir = fabro_workflows::run_lookup::default_data_dir();
|
||||
let runs_base = fabro_workflows::run_lookup::default_runs_base();
|
||||
let logs_base = fabro_workflows::run_lookup::default_logs_base();
|
||||
df_from(args, &data_dir, &runs_base, &logs_base)
|
||||
}
|
||||
|
||||
pub fn prune_command(args: &RunsPruneArgs) -> Result<()> {
|
||||
let base = fabro_workflows::run_lookup::default_runs_base();
|
||||
prune_from(args, &base)
|
||||
}
|
||||
|
||||
pub async fn remove_command(args: &RunsRemoveArgs) -> Result<()> {
|
||||
let base = fabro_workflows::run_lookup::default_runs_base();
|
||||
remove_from(args, &base).await
|
||||
|
|
@ -239,276 +158,6 @@ fn truncate_str(s: &str, max_len: usize) -> String {
|
|||
format!("{truncated}...")
|
||||
}
|
||||
|
||||
fn dir_size(path: &Path) -> u64 {
|
||||
walkdir::WalkDir::new(path)
|
||||
.into_iter()
|
||||
.filter_map(|entry| entry.ok())
|
||||
.filter_map(|entry| entry.metadata().ok())
|
||||
.filter(|metadata| metadata.is_file())
|
||||
.map(|metadata| metadata.len())
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn df_from(args: &DfArgs, data_dir: &Path, runs_base: &Path, logs_base: &Path) -> Result<()> {
|
||||
let runs = fabro_workflows::run_lookup::scan_runs(runs_base)?;
|
||||
let mut active_count = 0u64;
|
||||
let mut total_run_size = 0u64;
|
||||
let mut reclaimable_run_size = 0u64;
|
||||
|
||||
struct RunSizeInfo {
|
||||
run_id: String,
|
||||
workflow_name: String,
|
||||
status: fabro_workflows::run_status::RunStatus,
|
||||
start_time_dt: Option<DateTime<Utc>>,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
let mut run_details = Vec::new();
|
||||
for run in &runs {
|
||||
let size = dir_size(&run.path);
|
||||
total_run_size += size;
|
||||
if run.status.is_active() {
|
||||
active_count += 1;
|
||||
} else {
|
||||
reclaimable_run_size += size;
|
||||
}
|
||||
if args.verbose {
|
||||
run_details.push(RunSizeInfo {
|
||||
run_id: run.run_id.clone(),
|
||||
workflow_name: run.workflow_name.clone(),
|
||||
status: run.status,
|
||||
start_time_dt: run.start_time_dt,
|
||||
size,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut log_count = 0u64;
|
||||
let mut total_log_size = 0u64;
|
||||
if let Ok(entries) = std::fs::read_dir(logs_base) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
if path.extension().is_some_and(|ext| ext == "log") {
|
||||
if let Ok(meta) = path.metadata() {
|
||||
log_count += 1;
|
||||
total_log_size += meta.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut db_count = 0u64;
|
||||
let mut total_db_size = 0u64;
|
||||
if let Ok(entries) = std::fs::read_dir(data_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.ends_with(".db") || name.ends_with(".db-wal") || name.ends_with(".db-shm") {
|
||||
if let Ok(meta) = path.metadata() {
|
||||
db_count += 1;
|
||||
total_db_size += meta.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let run_reclaim_pct = if total_run_size > 0 {
|
||||
(reclaimable_run_size as f64 / total_run_size as f64 * 100.0) as u64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let log_reclaim_pct = if total_log_size > 0 { 100 } else { 0 };
|
||||
|
||||
let summary_title = vec![
|
||||
"TYPE".cell().bold(true),
|
||||
"COUNT".cell().bold(true).justify(Justify::Right),
|
||||
"ACTIVE".cell().bold(true).justify(Justify::Right),
|
||||
"SIZE".cell().bold(true).justify(Justify::Right),
|
||||
"RECLAIMABLE".cell().bold(true).justify(Justify::Right),
|
||||
];
|
||||
let summary_rows: Vec<Vec<CellStruct>> = vec![
|
||||
vec![
|
||||
"Runs".cell(),
|
||||
runs.len().cell().justify(Justify::Right),
|
||||
active_count.cell().justify(Justify::Right),
|
||||
format_size(total_run_size).cell().justify(Justify::Right),
|
||||
format!("{} ({run_reclaim_pct}%)", format_size(reclaimable_run_size))
|
||||
.cell()
|
||||
.justify(Justify::Right),
|
||||
],
|
||||
vec![
|
||||
"Logs".cell(),
|
||||
log_count.cell().justify(Justify::Right),
|
||||
"-".cell().justify(Justify::Right),
|
||||
format_size(total_log_size).cell().justify(Justify::Right),
|
||||
format!("{} ({log_reclaim_pct}%)", format_size(total_log_size))
|
||||
.cell()
|
||||
.justify(Justify::Right),
|
||||
],
|
||||
vec![
|
||||
"Databases".cell(),
|
||||
db_count.cell().justify(Justify::Right),
|
||||
"-".cell().justify(Justify::Right),
|
||||
format_size(total_db_size).cell().justify(Justify::Right),
|
||||
format!("{} (0%)", format_size(0))
|
||||
.cell()
|
||||
.justify(Justify::Right),
|
||||
],
|
||||
];
|
||||
let summary_table = summary_rows
|
||||
.table()
|
||||
.title(summary_title)
|
||||
.border(Border::builder().build())
|
||||
.separator(Separator::builder().build());
|
||||
print_stdout(summary_table)?;
|
||||
|
||||
println!();
|
||||
println!("Data directory: {}", data_dir.display());
|
||||
|
||||
if !args.verbose {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!();
|
||||
let verbose_title = vec![
|
||||
"RUN ID".cell().bold(true),
|
||||
"WORKFLOW".cell().bold(true),
|
||||
"STATUS".cell().bold(true),
|
||||
"AGE".cell().bold(true).justify(Justify::Right),
|
||||
"SIZE".cell().bold(true).justify(Justify::Right),
|
||||
];
|
||||
|
||||
let now = Utc::now();
|
||||
let verbose_rows: Vec<Vec<CellStruct>> = run_details
|
||||
.iter()
|
||||
.map(|detail| {
|
||||
let age = if let Some(dt) = detail.start_time_dt {
|
||||
let dur = now.signed_duration_since(dt);
|
||||
if dur.num_days() > 0 {
|
||||
format!("{}d", dur.num_days())
|
||||
} else if dur.num_hours() > 0 {
|
||||
format!("{}h", dur.num_hours())
|
||||
} else {
|
||||
format!("{}m", dur.num_minutes().max(1))
|
||||
}
|
||||
} else {
|
||||
"-".to_string()
|
||||
};
|
||||
let size_display = if detail.status.is_active() {
|
||||
format_size(detail.size)
|
||||
} else {
|
||||
format!("{} *", format_size(detail.size))
|
||||
};
|
||||
vec![
|
||||
short_run_id(&detail.run_id).cell(),
|
||||
truncate_str(&detail.workflow_name, 16).cell(),
|
||||
detail.status.to_string().cell(),
|
||||
age.cell().justify(Justify::Right),
|
||||
size_display.cell().justify(Justify::Right),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
let verbose_table = verbose_rows
|
||||
.table()
|
||||
.title(verbose_title)
|
||||
.border(Border::builder().build())
|
||||
.separator(Separator::builder().build());
|
||||
print_stdout(verbose_table)?;
|
||||
println!();
|
||||
println!("* = reclaimable");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_duration(s: &str) -> Result<chrono::Duration> {
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
bail!("empty duration string");
|
||||
}
|
||||
let (num_str, unit) = s.split_at(s.len() - 1);
|
||||
let num: u64 = num_str
|
||||
.parse()
|
||||
.with_context(|| format!("invalid duration: {s}"))?;
|
||||
match unit {
|
||||
"h" => Ok(chrono::Duration::hours(num as i64)),
|
||||
"d" => Ok(chrono::Duration::days(num as i64)),
|
||||
_ => bail!("invalid duration unit '{unit}' in '{s}' (expected 'h' or 'd')"),
|
||||
}
|
||||
}
|
||||
|
||||
fn prune_from(args: &RunsPruneArgs, base: &Path) -> Result<()> {
|
||||
let runs = fabro_workflows::run_lookup::scan_runs(base)?;
|
||||
let label_filters = parse_label_filters(&args.filter.label);
|
||||
let mut filtered = fabro_workflows::run_lookup::filter_runs(
|
||||
&runs,
|
||||
args.filter.before.as_deref(),
|
||||
args.filter.workflow.as_deref(),
|
||||
&label_filters,
|
||||
args.filter.orphans,
|
||||
fabro_workflows::run_lookup::StatusFilter::All,
|
||||
);
|
||||
|
||||
let has_explicit_filters =
|
||||
args.filter.before.is_some() || args.filter.workflow.is_some() || !label_filters.is_empty();
|
||||
let staleness_threshold = if let Some(duration) = args.older_than {
|
||||
Some(duration)
|
||||
} else if !has_explicit_filters {
|
||||
Some(chrono::Duration::hours(24))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(threshold) = staleness_threshold {
|
||||
let cutoff = Utc::now() - threshold;
|
||||
filtered.retain(|run| {
|
||||
if run.status.is_active() {
|
||||
return false;
|
||||
}
|
||||
run.end_time
|
||||
.or(run.start_time_dt)
|
||||
.is_some_and(|time| time < cutoff)
|
||||
});
|
||||
}
|
||||
|
||||
if filtered.is_empty() {
|
||||
eprintln!("No matching runs to prune.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let total_bytes: u64 = filtered.iter().map(|run| dir_size(&run.path)).sum();
|
||||
info!(count = filtered.len(), bytes = total_bytes, "pruning runs");
|
||||
|
||||
if args.yes {
|
||||
for run in &filtered {
|
||||
info!(run_id = %run.run_id, path = %run.path.display(), "deleting run");
|
||||
std::fs::remove_dir_all(&run.path)?;
|
||||
}
|
||||
eprintln!(
|
||||
"{} run(s) deleted ({} freed).",
|
||||
filtered.len(),
|
||||
format_size(total_bytes)
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for run in &filtered {
|
||||
debug!(run_id = %run.run_id, "would delete run (dry-run)");
|
||||
println!("would delete: {} ({})", run.dir_name, run.workflow_name);
|
||||
}
|
||||
eprintln!(
|
||||
"\n{} run(s) would be deleted ({} freed). Pass --yes to confirm.",
|
||||
filtered.len(),
|
||||
format_size(total_bytes)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_from(args: &RunsRemoveArgs, base: &Path) -> Result<()> {
|
||||
let mut had_errors = false;
|
||||
|
||||
|
|
@ -568,6 +217,8 @@ async fn remove_from(args: &RunsRemoveArgs, base: &Path) -> Result<()> {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::commands::system::parse_duration;
|
||||
use crate::shared::format_size;
|
||||
|
||||
#[test]
|
||||
fn parse_duration_hours() {
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
15
lib/crates/fabro-cli/src/commands/secret/get.rs
Normal file
15
lib/crates/fabro-cli/src/commands/secret/get.rs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
use anyhow::{bail, Result};
|
||||
|
||||
use crate::args::SecretGetArgs;
|
||||
use fabro_config::dotenv;
|
||||
|
||||
pub fn get_command(args: &SecretGetArgs) -> Result<()> {
|
||||
let path = dotenv::env_file_path()?;
|
||||
match dotenv::get_env_value(&path, &args.key)? {
|
||||
Some(value) => {
|
||||
println!("{value}");
|
||||
Ok(())
|
||||
}
|
||||
None => bail!("secret not found: {}", args.key),
|
||||
}
|
||||
}
|
||||
22
lib/crates/fabro-cli/src/commands/secret/list.rs
Normal file
22
lib/crates/fabro-cli/src/commands/secret/list.rs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
use anyhow::{bail, Result};
|
||||
|
||||
use crate::args::SecretListArgs;
|
||||
use fabro_config::dotenv;
|
||||
|
||||
pub fn list_command(args: &SecretListArgs) -> Result<()> {
|
||||
let path = dotenv::env_file_path()?;
|
||||
let contents = match std::fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(e) => bail!("failed to read {}: {e}", path.display()),
|
||||
};
|
||||
let pairs = dotenv::parse_env(&contents);
|
||||
for (key, value) in pairs {
|
||||
if args.show_values {
|
||||
println!("{key}={value}");
|
||||
} else {
|
||||
println!("{key}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
17
lib/crates/fabro-cli/src/commands/secret/mod.rs
Normal file
17
lib/crates/fabro-cli/src/commands/secret/mod.rs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
mod get;
|
||||
mod list;
|
||||
mod rm;
|
||||
mod set;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::args::{SecretCommand, SecretNamespace};
|
||||
|
||||
pub fn dispatch(ns: SecretNamespace) -> Result<()> {
|
||||
match ns.command {
|
||||
SecretCommand::Get(args) => get::get_command(&args),
|
||||
SecretCommand::List(args) => list::list_command(&args),
|
||||
SecretCommand::Rm(args) => rm::rm_command(&args),
|
||||
SecretCommand::Set(args) => set::set_command(&args),
|
||||
}
|
||||
}
|
||||
24
lib/crates/fabro-cli/src/commands/secret/rm.rs
Normal file
24
lib/crates/fabro-cli/src/commands/secret/rm.rs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
use anyhow::{bail, Result};
|
||||
|
||||
use crate::args::SecretRmArgs;
|
||||
use fabro_config::dotenv;
|
||||
|
||||
pub fn rm_command(args: &SecretRmArgs) -> Result<()> {
|
||||
let path = dotenv::env_file_path()?;
|
||||
let contents = match std::fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
bail!("secret not found: {}", args.key)
|
||||
}
|
||||
Err(e) => bail!("failed to read {}: {e}", path.display()),
|
||||
};
|
||||
let updated = dotenv::remove_env_key(&contents, &args.key);
|
||||
match updated {
|
||||
Some(new_contents) => {
|
||||
dotenv::write_env_file(&path, &new_contents)?;
|
||||
eprintln!("Removed {}", args.key);
|
||||
Ok(())
|
||||
}
|
||||
None => bail!("secret not found: {}", args.key),
|
||||
}
|
||||
}
|
||||
13
lib/crates/fabro-cli/src/commands/secret/set.rs
Normal file
13
lib/crates/fabro-cli/src/commands/secret/set.rs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
use anyhow::Result;
|
||||
|
||||
use crate::args::SecretSetArgs;
|
||||
use fabro_config::dotenv;
|
||||
|
||||
pub fn set_command(args: &SecretSetArgs) -> Result<()> {
|
||||
let path = dotenv::env_file_path()?;
|
||||
let existing = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
let merged = dotenv::merge_env(&existing, &[(&args.key, &args.value)]);
|
||||
dotenv::write_env_file(&path, &merged)?;
|
||||
eprintln!("Set {}", args.key);
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -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();
|
||||
13
lib/crates/fabro-cli/src/commands/skill/mod.rs
Normal file
13
lib/crates/fabro-cli/src/commands/skill/mod.rs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
mod install;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::args::{SkillCommand, SkillNamespace};
|
||||
|
||||
pub use install::install_skill_to;
|
||||
|
||||
pub fn dispatch(ns: SkillNamespace) -> Result<()> {
|
||||
match ns.command {
|
||||
SkillCommand::Install(args) => install::run_skill_install(&args),
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
220
lib/crates/fabro-cli/src/commands/system/df.rs
Normal file
220
lib/crates/fabro-cli/src/commands/system/df.rs
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Utc};
|
||||
use cli_table::format::{Border, Justify, Separator};
|
||||
use cli_table::{print_stdout, Cell, CellStruct, Style, Table};
|
||||
|
||||
use crate::args::DfArgs;
|
||||
use crate::shared::format_size;
|
||||
|
||||
pub fn df_command(args: &DfArgs) -> Result<()> {
|
||||
let data_dir = fabro_workflows::run_lookup::default_data_dir();
|
||||
let runs_base = fabro_workflows::run_lookup::default_runs_base();
|
||||
let logs_base = fabro_workflows::run_lookup::default_logs_base();
|
||||
df_from(args, &data_dir, &runs_base, &logs_base)
|
||||
}
|
||||
|
||||
fn df_from(args: &DfArgs, data_dir: &Path, runs_base: &Path, logs_base: &Path) -> Result<()> {
|
||||
let runs = fabro_workflows::run_lookup::scan_runs(runs_base)?;
|
||||
let mut active_count = 0u64;
|
||||
let mut total_run_size = 0u64;
|
||||
let mut reclaimable_run_size = 0u64;
|
||||
|
||||
struct RunSizeInfo {
|
||||
run_id: String,
|
||||
workflow_name: String,
|
||||
status: fabro_workflows::run_status::RunStatus,
|
||||
start_time_dt: Option<DateTime<Utc>>,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
let mut run_details = Vec::new();
|
||||
for run in &runs {
|
||||
let size = dir_size(&run.path);
|
||||
total_run_size += size;
|
||||
if run.status.is_active() {
|
||||
active_count += 1;
|
||||
} else {
|
||||
reclaimable_run_size += size;
|
||||
}
|
||||
if args.verbose {
|
||||
run_details.push(RunSizeInfo {
|
||||
run_id: run.run_id.clone(),
|
||||
workflow_name: run.workflow_name.clone(),
|
||||
status: run.status,
|
||||
start_time_dt: run.start_time_dt,
|
||||
size,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut log_count = 0u64;
|
||||
let mut total_log_size = 0u64;
|
||||
if let Ok(entries) = std::fs::read_dir(logs_base) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
if path.extension().is_some_and(|ext| ext == "log") {
|
||||
if let Ok(meta) = path.metadata() {
|
||||
log_count += 1;
|
||||
total_log_size += meta.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut db_count = 0u64;
|
||||
let mut total_db_size = 0u64;
|
||||
if let Ok(entries) = std::fs::read_dir(data_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.ends_with(".db") || name.ends_with(".db-wal") || name.ends_with(".db-shm") {
|
||||
if let Ok(meta) = path.metadata() {
|
||||
db_count += 1;
|
||||
total_db_size += meta.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let run_reclaim_pct = if total_run_size > 0 {
|
||||
(reclaimable_run_size as f64 / total_run_size as f64 * 100.0) as u64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let log_reclaim_pct = if total_log_size > 0 { 100 } else { 0 };
|
||||
|
||||
let summary_title = vec![
|
||||
"TYPE".cell().bold(true),
|
||||
"COUNT".cell().bold(true).justify(Justify::Right),
|
||||
"ACTIVE".cell().bold(true).justify(Justify::Right),
|
||||
"SIZE".cell().bold(true).justify(Justify::Right),
|
||||
"RECLAIMABLE".cell().bold(true).justify(Justify::Right),
|
||||
];
|
||||
let summary_rows: Vec<Vec<CellStruct>> = vec![
|
||||
vec![
|
||||
"Runs".cell(),
|
||||
runs.len().cell().justify(Justify::Right),
|
||||
active_count.cell().justify(Justify::Right),
|
||||
format_size(total_run_size).cell().justify(Justify::Right),
|
||||
format!("{} ({run_reclaim_pct}%)", format_size(reclaimable_run_size))
|
||||
.cell()
|
||||
.justify(Justify::Right),
|
||||
],
|
||||
vec![
|
||||
"Logs".cell(),
|
||||
log_count.cell().justify(Justify::Right),
|
||||
"-".cell().justify(Justify::Right),
|
||||
format_size(total_log_size).cell().justify(Justify::Right),
|
||||
format!("{} ({log_reclaim_pct}%)", format_size(total_log_size))
|
||||
.cell()
|
||||
.justify(Justify::Right),
|
||||
],
|
||||
vec![
|
||||
"Databases".cell(),
|
||||
db_count.cell().justify(Justify::Right),
|
||||
"-".cell().justify(Justify::Right),
|
||||
format_size(total_db_size).cell().justify(Justify::Right),
|
||||
format!("{} (0%)", format_size(0))
|
||||
.cell()
|
||||
.justify(Justify::Right),
|
||||
],
|
||||
];
|
||||
let summary_table = summary_rows
|
||||
.table()
|
||||
.title(summary_title)
|
||||
.border(Border::builder().build())
|
||||
.separator(Separator::builder().build());
|
||||
print_stdout(summary_table)?;
|
||||
|
||||
println!();
|
||||
println!("Data directory: {}", data_dir.display());
|
||||
|
||||
if !args.verbose {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!();
|
||||
let verbose_title = vec![
|
||||
"RUN ID".cell().bold(true),
|
||||
"WORKFLOW".cell().bold(true),
|
||||
"STATUS".cell().bold(true),
|
||||
"AGE".cell().bold(true).justify(Justify::Right),
|
||||
"SIZE".cell().bold(true).justify(Justify::Right),
|
||||
];
|
||||
|
||||
let now = Utc::now();
|
||||
let verbose_rows: Vec<Vec<CellStruct>> = run_details
|
||||
.iter()
|
||||
.map(|detail| {
|
||||
let age = if let Some(dt) = detail.start_time_dt {
|
||||
let dur = now.signed_duration_since(dt);
|
||||
if dur.num_days() > 0 {
|
||||
format!("{}d", dur.num_days())
|
||||
} else if dur.num_hours() > 0 {
|
||||
format!("{}h", dur.num_hours())
|
||||
} else {
|
||||
format!("{}m", dur.num_minutes().max(1))
|
||||
}
|
||||
} else {
|
||||
"-".to_string()
|
||||
};
|
||||
let size_display = if detail.status.is_active() {
|
||||
format_size(detail.size)
|
||||
} else {
|
||||
format!("{} *", format_size(detail.size))
|
||||
};
|
||||
vec![
|
||||
short_run_id(&detail.run_id).cell(),
|
||||
truncate_str(&detail.workflow_name, 16).cell(),
|
||||
detail.status.to_string().cell(),
|
||||
age.cell().justify(Justify::Right),
|
||||
size_display.cell().justify(Justify::Right),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
let verbose_table = verbose_rows
|
||||
.table()
|
||||
.title(verbose_title)
|
||||
.border(Border::builder().build())
|
||||
.separator(Separator::builder().build());
|
||||
print_stdout(verbose_table)?;
|
||||
println!();
|
||||
println!("* = reclaimable");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn short_run_id(id: &str) -> &str {
|
||||
if id.len() > 12 {
|
||||
&id[..12]
|
||||
} else {
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_str(s: &str, max_len: usize) -> String {
|
||||
let char_count = s.chars().count();
|
||||
if char_count <= max_len {
|
||||
return s.to_string();
|
||||
}
|
||||
let truncated: String = s.chars().take(max_len - 3).collect();
|
||||
format!("{truncated}...")
|
||||
}
|
||||
|
||||
fn dir_size(path: &Path) -> u64 {
|
||||
walkdir::WalkDir::new(path)
|
||||
.into_iter()
|
||||
.filter_map(|entry| entry.ok())
|
||||
.filter_map(|entry| entry.metadata().ok())
|
||||
.filter(|metadata| metadata.is_file())
|
||||
.map(|metadata| metadata.len())
|
||||
.sum()
|
||||
}
|
||||
15
lib/crates/fabro-cli/src/commands/system/mod.rs
Normal file
15
lib/crates/fabro-cli/src/commands/system/mod.rs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
mod df;
|
||||
mod prune;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::args::{SystemCommand, SystemNamespace};
|
||||
|
||||
pub(crate) use prune::parse_duration;
|
||||
|
||||
pub fn dispatch(ns: SystemNamespace) -> Result<()> {
|
||||
match ns.command {
|
||||
SystemCommand::Prune(args) => prune::prune_command(&args),
|
||||
SystemCommand::Df(args) => df::df_command(&args),
|
||||
}
|
||||
}
|
||||
114
lib/crates/fabro-cli/src/commands/system/prune.rs
Normal file
114
lib/crates/fabro-cli/src/commands/system/prune.rs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
use std::path::Path;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use chrono::Utc;
|
||||
use tracing::{debug, info};
|
||||
|
||||
use crate::args::RunsPruneArgs;
|
||||
use crate::shared::format_size;
|
||||
|
||||
pub fn prune_command(args: &RunsPruneArgs) -> Result<()> {
|
||||
let base = fabro_workflows::run_lookup::default_runs_base();
|
||||
prune_from(args, &base)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_duration(s: &str) -> Result<chrono::Duration> {
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
bail!("empty duration string");
|
||||
}
|
||||
let (num_str, unit) = s.split_at(s.len() - 1);
|
||||
let num: u64 = num_str
|
||||
.parse()
|
||||
.with_context(|| format!("invalid duration: {s}"))?;
|
||||
match unit {
|
||||
"h" => Ok(chrono::Duration::hours(num as i64)),
|
||||
"d" => Ok(chrono::Duration::days(num as i64)),
|
||||
_ => bail!("invalid duration unit '{unit}' in '{s}' (expected 'h' or 'd')"),
|
||||
}
|
||||
}
|
||||
|
||||
fn prune_from(args: &RunsPruneArgs, base: &Path) -> Result<()> {
|
||||
let runs = fabro_workflows::run_lookup::scan_runs(base)?;
|
||||
let label_filters = parse_label_filters(&args.filter.label);
|
||||
let mut filtered = fabro_workflows::run_lookup::filter_runs(
|
||||
&runs,
|
||||
args.filter.before.as_deref(),
|
||||
args.filter.workflow.as_deref(),
|
||||
&label_filters,
|
||||
args.filter.orphans,
|
||||
fabro_workflows::run_lookup::StatusFilter::All,
|
||||
);
|
||||
|
||||
let has_explicit_filters =
|
||||
args.filter.before.is_some() || args.filter.workflow.is_some() || !label_filters.is_empty();
|
||||
let staleness_threshold = if let Some(duration) = args.older_than {
|
||||
Some(duration)
|
||||
} else if !has_explicit_filters {
|
||||
Some(chrono::Duration::hours(24))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(threshold) = staleness_threshold {
|
||||
let cutoff = Utc::now() - threshold;
|
||||
filtered.retain(|run| {
|
||||
if run.status.is_active() {
|
||||
return false;
|
||||
}
|
||||
run.end_time
|
||||
.or(run.start_time_dt)
|
||||
.is_some_and(|time| time < cutoff)
|
||||
});
|
||||
}
|
||||
|
||||
if filtered.is_empty() {
|
||||
eprintln!("No matching runs to prune.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let total_bytes: u64 = filtered.iter().map(|run| dir_size(&run.path)).sum();
|
||||
info!(count = filtered.len(), bytes = total_bytes, "pruning runs");
|
||||
|
||||
if args.yes {
|
||||
for run in &filtered {
|
||||
info!(run_id = %run.run_id, path = %run.path.display(), "deleting run");
|
||||
std::fs::remove_dir_all(&run.path)?;
|
||||
}
|
||||
eprintln!(
|
||||
"{} run(s) deleted ({} freed).",
|
||||
filtered.len(),
|
||||
format_size(total_bytes)
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for run in &filtered {
|
||||
debug!(run_id = %run.run_id, "would delete run (dry-run)");
|
||||
println!("would delete: {} ({})", run.dir_name, run.workflow_name);
|
||||
}
|
||||
eprintln!(
|
||||
"\n{} run(s) would be deleted ({} freed). Pass --yes to confirm.",
|
||||
filtered.len(),
|
||||
format_size(total_bytes)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_label_filters(label_args: &[String]) -> Vec<(String, String)> {
|
||||
label_args
|
||||
.iter()
|
||||
.filter_map(|s| s.split_once('='))
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn dir_size(path: &Path) -> u64 {
|
||||
walkdir::WalkDir::new(path)
|
||||
.into_iter()
|
||||
.filter_map(|entry| entry.ok())
|
||||
.filter_map(|entry| entry.metadata().ok())
|
||||
.filter(|metadata| metadata.is_file())
|
||||
.map(|metadata| metadata.len())
|
||||
.sum()
|
||||
}
|
||||
|
|
@ -7,22 +7,7 @@ use semver::Version;
|
|||
use sha2::{Digest, Sha256};
|
||||
use tracing::debug;
|
||||
|
||||
// ── Clap args ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(clap::Args)]
|
||||
pub struct UpgradeArgs {
|
||||
/// Target version (e.g. "0.5.0" or "v0.5.0")
|
||||
#[arg(long)]
|
||||
version: Option<String>,
|
||||
|
||||
/// Upgrade even if already on the target version
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
|
||||
/// Preview what would happen without making changes
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
}
|
||||
use crate::args::UpgradeArgs;
|
||||
|
||||
// ── Download backend abstraction ───────────────────────────────────────────
|
||||
|
||||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -1,30 +1,12 @@
|
|||
use std::io::Write;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use clap::Args;
|
||||
use fabro_util::terminal::Styles;
|
||||
use fabro_workflows::run_status::{RunStatus, RunStatusRecord};
|
||||
use tracing::info;
|
||||
|
||||
use super::shared::format_duration_ms;
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct WaitArgs {
|
||||
/// Run ID prefix or workflow name (most recent run)
|
||||
pub run: String,
|
||||
|
||||
/// Maximum time to wait in seconds
|
||||
#[arg(long, value_name = "SECONDS")]
|
||||
pub timeout: Option<u64>,
|
||||
|
||||
/// Poll interval in milliseconds
|
||||
#[arg(long, value_name = "MS", default_value = "1000")]
|
||||
pub interval: u64,
|
||||
|
||||
/// Output conclusion as JSON
|
||||
#[arg(long)]
|
||||
pub json: bool,
|
||||
}
|
||||
use crate::args::WaitArgs;
|
||||
use crate::shared::format_duration_ms;
|
||||
|
||||
pub fn run(args: WaitArgs, styles: &Styles) -> Result<()> {
|
||||
let base = fabro_workflows::run_lookup::default_runs_base();
|
||||
|
|
|
|||
|
|
@ -1,231 +0,0 @@
|
|||
use std::path::Path;
|
||||
|
||||
use anyhow::{bail, Context};
|
||||
use clap::Args;
|
||||
use fabro_util::terminal::Styles;
|
||||
|
||||
use crate::commands::shared::relative_path;
|
||||
|
||||
const GOAL_MAX_LEN: usize = 60;
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct WorkflowListArgs {}
|
||||
|
||||
pub fn list_command(_args: &WorkflowListArgs) -> anyhow::Result<()> {
|
||||
let styles = Styles::detect_stderr();
|
||||
let cwd = std::env::current_dir()?;
|
||||
|
||||
let (config_path, config) = match fabro_config::project::discover_project_config(&cwd)? {
|
||||
Some(found) => found,
|
||||
None => bail!(
|
||||
"No fabro.toml found in {cwd} or any parent directory",
|
||||
cwd = cwd.display()
|
||||
),
|
||||
};
|
||||
|
||||
let fabro_root = fabro_config::project::resolve_fabro_root(&config_path, &config);
|
||||
let project_wf_dir = fabro_root.join("workflows");
|
||||
let user_wf_dir = dirs::home_dir().map(|h| h.join(".fabro").join("workflows"));
|
||||
|
||||
let workflows = fabro_config::project::list_workflows_detailed(
|
||||
Some(&project_wf_dir),
|
||||
user_wf_dir.as_deref(),
|
||||
);
|
||||
|
||||
let project: Vec<_> = workflows
|
||||
.iter()
|
||||
.filter(|w| w.source == fabro_config::project::WorkflowSource::Project)
|
||||
.collect();
|
||||
let user: Vec<_> = workflows
|
||||
.iter()
|
||||
.filter(|w| w.source == fabro_config::project::WorkflowSource::User)
|
||||
.collect();
|
||||
|
||||
let name_width = workflows.iter().map(|w| w.name.len()).max().unwrap_or(0);
|
||||
|
||||
eprintln!(
|
||||
"{} workflow(s) found\n",
|
||||
styles.bold.apply_to(workflows.len())
|
||||
);
|
||||
|
||||
let user_path = user_wf_dir
|
||||
.as_deref()
|
||||
.map(relative_path)
|
||||
.unwrap_or_else(|| "~/.fabro/workflows".to_string());
|
||||
print_section("User Workflows", &user_path, &user, name_width, &styles);
|
||||
|
||||
eprintln!();
|
||||
|
||||
print_section(
|
||||
"Project Workflows",
|
||||
&relative_path(&project_wf_dir),
|
||||
&project,
|
||||
name_width,
|
||||
&styles,
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct WorkflowCreateArgs {
|
||||
/// Name of the workflow
|
||||
pub name: String,
|
||||
|
||||
/// Goal description for the workflow
|
||||
#[arg(short, long)]
|
||||
goal: Option<String>,
|
||||
}
|
||||
|
||||
pub fn create_command(args: &WorkflowCreateArgs) -> anyhow::Result<()> {
|
||||
let cwd = std::env::current_dir()?;
|
||||
|
||||
let (config_path, config) = match fabro_config::project::discover_project_config(&cwd)? {
|
||||
Some(found) => found,
|
||||
None => bail!(
|
||||
"No fabro.toml found in {cwd} or any parent directory",
|
||||
cwd = cwd.display()
|
||||
),
|
||||
};
|
||||
|
||||
let fabro_root = fabro_config::project::resolve_fabro_root(&config_path, &config);
|
||||
write_workflow_scaffold(args, &fabro_root)?;
|
||||
|
||||
let workflows_dir = fabro_root.join("workflows").join(&args.name);
|
||||
let green = console::Style::new().green();
|
||||
let bold = console::Style::new().bold();
|
||||
let cyan_bold = console::Style::new().cyan().bold();
|
||||
let dim = console::Style::new().dim();
|
||||
|
||||
let rel_dir = relative_path(&workflows_dir);
|
||||
eprintln!(
|
||||
" {} {}",
|
||||
green.apply_to("✔"),
|
||||
dim.apply_to(format!("{rel_dir}/workflow.fabro"))
|
||||
);
|
||||
eprintln!(
|
||||
" {} {}",
|
||||
green.apply_to("✔"),
|
||||
dim.apply_to(format!("{rel_dir}/workflow.toml"))
|
||||
);
|
||||
|
||||
eprintln!("\n{} Next steps:\n", bold.apply_to("Workflow created!"));
|
||||
eprintln!(
|
||||
" 1. Edit the graph: {}",
|
||||
cyan_bold.apply_to(format!("{rel_dir}/workflow.fabro"))
|
||||
);
|
||||
eprintln!(
|
||||
" 2. Validate: {}",
|
||||
cyan_bold.apply_to(format!("fabro validate {}", args.name))
|
||||
);
|
||||
eprintln!(
|
||||
" 3. Run: {}",
|
||||
cyan_bold.apply_to(format!("fabro run {}", args.name))
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_workflow_scaffold(args: &WorkflowCreateArgs, fabro_root: &Path) -> anyhow::Result<()> {
|
||||
let workflows_dir = fabro_root.join("workflows").join(&args.name);
|
||||
|
||||
if workflows_dir.exists() {
|
||||
bail!(
|
||||
"Workflow '{}' already exists at {}",
|
||||
args.name,
|
||||
workflows_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
std::fs::create_dir_all(&workflows_dir)
|
||||
.with_context(|| format!("failed to create {}", workflows_dir.display()))?;
|
||||
|
||||
let goal = args.goal.as_deref().unwrap_or("TODO: describe the goal");
|
||||
let digraph_name = to_pascal_case(&args.name);
|
||||
|
||||
let fabro_content = format!(
|
||||
r#"digraph {digraph_name} {{
|
||||
graph [goal="{goal}"]
|
||||
rankdir=LR
|
||||
|
||||
start [shape=Mdiamond, label="Start"]
|
||||
exit [shape=Msquare, label="Exit"]
|
||||
|
||||
main [label="Main", prompt="TODO: describe what this agent should do"]
|
||||
|
||||
start -> main -> exit
|
||||
}}
|
||||
"#
|
||||
);
|
||||
|
||||
let dot_path = workflows_dir.join("workflow.fabro");
|
||||
std::fs::write(&dot_path, &fabro_content)
|
||||
.with_context(|| format!("failed to write {}", dot_path.display()))?;
|
||||
|
||||
let toml_path = workflows_dir.join("workflow.toml");
|
||||
std::fs::write(&toml_path, "version = 1\n")
|
||||
.with_context(|| format!("failed to write {}", toml_path.display()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn to_pascal_case(s: &str) -> String {
|
||||
s.split(['-', '_'])
|
||||
.filter(|part| !part.is_empty())
|
||||
.map(|part| {
|
||||
let mut chars = part.chars();
|
||||
match chars.next() {
|
||||
Some(first) => {
|
||||
let upper: String = first.to_uppercase().collect();
|
||||
format!("{upper}{rest}", rest = chars.as_str())
|
||||
}
|
||||
None => String::new(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn print_section(
|
||||
title: &str,
|
||||
path: &str,
|
||||
workflows: &[&fabro_config::project::WorkflowInfo],
|
||||
name_width: usize,
|
||||
styles: &Styles,
|
||||
) {
|
||||
eprintln!(
|
||||
"{} {}",
|
||||
styles.bold.apply_to(title),
|
||||
styles.dim.apply_to(format!("({path})")),
|
||||
);
|
||||
if workflows.is_empty() {
|
||||
eprintln!(" {}", styles.dim.apply_to("(none)"));
|
||||
return;
|
||||
}
|
||||
eprintln!();
|
||||
eprintln!(
|
||||
" {:<name_width$} {}",
|
||||
styles.bold_dim.apply_to("NAME"),
|
||||
styles.bold_dim.apply_to("DESCRIPTION"),
|
||||
);
|
||||
for w in workflows {
|
||||
let goal_str = w
|
||||
.goal
|
||||
.as_deref()
|
||||
.map(|g| truncate_str(g, GOAL_MAX_LEN))
|
||||
.unwrap_or_default();
|
||||
eprintln!(
|
||||
" {:<name_width$} {}",
|
||||
styles.cyan.apply_to(&w.name),
|
||||
styles.dim.apply_to(goal_str),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_str(s: &str, max: usize) -> String {
|
||||
let first_line = s.lines().next().unwrap_or(s);
|
||||
if first_line.len() <= max {
|
||||
first_line.to_string()
|
||||
} else {
|
||||
format!("{}...", &first_line[..max - 3])
|
||||
}
|
||||
}
|
||||
114
lib/crates/fabro-cli/src/commands/workflow/create.rs
Normal file
114
lib/crates/fabro-cli/src/commands/workflow/create.rs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
use std::path::Path;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
|
||||
use crate::args::WorkflowCreateArgs;
|
||||
use crate::shared::relative_path;
|
||||
|
||||
pub fn create_command(args: &WorkflowCreateArgs) -> Result<()> {
|
||||
let cwd = std::env::current_dir()?;
|
||||
|
||||
let (config_path, config) = match fabro_config::project::discover_project_config(&cwd)? {
|
||||
Some(found) => found,
|
||||
None => bail!(
|
||||
"No fabro.toml found in {cwd} or any parent directory",
|
||||
cwd = cwd.display()
|
||||
),
|
||||
};
|
||||
|
||||
let fabro_root = fabro_config::project::resolve_fabro_root(&config_path, &config);
|
||||
write_workflow_scaffold(args, &fabro_root)?;
|
||||
|
||||
let workflows_dir = fabro_root.join("workflows").join(&args.name);
|
||||
let green = console::Style::new().green();
|
||||
let bold = console::Style::new().bold();
|
||||
let cyan_bold = console::Style::new().cyan().bold();
|
||||
let dim = console::Style::new().dim();
|
||||
|
||||
let rel_dir = relative_path(&workflows_dir);
|
||||
eprintln!(
|
||||
" {} {}",
|
||||
green.apply_to("✔"),
|
||||
dim.apply_to(format!("{rel_dir}/workflow.fabro"))
|
||||
);
|
||||
eprintln!(
|
||||
" {} {}",
|
||||
green.apply_to("✔"),
|
||||
dim.apply_to(format!("{rel_dir}/workflow.toml"))
|
||||
);
|
||||
|
||||
eprintln!("\n{} Next steps:\n", bold.apply_to("Workflow created!"));
|
||||
eprintln!(
|
||||
" 1. Edit the graph: {}",
|
||||
cyan_bold.apply_to(format!("{rel_dir}/workflow.fabro"))
|
||||
);
|
||||
eprintln!(
|
||||
" 2. Validate: {}",
|
||||
cyan_bold.apply_to(format!("fabro validate {}", args.name))
|
||||
);
|
||||
eprintln!(
|
||||
" 3. Run: {}",
|
||||
cyan_bold.apply_to(format!("fabro run {}", args.name))
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_workflow_scaffold(args: &WorkflowCreateArgs, fabro_root: &Path) -> Result<()> {
|
||||
let workflows_dir = fabro_root.join("workflows").join(&args.name);
|
||||
|
||||
if workflows_dir.exists() {
|
||||
bail!(
|
||||
"Workflow '{}' already exists at {}",
|
||||
args.name,
|
||||
workflows_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
std::fs::create_dir_all(&workflows_dir)
|
||||
.with_context(|| format!("failed to create {}", workflows_dir.display()))?;
|
||||
|
||||
let goal = args.goal.as_deref().unwrap_or("TODO: describe the goal");
|
||||
let digraph_name = to_pascal_case(&args.name);
|
||||
|
||||
let fabro_content = format!(
|
||||
r#"digraph {digraph_name} {{
|
||||
graph [goal="{goal}"]
|
||||
rankdir=LR
|
||||
|
||||
start [shape=Mdiamond, label="Start"]
|
||||
exit [shape=Msquare, label="Exit"]
|
||||
|
||||
main [label="Main", prompt="TODO: describe what this agent should do"]
|
||||
|
||||
start -> main -> exit
|
||||
}}
|
||||
"#
|
||||
);
|
||||
|
||||
let dot_path = workflows_dir.join("workflow.fabro");
|
||||
std::fs::write(&dot_path, &fabro_content)
|
||||
.with_context(|| format!("failed to write {}", dot_path.display()))?;
|
||||
|
||||
let toml_path = workflows_dir.join("workflow.toml");
|
||||
std::fs::write(&toml_path, "version = 1\n")
|
||||
.with_context(|| format!("failed to write {}", toml_path.display()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn to_pascal_case(s: &str) -> String {
|
||||
s.split(['-', '_'])
|
||||
.filter(|part| !part.is_empty())
|
||||
.map(|part| {
|
||||
let mut chars = part.chars();
|
||||
match chars.next() {
|
||||
Some(first) => {
|
||||
let upper: String = first.to_uppercase().collect();
|
||||
format!("{upper}{rest}", rest = chars.as_str())
|
||||
}
|
||||
None => String::new(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
108
lib/crates/fabro-cli/src/commands/workflow/list.rs
Normal file
108
lib/crates/fabro-cli/src/commands/workflow/list.rs
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
use anyhow::{bail, Result};
|
||||
use fabro_util::terminal::Styles;
|
||||
|
||||
use crate::args::WorkflowListArgs;
|
||||
use crate::shared::relative_path;
|
||||
|
||||
const GOAL_MAX_LEN: usize = 60;
|
||||
|
||||
pub fn list_command(_args: &WorkflowListArgs) -> Result<()> {
|
||||
let styles = Styles::detect_stderr();
|
||||
let cwd = std::env::current_dir()?;
|
||||
|
||||
let (config_path, config) = match fabro_config::project::discover_project_config(&cwd)? {
|
||||
Some(found) => found,
|
||||
None => bail!(
|
||||
"No fabro.toml found in {cwd} or any parent directory",
|
||||
cwd = cwd.display()
|
||||
),
|
||||
};
|
||||
|
||||
let fabro_root = fabro_config::project::resolve_fabro_root(&config_path, &config);
|
||||
let project_wf_dir = fabro_root.join("workflows");
|
||||
let user_wf_dir = dirs::home_dir().map(|h| h.join(".fabro").join("workflows"));
|
||||
|
||||
let workflows = fabro_config::project::list_workflows_detailed(
|
||||
Some(&project_wf_dir),
|
||||
user_wf_dir.as_deref(),
|
||||
);
|
||||
|
||||
let project: Vec<_> = workflows
|
||||
.iter()
|
||||
.filter(|w| w.source == fabro_config::project::WorkflowSource::Project)
|
||||
.collect();
|
||||
let user: Vec<_> = workflows
|
||||
.iter()
|
||||
.filter(|w| w.source == fabro_config::project::WorkflowSource::User)
|
||||
.collect();
|
||||
|
||||
let name_width = workflows.iter().map(|w| w.name.len()).max().unwrap_or(0);
|
||||
|
||||
eprintln!(
|
||||
"{} workflow(s) found\n",
|
||||
styles.bold.apply_to(workflows.len())
|
||||
);
|
||||
|
||||
let user_path = user_wf_dir
|
||||
.as_deref()
|
||||
.map(relative_path)
|
||||
.unwrap_or_else(|| "~/.fabro/workflows".to_string());
|
||||
print_section("User Workflows", &user_path, &user, name_width, &styles);
|
||||
|
||||
eprintln!();
|
||||
|
||||
print_section(
|
||||
"Project Workflows",
|
||||
&relative_path(&project_wf_dir),
|
||||
&project,
|
||||
name_width,
|
||||
&styles,
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_section(
|
||||
title: &str,
|
||||
path: &str,
|
||||
workflows: &[&fabro_config::project::WorkflowInfo],
|
||||
name_width: usize,
|
||||
styles: &Styles,
|
||||
) {
|
||||
eprintln!(
|
||||
"{} {}",
|
||||
styles.bold.apply_to(title),
|
||||
styles.dim.apply_to(format!("({path})")),
|
||||
);
|
||||
if workflows.is_empty() {
|
||||
eprintln!(" {}", styles.dim.apply_to("(none)"));
|
||||
return;
|
||||
}
|
||||
eprintln!();
|
||||
eprintln!(
|
||||
" {:<name_width$} {}",
|
||||
styles.bold_dim.apply_to("NAME"),
|
||||
styles.bold_dim.apply_to("DESCRIPTION"),
|
||||
);
|
||||
for w in workflows {
|
||||
let goal_str = w
|
||||
.goal
|
||||
.as_deref()
|
||||
.map(|g| truncate_str(g, GOAL_MAX_LEN))
|
||||
.unwrap_or_default();
|
||||
eprintln!(
|
||||
" {:<name_width$} {}",
|
||||
styles.cyan.apply_to(&w.name),
|
||||
styles.dim.apply_to(goal_str),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_str(s: &str, max: usize) -> String {
|
||||
let first_line = s.lines().next().unwrap_or(s);
|
||||
if first_line.len() <= max {
|
||||
first_line.to_string()
|
||||
} else {
|
||||
format!("{}...", &first_line[..max - 3])
|
||||
}
|
||||
}
|
||||
13
lib/crates/fabro-cli/src/commands/workflow/mod.rs
Normal file
13
lib/crates/fabro-cli/src/commands/workflow/mod.rs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
mod create;
|
||||
mod list;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::args::{WorkflowCommand, WorkflowNamespace};
|
||||
|
||||
pub fn dispatch(ns: WorkflowNamespace) -> Result<()> {
|
||||
match ns.command {
|
||||
WorkflowCommand::List(args) => list::list_command(&args),
|
||||
WorkflowCommand::Create(args) => create::create_command(&args),
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
17
lib/crates/fabro-cli/src/shared/github.rs
Normal file
17
lib/crates/fabro-cli/src/shared/github.rs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
pub(crate) fn build_github_app_credentials(
|
||||
app_id: Option<&str>,
|
||||
) -> Option<fabro_github::GitHubAppCredentials> {
|
||||
let app_id = app_id?;
|
||||
let raw = std::env::var("GITHUB_APP_PRIVATE_KEY").ok()?;
|
||||
let private_key_pem = if raw.starts_with("-----") {
|
||||
raw
|
||||
} else {
|
||||
let pem_bytes =
|
||||
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &raw).ok()?;
|
||||
String::from_utf8(pem_bytes).ok()?
|
||||
};
|
||||
Some(fabro_github::GitHubAppCredentials {
|
||||
app_id: app_id.to_string(),
|
||||
private_key_pem,
|
||||
})
|
||||
}
|
||||
5
lib/crates/fabro-cli/src/shared/mod.rs
Normal file
5
lib/crates/fabro-cli/src/shared/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
pub(crate) mod github;
|
||||
pub(crate) mod provider_auth;
|
||||
mod utilities;
|
||||
|
||||
pub(crate) use utilities::*;
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
36
lib/crates/fabro-cli/tests/snapshots/cli__serve_help.snap
Normal file
36
lib/crates/fabro-cli/tests/snapshots/cli__serve_help.snap
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
---
|
||||
source: lib/crates/fabro-cli/tests/cli.rs
|
||||
assertion_line: 349
|
||||
expression: stdout
|
||||
---
|
||||
Start the HTTP API server
|
||||
|
||||
Usage: fabro serve [OPTIONS]
|
||||
|
||||
Options:
|
||||
--debug
|
||||
Enable DEBUG-level logging (default is INFO)
|
||||
--port <PORT>
|
||||
Port to listen on [default: 3000]
|
||||
--host <HOST>
|
||||
Host address to bind to [default: 127.0.0.1]
|
||||
--no-upgrade-check
|
||||
Disable automatic upgrade check
|
||||
--mode <MODE>
|
||||
Execution mode: standalone (in-process) or server (delegate to API)
|
||||
--model <MODEL>
|
||||
Override default LLM model
|
||||
--provider <PROVIDER>
|
||||
Override default LLM provider
|
||||
--server-url <SERVER_URL>
|
||||
Server URL (overrides server.base_url from cli.toml)
|
||||
--dry-run
|
||||
Execute with simulated LLM backend
|
||||
--sandbox <SANDBOX>
|
||||
Sandbox for agent tools
|
||||
--max-concurrent-runs <MAX_CONCURRENT_RUNS>
|
||||
Maximum number of concurrent run executions
|
||||
--config <CONFIG>
|
||||
Path to server config file (default: ~/.fabro/server.toml)
|
||||
-h, --help
|
||||
Print help
|
||||
Loading…
Add table
Reference in a new issue