Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,25 @@ export GH_AS_BOT_PRIVATE_KEY="$(security find-generic-password -s gh-as-bot -w)"
export GH_AS_BOT_PRIVATE_KEY="$(op read 'op://Private/gh-as-bot/private-key')"
```

## One App per person (recommended)

`gh as-bot` is designed for the **per-person** pattern: each developer creates their own GitHub App under their personal account, with a name like `<your-username>-claude`. PR comments are then attributed to e.g. `hinne-claude[bot]` vs `marcus-claude[bot]`, so it's always clear whose Claude Code session produced a review.

Why per-person:

- **Attribution.** Reviews link back to the person whose context produced them — important when bot output is shaped by individual project memory and prompts.
- **Blast radius.** Revoking one developer's App or rotating their key affects only them.
- **Self-service.** No org admin coordination needed to onboard a new dev.
- **No shared private key.** Each dev controls and stashes their own `.pem`.

Trade-off: N Apps to manage instead of one shared bot. For a small team this is barely visible; for a large team you may prefer a shared org-owned App, in which case use `https://github.com/organizations/<your-org>/settings/apps/new` instead — `gh as-bot` itself works identically either way.

## GitHub App reference

`gh as-bot setup` covers this interactively. For reference, the App needs:

- **Owner**: your personal account (per-person) or your org (shared bot).
- **"Where can this GitHub App be installed?"**: Any account — so personal Apps can still install on org repos.
- **Repository permissions**:
- `Pull requests: Read & write` — post reviews and review comments
- `Contents: Read` — `gh` needs this for most read paths
Expand All @@ -75,7 +90,7 @@ export GH_AS_BOT_PRIVATE_KEY="$(op read 'op://Private/gh-as-bot/private-key')"
- **Private key**: generate from App settings → "Private keys". The `.pem` is shown once; setup helps you stash it.
- **Install** the App on the repos that should accept bot reviews.

The bot identity that posts reviews will be `<app-slug>[bot]` (e.g. `negsoft-claude[bot]`).
The bot identity that posts reviews will be `<app-slug>[bot]` (e.g. `hinne-claude[bot]`).

## Why not just `gh auth switch`?

Expand Down
28 changes: 18 additions & 10 deletions internal/cli/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,23 +57,34 @@ func (s *setup) run() int {

// Step 1
_, _ = fmt.Fprintln(s.out, "")
_, _ = fmt.Fprintln(s.out, "Step 1/4 — Create the GitHub App")
_, _ = fmt.Fprintln(s.out, " Open this URL (replace YOUR-ORG):")
_, _ = fmt.Fprintln(s.out, " https://github.com/organizations/YOUR-ORG/settings/apps/new")
_, _ = fmt.Fprintln(s.out, "Step 1/4 — Create your GitHub App (one App per person, recommended)")
_, _ = fmt.Fprintln(s.out, " Per-person Apps give each developer their own bot identity")
_, _ = fmt.Fprintln(s.out, " (e.g. `<your-username>-claude[bot]`), so PR comments are attributed")
_, _ = fmt.Fprintln(s.out, " to whoever's session produced them. No shared private keys.")
_, _ = fmt.Fprintln(s.out, "")
_, _ = fmt.Fprintln(s.out, " Open this URL (creates the App under YOUR account):")
_, _ = fmt.Fprintln(s.out, " https://github.com/settings/apps/new")
_, _ = fmt.Fprintln(s.out, "")
_, _ = fmt.Fprintln(s.out, " Suggested name: <your-username>-claude")
_, _ = fmt.Fprintln(s.out, "")
_, _ = fmt.Fprintln(s.out, " Required Repository permissions:")
_, _ = fmt.Fprintln(s.out, " - Pull requests: Read & write (post reviews / review comments)")
_, _ = fmt.Fprintln(s.out, " - Contents: Read (gh needs this for most read paths)")
_, _ = fmt.Fprintln(s.out, " - Issues: Read & write (post issue / PR conversation comments)")
_, _ = fmt.Fprintln(s.out, "")
_, _ = fmt.Fprintln(s.out, " Webhook: uncheck \"Active\" — we don't use webhooks.")
_, _ = fmt.Fprintln(s.out, " \"Where can this GitHub App be installed?\": Any account.")
_, _ = fmt.Fprintln(s.out, "")
_, _ = fmt.Fprintln(s.out, " After saving the App:")
_, _ = fmt.Fprintln(s.out, " a) Generate a private key (.pem) and download it.")
_, _ = fmt.Fprintln(s.out, " b) Install the App on the org/repos that should accept bot reviews.")
_, _ = fmt.Fprintln(s.out, " c) Note the App ID (App settings header) and installation ID")
_, _ = fmt.Fprintln(s.out, " (visible in the URL after install: .../installations/<id>/...)")
_, _ = fmt.Fprintln(s.out, "")
_, _ = fmt.Fprintln(s.out, " (Want a shared org-owned bot instead? Use")
_, _ = fmt.Fprintln(s.out, " https://github.com/organizations/<your-org>/settings/apps/new — but")
_, _ = fmt.Fprintln(s.out, " you lose per-person attribution and share one private key.)")
_, _ = fmt.Fprintln(s.out, "")

appID, err := s.requirePrompt("App ID (numeric)")
if err != nil {
Expand Down Expand Up @@ -139,14 +150,11 @@ func (s *setup) run() int {
// Step 4
_, _ = fmt.Fprintln(s.out, "")
_, _ = fmt.Fprintln(s.out, "Step 4/4 — Shell configuration")
_, _ = fmt.Fprintln(s.out, " Add this to your shell profile (~/.zshrc or ~/.bashrc):")
shell := detectShell()
_, _ = fmt.Fprintf(s.out, " Add this to your shell profile (%s):\n", shell.rcDescription)
_, _ = fmt.Fprintln(s.out, "")
_, _ = fmt.Fprintf(s.out, " export GH_AS_BOT_APP_ID=%s\n", appID)
_, _ = fmt.Fprintf(s.out, " export GH_AS_BOT_INSTALLATION_ID=%s\n", instID)
if strings.HasPrefix(keyRef, "$(") {
_, _ = fmt.Fprintf(s.out, " export GH_AS_BOT_PRIVATE_KEY=\"%s\"\n", keyRef)
} else {
_, _ = fmt.Fprintf(s.out, " export GH_AS_BOT_PRIVATE_KEY=%s\n", keyRef)
for _, line := range shell.exportLines(appID, instID, keyRef) {
_, _ = fmt.Fprintf(s.out, " %s\n", line)
}
_, _ = fmt.Fprintln(s.out, "")
_, _ = fmt.Fprintln(s.out, " Open a fresh shell (or `source` your profile), then verify:")
Expand Down
77 changes: 77 additions & 0 deletions internal/cli/shell.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package cli

import (
"fmt"
"os"
"path/filepath"
"strings"
)

// shellProfile describes how to render the env-export snippet for a
// detected shell. Bash and zsh share the same `export` syntax; fish uses
// `set -x`. Unknown shells fall back to the bash/zsh snippet with a
// generic rc-file description, which is correct for ~99% of dev setups.
type shellProfile struct {
// rcDescription is the human-readable file path shown in the prompt
// (e.g. "~/.bashrc"). For unknown shells we widen this to a list.
rcDescription string

// quotedAssign is the function that renders `KEY=value` (or its
// equivalent) for this shell, given a value that may contain a
// command substitution.
render func(key, value string, isCommandSub bool) string
}

func (p shellProfile) exportLines(appID, instID, keyRef string) []string {
keyIsSub := strings.HasPrefix(keyRef, "$(")
return []string{
p.render("GH_AS_BOT_APP_ID", appID, false),
p.render("GH_AS_BOT_INSTALLATION_ID", instID, false),
p.render("GH_AS_BOT_PRIVATE_KEY", keyRef, keyIsSub),
}
}

// detectShell inspects $SHELL and returns a profile describing how to
// emit the export snippet. We deliberately keep the matching loose
// (basename suffix only) — exotic shell paths (/opt/homebrew/bin/zsh,
// custom builds, etc.) all resolve correctly that way.
func detectShell() shellProfile {
switch shellBasename() {
case "zsh":
return shellProfile{rcDescription: "~/.zshrc", render: bashStyleExport}
case "bash":
return shellProfile{rcDescription: "~/.bashrc", render: bashStyleExport}
case "fish":
return shellProfile{rcDescription: "~/.config/fish/config.fish", render: fishStyleExport}
default:
return shellProfile{rcDescription: "~/.zshrc, ~/.bashrc, or equivalent", render: bashStyleExport}
}
}

func shellBasename() string {
sh := os.Getenv("SHELL")
if sh == "" {
return ""
}
return filepath.Base(sh)
}

func bashStyleExport(key, value string, isCommandSub bool) string {
if isCommandSub {
// Quote command substitutions so the shell defers evaluation
// until the variable is read, not at profile load.
return fmt.Sprintf(`export %s="%s"`, key, value)
}
return fmt.Sprintf("export %s=%s", key, value)
}
Comment on lines +59 to +66
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The values in the export snippet should be quoted to handle paths or values containing spaces or special characters. Additionally, the comment regarding "deferring evaluation" is inaccurate; in bash/zsh, export VAR="$(cmd)" executes the command at the time of assignment (e.g., when the shell profile is sourced), not when the variable is read. Quoting is primarily useful here to preserve newlines in PEM data and handle spaces in paths.

func bashStyleExport(key, value string, isCommandSub bool) string {
	// We always quote the value to handle spaces and special characters.
	// Note: for command substitutions, this prevents word splitting but
	// evaluation still occurs at profile load (assignment) time.
	return fmt.Sprintf("export %s=\"%s\"", key, value)
}


func fishStyleExport(key, value string, isCommandSub bool) string {
// fish uses `set -x` for exported vars. Command substitution in
// fish is `(cmd)` not `$(cmd)`, so we rewrite if we see one.
if isCommandSub {
v := strings.TrimPrefix(value, "$(")
v = strings.TrimSuffix(v, ")")
return fmt.Sprintf("set -x %s (%s)", key, v)
}
return fmt.Sprintf("set -x %s %s", key, value)
}
Comment on lines +68 to +77
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For literal values (where isCommandSub is false), the value should be quoted to handle spaces or special characters in paths. In Fish, set -x KEY value with spaces would treat the value as a list of multiple elements.

Suggested change
func fishStyleExport(key, value string, isCommandSub bool) string {
// fish uses `set -x` for exported vars. Command substitution in
// fish is `(cmd)` not `$(cmd)`, so we rewrite if we see one.
if isCommandSub {
v := strings.TrimPrefix(value, "$(")
v = strings.TrimSuffix(v, ")")
return fmt.Sprintf("set -x %s (%s)", key, v)
}
return fmt.Sprintf("set -x %s %s", key, value)
}
func fishStyleExport(key, value string, isCommandSub bool) string {
// fish uses `set -x` for exported vars. Command substitution in
// fish is `(cmd)` not `$(cmd)`, so we rewrite if we see one.
if isCommandSub {
v := strings.TrimPrefix(value, "$(")
v = strings.TrimSuffix(v, ")")
return fmt.Sprintf("set -x %s (%s)", key, v)
}
return fmt.Sprintf("set -x %s \"%s\"", key, value)
}

77 changes: 77 additions & 0 deletions internal/cli/shell_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package cli

import (
"strings"
"testing"
)

func TestDetectShell_Bash(t *testing.T) {
t.Setenv("SHELL", "/bin/bash")
p := detectShell()
if p.rcDescription != "~/.bashrc" {
t.Errorf("rcDescription = %q, want ~/.bashrc", p.rcDescription)
}
lines := p.exportLines("1", "2", "/path/to/key.pem")
if !strings.HasPrefix(lines[0], "export ") {
t.Errorf("bash should emit `export`; got %q", lines[0])
}
}

func TestDetectShell_Zsh(t *testing.T) {
t.Setenv("SHELL", "/usr/bin/zsh")
p := detectShell()
if p.rcDescription != "~/.zshrc" {
t.Errorf("rcDescription = %q, want ~/.zshrc", p.rcDescription)
}
}

func TestDetectShell_Fish(t *testing.T) {
t.Setenv("SHELL", "/opt/homebrew/bin/fish")
p := detectShell()
if p.rcDescription != "~/.config/fish/config.fish" {
t.Errorf("rcDescription = %q", p.rcDescription)
}
lines := p.exportLines("1", "2", "$(security find-generic-password -s gh-as-bot -w)")
keyLine := lines[2]
if !strings.HasPrefix(keyLine, "set -x ") {
t.Errorf("fish should emit `set -x`; got %q", keyLine)
}
if strings.Contains(keyLine, "$(") {
t.Errorf("fish command substitution should be (...) not $(...); got %q", keyLine)
}
if !strings.Contains(keyLine, "(security find-generic-password -s gh-as-bot -w)") {
t.Errorf("fish should keep the substitution body; got %q", keyLine)
}
}

func TestDetectShell_UnknownFallsBackToBashStyle(t *testing.T) {
t.Setenv("SHELL", "/usr/local/bin/elvish")
p := detectShell()
if !strings.Contains(p.rcDescription, "~/.zshrc") || !strings.Contains(p.rcDescription, "~/.bashrc") {
t.Errorf("unknown shell should mention both rc files; got %q", p.rcDescription)
}
lines := p.exportLines("1", "2", "/path/key.pem")
if !strings.HasPrefix(lines[0], "export ") {
t.Errorf("fallback should use bash-style export; got %q", lines[0])
}
}

func TestExportLines_QuotesCommandSubstitution(t *testing.T) {
t.Setenv("SHELL", "/bin/bash")
p := detectShell()
lines := p.exportLines("1", "2", "$(security find-generic-password -s gh-as-bot -w)")
keyLine := lines[2]
if !strings.Contains(keyLine, `"$(security`) {
t.Errorf("bash command sub should be wrapped in double quotes; got %q", keyLine)
}
}

func TestExportLines_LiteralPathUnquoted(t *testing.T) {
t.Setenv("SHELL", "/bin/bash")
p := detectShell()
lines := p.exportLines("1", "2", "/abs/path/key.pem")
keyLine := lines[2]
if strings.Contains(keyLine, `"`) {
t.Errorf("literal path should not be quoted; got %q", keyLine)
}
}
Comment on lines +69 to +77
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This test explicitly checks that literal paths are unquoted. If the implementation is updated to quote values (to support paths with spaces), this test should be updated to expect quotes.

Suggested change
func TestExportLines_LiteralPathUnquoted(t *testing.T) {
t.Setenv("SHELL", "/bin/bash")
p := detectShell()
lines := p.exportLines("1", "2", "/abs/path/key.pem")
keyLine := lines[2]
if strings.Contains(keyLine, `"`) {
t.Errorf("literal path should not be quoted; got %q", keyLine)
}
}
func TestExportLines_LiteralPathQuoted(t *testing.T) {
t.Setenv("SHELL", "/bin/bash")
p := detectShell()
lines := p.exportLines("1", "2", "/abs/path/key.pem")
keyLine := lines[2]
if !strings.Contains(keyLine, "\"") {
t.Errorf("literal path should be quoted; got %q", keyLine)
}
}

Loading