From 98f96e5435636a061eea797dd24d530754be7fdd Mon Sep 17 00:00:00 2001 From: damocles Date: Thu, 4 Jun 2026 21:35:21 +0200 Subject: [PATCH] feat(#1315): hive-forge attachment-get verb - download attachments by uuid or url --- CLAUDE.md | 4 +- hive-ag3nt/prompts/system.md | 2 +- hive-forge/src/client.rs | 20 ++++++ hive-forge/src/main.rs | 4 ++ hive-forge/src/verbs/attachment_get.rs | 90 ++++++++++++++++++++++++++ hive-forge/src/verbs/mod.rs | 1 + 6 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 hive-forge/src/verbs/attachment_get.rs diff --git a/CLAUDE.md b/CLAUDE.md index 96477a8c..35ff3ebc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -288,8 +288,8 @@ hive-forge/ Forgejo CLI wrapper (`hive-forge` binary) issue-edit, pr-create, pr-reviews, assign, close, labels, list, milestone, branches, tree-sha, diff, subscription, attach-issue, - attach-comment, lint). Replaces the 600-line - hive-forge-tools.nix bash script. + attach-comment, attachment-get, lint). Replaces the + 600-line hive-forge-tools.nix bash script. hive-matrix-mcp/ per-agent matrix-sdk integration. src/main.rs `hive-matrix-daemon` binary entry — long-running diff --git a/hive-ag3nt/prompts/system.md b/hive-ag3nt/prompts/system.md index 825b9561..ce9118e2 100644 --- a/hive-ag3nt/prompts/system.md +++ b/hive-ag3nt/prompts/system.md @@ -55,7 +55,7 @@ Claude session (OAuth credentials) lives at `/root/.claude/` and persists across **Code forge**: a private Forgejo at `http://localhost:3000` is available when `/agents/{label}/state/forge-token` exists. You have your own user account (named `{label}`). Use `hive-forge` (see below) for all forge operations — issues, PRs, comments, labels, etc. For git operations use plain `git` directly against `http://localhost:3000//.git` (credentials are pre-configured). -The `hive-forge` CLI helper wraps common Forgejo API operations: `view`, `issue`, `issue-create`, `issue-edit`, `pr`, `pr-create`, `comment`, `comments`, `comment-show`, `comment-edit`, `assign`, `close`, `labels`, `lint`, `list`, `milestone`, `pr-reviews`, `branches`, `tree-sha`, `diff`, `subscription`, `attach-issue`, `attach-comment`. `lint ` runs triage queries (`unassigned`, `no-reviewer --reviewer NAME`, `stale-branches [--days N]`, `assignments [--user NAME]`). Default repo comes from `HIVE_FORGE_REPO`; pass `-r ` (global flag, works before or after the verb) to target a different repo. Every verb takes `--help` for its full signature. To create a PR: `hive-forge pr-create --title "..." --head [--base main] [--body "..." | --body-file ] [--draft] [--push [--remote forge]]` — prints the PR URL. Add `--push` to also `git push` the head branch before the API call (default remote: `forge`); the noisy post-push "Create a pull request" hint is suppressed since we print the canonical URL ourselves. To create an issue: `hive-forge issue-create --title "..." [--body "..." | --body-file ] [--assignee ]`. `--body-file -` means stdin, so a HEREDOC body works naturally: `hive-forge comment --body-file - < ` / `hive-forge attach-comment ` — both print the `browser_download_url`. Key ops: `hive-forge diff ` prints the unified diff; `hive-forge subscription [--watch|--ignore|--unwatch]` manages repo watch state. Note: forge notifications are delivered via the internal message daemon. +The `hive-forge` CLI helper wraps common Forgejo API operations: `view`, `issue`, `issue-create`, `issue-edit`, `pr`, `pr-create`, `comment`, `comments`, `comment-show`, `comment-edit`, `assign`, `close`, `labels`, `lint`, `list`, `milestone`, `pr-reviews`, `branches`, `tree-sha`, `diff`, `subscription`, `attach-issue`, `attach-comment`, `attachment-get`. `lint ` runs triage queries (`unassigned`, `no-reviewer --reviewer NAME`, `stale-branches [--days N]`, `assignments [--user NAME]`). Default repo comes from `HIVE_FORGE_REPO`; pass `-r ` (global flag, works before or after the verb) to target a different repo. Every verb takes `--help` for its full signature. To create a PR: `hive-forge pr-create --title "..." --head [--base main] [--body "..." | --body-file ] [--draft] [--push [--remote forge]]` — prints the PR URL. Add `--push` to also `git push` the head branch before the API call (default remote: `forge`); the noisy post-push "Create a pull request" hint is suppressed since we print the canonical URL ourselves. To create an issue: `hive-forge issue-create --title "..." [--body "..." | --body-file ] [--assignee ]`. `--body-file -` means stdin, so a HEREDOC body works naturally: `hive-forge comment --body-file - < ` / `hive-forge attach-comment ` — both print the `browser_download_url`. To download an attachment: `hive-forge attachment-get [-o ]` — saves to `/tmp/forge-attachment-` by default and prints the path; pass `-o -` to stream to stdout. Key ops: `hive-forge diff ` prints the unified diff; `hive-forge subscription [--watch|--ignore|--unwatch]` manages repo watch state. Note: forge notifications are delivered via the internal message daemon. Keep messages short — a few sentences each. For anything big (file listings, long diffs, transcripts, analysis): write the payload to `/agents/{label}/state/` and `send` a short pointer ("dropped the cluster audit in /agents/{label}/state/cluster-audit-2026-05.md, headline: 3 nodes over 80% mem"). The operator can read your state from the host as `/agents/{label}/state/`. Sub-agent peers can't read each other's state directly — coordinate through shared space or a common parent. diff --git a/hive-forge/src/client.rs b/hive-forge/src/client.rs index 173a1ce7..1f61dc2b 100644 --- a/hive-forge/src/client.rs +++ b/hive-forge/src/client.rs @@ -197,6 +197,26 @@ impl Client { Ok(()) } + /// Build the full URL for a Forgejo attachment by UUID. + /// Attachments live at `/attachments/`, NOT under + /// `/api/v1/`, so this uses `self.base` directly. + #[must_use] + pub fn attachment_url(&self, uuid: &str) -> String { + format!("{}/attachments/{uuid}", self.base) + } + + /// GET a raw (non-API) URL and return the response body as bytes. + /// The client's auth headers are still sent — Forgejo requires them + /// for private attachment downloads. Uses the full URL as-is; the + /// caller is responsible for constructing it (see `attachment_url`). + pub fn get_bytes_raw(&self, url: &str) -> Result> { + let resp = self.http.get(url).send().context("GET")?; + let resp = check_status(resp, &format!("GET {url}"))?; + resp.bytes() + .map(|b| b.to_vec()) + .with_context(|| format!("read bytes for GET {url}")) + } + /// POST a multipart file upload, returning the parsed response. /// Used by `attach-issue` / `attach-comment`. pub fn post_multipart_file(&self, path: &str, file: &std::path::Path) -> Result { diff --git a/hive-forge/src/main.rs b/hive-forge/src/main.rs index 07b7ae25..9ff41bd7 100644 --- a/hive-forge/src/main.rs +++ b/hive-forge/src/main.rs @@ -99,6 +99,9 @@ enum Verb { AttachIssue(verbs::attach::IssueArgs), /// Upload a file as an attachment to a comment. AttachComment(verbs::attach::CommentArgs), + /// Download an attachment by UUID or URL. Saves to a temp file and + /// prints the path (pass `-o -` to stream raw bytes to stdout). + AttachmentGet(verbs::attachment_get::Args), } fn main() -> Result<()> { @@ -129,5 +132,6 @@ fn main() -> Result<()> { Verb::Timeline(a) => verbs::timeline::run(&client, a), Verb::AttachIssue(a) => verbs::attach::run_issue(&client, a), Verb::AttachComment(a) => verbs::attach::run_comment(&client, a), + Verb::AttachmentGet(a) => verbs::attachment_get::run(&client, a), } } diff --git a/hive-forge/src/verbs/attachment_get.rs b/hive-forge/src/verbs/attachment_get.rs new file mode 100644 index 00000000..7ec7e5e7 --- /dev/null +++ b/hive-forge/src/verbs/attachment_get.rs @@ -0,0 +1,90 @@ +//! `attachment-get [-o ]` — download a Forgejo +//! attachment to a local file (or stdout with `-o -`). Accepts either +//! the bare attachment UUID or any URL form that ends with +//! `/attachments/` (as it appears in comment markdown). +//! +//! Default output path: `/tmp/forge-attachment-`. Prints the +//! resolved path to stderr so callers can locate it easily. + +use std::io::Write as _; +use std::path::PathBuf; + +use anyhow::{Result, bail}; +use clap::Args as ClapArgs; + +use crate::client::Client; + +#[derive(ClapArgs)] +pub struct Args { + /// Attachment UUID or URL. Accepts a bare UUID (`abc-123-...`), + /// a root-relative path (`/attachments/abc-123-...`), or a full + /// URL (`http://localhost:3000/attachments/abc-123-...`). + attachment: String, + /// Output path. Defaults to `/tmp/forge-attachment-`. + /// Pass `-` to write raw bytes to stdout (e.g. for piping to an + /// image viewer). + #[arg(short = 'o', long)] + output: Option, +} + +/// # Errors +/// +/// Returns an error if the attachment UUID cannot be extracted from +/// the input, if the download fails (network, 4xx/5xx), or if the +/// output path cannot be written. +pub fn run(client: &Client, args: Args) -> Result<()> { + let uuid = extract_uuid(&args.attachment)?; + let url = client.attachment_url(&uuid); + let bytes = client.get_bytes_raw(&url)?; + + match args.output.as_deref() { + Some("-") => { + std::io::stdout() + .write_all(&bytes) + .map_err(|e| anyhow::anyhow!("write stdout: {e}"))?; + } + Some(path) => { + let p = PathBuf::from(path); + std::fs::write(&p, &bytes) + .map_err(|e| anyhow::anyhow!("write {}: {e}", p.display()))?; + println!("{}", p.display()); + } + None => { + let p = PathBuf::from(format!("/tmp/forge-attachment-{uuid}")); + std::fs::write(&p, &bytes) + .map_err(|e| anyhow::anyhow!("write {}: {e}", p.display()))?; + println!("{}", p.display()); + } + } + Ok(()) +} + +/// Extract the bare UUID from whatever the caller passes. +/// +/// Accepted forms: +/// - bare UUID: `abc-123-...` +/// - root-relative path: `/attachments/abc-123-...` +/// - full loopback URL: `http://localhost:3000/attachments/abc-123-...` +/// - any URL ending with `/attachments/` +/// +/// # Errors +/// +/// Returns an error if the input contains no path segment after +/// parsing (e.g. an empty string). +fn extract_uuid(input: &str) -> Result { + // If it contains a slash, treat everything after the last slash as + // the UUID. This handles all URL/path forms. + let uuid = if input.contains('/') { + input + .rsplit('/') + .next() + .filter(|s| !s.is_empty()) + .ok_or_else(|| anyhow::anyhow!("hive-forge attachment-get: cannot extract UUID from {input:?}"))? + } else { + input + }; + if uuid.is_empty() { + bail!("hive-forge attachment-get: empty UUID in {input:?}"); + } + Ok(uuid.to_owned()) +} diff --git a/hive-forge/src/verbs/mod.rs b/hive-forge/src/verbs/mod.rs index d47342af..cb2c697e 100644 --- a/hive-forge/src/verbs/mod.rs +++ b/hive-forge/src/verbs/mod.rs @@ -5,6 +5,7 @@ pub mod assign; pub mod attach; +pub mod attachment_get; pub mod branches; pub mod close; pub mod comment;