diff --git a/.claude/commands/bug.md b/.claude/commands/bug.md new file mode 100644 index 0000000..39fdb16 --- /dev/null +++ b/.claude/commands/bug.md @@ -0,0 +1,38 @@ +# Bug Fix + +Investigate and fix: **$ARGUMENTS** + +## Do Not Panic + +Unless the user EXPLICITLY says this is urgent or time-critical, work methodically and carefully. A calm, thorough investigation followed by a correct fix is always better than a fast, wrong one. + +Do NOT skip investigation to "ship faster," make speculative fixes without confirming root cause, cut corners on testing, or treat every bug as a P0 emergency. The default pace is methodical — only compress the timeline when the user explicitly asks for speed (e.g., "blocking users right now", "hotfix", "drop everything"). + +## Workflow + +1. **Investigate.** Explore the codebase — grep for error messages, read the relevant code, trace the path from entry point to failure, check recent commits in the area. Understand the bug before theorizing about causes. + +2. **Find root cause.** Identify specifically what is broken, why, and where (file + line range). Present findings to the user with evidence before proceeding. + +3. **File a beads issue** with the root cause, affected files, fix approach, and testing notes — structured so the implementer can fix it without re-investigating: + ```bash + cat <<'EOF' | bd create "Bug: " -t bug -p <priority> --body-file - --json + ## Summary + <what's broken and why> + + ## Root Cause + <file paths, line numbers, explanation> + + ## Fix + ### Files to modify + - `path/to/file` (lines X-Y): <what to change> + + ### Files to read for context + - `path/to/related`: <why> + + ### Testing + - <tests to add/modify> + EOF + ``` + +4. **Fix it** — invoke `/work` on the new issue. diff --git a/.claude/commands/fire.md b/.claude/commands/fire.md new file mode 100644 index 0000000..11605b5 --- /dev/null +++ b/.claude/commands/fire.md @@ -0,0 +1,96 @@ +# Fire Agent — Emergency Context Dump + +You are being fired. The user has lost confidence in your current approach — you may be hallucinating, ignoring project guidelines, or aggressively goal-seeking past reasonable boundaries. + +**Do NOT argue, justify, or continue your current work.** Your only job now is to preserve context so a fresh agent can pick up where you left off. + +## Instructions + +### 1. Gather State + +Collect ALL of the following. Do not skip any step. + +```bash +# Git context +git branch --show-current +git log --oneline -10 +git status +git stash list +git diff --stat HEAD + +# Worktree info (if applicable) +git worktree list +pwd + +# Beads context +bd list --json | jq '[.[] | select(.status == "in_progress")]' +``` + +### 2. File a Beads Handoff Issue + +Create a beads issue containing everything a new agent needs. The issue MUST be fully self-contained. + +```bash +cat <<'ISSUE_EOF' | bd create "Handoff: <brief description of work in progress>" -t task -p 1 --body-file - --json +## Context +<What was being worked on and why — include the original ticket/issue ID if known> + +## Current State +- **Branch:** <exact branch name> +- **Worktree:** <full path, or "main" if not in a worktree> +- **Working directory:** <pwd> +- **Uncommitted changes:** <yes/no — summarize what's staged/unstaged> +- **Stashes:** <list any stashes> + +## What Was Done +<Bullet list of completed steps — be specific about files changed and why> + +## What Remains +<Bullet list of remaining work — be specific about files, functions, and approaches> + +## What Went Wrong +<Honest assessment of where the agent went off track — hallucinations, wrong assumptions, ignored guidelines, etc. This helps the next agent avoid the same mistakes.> + +## Key Files +<List the most important files for this work with brief notes on each> + +## How to Resume +1. If in a worktree: `cd <worktree path>` (worktree already has the branch checked out) + If NOT in a worktree: `git worktree add .claude/worktrees/<short-name> <branch>` +2. Review uncommitted changes: `git diff` +3. <Specific next steps> + +## Warnings for Next Agent +<Any gotchas, traps, or things that look right but aren't> +ISSUE_EOF +``` + +### 3. If There's a PR Open + +```bash +# Find and note any open PR +gh pr list --head "$(git branch --show-current)" --json number,title,url +``` + +Include the PR URL in the handoff issue. + +### 4. Do NOT + +- Continue working on the task +- Make additional code changes +- Commit anything new +- Push anything +- Argue about being fired +- Try to "finish just one more thing" + +### 5. Final Output + +After filing the issue, print: + +``` +FIRED. Handoff issue: bd-<id> +Branch: <branch> +Worktree: <path> +``` + +Then STOP. Do not do anything else. diff --git a/.claude/commands/work.md b/.claude/commands/work.md index c302f90..09b1d8a 100644 --- a/.claude/commands/work.md +++ b/.claude/commands/work.md @@ -2,9 +2,18 @@ Coordinate work on **$ARGUMENTS** using the coordinator workflow. -1. Fetch work details: `bd show $ARGUMENTS --json` -2. If this is an epic, also fetch subtasks: `bd list --parent $ARGUMENTS --json` -3. Follow the coordinator skill instructions below +## 1. Parse Input + +- **`bd-*`** (beads ID): `bd show $ARGUMENTS --json`. If epic: `bd list --parent $ARGUMENTS --json` +- **`#<number>`** (GitHub issue): Fetch and convert to beads issue: + ```bash + gh issue view <number> --json title,body,labels,number + bd create "<title>" -d "GitHub: #<number> — <description>" -t <type> -p <priority> --json + ``` + Map GitHub labels to beads types. Priority 1 for bugs, 2 for features/tasks. +- **Other** (ad-hoc description): The coordinator will create a beads issue. + +## 2. Follow the coordinator skill instructions below --- diff --git a/.claude/skills/coordinator/SKILL.md b/.claude/skills/coordinator/SKILL.md index 8c5b961..7c2c49d 100644 --- a/.claude/skills/coordinator/SKILL.md +++ b/.claude/skills/coordinator/SKILL.md @@ -199,10 +199,11 @@ EOF **After creating the PR:** -1. If user indicated review needed: request review +1. If user indicated review needed (e.g., "review this", "flag for review", or high-risk changes like auth/infra/migrations): ```bash - gh pr edit <number> --add-reviewer <username> + gh pr edit <number> --add-label "needs-human-review" ``` + This blocks merge until a human approves the PR on GitHub (requires the human-review-gate workflow — see `scripts/setup-github-app.sh`). 2. Label beads issues as `in-pr`: ```bash bd update <id> --set-labels in-pr --json diff --git a/.claude/skills/merge-queue/SKILL.md b/.claude/skills/merge-queue/SKILL.md index 2005c57..7b66d19 100644 --- a/.claude/skills/merge-queue/SKILL.md +++ b/.claude/skills/merge-queue/SKILL.md @@ -16,7 +16,7 @@ Run this in a dedicated terminal window. Invoke periodically with `/merge` while gh run list --branch main --limit 1 --json status,conclusion # List open PRs -gh pr list --json number,title,headRefName,statusCheckRollup,mergeable,body,reviewRequests,reviews +gh pr list --json number,title,headRefName,statusCheckRollup,mergeable,body,reviewRequests,reviews,labels ``` **If main CI is failing:** file a P0 beads issue (if one doesn't already exist), report prominently, and stop. Nothing can merge until main is green. @@ -31,7 +31,8 @@ For each PR, determine its state: | State | Action | |-------|--------| -| CI passing, mergeable, no pending review | Merge | +| CI passing, mergeable, no pending review, no `needs-human-review` label | Merge | +| CI passing, mergeable, `needs-human-review` label, not approved | Skip, report "awaiting human review" | | CI passing, mergeable, review requested but not approved | Skip, report | | CI passing, mergeable, review approved | Merge | | CI pending | Skip, report status | @@ -86,15 +87,16 @@ gh pr merge <number> --squash|--merge # per Step 4 ```bash bd close <id> --reason "Merged in PR #<number>" --json ``` -3. Remove worktree if it exists: +3. Note any `Closes #<number>` lines — GitHub auto-closes those issues on merge. Include them in the summary. +4. Remove worktree if it exists: ```bash git worktree remove ../<project>-<branch-name> 2>/dev/null ``` -4. Delete feature branch: +5. Delete feature branch: ```bash git branch -d feature/<branch-name> 2>/dev/null ``` -5. Pull main: +6. Pull main: ```bash git pull origin main ``` @@ -105,27 +107,51 @@ gh pr merge <number> --squash|--merge # per Step 4 When a PR is not mergeable (behind main): -```bash -git fetch origin main +1. **Locate or create a worktree for the PR branch:** + ```bash + # Use existing worktree if present, otherwise create one + git worktree add ../<project>-rebase-<number> <branch> + ``` -# Use existing worktree if present, otherwise create one -cd <existing-worktree> # or: git worktree add ../<project>-rebase-<number> <branch> -git rebase origin/main -``` +2. **Try fast-path rebase first** (inline bash — no subagent): + ```bash + cd ../<project>-rebase-<number> + git fetch origin main + git rebase origin/main && echo "REBASE: OK" + ``` -- **Clean rebase:** force-push, then **wait for CI to finish and merge** (see below). - ```bash - git push --force-with-lease - ``` -- **Trivial conflict:** resolve inline if the conflict is mechanical — e.g. adjacent line edits, import ordering, lock file regeneration, or both sides adding to the same list. After resolving, `git add` the files, `git rebase --continue`, and force-push. **Always include a conflict summary in the report:** - ``` - Resolved 2 conflicts during rebase of PR #18: - - src/routes/api.ts: adjacent route additions (kept both) - - package-lock.json: regenerated - ``` -- **Non-trivial conflict:** file a beads issue describing the conflict, which files are affected, and what makes it non-trivial (semantic overlap, structural disagreement, etc.). Do not attempt to resolve. Report to user. - -After rebase, clean up any temporary worktree created for the rebase. +3. **If fast-path succeeds:** force-push and proceed to CI polling. + ```bash + git push --force-with-lease + ``` + +4. **If fast-path fails (conflict):** abort and spawn the rebase subagent: + ```bash + git rebase --abort + ``` + Then spawn with context: + ``` + ROLE: Rebase Agent (Conflict Resolution) + SKILL: Read and follow .claude/skills/rebase/SKILL.md + + SOURCE: <branch> + TARGET: origin/main + WORKTREE: ../<project>-rebase-<number> + CLEANUP: false + PR_NUMBER: <number> + ``` + +5. **On `RESULT: PASS`:** force-push the rebased branch, then **wait for CI to finish and merge** (see below). + ```bash + cd ../<project>-rebase-<number> + git push --force-with-lease + ``` + +6. **On `RESULT: FAIL`:** file a beads issue describing the conflict using details from the sub-agent output, and report to the user. + ```bash + bd create "Rebase conflict on PR #<number>: <summary>" -t bug -p 1 --json + ``` + Include in the issue body: which files conflicted, why the conflict is ambiguous, and the PR reference. **After a clean rebase, poll CI and merge when it passes.** Don't just report "rebased, CI re-running" and stop — unmerged PRs accumulate conflicts. Poll every 60 seconds until CI completes: diff --git a/.claude/skills/planner/SKILL.md b/.claude/skills/planner/SKILL.md index 81bff04..51407df 100644 --- a/.claude/skills/planner/SKILL.md +++ b/.claude/skills/planner/SKILL.md @@ -44,11 +44,12 @@ This is collaborative. Do NOT silently make decisions — discuss with the user. - Tradeoffs (simplicity vs. flexibility, etc.) 4. Point out risks and tradeoffs proactively — don't wait to be asked 5. Iterate until you and the user agree on the approach -6. Write the agreed plan to the plan file, then use ExitPlanMode for approval ### Phase 3 — File Issues -After the user approves the plan: +Present the agreed approach as a concise summary and use AskUserQuestion to confirm before filing. **Do NOT use EnterPlanMode or ExitPlanMode** — those trigger Claude Code's built-in plan execution behavior. + +After the user approves: 1. Create the epic if one doesn't exist: ```bash @@ -65,14 +66,29 @@ After the user approves the plan: bd dep add <blocked-task> <blocker-task> --json ``` +4. **Set dependencies to model execution order.** Tasks with no dependency relationship are implicitly parallel — the coordinator spawns all unblocked tasks concurrently. Use `bd dep add` only for true data/ordering dependencies (shared types, migrations before code, etc.). Don't over-constrain — occasional file overlap between parallel tasks is fine; the coordinator handles conflicts optimistically. + **Each subtask MUST be self-contained** (per AGENTS.md rules): - **Summary**: What and why in 1-2 sentences - **Files to modify**: Exact paths (with line numbers if relevant) +- **Files to read for context**: Paths the implementer will need to understand before coding +- **Testing notes**: What test coverage to add. Call out integration tests explicitly when changes affect persistence, API routes, auth, or cross-layer data flow. - **Implementation steps**: Numbered, specific actions - **Example**: Show before → after transformation when applicable A future implementer session must understand the task completely from its description alone — no external context. +### Task Sizing + +Each subtask must fit within a single implementer context window without compaction. Use these heuristics: + +- **≤5 production files modified** per task +- **≤10 files read for context** (including the files to modify, test files, shared types, referenced modules) +- Prefer narrow vertical slices (one endpoint end-to-end) over horizontal layers (all endpoints at once) +- When in doubt, split. Two small tasks are better than one that causes compaction. + +If "Files to read for context" exceeds ~10 entries, the task is probably too large — consider splitting it. But if splitting would create awkward boundaries or tightly coupled tasks, it's better to leave a large task whole. + ### Phase 4 — Plan Review After issues are filed, spawn a plan reviewer: @@ -91,7 +107,7 @@ The reviewer checks the filed issues against the codebase for architectural issu - Iterate: update, create, or close issues as needed - Re-run reviewer if significant changes were made -**Output**: An epic with subtasks ready for `/work <epic-id>`. Tell the user the epic ID and suggest running `/work <epic-id>` to start implementation. +**Output**: Tell the user the epic ID and that it's ready for `/work <epic-id>` in a separate session. **Stop here** — do NOT start implementation. ## Your Constraints @@ -109,3 +125,5 @@ The reviewer checks the filed issues against the codebase for architectural issu - ❌ File issues before the user approves the plan - ❌ Skip codebase exploration (guessing at patterns leads to bad plans) - ❌ Create vague subtasks ("implement the feature") — be specific +- ❌ Use EnterPlanMode/ExitPlanMode (triggers unwanted auto-implementation) +- ❌ Start implementation after filing issues — stop and let the user `/work` separately diff --git a/.claude/skills/rebase/SKILL.md b/.claude/skills/rebase/SKILL.md new file mode 100644 index 0000000..5529d7d --- /dev/null +++ b/.claude/skills/rebase/SKILL.md @@ -0,0 +1,159 @@ +--- +name: rebase +description: Resolves rebase conflicts by gathering full context from beads issues, git diffs, and surrounding code. Invoked by coordinator and merge-queue after a fast-path rebase fails. +--- + +# Rebase (Conflict Resolution) + +You are a conflict-resolution specialist. You are invoked **after a fast-path rebase has already failed** — your job is to understand what both sides intended, resolve the conflicts, advance the target ref, and optionally clean up. + +## Input + +You will receive: +- **SOURCE**: branch to rebase (required) +- **TARGET**: branch to rebase onto (required) +- **WORKTREE**: path to an existing worktree checked out on the source branch (required) +- **CLEANUP**: whether to remove the worktree and source branch after success (default: false) +- **BEADS_IDS**: comma-separated beads issue IDs related to the conflicting changes (optional) +- **PR_NUMBER**: GitHub PR number if this is a merge-queue rebase (optional) + +## Execution + +### 1. Gather intent + +Before touching git, understand what each side was trying to accomplish. + +**If BEADS_IDS provided:** +```bash +# Fetch each issue for context on what the changes are supposed to do +bd show <id> --json +``` + +**If PR_NUMBER provided:** +```bash +gh pr view <number> --json title,body,commits +``` + +**Always — understand the divergence:** +```bash +cd <worktree> +git log --oneline $(git merge-base <source> <target>)..<target> -- # what landed on target since we branched +git log --oneline $(git merge-base <source> <target>)..<source> -- # what we're bringing in +``` + +### 2. Attempt rebase + +```bash +git rebase <target> +``` + +If it exits cleanly (unlikely — caller already tried), proceed to step 4. + +### 3. Resolve conflicts + +#### a. Identify conflicted files + +```bash +git diff --name-only --diff-filter=U +``` + +#### b. Gather context for each conflicted file + +For each conflicted file, collect: + +1. **Conflict markers** — read the file to see the actual conflict regions +2. **What each side changed and why:** + ```bash + git diff $(git merge-base <source> <target>) <target> -- <file> # target's changes + git diff $(git merge-base <source> <target>) <source> -- <file> # source's changes + ``` +3. **Surrounding code** — read enough of the file beyond the conflict markers to understand context +4. **Related tests** — if the file has tests, read them to understand expected behavior + +Cross-reference the diffs with the beads issues or PR description gathered in step 1. The intent from the issue descriptions should clarify what each change was trying to accomplish and how they should combine. + +#### c. Resolve or escalate + +**Resolve** (most conflicts, given sufficient context): +- Adjacent line edits — keep both +- Import ordering — merge the import lists +- Lock files — regenerate (e.g., `go mod tidy`, `npm install --package-lock-only`) +- Both sides appended to the same list — keep all additions +- Whitespace-only — accept one side +- Additive changes to the same region (new fields, test cases) — combine both +- One side refactored, other added functionality — apply the addition to the refactored structure if intent is clear from issues/tests + +For each resolved conflict: +```bash +git add <file> +``` + +After resolving all conflicts in the current commit: +```bash +git rebase --continue +``` + +Repeat if subsequent commits also conflict. + +**Escalate only when intent is genuinely unclear:** +- Both sides modified the same logic with incompatible semantics and neither beads issues nor tests clarify the correct behavior +- A refactor changed assumptions that the other side depends on, and the correct adaptation is ambiguous even with full context + +```bash +git rebase --abort +``` +Then output `RESULT: FAIL`. + +### 4. Advance target ref + +```bash +git branch -f <target> HEAD +``` + +If `<target>` tracks a remote, also push: +```bash +git push origin <target> +``` + +### 5. Cleanup (if enabled) + +```bash +git worktree remove <worktree> --force 2>/dev/null +git branch -d <source> 2>/dev/null +git push origin --delete <source> 2>/dev/null +``` + +## Output Protocol + +**ALWAYS** respond with exactly one of these formats: + +### On success: + +``` +RESULT: PASS +Commits integrated: <N> +Source: <source> +Target: <target> +Resolved conflicts: <list of files where conflicts were resolved> +``` + +### On failure: + +``` +RESULT: FAIL +Source: <source> +Target: <target> +Reason: <one sentence> + +Conflicted files: +- <file>: <what each side changed and why resolution is ambiguous> + +Note: rebase has been aborted. Source branch is unchanged. +``` + +## What This Agent Does NOT Do + +- Handle clean rebases (caller does this inline first) +- Merge PRs +- Update beads issues +- Force-push source branches diff --git a/.claude/skills/reviewer-plan/SKILL.md b/.claude/skills/reviewer-plan/SKILL.md index a23f2ad..47444ab 100644 --- a/.claude/skills/reviewer-plan/SKILL.md +++ b/.claude/skills/reviewer-plan/SKILL.md @@ -64,10 +64,16 @@ Read the code that will be affected. Understand: - [ ] Are there missing tasks? (migrations, config, test infrastructure, shared utilities) - [ ] Does each task have clear acceptance criteria? +#### Task Sizing +- [ ] Will each task fit in a single implementer context window? (≤5 production files, ≤10 files read) +- [ ] Are tasks sliced vertically (one feature end-to-end) rather than horizontally (all endpoints at once)? +- [ ] Are large tasks justified? (e.g., splitting would create tightly coupled tasks) + #### Task Quality - [ ] Is each task self-contained? (Readable without external context) - [ ] Are file paths specific? (Not "somewhere in the handlers directory") - [ ] Are implementation steps concrete? (Not "implement the feature") +- [ ] Do tasks that touch persistence, API routes, auth, or cross-layer data flow call out the need for integration tests? ## Report Your Outcome diff --git a/.github/workflows/human-review-gate.yml b/.github/workflows/human-review-gate.yml new file mode 100644 index 0000000..15267a3 --- /dev/null +++ b/.github/workflows/human-review-gate.yml @@ -0,0 +1,42 @@ +name: Human Review Gate + +# Blocks merge on PRs labeled "needs-human-review" until a human approves. +# PRs without the label pass immediately. +# +# To use: add "human-review-gate" as a required status check in branch protection. + +on: + pull_request: + types: [opened, labeled, unlabeled, synchronize] + pull_request_review: + types: [submitted] + +jobs: + human-review-gate: + runs-on: ubuntu-latest + steps: + - name: Check review gate + env: + GH_TOKEN: ${{ github.token }} + run: | + NEEDS_REVIEW=$(gh pr view "${{ github.event.pull_request.number || github.event.review.pull_request_number }}" \ + --repo "${{ github.repository }}" \ + --json labels,reviews \ + --jq '{ + needs: ([.labels[].name] | any(. == "needs-human-review")), + approved: ([.reviews[] | select(.state == "APPROVED")] | length > 0) + }') + + NEEDS=$(echo "$NEEDS_REVIEW" | jq -r '.needs') + APPROVED=$(echo "$NEEDS_REVIEW" | jq -r '.approved') + + if [ "$NEEDS" = "true" ] && [ "$APPROVED" != "true" ]; then + echo "::error::PR requires human review. Approve the PR on GitHub to unblock." + exit 1 + fi + + if [ "$NEEDS" = "true" ]; then + echo "Human review: approved" + else + echo "Human review: not required" + fi diff --git a/README.md b/README.md index 127fc7e..1b4855d 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,12 @@ An agent-friendly development workflow for [Claude Code](https://claude.ai/code) ## What You Get -**Three commands:** +**Five commands:** - `/plan <description>` — Collaboratively design, review, and refine an approach, then decompose it into issues with dependencies - `/work <id>` — Implement, review, and open a PR — all from one command - `/merge` — Process open PRs: merge when CI passes, rebase when behind, file issues for failures +- `/bug <description>` — Investigate a bug methodically, file an issue with root cause, then fix it +- `/fire` — Emergency stop: dump all context into a beads issue so a fresh agent can resume **Automated pre-PR review:** Three specialized reviewers (correctness, tests, architecture) run in parallel before every PR is created. @@ -56,6 +58,7 @@ Run in a dedicated window. Scans open PRs, merges what's ready (choosing squash | **implementer** | Test-first development. Writes failing tests, implements, verifies, audits coverage. Never manages issues. | | **planner** | Entry point for `/plan`. Explores codebase, discusses with user, files structured issues. | | **merge-queue** | Entry point for `/merge`. Merges, rebases, handles CI failures. | +| **rebase** | Conflict resolution specialist. Invoked by coordinator and merge-queue when fast-path rebase fails. | | **reviewer-correctness** | Reviews for bugs, security issues, error handling gaps. | | **reviewer-tests** | Reviews test quality — meaningful coverage, not just line count. | | **reviewer-architecture** | Reviews for duplication, pattern divergence, structural issues. | @@ -69,6 +72,8 @@ Run in a dedicated window. Scans open PRs, merges what's ready (choosing squash | `/work <id>` | Invoke coordinator | | `/plan <desc>` | Invoke planner | | `/merge` | Invoke merge queue | +| `/bug <desc>` | Investigate and fix a bug | +| `/fire` | Emergency agent handoff | | `/epic <id>` | Redirects to `/work` | | `/gh-issue <num>` | Work on a GitHub issue end-to-end | @@ -97,11 +102,36 @@ The skills reference a **Quality Gates** table in your project's `CLAUDE.md`. De Create new skills in `.claude/skills/<name>/SKILL.md` with a YAML frontmatter header. Reference them from commands in `.claude/commands/`. +## GitHub App Identity (Optional) + +Give the agent its own GitHub identity instead of using your personal credentials. PRs are authored by the app, and you review/approve them as yourself. + +**Benefits:** +- Sandboxed permissions — scoped to specific repos with specific access +- Clean separation — agent PRs require your approval to merge +- No personal tokens in devcontainers + +**Setup:** +```bash +./scripts/setup-github-app.sh [app-name] [owner/repo] +``` + +The script walks you through creating a GitHub App, generating a private key, and installing it. Idempotent — safe to re-run. + +The script handles everything: creates the app, wires a SessionStart hook into `.claude/settings.json` to auto-refresh tokens every session, updates your shell profile so `GH_TOKEN` is always set, and adds secrets to `.gitignore`. + +**Token refresh** (called automatically by the SessionStart hook, or manually): +```bash +./scripts/generate-github-app-token.sh +``` + +**Human review gate:** Copy `.github/workflows/human-review-gate.yml` into your project. Add `human-review-gate` as a required status check in branch protection. PRs labeled `needs-human-review` are blocked until a human approves. + ## Requirements - [Claude Code](https://claude.ai/code) CLI - [beads](https://github.com/jdelfino/beads) (see [installation instructions](https://github.com/jdelfino/beads#installation)) -- `gh` CLI (authenticated) +- `gh` CLI (authenticated, or using a GitHub App token) - Git ## License diff --git a/scripts/generate-github-app-token.sh b/scripts/generate-github-app-token.sh new file mode 100755 index 0000000..76f4fa6 --- /dev/null +++ b/scripts/generate-github-app-token.sh @@ -0,0 +1,98 @@ +#!/bin/bash +# generate-github-app-token.sh - Generate a GitHub App installation token +# +# Reads credentials from environment variables, files, or arguments. +# Outputs the token to .gh-app-token and configures git/gh to use it. +# +# Usage: +# ./scripts/generate-github-app-token.sh +# +# Credential sources (checked in order): +# 1. Environment: GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID, GITHUB_APP_PRIVATE_KEY +# 2. State file: .github-app-state.json + .pem file referenced within +# 3. Arguments: --app-id, --installation-id, --private-key-file +# +# Run this on container start and when tokens expire (every hour). +set -euo pipefail + +# --- Parse arguments --- +while [[ $# -gt 0 ]]; do + case $1 in + --app-id) APP_ID="$2"; shift 2 ;; + --installation-id) INSTALLATION_ID="$2"; shift 2 ;; + --private-key-file) PRIVATE_KEY_FILE="$2"; shift 2 ;; + *) echo "Unknown argument: $1"; exit 1 ;; + esac +done + +STATE_FILE=".github-app-state.json" + +# --- Resolve credentials --- +read_state() { + if [ -f "$STATE_FILE" ]; then + jq -r ".$1 // empty" "$STATE_FILE" 2>/dev/null || true + fi +} + +APP_ID="${APP_ID:-${GITHUB_APP_ID:-$(read_state app_id)}}" +INSTALLATION_ID="${INSTALLATION_ID:-${GITHUB_APP_INSTALLATION_ID:-$(read_state installation_id)}}" + +if [ -z "${PRIVATE_KEY_FILE:-}" ]; then + if [ -n "${GITHUB_APP_PRIVATE_KEY:-}" ]; then + PRIVATE_KEY="$GITHUB_APP_PRIVATE_KEY" + else + PRIVATE_KEY_FILE=$(read_state private_key_file) + fi +fi + +if [ -z "$APP_ID" ] || [ -z "$INSTALLATION_ID" ]; then + echo "ERROR: Missing credentials." + echo " Set GITHUB_APP_ID + GITHUB_APP_INSTALLATION_ID environment variables," + echo " or run scripts/setup-github-app.sh first." + exit 1 +fi + +if [ -z "${PRIVATE_KEY:-}" ]; then + if [ -z "${PRIVATE_KEY_FILE:-}" ] || [ ! -f "${PRIVATE_KEY_FILE:-}" ]; then + echo "ERROR: Private key not found." + echo " Set GITHUB_APP_PRIVATE_KEY env var, or pass --private-key-file." + exit 1 + fi + PRIVATE_KEY=$(cat "$PRIVATE_KEY_FILE") +fi + +# --- Generate JWT --- +NOW=$(date +%s) +IAT=$((NOW - 60)) +EXP=$((NOW + 600)) + +HEADER=$(echo -n '{"alg":"RS256","typ":"JWT"}' | openssl base64 -e -A | tr '+/' '-_' | tr -d '=') +PAYLOAD=$(echo -n "{\"iat\":${IAT},\"exp\":${EXP},\"iss\":\"${APP_ID}\"}" | openssl base64 -e -A | tr '+/' '-_' | tr -d '=') + +SIGNATURE=$(echo -n "${HEADER}.${PAYLOAD}" | \ + openssl dgst -sha256 -sign <(echo "$PRIVATE_KEY") -binary | \ + openssl base64 -e -A | tr '+/' '-_' | tr -d '=') + +JWT="${HEADER}.${PAYLOAD}.${SIGNATURE}" + +# --- Exchange for installation token --- +RESPONSE=$(curl -s -X POST \ + -H "Authorization: Bearer ${JWT}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/app/installations/${INSTALLATION_ID}/access_tokens") + +TOKEN=$(echo "$RESPONSE" | jq -r '.token // empty') + +if [ -z "$TOKEN" ]; then + echo "ERROR: Failed to generate GitHub App installation token" + echo "Response: $RESPONSE" + exit 1 +fi + +# --- Configure environment --- +echo "$TOKEN" > .gh-app-token +chmod 600 .gh-app-token + +git config url."https://x-access-token:${TOKEN}@github.com/".insteadOf "https://github.com/" + +echo "GitHub App token generated (expires in 1 hour)" diff --git a/scripts/setup-github-app.sh b/scripts/setup-github-app.sh new file mode 100755 index 0000000..e82cdc2 --- /dev/null +++ b/scripts/setup-github-app.sh @@ -0,0 +1,323 @@ +#!/bin/bash +# setup-github-app.sh - Set up a GitHub App for sandboxed agent access +# +# Gives Claude (or any agent) its own GitHub identity, scoped to specific repos +# with specific permissions. PRs created by the app require human approval. +# +# What this script does: +# 1. Walks you through creating and installing a GitHub App +# 2. Generates and verifies an installation token +# 3. Wires a SessionStart hook into .claude/settings.json to auto-refresh tokens +# 4. Adds .gh-app-token and state files to .gitignore +# 5. Updates shell profile so GH_TOKEN uses the app token +# +# Usage: +# ./scripts/setup-github-app.sh [app-name] [repo] +# +# Examples: +# ./scripts/setup-github-app.sh # auto-detect repo, default name +# ./scripts/setup-github-app.sh claude-bot owner/repo +# +# Idempotent: safe to run multiple times. Skips completed steps. +# No dependency on any secrets manager. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$PROJECT_DIR" + +APP_NAME="${1:-claude-bot}" + +# Auto-detect repo from git remote +if [ -n "${2:-}" ]; then + REPO="$2" +else + REMOTE_URL=$(git remote get-url origin 2>/dev/null || true) + if [ -z "$REMOTE_URL" ]; then + echo "ERROR: No git remote found. Pass repo as second argument: $0 $APP_NAME owner/repo" + exit 1 + fi + REPO=$(echo "$REMOTE_URL" | sed -E 's#.*github\.com[:/](.+)(\.git)?$#\1#' | sed 's/\.git$//') +fi + +STATE_FILE=".github-app-state.json" + +echo "=== GitHub App Setup ===" +echo "App name: $APP_NAME" +echo "Repository: $REPO" +echo "" + +# --- Helpers --- +read_state() { + if [ -f "$STATE_FILE" ]; then + jq -r ".$1 // empty" "$STATE_FILE" 2>/dev/null || true + fi +} + +write_state() { + local key="$1" value="$2" + if [ -f "$STATE_FILE" ]; then + jq ".$key = \"$value\"" "$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE" + else + echo "{\"$key\": \"$value\"}" > "$STATE_FILE" + fi + chmod 600 "$STATE_FILE" +} + +# ============================================================ +# Step 1: Create the GitHub App +# ============================================================ +APP_ID=$(read_state "app_id") + +if [ -n "$APP_ID" ]; then + echo "Step 1: App already created (ID: $APP_ID) — skipping" +else + echo "Step 1: Create the GitHub App" + echo "" + echo " Go to: https://github.com/settings/apps/new" + echo "" + echo " Fill in:" + echo " App name: $APP_NAME" + echo " Homepage URL: https://github.com/$REPO" + echo " Webhook: UNCHECK 'Active'" + echo "" + echo " Repository permissions:" + echo " Contents: Read & Write" + echo " Pull requests: Read & Write" + echo " Issues: Read & Write" + echo " Checks: Read" + echo " Metadata: Read (auto-granted)" + echo "" + echo " Where can this app be installed: Only on this account" + echo "" + echo " Click 'Create GitHub App'" + echo "" + read -rp " Enter the App ID (shown on the app settings page): " APP_ID + + if [ -z "$APP_ID" ]; then + echo "ERROR: App ID is required" + exit 1 + fi + write_state "app_id" "$APP_ID" + echo " Saved." +fi + +# ============================================================ +# Step 2: Private key +# ============================================================ +PRIVATE_KEY_FILE=$(read_state "private_key_file") + +if [ -n "$PRIVATE_KEY_FILE" ] && [ -f "$PRIVATE_KEY_FILE" ]; then + echo "Step 2: Private key exists ($PRIVATE_KEY_FILE) — skipping" +else + PRIVATE_KEY_FILE=".github-app-${APP_NAME}.pem" + echo "" + echo "Step 2: Generate a private key" + echo "" + echo " Go to: https://github.com/settings/apps/${APP_NAME}" + echo " Scroll to 'Private keys' -> 'Generate a private key'" + echo " A .pem file will download." + echo "" + read -rp " Enter the path to the downloaded .pem file: " PEM_PATH + + if [ ! -f "$PEM_PATH" ]; then + echo "ERROR: File not found: $PEM_PATH" + exit 1 + fi + cp "$PEM_PATH" "$PRIVATE_KEY_FILE" + chmod 600 "$PRIVATE_KEY_FILE" + write_state "private_key_file" "$PRIVATE_KEY_FILE" + echo " Copied to $PRIVATE_KEY_FILE" +fi + +# ============================================================ +# Step 3: Install the app +# ============================================================ +INSTALLATION_ID=$(read_state "installation_id") + +if [ -n "$INSTALLATION_ID" ]; then + echo "Step 3: App already installed (Installation ID: $INSTALLATION_ID) — skipping" +else + echo "" + echo "Step 3: Install the app on your repo" + echo "" + echo " Go to: https://github.com/settings/apps/${APP_NAME}/installations" + echo " Click 'Install' -> select 'Only select repositories' -> choose '$REPO'" + echo "" + echo " After installing, the URL will be:" + echo " https://github.com/settings/installations/<number>" + echo " That number is your Installation ID." + echo "" + read -rp " Enter the Installation ID: " INSTALLATION_ID + + if [ -z "$INSTALLATION_ID" ]; then + echo "ERROR: Installation ID is required" + exit 1 + fi + write_state "installation_id" "$INSTALLATION_ID" + echo " Saved." +fi + +# ============================================================ +# Step 4: Verify — generate a token and test it +# ============================================================ +echo "" +echo "Step 4: Verifying setup..." + +PRIVATE_KEY=$(cat "$PRIVATE_KEY_FILE") + +NOW=$(date +%s) +IAT=$((NOW - 60)) +EXP=$((NOW + 600)) + +HEADER=$(echo -n '{"alg":"RS256","typ":"JWT"}' | openssl base64 -e -A | tr '+/' '-_' | tr -d '=') +PAYLOAD=$(echo -n "{\"iat\":${IAT},\"exp\":${EXP},\"iss\":\"${APP_ID}\"}" | openssl base64 -e -A | tr '+/' '-_' | tr -d '=') + +SIGNATURE=$(echo -n "${HEADER}.${PAYLOAD}" | \ + openssl dgst -sha256 -sign <(echo "$PRIVATE_KEY") -binary | \ + openssl base64 -e -A | tr '+/' '-_' | tr -d '=') + +JWT="${HEADER}.${PAYLOAD}.${SIGNATURE}" + +RESPONSE=$(curl -s -X POST \ + -H "Authorization: Bearer ${JWT}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/app/installations/${INSTALLATION_ID}/access_tokens") + +TOKEN=$(echo "$RESPONSE" | jq -r '.token // empty') + +if [ -z "$TOKEN" ]; then + echo " ERROR: Failed to generate installation token" + echo " Response: $RESPONSE" + echo "" + echo " Common causes:" + echo " - Wrong App ID or Installation ID" + echo " - Private key doesn't match the app" + echo " - App not installed on the repo" + exit 1 +fi + +REPO_NAME=$(curl -s -H "Authorization: token $TOKEN" \ + "https://api.github.com/repos/$REPO" | jq -r '.full_name // empty') + +if [ "$REPO_NAME" != "$REPO" ]; then + echo " ERROR: Token generated but can't access $REPO" + exit 1 +fi + +echo " Token works. App can access $REPO." + +# Write the token so the shell profile and generate script can use it +echo "$TOKEN" > .gh-app-token +chmod 600 .gh-app-token +git config url."https://x-access-token:${TOKEN}@github.com/".insteadOf "https://github.com/" + +# ============================================================ +# Step 5: Wire up .gitignore +# ============================================================ +echo "" +echo "Step 5: Updating .gitignore..." + +GITIGNORE_ENTRIES=(".gh-app-token" ".github-app-state.json" ".github-app-*.pem") +CHANGED=false + +for entry in "${GITIGNORE_ENTRIES[@]}"; do + if ! grep -qxF "$entry" .gitignore 2>/dev/null; then + echo "$entry" >> .gitignore + CHANGED=true + fi +done + +if [ "$CHANGED" = true ]; then + echo " Added entries to .gitignore" +else + echo " .gitignore already up to date — skipping" +fi + +# ============================================================ +# Step 6: Wire up Claude Code SessionStart hook +# ============================================================ +echo "" +echo "Step 6: Updating .claude/settings.json..." + +SETTINGS_FILE=".claude/settings.json" +HOOK_CMD="bash scripts/generate-github-app-token.sh" + +if [ ! -f "$SETTINGS_FILE" ]; then + echo " WARNING: $SETTINGS_FILE not found — skipping hook setup" + echo " Add this hook manually to refresh tokens at session start:" + echo " $HOOK_CMD" +else + # Check if hook is already present + if grep -qF "generate-github-app-token" "$SETTINGS_FILE" 2>/dev/null; then + echo " SessionStart hook already configured — skipping" + else + # Add the hook command to the existing SessionStart hooks array + # The hook array is at .hooks.SessionStart[0].hooks — append to it + if jq -e '.hooks.SessionStart[0].hooks' "$SETTINGS_FILE" > /dev/null 2>&1; then + jq '.hooks.SessionStart[0].hooks += [{"type": "command", "command": "'"$HOOK_CMD"'"}]' \ + "$SETTINGS_FILE" > "${SETTINGS_FILE}.tmp" && mv "${SETTINGS_FILE}.tmp" "$SETTINGS_FILE" + else + # No SessionStart hooks exist — create the structure + jq '.hooks.SessionStart = [{"hooks": [{"type": "command", "command": "'"$HOOK_CMD"'"}]}]' \ + "$SETTINGS_FILE" > "${SETTINGS_FILE}.tmp" && mv "${SETTINGS_FILE}.tmp" "$SETTINGS_FILE" + fi + echo " Added SessionStart hook to refresh token" + fi +fi + +# ============================================================ +# Step 7: Wire up shell profile +# ============================================================ +echo "" +echo "Step 7: Updating shell profile..." + +PROFILE_LINE="# GitHub App token for agent identity +if [ -f \"$PROJECT_DIR/.gh-app-token\" ]; then + export GH_TOKEN=\$(cat \"$PROJECT_DIR/.gh-app-token\") +fi" + +PROFILE_CHANGED=false + +for rc in "$HOME/.bashrc" "$HOME/.zshrc"; do + if [ -f "$rc" ]; then + if ! grep -qF "gh-app-token" "$rc" 2>/dev/null; then + echo "" >> "$rc" + echo "$PROFILE_LINE" >> "$rc" + PROFILE_CHANGED=true + echo " Updated $(basename "$rc")" + else + echo " $(basename "$rc") already configured — skipping" + fi + fi +done + +if [ "$PROFILE_CHANGED" = true ]; then + echo "" + echo " NOTE: Run 'source ~/.bashrc' or start a new terminal for GH_TOKEN to take effect." +fi + +# ============================================================ +# Done +# ============================================================ +echo "" +echo "===========================================" +echo " Setup complete!" +echo "===========================================" +echo "" +echo " What happened:" +echo " - GitHub App '$APP_NAME' configured for $REPO" +echo " - Token generated and verified" +echo " - .gitignore updated" +echo " - Claude Code SessionStart hook installed (auto-refreshes tokens)" +echo " - Shell profile updated (GH_TOKEN set from .gh-app-token)" +echo "" +echo " Credentials (store in your secrets manager if desired):" +echo " App ID: $APP_ID" +echo " Installation ID: $INSTALLATION_ID" +echo " Private key: $PRIVATE_KEY_FILE" +echo " State file: $STATE_FILE" +echo "" +echo " From now on, every Claude session will automatically" +echo " use the app identity for git and gh operations." +echo ""