fabro asset CLI subcommands (#10)

This PR adds `fabro asset list` and `fabro asset cp` CLI subcommands for
inspecting and copying run artifacts (screenshots, test reports, traces)
that are collected to `~/.fabro/runs/{id}/artifacts/assets/`.
Previously, users had to manually browse the filesystem to find these
files.

The core implementation lives in a new `asset.rs` module that provides
`scan_assets()`, which walks the asset manifest files under each node's
retry directories to build structured `AssetEntry` records. The `list`
subcommand displays a formatted table (or JSON with `--json`) showing
node, retry number, size, and path for each asset. The `cp` subcommand
supports copying all assets or a specific file (via `RUN_ID:path`
syntax), with a `--tree` flag to preserve the `{node}/retry_{N}/`
directory structure and collision detection in flat mode.

The implementation reuses existing utilities throughout:
`split_run_path()` for parsing the colon-separated source syntax (made
`pub(crate)`), `resolve_run()` for run ID prefix matching,
`format_size()` for human-readable byte formatting (also made
`pub(crate)`), and `AssetCollectionSummary` for manifest
deserialization.

### Fabro Details

<details>
<summary>Ran 7 stages in 12m 31s for $3.61</summary>

| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 0s | – | 0 |
| preflight_compile | 0s | – | 0 |
| preflight_lint | 0s | – | 0 |
| implement | 0s | $1.33 | 0 |
| simplify | 0s | $2.28 | 0 |
| verify | 0s | – | 0 |
| **Total** | **12m 31s** | **$3.61** | **0** |

</details>

<details>
<summary>Ran <code>ImplementAndSimplify.fabro</code> (10 nodes and 13
edges)</summary>

```dot
digraph ImplementAndSimplify {
    graph [
        goal="Implement and simplify",
        model_stylesheet="
            * { backend: api; model: claude-opus-4-6;}
        "
    ]
    rankdir=LR

    start [shape=Mdiamond, label="Start"]
    exit  [shape=Msquare, label="Exit"]

    toolchain         [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
    preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check 2>&1", max_retries=0]
    preflight_lint    [label="Preflight Lint", shape=parallelogram, script="cargo clippy -- -D warnings 2>&1", max_retries=0]
    fix_lints         [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
    implement         [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan."]
    simplify          [label="Simplify", prompt="@prompts/simplify.md"]
    verify            [label="Verify", shape=parallelogram, script="cargo clippy -- -D warnings 2>&1 && cargo test 2>&1", goal_gate=true, retry_target="fixup"]
    fixup             [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all clippy lint warnings and test failures.", max_visits=3]

    start -> toolchain
    toolchain -> preflight_compile [condition="outcome=success"]
    toolchain -> exit
    preflight_compile -> preflight_lint [condition="outcome=success"]
    preflight_compile -> exit
    preflight_lint -> implement [condition="outcome=success"]
    preflight_lint -> fix_lints
    fix_lints -> preflight_lint
    implement -> simplify -> verify
    verify -> exit  [condition="outcome=success"]
    verify -> fixup
    fixup -> verify
}

```

</details>

⚒️ Generated with [Fabro](https://fabro.sh)

---------

Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
brynary-fabro[bot] 2026-03-15 19:54:31 -04:00 • committed by GitHub
parent d6f1bce7ef
commit b3f83264dc
5 changed files with 345 additions and 2 deletions

View file

@ -72,6 +72,11 @@ enum Command {
/// Parse a DOT file and print its AST
#[command(hide = true)]
Parse(fabro_workflows::cli::ParseArgs),
/// Inspect and copy run assets (screenshots, reports, traces)
Asset {
#[command(subcommand)]
command: AssetCommand,
},
/// Copy files to/from a run's sandbox
Cp(fabro_workflows::cli::cp::CpArgs),
/// Get a preview URL for a port on a run's sandbox
@ -180,6 +185,14 @@ enum WorkflowCommand {
Create(fabro_workflows::cli::workflow::WorkflowCreateArgs),
}
#[derive(Subcommand)]
enum AssetCommand {
/// List assets for a workflow run
List(fabro_workflows::cli::asset::AssetListArgs),
/// Copy assets from a workflow run
Cp(fabro_workflows::cli::asset::AssetCpArgs),
}
#[derive(Subcommand)]
enum LlmCommand {
/// Execute a prompt
@ -380,6 +393,10 @@ async fn main_inner() -> (String, Result<()>) {
LlmCommand::Prompt(_) => "llm prompt",
LlmCommand::Chat(_) => "llm chat",
},
Command::Asset { command } => match command {
AssetCommand::List(_) => "asset list",
AssetCommand::Cp(_) => "asset cp",
},
Command::Exec(_) => "exec",
Command::Run(_) => "run",
Command::Validate(_) => "validate",
@ -636,6 +653,14 @@ async fn main_inner() -> (String, Result<()>) {
Command::Parse(args) => {
fabro_workflows::cli::parse::parse_command(&args)?;
}
Command::Asset { command } => match command {
AssetCommand::List(args) => {
fabro_workflows::cli::asset::list_command(&args)?;
}
AssetCommand::Cp(args) => {
fabro_workflows::cli::asset::cp_command(&args)?;
}
},
Command::Cp(args) => {
fabro_workflows::cli::cp::cp_command(args).await?;
}

View file

@ -0,0 +1,317 @@
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use clap::Args;
use crate::asset_snapshot::AssetCollectionSummary;
use crate::cli::cp::split_run_path;
use crate::cli::runs::{default_runs_base, format_size, resolve_run};
/// An individual asset file discovered from a run's asset manifests.
#[derive(Debug, Clone, serde::Serialize)]
pub struct AssetEntry {
pub node_slug: String,
pub retry: u32,
pub relative_path: String,
#[serde(serialize_with = "serialize_path")]
pub absolute_path: PathBuf,
pub size: u64,
}
fn serialize_path<S: serde::Serializer>(path: &Path, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&path.display().to_string())
}
/// Walk `{run_dir}/artifacts/assets/*/retry_*/manifest.json`, stat each file, return entries.
pub fn scan_assets(run_dir: &Path, node_filter: Option<&str>) -> Result<Vec<AssetEntry>> {
let assets_dir = run_dir.join("artifacts/assets");
let nodes = match std::fs::read_dir(&assets_dir) {
Ok(rd) => rd,
Err(_) => return Ok(Vec::new()),
};
let mut entries = Vec::new();
for node_entry in nodes.flatten() {
if !node_entry.path().is_dir() {
continue;
}
let node_slug = node_entry.file_name().to_string_lossy().into_owned();
if let Some(filter) = node_filter {
if node_slug != filter {
continue;
}
}
let Ok(retries) = std::fs::read_dir(node_entry.path()) else {
continue;
};
for retry_entry in retries.flatten() {
let retry_dir = retry_entry.path();
let dir_name = retry_entry.file_name().to_string_lossy().into_owned();
let retry: u32 = dir_name
.strip_prefix("retry_")
.and_then(|n| n.parse().ok())
.unwrap_or(0);
let manifest = retry_dir.join("manifest.json");
let Ok(contents) = std::fs::read_to_string(&manifest) else {
continue;
};
let Ok(summary) = serde_json::from_str::<AssetCollectionSummary>(&contents) else {
continue;
};
for relative_path in &summary.copied_paths {
let absolute_path = retry_dir.join(relative_path);
let size = std::fs::metadata(&absolute_path)
.map(|m| m.len())
.unwrap_or(0);
entries.push(AssetEntry {
node_slug: node_slug.clone(),
retry,
relative_path: relative_path.clone(),
absolute_path,
size,
});
}
}
}
Ok(entries)
}
#[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,
}
/// Parse `source` into (run_id, optional_asset_path) using the same colon-split logic as `cp`.
fn parse_source(s: &str) -> (&str, Option<&str>) {
match split_run_path(s) {
Some((run_id, path)) => (run_id, Some(path)),
None => (s, None),
}
}
pub fn list_command(args: &AssetListArgs) -> Result<()> {
let base = default_runs_base();
let run_info = resolve_run(&base, &args.run_id)?;
let entries = scan_assets(&run_info.path, args.node.as_deref())?;
if args.json {
let json = serde_json::to_string_pretty(&entries)?;
println!("{json}");
return Ok(());
}
if entries.is_empty() {
println!("No assets found for this run.");
return Ok(());
}
// Compute column widths
let node_width = entries
.iter()
.map(|e| e.node_slug.len())
.max()
.unwrap_or(4)
.max(4);
let retry_width = 5; // "RETRY"
let size_width = entries
.iter()
.map(|e| format_size(e.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(|e| e.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(())
}
pub fn cp_command(args: &AssetCpArgs) -> Result<()> {
let base = default_runs_base();
let (run_id, asset_path) = parse_source(&args.source);
let run_info = resolve_run(&base, run_id)?;
let entries = scan_assets(&run_info.path, args.node.as_deref())?;
if entries.is_empty() {
bail!("No assets found for this run");
}
std::fs::create_dir_all(&args.dest)
.with_context(|| format!("Failed to create destination: {}", args.dest.display()))?;
if let Some(path) = asset_path {
// Copy a specific asset
let matching: Vec<_> = entries.iter().filter(|e| e.relative_path == path).collect();
if matching.is_empty() {
bail!("No asset matching path '{path}' found in this run");
}
if matching.len() > 1 && args.node.is_none() {
let nodes: Vec<_> = matching.iter().map(|e| e.node_slug.as_str()).collect();
bail!(
"Path '{path}' exists in multiple nodes: {}. Use --node to disambiguate.",
nodes.join(", ")
);
}
let entry = matching[0];
let dest_file = args.dest.join(
Path::new(&entry.relative_path)
.file_name()
.unwrap_or_else(|| std::ffi::OsStr::new(&entry.relative_path)),
);
if let Some(parent) = dest_file.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(&entry.absolute_path, &dest_file).with_context(|| {
format!(
"Failed to copy {} to {}",
entry.absolute_path.display(),
dest_file.display()
)
})?;
println!("Copied {} to {}", entry.relative_path, dest_file.display());
} else {
// Copy all assets
if args.tree {
// Preserve directory structure: {node_slug}/retry_{N}/...
for entry in &entries {
let rel = PathBuf::from(&entry.node_slug)
.join(format!("retry_{}", entry.retry))
.join(&entry.relative_path);
let dest_file = args.dest.join(&rel);
if let Some(parent) = dest_file.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(&entry.absolute_path, &dest_file).with_context(|| {
format!(
"Failed to copy {} to {}",
entry.absolute_path.display(),
dest_file.display()
)
})?;
}
} else {
// Flat mode: build filename map and check for collisions
let mut by_filename: Vec<(String, &AssetEntry)> = Vec::with_capacity(entries.len());
for entry in &entries {
let filename = Path::new(&entry.relative_path)
.file_name()
.unwrap_or_else(|| std::ffi::OsStr::new(&entry.relative_path))
.to_string_lossy()
.into_owned();
if let Some((_, existing)) = by_filename.iter().find(|(f, _)| f == &filename) {
bail!(
"Filename collision: '{}' exists in both node '{}' and '{}'. \
Use --tree to preserve directory structure, or --node to filter.",
filename,
existing.node_slug,
entry.node_slug
);
}
by_filename.push((filename, entry));
}
for (filename, entry) in &by_filename {
let dest_file = args.dest.join(filename);
if let Some(parent) = dest_file.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(&entry.absolute_path, &dest_file).with_context(|| {
format!(
"Failed to copy {} to {}",
entry.absolute_path.display(),
dest_file.display()
)
})?;
}
}
println!(
"Copied {} asset(s) to {}",
entries.len(),
args.dest.display()
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_source_bare_run_id() {
let (id, path) = parse_source("01ABC");
assert_eq!(id, "01ABC");
assert_eq!(path, None);
}
#[test]
fn parse_source_with_path() {
let (id, path) = parse_source("01ABC:test-results/report.xml");
assert_eq!(id, "01ABC");
assert_eq!(path, Some("test-results/report.xml"));
}
#[test]
fn parse_source_local_absolute_path() {
let (id, path) = parse_source("/tmp/foo");
assert_eq!(id, "/tmp/foo");
assert_eq!(path, None);
}
#[test]
fn parse_source_local_relative_path() {
let (id, path) = parse_source("./foo");
assert_eq!(id, "./foo");
assert_eq!(path, None);
}
}

View file

@ -66,7 +66,7 @@ fn parse_direction(src: &str, dst: &str) -> Result<CopyDirection> {
/// We distinguish local paths from run references by checking:
/// - Paths starting with `/`, `./`, or `../` are always local
/// - Otherwise, split on the first colon
fn split_run_path(s: &str) -> Option<(&str, &str)> {
pub(crate) fn split_run_path(s: &str) -> Option<(&str, &str)> {
if s.starts_with('/') || s.starts_with("./") || s.starts_with("../") {
return None;
}

View file

@ -1,3 +1,4 @@
pub mod asset;
pub mod backend;
pub mod cli_backend;
pub mod cp;

View file

@ -564,7 +564,7 @@ fn dir_size(path: &Path) -> u64 {
.sum()
}
fn format_size(bytes: u64) -> String {
pub(crate) fn format_size(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = 1024 * KB;
const GB: u64 = 1024 * MB;