From a95ca22b49df17757fdd0bf994dc54a2841e16cb Mon Sep 17 00:00:00 2001 From: damocles Date: Wed, 20 May 2026 21:43:26 +0200 Subject: [PATCH] hive-forge: single command with subverbs instead of per-verb scripts --- nix/packages/hive-forge-tools.nix | 238 ++++++++++++++---------------- nix/templates/harness-base.nix | 112 +++++++------- 2 files changed, 165 insertions(+), 185 deletions(-) diff --git a/nix/packages/hive-forge-tools.nix b/nix/packages/hive-forge-tools.nix index d8c8d6b..b5e40a3 100644 --- a/nix/packages/hive-forge-tools.nix +++ b/nix/packages/hive-forge-tools.nix @@ -1,18 +1,21 @@ { pkgs, lib }: -# Small shell wrappers that replace ad-hoc curl pipelines for -# common Forgejo API operations. Every script reads credentials -# from the environment: +# Single `hive-forge ` CLI wrapping common Forgejo API operations. +# Reads credentials from the environment: # # HIVE_FORGE_URL -- base URL, e.g. http://localhost:3000 # HIVE_FORGE_REPO -- default repo, e.g. hyperhive/hyperhive # HYPERHIVE_STATE_DIR -- state dir; forge-token lives here # -# All scripts exit non-zero on HTTP errors and print diagnostics -# to stderr. -let - # Common prologue injected into every script: reads token and - # defines forge_get / forge_post / forge_patch / forge_delete. - commonPrologue = '' +# Usage: hive-forge [args...] +# Verbs: view, issue, pr, comment, assign, close, labels, pr-reviews, +# branches, tree-sha +pkgs.writeShellApplication { + name = "hive-forge"; + runtimeInputs = [ + pkgs.curl + pkgs.jq + ]; + text = '' : "''${HIVE_FORGE_URL:=http://localhost:3000}" : "''${HIVE_FORGE_REPO:=hyperhive/hyperhive}" _state_dir="''${HYPERHIVE_STATE_DIR:-}" @@ -30,7 +33,6 @@ let -H "Accept: application/json" \ "$1" } - forge_post() { ${pkgs.curl}/bin/curl -sf -X POST \ -H "Authorization: token $_token" \ @@ -38,7 +40,6 @@ let -H "Accept: application/json" \ -d "$2" "$1" } - forge_patch() { ${pkgs.curl}/bin/curl -sf -X PATCH \ -H "Authorization: token $_token" \ @@ -46,7 +47,6 @@ let -H "Accept: application/json" \ -d "$2" "$1" } - forge_delete() { local _url="$1" local _body="''${2:-}" @@ -63,87 +63,57 @@ let "$_url" fi } - ''; - - mkScript = name: text: - pkgs.writeShellApplication { - inherit name; - runtimeInputs = [ pkgs.curl pkgs.jq ]; - text = commonPrologue + "\n" + text; - }; -in -pkgs.symlinkJoin { - name = "hive-forge-tools"; - paths = [ - - # hive-forge-view [repo] - # Dump title + body + all comments for an issue or PR. - (mkScript "hive-forge-view" '' - if [ $# -lt 1 ]; then - echo "usage: hive-forge-view [repo]" >&2; exit 1 - fi - _n="$1" - _repo="''${2:-$HIVE_FORGE_REPO}" + cmd_view() { + # view [repo] + # Dump title + body + all comments for an issue or PR. + if [ $# -lt 1 ]; then echo "usage: hive-forge view [repo]" >&2; exit 1; fi + local _n="$1" _repo="''${2:-$HIVE_FORGE_REPO}" + local _issue _title _body _state _user _is_pr _kind _issue=$(forge_get "$FORGE_API/repos/$_repo/issues/$_n") _title=$(printf '%s' "$_issue" | jq -r '.title') _body=$(printf '%s' "$_issue" | jq -r '.body // ""') _state=$(printf '%s' "$_issue" | jq -r '.state') _user=$(printf '%s' "$_issue" | jq -r '.user.login') _is_pr=$(printf '%s' "$_issue" | jq -r '.pull_request != null') - _kind="issue" if [ "$_is_pr" = "true" ]; then _kind="PR"; fi - printf '# %s #%s [%s] by %s\n' "$_kind" "$_n" "$_state" "$_user" printf '%s\n' "$_title" - if [ -n "$_body" ]; then - printf '\n%s\n' "$_body" - fi - + if [ -n "$_body" ]; then printf '\n%s\n' "$_body"; fi + local _comments _count _comments=$(forge_get "$FORGE_API/repos/$_repo/issues/$_n/comments?limit=50") _count=$(printf '%s' "$_comments" | jq 'length') if [ "$_count" -gt 0 ]; then printf '\n---\n## Comments (%s)\n\n' "$_count" printf '%s' "$_comments" | jq -r '.[] | "**\(.user.login)**: \(.body)\n"' fi - '') + } - # hive-forge-issue [repo] - # Print key fields of an issue as JSON. - (mkScript "hive-forge-issue" '' - if [ $# -lt 1 ]; then - echo "usage: hive-forge-issue [repo]" >&2; exit 1 - fi - _n="$1" - _repo="''${2:-$HIVE_FORGE_REPO}" + cmd_issue() { + # issue [repo] + # Print key fields of an issue as JSON. + if [ $# -lt 1 ]; then echo "usage: hive-forge issue [repo]" >&2; exit 1; fi + local _n="$1" _repo="''${2:-$HIVE_FORGE_REPO}" forge_get "$FORGE_API/repos/$_repo/issues/$_n" \ | jq '{number,title,state,user:.user.login,assignees:[.assignees[]?.login],labels:[.labels[]?.name],body}' - '') + } - # hive-forge-pr [repo] - # Print key fields of a PR as JSON. - (mkScript "hive-forge-pr" '' - if [ $# -lt 1 ]; then - echo "usage: hive-forge-pr [repo]" >&2; exit 1 - fi - _n="$1" - _repo="''${2:-$HIVE_FORGE_REPO}" + cmd_pr() { + # pr [repo] + # Print key fields of a PR as JSON. + if [ $# -lt 1 ]; then echo "usage: hive-forge pr [repo]" >&2; exit 1; fi + local _n="$1" _repo="''${2:-$HIVE_FORGE_REPO}" forge_get "$FORGE_API/repos/$_repo/pulls/$_n" \ | jq '{number,title,state,merged,user:.user.login,head_sha:.head.sha,head_branch:.head.label,base_branch:.base.label}' - '') - - # hive-forge-comment [body | -f | - for stdin] - # Post a comment on an issue or PR. Body can be passed as: - # - trailing args (joined with spaces) - # - -f to read from a file - # - - or no args to read from stdin - (mkScript "hive-forge-comment" '' - if [ $# -lt 1 ]; then - echo "usage: hive-forge-comment [body | -f | -]" >&2; exit 1 - fi - _n="$1"; shift + } + cmd_comment() { + # comment [body | -f | - for stdin] + # Post a comment on an issue or PR. + if [ $# -lt 1 ]; then echo "usage: hive-forge comment [body | -f | -]" >&2; exit 1; fi + local _n="$1"; shift + local _body if [ $# -eq 0 ] || [ "''${1:-}" = "-" ]; then _body=$(cat) elif [ "''${1:-}" = "-f" ]; then @@ -151,20 +121,18 @@ pkgs.symlinkJoin { else _body="$*" fi - + local _payload _payload=$(jq -n --arg body "$_body" '{body:$body}') forge_post "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$_n/comments" "$_payload" \ | jq '{id,url:.html_url}' - '') - - # hive-forge-assign [--remove] - # Assign or unassign a user on an issue or PR. - (mkScript "hive-forge-assign" '' - if [ $# -lt 2 ]; then - echo "usage: hive-forge-assign [--remove]" >&2; exit 1 - fi - _n="$1"; _user="$2"; _remove="''${3:-}" + } + cmd_assign() { + # assign [--remove] + # Assign or unassign a user on an issue or PR. + if [ $# -lt 2 ]; then echo "usage: hive-forge assign [--remove]" >&2; exit 1; fi + local _n="$1" _user="$2" _remove="''${3:-}" + local _payload _payload=$(jq -n --arg u "$_user" '{assignees:[$u]}') if [ "$_remove" = "--remove" ]; then forge_delete "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$_n/assignees" "$_payload" \ @@ -173,31 +141,26 @@ pkgs.symlinkJoin { forge_post "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$_n/assignees" "$_payload" \ | jq '{number,assignees:[.assignees[]?.login]}' fi - '') + } - # hive-forge-close - # Close an issue or PR. - (mkScript "hive-forge-close" '' - if [ $# -lt 1 ]; then - echo "usage: hive-forge-close " >&2; exit 1 - fi + cmd_close() { + # close + # Close an issue or PR. + if [ $# -lt 1 ]; then echo "usage: hive-forge close " >&2; exit 1; fi forge_patch "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$1" '{"state":"closed"}' \ | jq '{number,state}' - '') + } - # hive-forge-labels [list | add | remove ] - # Manage labels on an issue or PR. Default action is list. - (mkScript "hive-forge-labels" '' - if [ $# -lt 1 ]; then - echo "usage: hive-forge-labels [list|add|remove] [labels...]" >&2; exit 1 - fi - _n="$1"; _action="''${2:-list}" + cmd_labels() { + # labels [list|add|remove] [labels...] + # Manage labels on an issue or PR. Default action is list. + if [ $# -lt 1 ]; then echo "usage: hive-forge labels [list|add|remove] [labels...]" >&2; exit 1; fi + local _n="$1" _action="''${2:-list}" shift; shift || true - + local _all _ids _id _label case "$_action" in list) - forge_get "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$_n/labels" \ - | jq '[.[].name]' + forge_get "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$_n/labels" | jq '[.[].name]' ;; add) _all=$(forge_get "$FORGE_API/repos/$HIVE_FORGE_REPO/labels?limit=100") @@ -213,60 +176,75 @@ pkgs.symlinkJoin { forge_delete "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$_n/labels/$_id" fi done - forge_get "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$_n/labels" \ - | jq '[.[].name]' + forge_get "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$_n/labels" | jq '[.[].name]' ;; *) echo "unknown action: $_action (use list, add, remove)" >&2; exit 1 ;; esac - '') + } - # hive-forge-pr-reviews [repo] - # List reviews on a PR with id/state/user/body. - (mkScript "hive-forge-pr-reviews" '' - if [ $# -lt 1 ]; then - echo "usage: hive-forge-pr-reviews [repo]" >&2; exit 1 - fi - _n="$1" - _repo="''${2:-$HIVE_FORGE_REPO}" + cmd_pr_reviews() { + # pr-reviews [repo] + # List reviews on a PR with id/state/user/body. + if [ $# -lt 1 ]; then echo "usage: hive-forge pr-reviews [repo]" >&2; exit 1; fi + local _n="$1" _repo="''${2:-$HIVE_FORGE_REPO}" forge_get "$FORGE_API/repos/$_repo/pulls/$_n/reviews" \ | jq '[.[] | {id,state,user:.user.login,body,comments_count}]' - '') + } - # hive-forge-branches [pattern] [repo] - # List branches, optionally filtered by a grep pattern. - (mkScript "hive-forge-branches" '' - _pattern="''${1:-}" - _repo="''${2:-$HIVE_FORGE_REPO}" - _result=$(forge_get "$FORGE_API/repos/$_repo/branches?limit=100" \ - | jq -r '.[].name') + cmd_branches() { + # branches [pattern] [repo] + # List branches, optionally filtered by a grep pattern. + local _pattern="''${1:-}" _repo="''${2:-$HIVE_FORGE_REPO}" + local _result + _result=$(forge_get "$FORGE_API/repos/$_repo/branches?limit=100" | jq -r '.[].name') if [ -n "$_pattern" ]; then printf '%s\n' "$_result" | grep "$_pattern" || true else printf '%s\n' "$_result" fi - '') + } - # hive-forge-tree-sha [repo] - # Print the tree SHA of the commit at the given branch or commit SHA. - (mkScript "hive-forge-tree-sha" '' - if [ $# -lt 1 ]; then - echo "usage: hive-forge-tree-sha [repo]" >&2; exit 1 - fi - _ref="$1" - _repo="''${2:-$HIVE_FORGE_REPO}" - # Resolve branch -> commit SHA first, then get the tree SHA - # from the git commit object (tree.sha field). + cmd_tree_sha() { + # tree-sha [repo] + # Print the tree SHA of the commit at the given branch or commit SHA. + if [ $# -lt 1 ]; then echo "usage: hive-forge tree-sha [repo]" >&2; exit 1; fi + local _ref="$1" _repo="''${2:-$HIVE_FORGE_REPO}" + local _commit_sha _commit_sha=$(forge_get "$FORGE_API/repos/$_repo/branches/$_ref" 2>/dev/null \ | jq -r '.commit.id // empty') || true if [ -z "$_commit_sha" ]; then - # Assume it's already a commit SHA _commit_sha="$_ref" fi forge_get "$FORGE_API/repos/$_repo/git/commits/$_commit_sha" \ | jq -r '.tree.sha // .sha' - '') + } - ]; + VERB="''${1:-}" + if [ -z "$VERB" ]; then + echo "usage: hive-forge [args...]" >&2 + echo "verbs: view, issue, pr, comment, assign, close, labels, pr-reviews, branches, tree-sha" >&2 + exit 1 + fi + shift + + case "$VERB" in + view) cmd_view "$@" ;; + issue) cmd_issue "$@" ;; + pr) cmd_pr "$@" ;; + comment) cmd_comment "$@" ;; + assign) cmd_assign "$@" ;; + close) cmd_close "$@" ;; + labels) cmd_labels "$@" ;; + pr-reviews) cmd_pr_reviews "$@" ;; + branches) cmd_branches "$@" ;; + tree-sha) cmd_tree_sha "$@" ;; + *) + echo "hive-forge: unknown verb '$VERB'" >&2 + echo "verbs: view, issue, pr, comment, assign, close, labels, pr-reviews, branches, tree-sha" >&2 + exit 1 + ;; + esac + ''; } diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index 402e26d..fa339f2 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -248,7 +248,10 @@ # `nix flake` constantly (devshells, ad-hoc evals, fetching their # own MCP-server flakes); without this they hit the "experimental # feature not enabled" wall on the first try. - nix.settings.experimental-features = [ "nix-command" "flakes" ]; + nix.settings.experimental-features = [ + "nix-command" + "flakes" + ]; # `claude-code` is unfree. Each per-agent container's nixosConfiguration # evaluates its own `nixpkgs` instance, so the operator's host-level @@ -273,9 +276,8 @@ jq # curl: HTTP client for forge REST API and other web requests. curl - # hive-forge-*: shell wrappers around the Forgejo REST API - # (hive-forge-view, hive-forge-pr, hive-forge-comment, etc.) - # that replace ad-hoc curl pipelines in agent turns. + # hive-forge : CLI wrapping common Forgejo REST API operations + # (view, pr, issue, comment, assign, close, labels, branches, etc.) (pkgs.callPackage ../packages/hive-forge-tools.nix { }) ]; @@ -298,57 +300,57 @@ pkgs.coreutils ]; script = '' - # No `set -e`: any subshell failure must not propagate. - # A failed unit aborts `nixos-container update` which blocks rebuilds. - FORGE_URL=${lib.escapeShellArg config.hyperhive.forge.url} - # Manager bind-mounts state at /state; sub-agents at - # /agents//state. Glob both — each container only sees - # its own mount, so there is exactly one hit (or zero when - # the forge hasn't been seeded yet). - TOKEN_FILE="" - for f in /state/forge-token /agents/*/state/forge-token; do - if [ -f "$f" ]; then - TOKEN_FILE="$f" - break - fi - done - if [ -z "$TOKEN_FILE" ]; then - echo "tea-login: no forge-token found; skipping" - exit 0 - fi - TOKEN=$(cat "$TOKEN_FILE") - # Resolve the agent username from the forge API. - USER=$(curl -sf --max-time 5 \ - -H "Authorization: token $TOKEN" \ - "$FORGE_URL/api/v1/user" \ - | python3 -c 'import sys,json; print(json.load(sys.stdin).get("login",""))' \ - 2>/dev/null || true) - if [ -z "$USER" ]; then - echo "tea-login: could not resolve username from forge API; skipping" - exit 0 - fi - # tea reads config from $HOME/.config/tea/config.yml. - # Write it directly so we control default:true and always - # refresh a rotated token — no 'tea login add' interactive dance. - CONFIG="$HOME/.config/tea/config.yml" - mkdir -p "$(dirname "$CONFIG")" || true - cat > "$CONFIG" << EOF -logins: - - name: forge - url: $FORGE_URL - token: $TOKEN - default: true - ssh_host: "" - ssh_key: "" - insecure: false - ssh_agent: false - user: $USER -preferences: - editor: false - flag_defaults: - remote: "" -EOF - echo "tea-login: configured for $FORGE_URL as $USER" + # No `set -e`: any subshell failure must not propagate. + # A failed unit aborts `nixos-container update` which blocks rebuilds. + FORGE_URL=${lib.escapeShellArg config.hyperhive.forge.url} + # Manager bind-mounts state at /state; sub-agents at + # /agents//state. Glob both — each container only sees + # its own mount, so there is exactly one hit (or zero when + # the forge hasn't been seeded yet). + TOKEN_FILE="" + for f in /state/forge-token /agents/*/state/forge-token; do + if [ -f "$f" ]; then + TOKEN_FILE="$f" + break + fi + done + if [ -z "$TOKEN_FILE" ]; then + echo "tea-login: no forge-token found; skipping" + exit 0 + fi + TOKEN=$(cat "$TOKEN_FILE") + # Resolve the agent username from the forge API. + USER=$(curl -sf --max-time 5 \ + -H "Authorization: token $TOKEN" \ + "$FORGE_URL/api/v1/user" \ + | python3 -c 'import sys,json; print(json.load(sys.stdin).get("login",""))' \ + 2>/dev/null || true) + if [ -z "$USER" ]; then + echo "tea-login: could not resolve username from forge API; skipping" + exit 0 + fi + # tea reads config from $HOME/.config/tea/config.yml. + # Write it directly so we control default:true and always + # refresh a rotated token — no 'tea login add' interactive dance. + CONFIG="$HOME/.config/tea/config.yml" + mkdir -p "$(dirname "$CONFIG")" || true + cat > "$CONFIG" << EOF + logins: + - name: forge + url: $FORGE_URL + token: $TOKEN + default: true + ssh_host: "" + ssh_key: "" + insecure: false + ssh_agent: false + user: $USER + preferences: + editor: false + flag_defaults: + remote: "" + EOF + echo "tea-login: configured for $FORGE_URL as $USER" ''; };