# agent-tools Small Gitea-automation CLIs, shipped together in one RPM (`agent-tools`). They act as an agent user (**`unkin-agent`** by default) by minting a scoped Gitea token from Vault, so automated PRs, comments and pushes are attributed to the agent — not to whoever happens to run the command. Set `AGENT_LOGIN` to act as a different agent identity. - **`agentpr`** — create and edit pull requests and issues, and post comments on either, as the agent user. - **`watchpr`** — poll one or more PRs and exit when one changes in a way worth acting on. - **`agentws`** — manage per-branch git worktrees for `unkin-agent`, cloning into Ben's source checkout and isolating agent work under the XDG cache. - **`agentvault`** — run deterministic Vault flows in one invocation, so agents never plumb secret material through a shell. ## How it gets a token On first use each tool performs a Vault AppRole login (`role_id` only, no `secret_id`), then reads `gitea/creds/$AGENT_LOGIN` — or `GITEA_CREDS_PATH` when set — to obtain a short-lived Gitea token, cached in-process for the run. With neither variable set that is `gitea/creds/unkin-agent`, as before. Everything is configured by environment variables, all with defaults: | Variable | Default | Purpose | |---|---|---| | `VAULT_ADDR` | `https://vault.service.consul:8200` | Vault/OpenBao address | | `AGENT_APPROLE_ROLE_ID` | built-in default | AppRole role_id (overridable) | | `GITEA_URL` | `https://git.unkin.net` | Gitea base URL | | `AGENT_LOGIN` | `unkin-agent` | agent identity: selects `gitea/creds/`, and the login whose comments `watchpr` ignores | | `GITEA_CREDS_PATH` | `gitea/creds/$AGENT_LOGIN` | Vault path minting the Gitea token (wins over `AGENT_LOGIN`) | | `AGENTWS_SRC_ROOT` | `~/src/prodenv` | source-of-truth checkout root (`agentws`) | | `AGENTWS_ROOT` | `~/.cache/agentws` | worktree root (`agentws`) | | `AGENTWS_OWNER` | `unkin` | Gitea org that owns the repos (`agentws`) | | `AUTHENTIK_URL` | `https://identity.k8s.syd1.au.unkin.net` | Authentik base URL (`agentvault`) | ## agentpr ```bash # Verify identity (prints the agent login, unkin-agent by default) agentpr whoami # Open a PR agentpr pr create --repo unkin/argocd-apps \ --base main --head benvin/my-change \ --title "Add woodpecker SA" --body "Adds the ServiceAccount ..." # prints: # # Comment on a PR agentpr pr comment --repo unkin/argocd-apps --pr 42 --body "Rebased, CI green." # Edit a PR's title and/or body; an omitted flag is left unchanged agentpr pr edit --repo unkin/argocd-apps --pr 42 --body "Adds the ServiceAccount ..." agentpr pr edit --repo unkin/argocd-apps --pr 42 --title "Add woodpecker SA" # prints: # # File an issue (--body optional) agentpr issue create --repo unkin/argocd-apps \ --title "Woodpecker SA missing" --body "The pipeline fails with ..." # prints: # # Comment on an issue (the same Gitea endpoint `pr comment` posts to) agentpr issue comment --repo unkin/argocd-apps --issue 43 --body "Fixed in #44." # Edit an issue's title and/or body; an omitted flag is left unchanged agentpr issue edit --repo unkin/argocd-apps --issue 43 --body "The pipeline fails with ..." # prints: # agentpr --version agentpr --help ``` Non-zero exit on any API error. ## watchpr Poll PRs and exit (reporting what changed) when a tracked PR **merges/closes**, gets a **new comment from someone other than the agent**, its **CI fails** (failure/error), or it **loses mergeability** (a conflict appears). Benign transitions — CI `pending`→`success`, the agent's own comments — are ignored. ```bash # Watch until something meaningful happens (default interval 60s) watchpr unkin/argocd-apps#42 # Multiple PRs, custom interval; refs accept #N or :N watchpr --interval 30s unkin/argocd-apps#42 unkin/terraform-vault:98 # --interval takes a duration (30s, 2m, 1h30m) or a bare number of seconds watchpr --interval 30 unkin/argocd-apps#42 # One-shot: print current state and exit 0 (great for scripts) watchpr --once unkin/argocd-apps#42 watchpr --once --json unkin/argocd-apps#42 ``` On a meaningful change `watchpr` prints the reason and the PR's current state, then exits 0. Use `--json` for machine-readable output. ### Exit behaviour A watcher that sees nothing must not look healthy, so every terminal failure exits non-zero naming its cause: | Cause | Message | |---|---| | the token was rejected and a fresh one was too | `gitea rejected the token and re-minting did not recover it` | | authenticated but not allowed (Gitea 403, no token named) | `gitea denied access to ` | | polling anonymously and the PR is not public | `gitea requires authentication and no token could be minted` | | the PR lookup 404s (repo deleted, renamed, made private) | `PR no longer visible` | Gitea tokens expire in ~1h, far shorter than a watch, so a rejected token is re-minted once and the request replayed; only a failure that survives that re-mint ends the watch. Anonymous polling of a public repo is unaffected — with no token there is nothing to reject and Vault is never reached for one. Transient failures (5xx, network errors, rate limiting) are warned about and retried, and give up after 20 consecutive failures of the same PR. ## agentws `agentws` gives an agent an isolated git worktree per branch without disturbing Ben's shared checkouts. Repos are cloned into the **source root** (`~/src/prodenv/`) so branches created here are visible in the main checkout too; the worktrees themselves live under the **worktree root** (`~/.cache/agentws/__`). ```bash # Clone unkin/argocd-apps into ~/src/prodenv if missing, then add a worktree for # the branch. Prints the worktree path. agentws new argocd-apps --branch benvin/my-change # Branch off a specific base instead of the remote default (new branches only) agentws new argocd-apps --branch benvin/hotfix --from release-1.2 # List managed worktrees (repo, branch, path) agentws list # Remove a worktree (by path or branch); refreshes the source repo afterwards agentws rm benvin/my-change agentws rm ~/.cache/agentws/argocd-apps__benvin-my-change --delete-branch # Classify every worktree found; dry run unless --yes is given agentws prune agentws prune --json agentws prune --no-fetch agentws prune --yes agentws prune --yes --keep-branches # Remove every managed worktree and prune each source repo agentws clean # Print a fresh Gitea token for the agent login agentws token ``` `agentws new` fetches first, then takes one of two paths and names the one it took on its last output line. A branch that **already exists on origin** is checked out at `origin/` and set to track it, so the worktree starts on the branch's own commits (`branch (tracking origin/ at )`); a local branch left from an earlier run is fast-forwarded onto it. A branch origin does **not** have is created from `--from`, or from the remote's default branch when `--from` is absent (`branch (new, from origin/)`) — the default is read from `origin/HEAD`, so a repo on `master` forks from `master`. `--from` is ignored, with a note, when the branch is already on origin. The one case the worktree does not land on `origin/` is a local branch carrying commits origin has never seen. Those commits exist nowhere else, so the checkout is left on them and the output says how many. ### prune `agentws prune` finds worktrees two ways and merges the results: the managed directories under the worktree root, and `git worktree list` on every source checkout — so hand-made worktrees, stale registrations whose directory is gone, and leftover directories whose source repo was deleted all show up too. It then decides, per worktree, whether its work is safely upstream: | Signal (first match wins) | Verdict | |---|---| | working tree gone (registration only) | prune the registration | | backing repo gone | delete the leftover directory | | locked by `git worktree lock` | keep | | rebase, merge, cherry-pick, revert or bisect in progress | keep | | uncommitted, staged or untracked changes | keep | | branch has an open PR | keep | | tip contained in `origin/` | remove worktree + local branch | | every commit patch-equivalent to one in `origin/`'s history | remove worktree + local branch | | PR merged **and** HEAD contained in the PR's head commit (or in a verified `origin/`) | remove worktree + local branch | | PR closed **and** HEAD contained in a verified `origin/` | remove worktree + local branch | | detached HEAD carrying commits on no remote | keep | | anything else | remove worktree, keep the branch | A branch is deleted only where git proves its commits survive elsewhere. PR state alone never authorises that: a merged or closed PR whose branch picked up commits since keeps its branch, because those commits exist nowhere but here. The delete runs `git branch -d` first so git's own unmerged check is a backstop, falling back to `-D` only for a proven branch — squash merges keep the guard tripping even once the work has landed. Patch equivalence comes from `git cherry`, which these squash-merging repos need because a merged branch's commits carry different SHAs upstream. It proves the patches reached the default branch's history at some point — a later revert still counts — not that they stand at its tip. `origin/` counts as evidence only when this run's `git fetch --prune` succeeded. A tracking ref left over from an earlier fetch may name a branch that is already gone upstream and is itself due for deletion, so a failed fetch downgrades those verdicts to `remove` and keeps the branch. Proofs that read only local objects — containment in `origin/`, patch equivalence, and containment in a merged PR's head SHA — stand on their own. Gitea PR state only adds to the git answer: when it cannot be reached, prune says so and never deletes a branch it could not prove, and a PR listing that hits the pagination cap is reported rather than read as "no PR". Matching a branch to its PR uses `head.label`, since Gitea rewrites `head.ref` to `refs/pull//head` once the branch is deleted on merge. The last row is safe only because the local branch keeps the commits, so the reason names the branch it is relying on. A detached HEAD has no such branch, so unique commits there are kept instead. Output is a table (`REPO BRANCH PATH VERDICT REASON`) with every verdict's reason spelled out, or `--json` for scripting. Neither form needs a terminal. | Flag | Effect | |---|---| | `--yes` | apply the plan; without it nothing is touched | | `--keep-branches` | remove worktrees only; verdicts print as `remove` | | `--no-fetch` | judge against the refs already on disk, for offline use | | `--json` | emit the report as JSON on stdout, notes on stderr | | `--include-unmanaged` | also remove worktrees outside the worktree root | | `--include-keep` | dangerous: also remove worktrees classified `keep`, destroying uncommitted and in-progress work | Without `--include-unmanaged` a hand-made worktree is reported and then skipped, naming the flag that would remove it. `--include-keep` is the only way past a `keep`. It leaves the branch, so committed work outlives the worktree, but `git worktree remove --force` discards a dirty working tree and a paused rebase's sequencer state without a word, and no branch was carrying those. A directory under the worktree root is deleted outright only when its git dir and the repo's shared `.git` are both proven absent by `stat`. When git merely fails to answer for a checkout, the verdict is `keep` with the error as its reason: an unread state is never a dead one. ### Auth / credential-helper design Gitea tokens minted from Vault are short-lived (~1h), so `agentws` never persists one in a remote URL or in git config. Instead it wires itself as an **ephemeral git credential helper**: - `agentws token` prints a fresh token to stdout (handy for scripts). - `agentws credential get` speaks the git credential protocol on stdin and, for the configured Gitea host only, emits `username=$AGENT_LOGIN` + `password=`. `agentws new` sets this up per worktree without touching the shared checkout: it enables `extensions.worktreeConfig` on the repo once, then writes `user.name` / `user.email` and `credential.helper = ! credential` to the **per-worktree** config. Clone/fetch use the same helper via a transient `-c credential.helper=...`; the shared `origin` URL is left clean. On worktree removal `agentws` fetches in `~/src/prodenv/` so its default branch stays current. ## agentvault Deterministic Vault flows, each a single self-contained invocation: the tool reads and writes the secrets itself, and prints only identifiers. ### seed-outpost Copy an Authentik outpost's token into Vault KV-v2. `agentvault` reads the Authentik API token from `kv/service/authentik/agent-api-token`, resolves the named outpost's `token_identifier`, fetches its key via `/api/v3/core/tokens//view_key/` and writes it to the destination KV path. The token value is never printed or logged. ```bash agentvault seed-outpost \ --outpost k8s-outpost \ --dest-path kubernetes/namespace/authentik/default/outpost-token ``` ``` outpost: k8s-outpost token_identifier: ak-outpost-k8s-outpost dest: kv/kubernetes/namespace/authentik/default/outpost-token version: 3 ``` Re-running is safe: it writes a new KV version. Flags: `--outpost` and `--dest-path` are required; `--dest-key` (default `token`), `--kv-mount` (default `kv`), `--token-path` (default `service/authentik/agent-api-token`) and `--authentik-url` override the rest. Errors name the failing stage: AppRole login, KV read denied (policy not applied), outpost not found (terraform not applied), `view_key` failure, or KV write denied. ### seed-oauth Make a Vault KV-v2 path hold a complete oauth2-proxy credential set. It is a read-modify-write: `client_id` is set from the flag, `client_secret` and `cookie_secret` are generated (32 bytes from `crypto/rand`) only when missing, every other key on the path is written back untouched, and nothing is written at all when the secret is already correct. `cookie_secret` is base64url so it decodes to exactly the 32 bytes oauth2-proxy requires. ```bash agentvault seed-oauth \ --path kubernetes/namespace/repospawner/default/oauth-credentials \ --client-id 4f1c… ``` ``` path: kv/kubernetes/namespace/repospawner/default/oauth-credentials keys: client_id, client_secret, cookie_secret client_id: created client_secret: kept cookie_secret: created version: 4 ``` That is the common case: the provider's `client_secret` already lives on the path, so only the missing keys are added. A run with nothing to do prints `version: unchanged` and issues no write. Flags: `--path` and `--client-id` are required; `--kv-mount` (default `kv`) and `--rotate` override the rest. `--rotate` regenerates both secrets — only use it when the IdP provider's secret is being rotated alongside, since a rotated `client_secret` no longer matches the provider. Errors name the failing stage: AppRole login, KV read denied, or KV write denied. Only key names, actions and the KV version are printed. ## Build & package ```bash make build # -> dist/agentpr, dist/watchpr, dist/agentws, dist/agentvault make test # go test -race ./... make rpm # build + package dist/agent-tools--1.x86_64.rpm ``` Release is tag-driven (`v*`) via Woodpecker: builds the RPM, `PUT`s it to the artifactapi `rpm-internal` yum repo, and cuts a Gitea release with cross-compiled binaries attached. ### Version bump ```bash make patch # or: make minor / make major — tags vX.Y.Z and pushes the tag ```