From b3f83264dceac0cb83409dc3b5c43c3450defd60 Mon Sep 17 00:00:00 2001
From: "brynary-fabro[bot]"
<265161896+brynary-fabro[bot]@users.noreply.github.com>
Date: Sun, 15 Mar 2026 19:54:31 -0400
Subject: [PATCH] `fabro asset` CLI subcommands (#10)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
Ran 7 stages in 12m 31s for $3.61
| 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** |
Ran ImplementAndSimplify.fabro (10 nodes and 13
edges)
```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
}
```
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro
Co-authored-by: Bryan Helmkamp
Co-authored-by: Claude Opus 4.6 (1M context)
---
lib/crates/fabro-cli/src/main.rs | 25 ++
lib/crates/fabro-workflows/src/cli/asset.rs | 317 ++++++++++++++++++++
lib/crates/fabro-workflows/src/cli/cp.rs | 2 +-
lib/crates/fabro-workflows/src/cli/mod.rs | 1 +
lib/crates/fabro-workflows/src/cli/runs.rs | 2 +-
5 files changed, 345 insertions(+), 2 deletions(-)
create mode 100644 lib/crates/fabro-workflows/src/cli/asset.rs
diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs
index e02a37324..8fff95800 100644
--- a/lib/crates/fabro-cli/src/main.rs
+++ b/lib/crates/fabro-cli/src/main.rs
@@ -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?;
}
diff --git a/lib/crates/fabro-workflows/src/cli/asset.rs b/lib/crates/fabro-workflows/src/cli/asset.rs
new file mode 100644
index 000000000..f6d6c0c7d
--- /dev/null
+++ b/lib/crates/fabro-workflows/src/cli/asset.rs
@@ -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(path: &Path, s: S) -> Result {
+ 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> {
+ 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::(&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,
+
+ /// Output as JSON
+ #[arg(long)]
+ pub json: bool,
+}
+
+#[derive(Args)]
+pub struct AssetCpArgs {
+ /// Source: RUN_ID (all assets) or RUN_ID:path (specific asset)
+ pub source: String,
+
+ /// Destination directory (defaults to current directory)
+ #[arg(default_value = ".")]
+ pub dest: PathBuf,
+
+ /// Filter to assets from a specific node
+ #[arg(long)]
+ pub node: Option,
+
+ /// Preserve {node_slug}/retry_{N}/ directory structure
+ #[arg(long)]
+ pub tree: bool,
+}
+
+/// 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!(
+ "{:retry_width$} {:>size_width$} PATH",
+ "NODE", "RETRY", "SIZE"
+ );
+ let total_size: u64 = entries.iter().map(|e| e.size).sum();
+ for entry in &entries {
+ println!(
+ "{:retry_width$} {:>size_width$} {}",
+ entry.node_slug,
+ entry.retry,
+ format_size(entry.size),
+ entry.relative_path
+ );
+ }
+ println!();
+ println!(
+ "{} asset(s), {} total",
+ entries.len(),
+ format_size(total_size)
+ );
+
+ Ok(())
+}
+
+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);
+ }
+}
diff --git a/lib/crates/fabro-workflows/src/cli/cp.rs b/lib/crates/fabro-workflows/src/cli/cp.rs
index 17a1461b0..d78ffc8cf 100644
--- a/lib/crates/fabro-workflows/src/cli/cp.rs
+++ b/lib/crates/fabro-workflows/src/cli/cp.rs
@@ -66,7 +66,7 @@ fn parse_direction(src: &str, dst: &str) -> Result {
/// 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;
}
diff --git a/lib/crates/fabro-workflows/src/cli/mod.rs b/lib/crates/fabro-workflows/src/cli/mod.rs
index dc376b0c1..3f3a3027f 100644
--- a/lib/crates/fabro-workflows/src/cli/mod.rs
+++ b/lib/crates/fabro-workflows/src/cli/mod.rs
@@ -1,3 +1,4 @@
+pub mod asset;
pub mod backend;
pub mod cli_backend;
pub mod cp;
diff --git a/lib/crates/fabro-workflows/src/cli/runs.rs b/lib/crates/fabro-workflows/src/cli/runs.rs
index 0c55842c8..c83164ef4 100644
--- a/lib/crates/fabro-workflows/src/cli/runs.rs
+++ b/lib/crates/fabro-workflows/src/cli/runs.rs
@@ -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;