@nvice-r/pr-commands 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,220 @@
1
+ # @nvice-r/pr-commands
2
+
3
+ Provider-agnostic pull-request helper for [Claude Code](https://claude.com/claude-code).
4
+ One `/pr` command that drafts a PR title + body in your team's convention, infers the base
5
+ branch, warns on oversized diffs, attaches reviewers, and creates the PR — with the provider
6
+ (Bitbucket / GitHub / GitLab) auto-detected from your git remote.
7
+
8
+ > **Status:** macOS + Bitbucket today. GitHub / GitLab and Linux are stubbed and land without
9
+ > new command files — see [Architecture](#architecture).
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npx @nvice-r/pr-commands
15
+ ```
16
+
17
+ The interactive wizard:
18
+
19
+ 1. **Environment** — detects OS → token source (macOS keychain).
20
+ 2. **Work directory** — the repo to configure. Prompted with the current dir pre-filled and
21
+ editable (or pass `--dir`). Must be a git repo.
22
+ 3. **Provider** — detected from `git remote get-url origin`.
23
+ 4. **Identity + token** — reads your API token from the keychain (or tells you how to add one).
24
+ 5. **Reviewers** — fetches the repo's default reviewers into an interactive checklist
25
+ (`↑/↓` move · `space` toggle · `a` all · `n` none · `b` back · `enter` confirm; all pre-selected).
26
+ 6. **Convention** — pick a preset, then review/edit its title-pattern, ticket-regex, and
27
+ template-sections (each pre-filled from the preset, editable). A placeholder legend
28
+ (`<ver>`, `<desc>`, `<ticket>`, …) is shown. Concrete values are written to config — `/pr`
29
+ reads those, not a hard-coded table.
30
+ 7. **Install** — writes config + roster, drops the binaries and the `/pr` skill.
31
+
32
+ Every prompt is reversible: in a list press `b` to step back; in a text field, append `:b` to
33
+ the value and press enter (the field is pre-filled, so `:b` at the end triggers back). Back
34
+ navigation is field-level (you can revisit any single convention field).
35
+
36
+ Flags:
37
+
38
+ | flag | effect |
39
+ |------|--------|
40
+ | `--dir <path>` | repo to configure (default: cwd) |
41
+ | `--provider <p>` | force `bitbucket`\|`github`\|`gitlab` instead of detecting |
42
+ | `--all` | install every ready provider binary, not just the detected one |
43
+ | `--yes` | non-interactive: reviewers = all, convention = `trueid` |
44
+ | `--dry-run` | print planned actions, write nothing |
45
+ | `--help` | usage |
46
+
47
+ **Dry-run first** to see exactly what will be written:
48
+
49
+ ```bash
50
+ npx @nvice-r/pr-commands --dry-run --dir /path/to/repo
51
+ ```
52
+
53
+ ### Prerequisites
54
+
55
+ - **Node ≥ 16** (for the installer only; the installed tools are bash).
56
+ - **`~/.local/bin` on your `PATH`.** The installer warns if it isn't:
57
+ ```bash
58
+ export PATH="$HOME/.local/bin:$PATH"
59
+ ```
60
+ - **A Bitbucket API token in the keychain** (the installer can't mint secrets):
61
+ ```bash
62
+ security add-generic-password -s bitbucket-api-token -a you@example.com -w '<api-token>'
63
+ ```
64
+ Without it, install still completes but reviewers aren't auto-fetched.
65
+
66
+ ## What gets installed
67
+
68
+ Everything is **global** — nothing lands in a repo's `.claude/`, so it works identically from
69
+ every clone and every git worktree, no symlinks.
70
+
71
+ | Artifact | Path |
72
+ |----------|------|
73
+ | Shared library | `~/.local/bin/pr-common.sh` |
74
+ | Provider binary | `~/.local/bin/bbpr` (`ghpr`/`glpr` later) |
75
+ | Config | `~/.config/pr/config` |
76
+ | Reviewer roster | `<main-repo-root>/.pr-reviewers.txt` (shared across worktrees) |
77
+ | `/pr` skill | `~/.claude/commands/pr.md` |
78
+
79
+ ## Usage
80
+
81
+ In Claude Code, from any configured repo:
82
+
83
+ ```
84
+ /pr # infer base, draft PR, confirm, create
85
+ /pr feature/2.3.0/center # explicit base branch
86
+ /pr draft # create as draft
87
+ /pr help # help
88
+ ```
89
+
90
+ `/pr` gathers git context, proposes a title + body in your convention, shows it for
91
+ confirmation, then calls the detected binary. It never creates a PR silently.
92
+
93
+ ## Configuration
94
+
95
+ `~/.config/pr/config` (written by the installer):
96
+
97
+ ```ini
98
+ os=macos # macos | linux
99
+ token_src=keychain # keychain | secret-tool | env
100
+ email=you@example.com
101
+ convention=trueid # see presets
102
+ ```
103
+
104
+ Edit by hand any time. **A missing config is a hard error** — `/pr` and the binaries refuse to
105
+ guess. Re-run the installer to regenerate.
106
+
107
+ ### Convention presets
108
+
109
+ | preset | title pattern | ticket regex | template sections |
110
+ |--------|---------------|--------------|-------------------|
111
+ | `trueid` | `[<ver>] Feature/<ver>/SC-XXXX <desc>` | `SC-\d+` | What Happened / Insight / Proof of Work |
112
+ | `conventional` | `<type>(<scope>): <desc>` | `[A-Z]+-\d+` | Summary / Changes / Testing |
113
+ | `github-default` | `<desc> (#<issue>)` | `#\d+` | Summary / Test plan |
114
+ | `custom` | your `title_pattern=` | your `ticket_regex=` | your `template_sections=` (pipe-separated) |
115
+
116
+ `custom` example:
117
+
118
+ ```ini
119
+ convention=custom
120
+ title_pattern=<ticket>: <desc>
121
+ ticket_regex=JIRA-\d+
122
+ template_sections=Context|Changes|Risk
123
+ ```
124
+
125
+ ### Reviewers
126
+
127
+ Roster lives **in the work dir** at the main repo root, so it's editable next to the code.
128
+ Resolved via `git --git-common-dir/..`, so all worktrees of a repo share one roster:
129
+
130
+ ```
131
+ <main-repo-root>/.pr-reviewers.txt
132
+ ```
133
+
134
+ You (the author) are excluded on install — bbpr also drops self at PR time. One entry per line,
135
+ trailing `# Name` comment optional:
136
+
137
+ ```
138
+ {01234567-89ab-cdef-0123-456789abcdef} # Alice
139
+ {fedcba98-7654-3210-fedc-ba9876543210} # Bob
140
+ ```
141
+
142
+ It's plain data — edit freely, no reinstall. Consider gitignoring it unless the team wants the
143
+ roster committed. Load order: `$BBPR_REVIEWERS` → work-dir file → global
144
+ `~/.config/pr/reviewers/<ws>-<slug>.txt` → legacy. Run-time overrides: `--no-reviewers` skips
145
+ all, `--reviewer <uuid>` appends.
146
+
147
+ ### Auth precedence (email)
148
+
149
+ `--email` flag → `$BB_EMAIL` → `git config bbpr.email` → config `email` → keychain account.
150
+ The token is always read from the keychain (or `$PR_TOKEN` when `token_src=env`).
151
+
152
+ ## Provider support
153
+
154
+ | provider | binary | status |
155
+ |----------|--------|--------|
156
+ | Bitbucket Cloud | `bbpr` | ✅ ready |
157
+ | GitHub | `ghpr` | 🚧 stub — errors `not-yet-supported` |
158
+ | GitLab | `glpr` | 🚧 stub |
159
+
160
+ | OS | token source | status |
161
+ |----|--------------|--------|
162
+ | macOS | keychain | ✅ ready |
163
+ | Linux | `secret-tool` / `$PR_TOKEN` | 🚧 present, untested |
164
+
165
+ ## Architecture
166
+
167
+ - **Split by provider, not by OS.** Each provider gets its own small binary (`bbpr`/`ghpr`/`glpr`)
168
+ because the REST APIs genuinely differ. OS differences (token retrieval) are a single
169
+ `get_token()` function switched by `uname`, not a separate binary. Ceiling: 3 binaries, never
170
+ 3 × OS.
171
+ - **Shared logic in `pr-common.sh`** — git scan, base save, reviewer load, arg parse, token —
172
+ sourced by every binary. A provider binary is ~40 lines: payload shape + endpoint + parse.
173
+ - **One generic `/pr` skill.** Convention lives in config, not the skill, so the skill is
174
+ provider- and team-neutral and installs globally. Adding a provider = one binary + one dispatch
175
+ line, zero new skill files.
176
+
177
+ Full rationale in [`DECISIONS.md`](./DECISIONS.md).
178
+
179
+ ## Troubleshooting
180
+
181
+ | symptom | fix |
182
+ |---------|-----|
183
+ | `no ~/.config/pr/config` | run `npx @nvice-r/pr-commands` |
184
+ | reviewers not fetched | add the keychain token (see Prerequisites), re-run |
185
+ | `WARN ~/.local/bin not on PATH` | add the export line above to your shell rc |
186
+ | `not-yet-supported: <provider>` | GitHub/GitLab aren't implemented yet |
187
+ | worktree runs old behavior | nothing is repo-local here; ensure `pr.md` is at `~/.claude/commands/pr.md`, not a stale repo copy |
188
+
189
+ ## Uninstall
190
+
191
+ ```bash
192
+ rm ~/.local/bin/bbpr ~/.local/bin/pr-common.sh
193
+ rm -rf ~/.config/pr
194
+ rm ~/.claude/commands/pr.md
195
+ # roster lives in each repo — remove per repo if wanted:
196
+ rm <main-repo-root>/.pr-reviewers.txt
197
+ ```
198
+
199
+ ## Development
200
+
201
+ ```
202
+ install.js npx entry (wizard)
203
+ assets/bin/* provider binaries + shared lib (source of truth; copied on install)
204
+ assets/skill/pr.md generic /pr skill (source of truth; copied on install)
205
+ ```
206
+
207
+ `assets/` is the single source of truth — both the installer (`install.js`)
208
+ and the published package (`files` in `package.json`) read from it. No duplicate
209
+ tree to keep in sync.
210
+
211
+ Publish (owner only):
212
+
213
+ ```bash
214
+ npm login
215
+ npm publish --access public
216
+ ```
217
+
218
+ ## License
219
+
220
+ MIT © nvice.r
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env bash
2
+ # bbpr-version: 8
3
+ # bbpr — create a Bitbucket Cloud pull request (REST API v2.0).
4
+ # Provider-specific layer; all git/config/creds/reviewer logic lives in pr-common.sh.
5
+ # Usage: bbpr --title "..." [--body|--body-file] [--source b] [--dest b] [--repo w/s]
6
+ # [--draft] [--reviewer uuid]... [--no-reviewers] [--email a] [--close-source]
7
+
8
+ set -euo pipefail
9
+
10
+ PR_CMD="bbpr"
11
+ KEYCHAIN_SERVICE="bitbucket-api-token"
12
+
13
+ # locate + source the shared lib (same dir as this binary)
14
+ _here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
15
+ # shellcheck source=pr-common.sh
16
+ . "$_here/pr-common.sh"
17
+
18
+ pr_common_init "$@"
19
+
20
+ # --- Bitbucket: drop the PR author's own UUID (BB rejects author-as-reviewer) ---
21
+ if [ "${#FINAL_REVIEWERS[@]}" -gt 0 ]; then
22
+ SELF_UUID="$(curl -sS -u "${EMAIL}:${TOKEN}" "https://api.bitbucket.org/2.0/user" 2>/dev/null \
23
+ | python3 -c 'import json,sys
24
+ try: print(json.load(sys.stdin).get("uuid",""))
25
+ except Exception: print("")' 2>/dev/null || true)"
26
+ if [ -n "$SELF_UUID" ]; then
27
+ FILTERED=()
28
+ for r in "${FINAL_REVIEWERS[@]}"; do
29
+ [ "$r" = "$SELF_UUID" ] || FILTERED+=("$r")
30
+ done
31
+ FINAL_REVIEWERS=("${FILTERED[@]:-}")
32
+ [ -z "${FINAL_REVIEWERS[0]:-}" ] && FINAL_REVIEWERS=()
33
+ fi
34
+ fi
35
+
36
+ # --- Bitbucket payload (source/destination keys, draft, reviewers[]) ---
37
+ PAYLOAD="$(
38
+ TITLE="$TITLE" BODY="$BODY" SOURCE="$SOURCE" DEST="$DEST" \
39
+ DRAFT="$DRAFT" CLOSE_SOURCE="$CLOSE_SOURCE" \
40
+ REVIEWERS_JSON="$(printf '%s\n' "${FINAL_REVIEWERS[@]:-}" | python3 -c 'import json,sys; print(json.dumps([{"uuid": l.strip()} for l in sys.stdin if l.strip()]))')" \
41
+ python3 -c '
42
+ import json, os
43
+ p = {
44
+ "title": os.environ["TITLE"],
45
+ "description": os.environ["BODY"],
46
+ "source": {"branch": {"name": os.environ["SOURCE"]}},
47
+ "destination": {"branch": {"name": os.environ["DEST"]}},
48
+ "draft": os.environ["DRAFT"] == "true",
49
+ "close_source_branch": os.environ["CLOSE_SOURCE"] == "true",
50
+ }
51
+ revs = json.loads(os.environ["REVIEWERS_JSON"])
52
+ if revs: p["reviewers"] = revs
53
+ print(json.dumps(p))
54
+ '
55
+ )"
56
+
57
+ URL="https://api.bitbucket.org/2.0/repositories/${REPO}/pullrequests"
58
+ echo "POST $URL"
59
+ echo "source=$SOURCE dest=$DEST draft=$DRAFT reviewers=${#FINAL_REVIEWERS[@]}"
60
+
61
+ RESP="$(curl -sS -X POST "$URL" -u "${EMAIL}:${TOKEN}" \
62
+ -H 'Content-Type: application/json' -d "$PAYLOAD")"
63
+
64
+ PR_INFO="$(echo "$RESP" | python3 -c '
65
+ import json, sys
66
+ try: d = json.loads(sys.stdin.read())
67
+ except Exception as e: print("non-JSON response", e); sys.exit(2)
68
+ if "error" in d: print("ERROR:", json.dumps(d["error"], indent=2)); sys.exit(1)
69
+ print(str(d.get("id")) + "\t" + str(d.get("links", {}).get("html", {}).get("href")))
70
+ ')"
71
+
72
+ NEW_PR_ID="$(echo "$PR_INFO" | cut -f1)"
73
+ NEW_PR_URL="$(echo "$PR_INFO" | cut -f2)"
74
+ ATTACHED="$(echo "$RESP" | python3 -c 'import json,sys; print(len(json.load(sys.stdin).get("reviewers", [])))')"
75
+ echo "created PR #${NEW_PR_ID} with ${ATTACHED} reviewers"
76
+ echo "$NEW_PR_URL"
77
+
78
+ [ -n "$NEW_PR_ID" ] && pr_save_base
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env bash
2
+ # pr-common-version: 1
3
+ # pr-common.sh — shared library for provider PR binaries (bbpr/ghpr/glpr).
4
+ # NOT executable on its own — sourced by a provider binary, which then builds the
5
+ # provider-specific payload + POST. Everything here is provider-AGNOSTIC.
6
+ #
7
+ # Provides (after `pr_common_init "$@"`): global vars
8
+ # TITLE BODY SOURCE DEST REPO EMAIL TOKEN DRAFT CLOSE_SOURCE
9
+ # FINAL_REVIEWERS[] (uuids, self already NOT dropped — provider does that)
10
+ # Provides functions:
11
+ # die, get_token, pr_load_config, pr_parse_args, pr_git_defaults,
12
+ # pr_resolve_creds, pr_load_reviewers, pr_save_base, pr_common_init
13
+ #
14
+ # Config: ~/.config/pr/config (KEY=value). HARD FAIL if missing (D8).
15
+
16
+ set -euo pipefail
17
+
18
+ CONFIG_FILE="${PR_CONFIG:-$HOME/.config/pr/config}"
19
+
20
+ die() { echo "${PR_CMD:-pr}: $*" >&2; exit 1; }
21
+
22
+ # --- config load (safe parse, no `source` of untrusted file) ---
23
+ # Sets: CFG_OS CFG_TOKEN_SRC CFG_EMAIL CFG_CONVENTION (+ optional custom_* keys)
24
+ pr_load_config() {
25
+ [ -f "$CONFIG_FILE" ] || die "no $CONFIG_FILE — run: npx @nvice-r/pr-commands"
26
+ local k v
27
+ CFG_OS=""; CFG_TOKEN_SRC=""; CFG_EMAIL=""; CFG_CONVENTION=""
28
+ while IFS='=' read -r k v; do
29
+ k="${k%%#*}"; k="${k//[[:space:]]/}" # strip comment + whitespace from key
30
+ [ -z "$k" ] && continue
31
+ v="${v%%#*}"; v="${v#"${v%%[![:space:]]*}"}"; v="${v%"${v##*[![:space:]]}"}" # trim v
32
+ case "$k" in
33
+ os) CFG_OS="$v" ;;
34
+ token_src) CFG_TOKEN_SRC="$v" ;;
35
+ email) CFG_EMAIL="$v" ;;
36
+ convention) CFG_CONVENTION="$v" ;;
37
+ esac
38
+ done < "$CONFIG_FILE"
39
+ : "${CFG_TOKEN_SRC:=keychain}"
40
+ }
41
+
42
+ # --- token retrieval, OS-switched (D1: OS is a function, not a binary axis) ---
43
+ # arg1 = email. Echoes token or dies.
44
+ get_token() {
45
+ local email="$1"
46
+ case "$CFG_TOKEN_SRC" in
47
+ keychain)
48
+ security find-generic-password -s "$KEYCHAIN_SERVICE" -a "$email" -w 2>/dev/null \
49
+ || die "no keychain entry for service=$KEYCHAIN_SERVICE account=$email" ;;
50
+ secret-tool) # Linux (libsecret)
51
+ secret-tool lookup service "$KEYCHAIN_SERVICE" account "$email" 2>/dev/null \
52
+ || die "no secret-tool entry for service=$KEYCHAIN_SERVICE account=$email" ;;
53
+ env)
54
+ [ -n "${PR_TOKEN:-}" ] || die "token_src=env but \$PR_TOKEN is empty"
55
+ printf '%s' "$PR_TOKEN" ;;
56
+ *) die "not-yet-supported: token_src=$CFG_TOKEN_SRC" ;;
57
+ esac
58
+ }
59
+
60
+ # --- arg parse (common flags across all providers) ---
61
+ pr_parse_args() {
62
+ TITLE=""; BODY=""; BODY_FILE=""; SOURCE=""; DEST=""; REPO=""
63
+ DRAFT="false"; CLOSE_SOURCE="false"; NO_REVIEWERS="false"; EMAIL_ARG=""
64
+ EXTRA_REVIEWERS=()
65
+ while [ $# -gt 0 ]; do
66
+ case "$1" in
67
+ --title) TITLE="$2"; shift 2 ;;
68
+ --body) BODY="$2"; shift 2 ;;
69
+ --body-file) BODY_FILE="$2"; shift 2 ;;
70
+ --source) SOURCE="$2"; shift 2 ;;
71
+ --dest|--target) DEST="$2"; shift 2 ;;
72
+ --repo) REPO="$2"; shift 2 ;;
73
+ --draft) DRAFT="true"; shift ;;
74
+ --close-source) CLOSE_SOURCE="true"; shift ;;
75
+ --reviewer) EXTRA_REVIEWERS+=("$2"); shift 2 ;;
76
+ --no-reviewers) NO_REVIEWERS="true"; shift ;;
77
+ --email) EMAIL_ARG="$2"; shift 2 ;;
78
+ -h|--help) sed -n '2,20p' "$0"; exit 0 ;;
79
+ *) die "unknown flag: $1" ;;
80
+ esac
81
+ done
82
+ [ -n "$TITLE" ] || die "--title required"
83
+ if [ -n "$BODY_FILE" ]; then
84
+ [ -f "$BODY_FILE" ] || die "body file not found: $BODY_FILE"
85
+ BODY="$(cat "$BODY_FILE")"
86
+ fi
87
+ }
88
+
89
+ # --- git-derived defaults: SOURCE, DEST, REPO ---
90
+ # REPO slug parse is generic (strip host + .git) — works for all providers.
91
+ pr_git_defaults() {
92
+ if [ -z "$SOURCE" ]; then
93
+ SOURCE="$(git rev-parse --abbrev-ref HEAD)"
94
+ [ "$SOURCE" = "HEAD" ] && die "detached HEAD — pass --source"
95
+ fi
96
+ if [ -z "$DEST" ]; then
97
+ DEST="$(git config --get "branch.${SOURCE}.bbprBase" 2>/dev/null || true)"
98
+ [ -n "$DEST" ] && echo "${PR_CMD:-pr}: using saved base (branch.${SOURCE}.bbprBase = ${DEST})" >&2
99
+ fi
100
+ [ -n "$DEST" ] || die "--dest required (no saved branch.${SOURCE}.bbprBase)"
101
+ if [ -z "$REPO" ]; then
102
+ local url
103
+ url="$(git remote get-url origin 2>/dev/null)" || die "no origin remote"
104
+ # generic: drop scp-style git@HOST: or https://HOST/ prefix and trailing .git
105
+ REPO="$(echo "$url" | sed -E 's#^git@[^:]+:##; s#^https?://[^/]+/##; s#\.git$##')"
106
+ [[ "$REPO" == */* ]] || die "could not parse repo from $url"
107
+ fi
108
+ }
109
+
110
+ # --- creds: email precedence + token via OS ---
111
+ pr_resolve_creds() {
112
+ # --email > $BB_EMAIL > git config bbpr.email > config email > keychain acct
113
+ EMAIL="$EMAIL_ARG"
114
+ [ -n "$EMAIL" ] || EMAIL="${BB_EMAIL:-}"
115
+ [ -n "$EMAIL" ] || EMAIL="$(git config bbpr.email 2>/dev/null || true)"
116
+ [ -n "$EMAIL" ] || EMAIL="${CFG_EMAIL:-}"
117
+ [ -n "$EMAIL" ] || die "no email resolved (set config email or pass --email)"
118
+ TOKEN="$(get_token "$EMAIL")"
119
+ }
120
+
121
+ # --- reviewer roster: work-dir file (shared across worktrees) then global fallbacks ---
122
+ pr_load_reviewers() {
123
+ DEFAULT_REVIEWERS=()
124
+ local file="${BBPR_REVIEWERS:-}"
125
+ if [ -z "$file" ]; then
126
+ local slug; slug="$(echo "$REPO" | tr '/' '-')" # workspace/slug -> workspace-slug
127
+ # main repo root, shared across worktrees: git-common-dir/.. (empty if git call fails)
128
+ local mainroot; mainroot="$(cd "$(git rev-parse --git-common-dir 2>/dev/null)/.." 2>/dev/null && pwd)"
129
+ for cand in \
130
+ ${mainroot:+"$mainroot/.pr-reviewers.txt"} \
131
+ "$HOME/.config/pr/reviewers/${slug}.txt" \
132
+ "$HOME/.config/bbpr/bbpr-reviewers.txt"; do # legacy fallback
133
+ [ -f "$cand" ] && { file="$cand"; break; }
134
+ done
135
+ fi
136
+ if [ -n "$file" ] && [ -f "$file" ]; then
137
+ local line uuid
138
+ while IFS= read -r line; do
139
+ uuid="${line%%#*}"; uuid="${uuid//[[:space:]]/}"
140
+ [ -n "$uuid" ] && DEFAULT_REVIEWERS+=("$uuid")
141
+ done < "$file"
142
+ else
143
+ echo "${PR_CMD:-pr}: WARN no reviewer roster for $REPO — none attached" >&2
144
+ fi
145
+ # merge defaults + --reviewer extras unless --no-reviewers
146
+ FINAL_REVIEWERS=()
147
+ if [ "$NO_REVIEWERS" != "true" ]; then
148
+ [ "${#DEFAULT_REVIEWERS[@]}" -gt 0 ] && FINAL_REVIEWERS+=("${DEFAULT_REVIEWERS[@]}")
149
+ [ "${#EXTRA_REVIEWERS[@]}" -gt 0 ] && FINAL_REVIEWERS+=("${EXTRA_REVIEWERS[@]}")
150
+ fi
151
+ return 0 # never let a false &&-guard above abort the caller under `set -e`
152
+ }
153
+
154
+ # --- persist resolved base for next run ---
155
+ pr_save_base() {
156
+ git config "branch.${SOURCE}.bbprBase" "$DEST" 2>/dev/null \
157
+ && echo "${PR_CMD:-pr}: saved branch.${SOURCE}.bbprBase = ${DEST}" >&2
158
+ return 0 # base-save is best-effort; never abort the binary on failure
159
+ }
160
+
161
+ # --- orchestrate everything a provider needs before its POST ---
162
+ pr_common_init() {
163
+ pr_load_config
164
+ pr_parse_args "$@"
165
+ pr_git_defaults
166
+ pr_resolve_creds
167
+ pr_load_reviewers
168
+ }
@@ -0,0 +1,157 @@
1
+ ---
2
+ description: Create a pull request for the current branch (provider auto-detected)
3
+ argument-hint: [base-branch] [draft]
4
+ model: sonnet
5
+ ---
6
+
7
+ Create a pull request for the current branch. Provider (Bitbucket / GitHub / GitLab) is
8
+ auto-detected from the git remote; the matching binary (`bbpr` / `ghpr` / `glpr`) is invoked.
9
+ Convention (title pattern, ticket regex, PR template) comes from `~/.config/pr/config`.
10
+
11
+ Installed by `npx @nvice-r/pr-commands`. If setup is missing, this command STOPS and tells the
12
+ user to run the installer — it never guesses config (D8: hard fail, no hardcoded fallback).
13
+
14
+ If `$ARGUMENTS` starts with `help`/`-help`/`--help` (case-insensitive), print the **Help** block and stop.
15
+
16
+ ## Help (`/pr help`)
17
+
18
+ ```
19
+ /pr — provider-agnostic PR helper (@nvice-r/pr-commands)
20
+
21
+ USAGE
22
+ /pr [base-branch] [draft] Create a PR for the current branch (default).
23
+ /pr help Show this help.
24
+
25
+ CREATE
26
+ base-branch Any arg containing "/". If omitted, inferred from closest ancestor
27
+ remote branch (saved per-branch after first run).
28
+ draft Create as draft. Otherwise opens ready (asks to confirm).
29
+
30
+ CONFIG ~/.config/pr/config (os, token_src, email, convention)
31
+ PROVIDER auto from `git remote get-url origin` -> bbpr | ghpr | glpr
32
+ REVIEWERS ~/.config/pr/reviewers/<workspace>-<slug>.txt (per-repo, global location)
33
+ SETUP npx @nvice-r/pr-commands
34
+ ```
35
+
36
+ ## Step 0 — verify install (HARD FAIL, no fallback)
37
+
38
+ Run the checks; if any fails, STOP and tell the user to run `npx @nvice-r/pr-commands` (name the
39
+ missing piece). Do NOT improvise config or reviewers.
40
+
41
+ ```bash
42
+ # a. config present?
43
+ CFG="${PR_CONFIG:-$HOME/.config/pr/config}"
44
+ [ -f "$CFG" ] || { echo "MISSING config: $CFG"; }
45
+
46
+ # b. detect provider from remote
47
+ URL="$(git remote get-url origin 2>/dev/null)" || echo "no origin remote"
48
+ case "$URL" in
49
+ *bitbucket.org*) PROVIDER=bitbucket; BIN=bbpr ;;
50
+ *github.com*) PROVIDER=github; BIN=ghpr ;;
51
+ *gitlab.com*) PROVIDER=gitlab; BIN=glpr ;;
52
+ *) echo "unknown provider for remote: $URL" ;;
53
+ esac
54
+
55
+ # c. matching binary installed?
56
+ command -v "$BIN" >/dev/null 2>&1 || echo "MISSING binary: $BIN (run installer, or provider not yet supported)"
57
+ ```
58
+
59
+ - config missing → STOP: "run `npx @nvice-r/pr-commands`".
60
+ - provider unknown → STOP: only bitbucket/github/gitlab supported.
61
+ - `$BIN` = `ghpr`/`glpr` and not installed → STOP: "provider not yet supported (macOS+Bitbucket only for now)". (D6 stub.)
62
+ - all pass → continue.
63
+
64
+ ## Step 1 — gather context (parallel)
65
+ - `git rev-parse --abbrev-ref HEAD` — source branch (CURR)
66
+ - `git config --get branch.<CURR>.bbprBase` — saved base, if any
67
+ - `git log --oneline -20`
68
+ - read `convention=` from `$CFG` (default `trueid` if unset — but config presence already enforced in Step 0)
69
+
70
+ ## Step 2 — determine base branch (priority)
71
+ a. `$1` (any arg containing `/`) → use it.
72
+ b. else `branch.<CURR>.bbprBase` from git config → use silently.
73
+ c. else infer — smallest non-zero `<ref>..HEAD` distance where `<ref>` is an ancestor of HEAD:
74
+ ```bash
75
+ for ref in $(git for-each-ref --format='%(refname:short)' refs/remotes/origin | grep -v "^origin/<CURR>$"); do
76
+ if git merge-base --is-ancestor "$ref" HEAD 2>/dev/null; then
77
+ ahead=$(git rev-list --count "$ref"..HEAD)
78
+ [ "$ahead" -gt 0 ] && echo "$ahead $ref"
79
+ fi
80
+ done | sort -n | head -5
81
+ ```
82
+ Propose smallest-`ahead` candidate; confirm. Do NOT auto-pick a naming-pattern match — a
83
+ stacked PR on another in-flight ticket branch is common. Ties or all >20 ahead → ask.
84
+ d. still ambiguous → ask.
85
+
86
+ The binary auto-saves the resolved base to `branch.<CURR>.bbprBase` on success.
87
+
88
+ ## Step 3 — build diff context
89
+ `git log <base>..HEAD --oneline` · `git diff --stat <base>..HEAD`. Multiple commits → summarize each.
90
+
91
+ ## Step 3.5 — split advisory (size gate, advisory, never blocks)
92
+ Compute from `git diff --numstat <base>..HEAD`: file count, churn Σ(add+del), distinct modules.
93
+ Flag if `>25 files` OR `>600 churn` OR `>4 modules`. Under → silent, continue. Over → cluster the
94
+ diff into seams, detect coupling (shared file/symbol → stacked; disjoint → independent; interleaved
95
+ → one atomic PR), present metrics + seam table, then `AskUserQuestion`: proceed-as-one /
96
+ split (`guard_cmd pr-split`, degrade if absent) / adjust-threshold. Never auto-split.
97
+
98
+ ## Step 4 — draft title (from config, authoritative)
99
+
100
+ Read `title_pattern=` and `ticket_regex=` directly from `$CFG` — the installer wrote concrete,
101
+ possibly user-edited values, so DO NOT reconstruct them from a preset table. Extract the ticket
102
+ key + any version from the branch name using `ticket_regex`, then fill `title_pattern`.
103
+
104
+ `convention=` is only a behavior label (drives the trueid body rules in Step 5). Example
105
+ (trueid): branch `feature/<ver>/SC-XXXX-...`, pattern `[<ver>] Feature/<ver>/SC-XXXX <desc>` →
106
+ kebab suffix becomes spaced lowercase `<desc>`.
107
+
108
+ ## Step 5 — draft body (from config)
109
+
110
+ Read `template_sections=` from `$CFG` (pipe-separated) and render each as an `## <section>`
111
+ heading. These are authoritative — the installer wrote them (preset seed, possibly edited).
112
+
113
+ **trueid template rules** (only when `convention=trueid`):
114
+ - `What Happened` = summary prose only — no file bullets, no commit-subject line.
115
+ - Changed-file detail lives under `Insight`.
116
+ - 3+ files sharing a parent dir → state common root once ("Under `<root>`:") + flat list of joined
117
+ relative paths (no nested bullets). Android module root → Gradle path `:features:<x>:<y>`, leaf
118
+ bullets = basenames only. Skip boilerplate (`settings.gradle.kts` include, empty manifest, lockfiles).
119
+ - Unrelated areas → full paths per bullet, no common-root statement.
120
+ - `Proof of Work` empty unless user gives screenshots/links.
121
+
122
+ For non-trueid presets, fill sections with a plain summary + changed-file list; no Gradle shorthand.
123
+
124
+ ## Step 6 — write body to `/tmp/pr-body.md` (Write tool).
125
+
126
+ ## Step 7 — confirm. Show proposed title + base + body + resolved provider/binary. WAIT. PR creation is user-visible; never silent.
127
+
128
+ ## Step 8 — create. Run the detected binary (append `--draft` only if draft chosen):
129
+ ```bash
130
+ <BIN> --title "<title>" --body-file /tmp/pr-body.md --dest "<base>" [--draft]
131
+ ```
132
+ Always ask open-vs-draft before creating unless `draft` was passed. The binary attaches the
133
+ per-repo reviewer roster automatically; `--no-reviewers` skips, `--reviewer <id>` appends.
134
+
135
+ ## Step 9 — print final PR URL + reviewer count from binary output. On error, surface raw response.
136
+
137
+ ## Step 10 — offer Jira sync (guarded, optional).
138
+ Reuse ticket key from Step 4. If none, skip. `guard_cmd jira-update`; if absent, just print URL + stop.
139
+ If present, `AskUserQuestion`: sync now → `/jira-update <KEY>`, or skip (print reminder).
140
+
141
+ ## Cross-command guard
142
+ Sibling commands (`/pr-split`, `/jira-update`) are optional — a teammate may not have them. Before
143
+ invoking any, confirm it exists; if absent, degrade gracefully (one-line note). Reusable check:
144
+ ```bash
145
+ guard_cmd() { # usage: guard_cmd pr-split → prints path or "no-<cmd>"
146
+ ls "$PWD/.claude/commands/$1.md" "$HOME/.claude/commands/$1.md" \
147
+ "$PWD/.claude/skills/$1/SKILL.md" "$HOME/.claude/skills/$1/SKILL.md" 2>/dev/null | head -1 \
148
+ || echo "no-$1"
149
+ }
150
+ ```
151
+
152
+ ## Notes
153
+ - Does NOT push — assumes branch pushed. Unpushed commits ahead of upstream → warn + ask.
154
+ - Provider/token/convention all from config + remote — NO hardcoded team values here (D3/D4).
155
+ - Verify worktree before any split branch work (`git symbolic-ref --short HEAD` + `git worktree list`);
156
+ background restacks can move branches mid-turn.
157
+ - Caveman mode applies to replies — but PR title/body must be normal English.
package/install.js ADDED
@@ -0,0 +1,472 @@
1
+ #!/usr/bin/env node
2
+ // @nvice-r/pr-commands installer. Zero external deps (Node >=16 builtins only).
3
+ // Wizard: detect OS + provider, pick reviewers + convention, write config/roster,
4
+ // install binaries -> ~/.local/bin, copy /pr skill -> ~/.claude/commands.
5
+ //
6
+ // Flags: --dir <path> --provider <p> --all --yes --dry-run --help
7
+
8
+ 'use strict';
9
+ const fs = require('fs');
10
+ const os = require('os');
11
+ const path = require('path');
12
+ const https = require('https');
13
+ const readline = require('readline');
14
+ const { execFileSync } = require('child_process');
15
+
16
+ const HOME = os.homedir();
17
+ const PKG = __dirname;
18
+ const ASSETS = path.join(PKG, 'assets');
19
+ const BACK = Symbol('back'); // returned by a prompt when the user wants the previous step
20
+
21
+ const PROVIDERS = {
22
+ bitbucket: { bin: 'bbpr', host: 'bitbucket.org', keychainSvc: 'bitbucket-api-token', ready: true },
23
+ github: { bin: 'ghpr', host: 'github.com', keychainSvc: 'github-api-token', ready: false },
24
+ gitlab: { bin: 'glpr', host: 'gitlab.com', keychainSvc: 'gitlab-api-token', ready: false },
25
+ };
26
+ const CONVENTIONS = ['trueid', 'conventional', 'github-default', 'custom'];
27
+ // Concrete, editable defaults per preset. Whatever the user confirms is written to config,
28
+ // so `custom` is just "a preset with blank seeds" — same edit flow for every choice.
29
+ const PRESET_DEFS = {
30
+ trueid: { title_pattern: '[<ver>] Feature/<ver>/SC-XXXX <desc>', ticket_regex: 'SC-\\d+', template_sections: 'What Happened|Insight|Proof of Work' },
31
+ conventional: { title_pattern: '<type>(<scope>): <desc>', ticket_regex: '[A-Z]+-\\d+', template_sections: 'Summary|Changes|Testing' },
32
+ 'github-default': { title_pattern: '<desc> (#<issue>)', ticket_regex: '#\\d+', template_sections: 'Summary|Test plan' },
33
+ custom: { title_pattern: '', ticket_regex: '', template_sections: '' },
34
+ };
35
+
36
+ // ---- arg parse ----
37
+ const argv = process.argv.slice(2);
38
+ const flags = { dir: null, provider: null, all: false, yes: false, dryRun: false };
39
+ for (let i = 0; i < argv.length; i++) {
40
+ const a = argv[i];
41
+ if (a === '--help' || a === '-h') { printHelp(); process.exit(0); }
42
+ else if (a === '--dir') flags.dir = argv[++i];
43
+ else if (a === '--provider') flags.provider = argv[++i];
44
+ else if (a === '--all') flags.all = true;
45
+ else if (a === '--yes' || a === '-y') flags.yes = true;
46
+ else if (a === '--dry-run') flags.dryRun = true;
47
+ else { console.error(`unknown flag: ${a}`); process.exit(1); }
48
+ }
49
+
50
+ function printHelp() {
51
+ console.log(`@nvice-r/pr-commands installer
52
+
53
+ npx @nvice-r/pr-commands [flags]
54
+
55
+ --dir <path> Repo to configure (default: cwd)
56
+ --provider <p> Force bitbucket|github|gitlab (default: detect from remote)
57
+ --all Install all provider binaries (default: only the detected one)
58
+ --yes Accept defaults, no prompts (reviewers=all, convention=trueid)
59
+ --dry-run Print planned actions, write nothing
60
+ --help This help`);
61
+ }
62
+
63
+ // ---- helpers ----
64
+ // Each text prompt gets its OWN terminal-mode readline, closed right after. Never share one
65
+ // readline across prompts + raw-mode selectors — the terminal modes corrupt each other
66
+ // (arrows leak into the line as literal ^[[A escape codes after a selector runs).
67
+ const ask = (q) => new Promise((res) => {
68
+ const r = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
69
+ r.question(q, (a) => { r.close(); res(a.trim()); });
70
+ });
71
+ // Editable pre-filled default: edit the shown value or hit enter to keep it.
72
+ // `:b` (or `:back`) returns BACK; empty keeps the default.
73
+ const askPrefill = (q, def, allowBack = true) => new Promise((res) => {
74
+ const r = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
75
+ r.question(q, (a) => {
76
+ r.close();
77
+ const t = a.trim();
78
+ // value is pre-filled, so `:b` typed at the end appends → match as a suffix, not just equality
79
+ if (allowBack && (t.endsWith(':b') || t.endsWith(':back'))) return res(BACK);
80
+ res((t || def || '').trim());
81
+ });
82
+ if (def && process.stdin.isTTY) r.write(def); // inject editable text
83
+ });
84
+
85
+ // Interactive checklist (↑/↓ move · space toggle · a all · n none · enter confirm).
86
+ // Zero-dep raw-mode TTY. Returns array of selected indices. items: [{label}].
87
+ // allDefault=true pre-selects everything. Falls back to select-all on a non-TTY.
88
+ function multiSelect(title, items, allDefault = true, preIdx = null) {
89
+ return new Promise((resolve) => {
90
+ if (!process.stdin.isTTY || items.length === 0) {
91
+ resolve(preIdx || (allDefault ? items.map((_, i) => i) : []));
92
+ return;
93
+ }
94
+ const sel = items.map((_, i) => (preIdx ? preIdx.includes(i) : allDefault));
95
+ let cur = 0, top = 0;
96
+ const VIEW = Math.min(items.length, 20); // visible rows; longer lists scroll/page through this window (tune this)
97
+ const LINES = VIEW + 3; // title + VIEW rows + counter + hint (fixed → cursor-up math stays correct)
98
+ const out = process.stdout;
99
+ const render = (first) => {
100
+ if (cur < top) top = cur; // scrolled above window → page up
101
+ else if (cur >= top + VIEW) top = cur - VIEW + 1; // scrolled below → page down
102
+ if (!first) out.write(`\x1b[${LINES}F`); // up LINES, to col 0
103
+ out.write(`\x1b[J`); // clear from cursor down
104
+ out.write(`\r\x1b[K ${title}\n`);
105
+ for (let r = 0; r < VIEW; r++) {
106
+ const i = top + r;
107
+ const it = items[i];
108
+ const box = sel[i] ? '[x]' : '[ ]';
109
+ const arrow = i === cur ? '\x1b[36m>\x1b[0m' : ' ';
110
+ const lbl = i === cur ? `\x1b[36m${it.label}\x1b[0m` : it.label;
111
+ out.write(`\r\x1b[K ${arrow} ${box} ${lbl}\n`);
112
+ }
113
+ const nsel = sel.filter(Boolean).length;
114
+ const range = items.length > VIEW ? `${top + 1}-${Math.min(top + VIEW, items.length)}/${items.length}` : `${items.length}`;
115
+ out.write(`\r\x1b[K \x1b[2m${range} shown · ${nsel} selected\x1b[0m\n`);
116
+ out.write(`\r\x1b[K \x1b[2m↑/↓ move · space toggle · a all · n none · b back · enter confirm\x1b[0m\n`);
117
+ };
118
+ process.stdin.setRawMode(true);
119
+ process.stdin.resume();
120
+ render(true);
121
+ const onData = (buf) => {
122
+ const k = buf.toString();
123
+ if (k === '\x03') { cleanup(); process.exit(130); } // ctrl-c
124
+ else if (k === 'b') { cleanup(); resolve(BACK); return; } // b = back (NOT esc — esc is the arrow-key prefix)
125
+ else if (k === '\x1b[A' || k === 'k') cur = (cur - 1 + items.length) % items.length;
126
+ else if (k === '\x1b[B' || k === 'j') cur = (cur + 1) % items.length;
127
+ else if (k === ' ') sel[cur] = !sel[cur];
128
+ else if (k === 'a') sel.fill(true);
129
+ else if (k === 'n') sel.fill(false);
130
+ else if (k === '\r' || k === '\n') {
131
+ cleanup();
132
+ resolve(items.map((_, i) => i).filter((i) => sel[i]));
133
+ return;
134
+ }
135
+ render(false);
136
+ };
137
+ function cleanup() {
138
+ process.stdin.removeListener('data', onData);
139
+ process.stdin.setRawMode(false);
140
+ process.stdin.pause();
141
+ }
142
+ process.stdin.on('data', onData);
143
+ });
144
+ }
145
+
146
+ // Single-select list (↑/↓ move · enter pick). Returns chosen index. Non-TTY → def.
147
+ function multiSelectOne(title, labels, def = 0) {
148
+ return new Promise((resolve) => {
149
+ if (!process.stdin.isTTY || labels.length === 0) { resolve(def); return; }
150
+ let cur = def;
151
+ const out = process.stdout;
152
+ const render = (first) => {
153
+ if (!first) out.write(`\x1b[${labels.length + 2}F`);
154
+ out.write(`\x1b[J`);
155
+ out.write(`\r\x1b[K ${title}\n`);
156
+ labels.forEach((l, i) => {
157
+ const arrow = i === cur ? '\x1b[36m>\x1b[0m' : ' ';
158
+ out.write(`\r\x1b[K ${arrow} ${i === cur ? `\x1b[36m${l}\x1b[0m` : l}\n`);
159
+ });
160
+ out.write(`\r\x1b[K \x1b[2m↑/↓ move · b back · enter select\x1b[0m\n`);
161
+ };
162
+ process.stdin.setRawMode(true); process.stdin.resume(); render(true);
163
+ const onData = (buf) => {
164
+ const k = buf.toString();
165
+ if (k === '\x03') { cleanup(); process.exit(130); }
166
+ else if (k === 'b') { cleanup(); resolve(BACK); return; } // b = back (NOT esc — esc is the arrow-key prefix)
167
+ else if (k === '\x1b[A' || k === 'k') cur = (cur - 1 + labels.length) % labels.length;
168
+ else if (k === '\x1b[B' || k === 'j') cur = (cur + 1) % labels.length;
169
+ else if (k === '\r' || k === '\n') { cleanup(); resolve(cur); return; }
170
+ render(false);
171
+ };
172
+ function cleanup() {
173
+ process.stdin.removeListener('data', onData);
174
+ process.stdin.setRawMode(false); process.stdin.pause();
175
+ }
176
+ process.stdin.on('data', onData);
177
+ });
178
+ }
179
+
180
+ function log(...a) { console.log(...a); }
181
+ function printLegend() {
182
+ console.log(' Placeholders (used in title_pattern; filled by /pr from the branch):');
183
+ console.log(' \x1b[2m<ver> branch version e.g. 2.3.0\x1b[0m');
184
+ console.log(' \x1b[2m<desc> description from branch suffix / commits\x1b[0m');
185
+ console.log(' \x1b[2m<ticket> ticket key matched by ticket_regex, e.g. SC-5424\x1b[0m');
186
+ console.log(' \x1b[2m<type> conventional type feat | fix | chore | ...\x1b[0m');
187
+ console.log(' \x1b[2m<scope> conventional scope e.g. auth\x1b[0m');
188
+ console.log(' \x1b[2m<issue> issue number e.g. 42\x1b[0m');
189
+ console.log(' \x1b[2menter keeps shown value · append :b then enter to go back\x1b[0m\n');
190
+ }
191
+ function sh(cmd, args, opts = {}) {
192
+ return execFileSync(cmd, args, { encoding: 'utf8', ...opts }).trim();
193
+ }
194
+ function shSafe(cmd, args, opts = {}) {
195
+ // probe variant: swallow the child's stderr (e.g. git's "fatal: not a git repository")
196
+ try { return sh(cmd, args, { stdio: ['ignore', 'pipe', 'ignore'], ...opts }); } catch { return null; }
197
+ }
198
+ function writeFile(p, content, mode) {
199
+ if (flags.dryRun) { log(` [dry-run] write ${p}`); return; }
200
+ fs.mkdirSync(path.dirname(p), { recursive: true });
201
+ fs.writeFileSync(p, content);
202
+ if (mode) fs.chmodSync(p, mode);
203
+ log(` wrote ${p}`);
204
+ }
205
+ // Main repo root, shared across worktrees: git-common-dir points at the main .git
206
+ // even from a linked worktree; its parent is the main checkout root. gitCommonDir
207
+ // may be relative ('.git') or absolute ('/main/.git') — path.resolve handles both.
208
+ function mainRepoRoot(wd) {
209
+ const gcd = shSafe('git', ['-C', wd, 'rev-parse', '--git-common-dir']);
210
+ return gcd ? path.resolve(wd, gcd, '..') : null;
211
+ }
212
+ // Depth-limited scan for git repos beneath a non-repo dir. Stops descending once a
213
+ // repo is found (no nested-repo noise); skips dotdirs + node_modules; caps results.
214
+ function scanGitRepos(root, maxDepth = 3, cap = 50) {
215
+ const found = [];
216
+ const walk = (dir, depth) => {
217
+ if (depth > maxDepth || found.length >= cap) return;
218
+ let ents;
219
+ try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
220
+ if (ents.some((e) => e.name === '.git')) { found.push(dir); return; } // this IS a repo
221
+ for (const e of ents) {
222
+ if (!e.isDirectory() || e.name === 'node_modules' || e.name.startsWith('.')) continue;
223
+ walk(path.join(dir, e.name), depth + 1);
224
+ }
225
+ };
226
+ walk(root, 0);
227
+ return found;
228
+ }
229
+
230
+ // ---- provider detection ----
231
+ function detectProvider(remoteUrl) {
232
+ if (flags.provider) return flags.provider;
233
+ for (const [name, cfg] of Object.entries(PROVIDERS)) {
234
+ if (remoteUrl && remoteUrl.includes(cfg.host)) return name;
235
+ }
236
+ return null;
237
+ }
238
+ function parseSlug(remoteUrl) {
239
+ return remoteUrl
240
+ .replace(/^git@[^:]+:/, '')
241
+ .replace(/^https?:\/\/[^/]+\//, '')
242
+ .replace(/\.git$/, '');
243
+ }
244
+
245
+ // ---- keychain (macOS) ----
246
+ function keychainAccounts(svc) {
247
+ if (os.platform() !== 'darwin') return [];
248
+ // dump-keychain is noisy + may prompt; instead try find-generic-password for the service
249
+ const out = shSafe('security', ['find-generic-password', '-s', svc]);
250
+ if (!out) return [];
251
+ const m = out.match(/"acct"<blob>="([^"]*)"/);
252
+ return m ? [m[1]] : [];
253
+ }
254
+ function keychainToken(svc, acct) {
255
+ return shSafe('security', ['find-generic-password', '-s', svc, '-a', acct, '-w']);
256
+ }
257
+
258
+ // ---- reviewer fetch (Bitbucket default-reviewers) ----
259
+ function httpsGetJson(url, auth) {
260
+ return new Promise((resolve, reject) => {
261
+ const u = new URL(url);
262
+ const opts = { method: 'GET', headers: { 'User-Agent': 'pr-commands' } };
263
+ if (auth) opts.headers.Authorization = 'Basic ' + Buffer.from(auth).toString('base64');
264
+ https.get(u, opts, (res) => {
265
+ let data = '';
266
+ res.on('data', (c) => (data += c));
267
+ res.on('end', () => {
268
+ try { resolve({ status: res.statusCode, json: JSON.parse(data) }); }
269
+ catch { resolve({ status: res.statusCode, json: null }); }
270
+ });
271
+ }).on('error', reject);
272
+ });
273
+ }
274
+ async function fetchSelfUuid(email, token) {
275
+ const { status, json } = await httpsGetJson('https://api.bitbucket.org/2.0/user', `${email}:${token}`);
276
+ if (status !== 200 || !json) return null;
277
+ return json.uuid || null;
278
+ }
279
+ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
280
+ const PAGELEN = 100; // max Bitbucket page; loop follows `next` if a repo ever exceeds it
281
+ let url = `https://api.bitbucket.org/2.0/repositories/${slug}/default-reviewers?pagelen=${PAGELEN}`;
282
+ const out = [];
283
+ let page = 0;
284
+ while (url) {
285
+ const { status, json } = await httpsGetJson(url, `${email}:${token}`);
286
+ if (status !== 200 || !json || !json.values) return page ? out : null; // page>0 → return what we got
287
+ page++;
288
+ for (const v of json.values) out.push({ uuid: v.uuid, name: v.display_name || v.nickname || v.uuid });
289
+ url = json.next || null; // Bitbucket gives a full next-page URL when more remain
290
+ }
291
+ const filtered = selfUuid ? out.filter((r) => r.uuid !== selfUuid) : out;
292
+ log(` fetched ${out.length} default-reviewer(s) across ${page} page(s)` +
293
+ (selfUuid && out.length !== filtered.length ? ` — excluded yourself` : ''));
294
+ return filtered;
295
+ }
296
+
297
+ // ---- main ----
298
+ (async () => {
299
+ // --- environment (computed once, not a back-able step; shown in every screen header) ---
300
+ const platform = os.platform();
301
+ const cfgOs = platform === 'darwin' ? 'macos' : platform === 'linux' ? 'linux' : platform;
302
+ const tokenSrc = cfgOs === 'macos' ? 'keychain' : 'env';
303
+
304
+ const st = { conv: {} }; // accumulates across steps; survives back/forward
305
+
306
+ // Breadcrumb of decisions committed BEFORE `stage` (so re-entering a step never shows its own value).
307
+ const crumb = (stage) => {
308
+ const L = [];
309
+ if (stage > 0 && st.workDir) {
310
+ L.push(` \x1b[32m✓\x1b[0m work dir ${st.workDir}`);
311
+ L.push(` \x1b[32m✓\x1b[0m provider ${st.provider} (${st.slug}) → ${st.pcfg.bin}`);
312
+ L.push(` \x1b[32m✓\x1b[0m auth ${st.email || '(none)'}${st.token ? ' · token ok' : ' · no token'}`);
313
+ }
314
+ if (stage > 1 && st.chosen) L.push(` \x1b[32m✓\x1b[0m reviewers ${st.chosen.length} selected`);
315
+ if (stage > 2 && st.convention) L.push(` \x1b[32m✓\x1b[0m convention ${st.convention}`);
316
+ return L;
317
+ };
318
+ // Full-screen redraw: wipe, reprint header + breadcrumb + active-step marker. No accumulation on back.
319
+ const screen = (stage, active) => {
320
+ if (process.stdout.isTTY) process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
321
+ console.log('@nvice-r/pr-commands installer' + (flags.dryRun ? ' (dry-run)' : ''));
322
+ console.log(` os=${cfgOs} token_src=${tokenSrc}` + (cfgOs !== 'macos' ? ' \x1b[33m(only macOS fully supported)\x1b[0m' : ''));
323
+ const c = crumb(stage);
324
+ if (c.length) { console.log(''); c.forEach((l) => console.log(l)); }
325
+ console.log(`\n▶ ${active}`);
326
+ };
327
+
328
+ // --- back-able steps: each returns BACK to revisit the previous step, else falls through ---
329
+
330
+ // Work dir + everything derived from it (provider/slug/identity/token).
331
+ const sWorkDir = async () => {
332
+ screen(0, 'Work directory');
333
+ let wd;
334
+ if (flags.dir || flags.yes) wd = path.resolve(flags.dir || process.cwd());
335
+ else {
336
+ const a = await askPrefill(' repo path: ', st.workDir || process.cwd());
337
+ if (a === BACK) return BACK; // first step — driver just stays here
338
+ wd = path.resolve(a);
339
+ }
340
+ log(` ${wd}`);
341
+ if (!shSafe('git', ['-C', wd, 'rev-parse', '--git-dir'])) {
342
+ log(' not a git repo here — scanning up to 3 levels below for repos…');
343
+ const repos = scanGitRepos(wd);
344
+ if (repos.length === 0) {
345
+ console.error(` ERROR: no git repo at ${wd}, and none found within 3 levels.`);
346
+ console.error(' Point --dir at a git repo, or cd into one and re-run.');
347
+ process.exit(1);
348
+ }
349
+ if (flags.yes) { wd = repos[0]; log(` picked ${wd}`); }
350
+ else {
351
+ const labels = repos.map((r) => path.relative(wd, r) || '.');
352
+ const idx = await multiSelectOne(`Found ${repos.length} repo(s) under ${wd} — pick one:`, labels, 0);
353
+ if (idx === BACK) return BACK;
354
+ wd = repos[idx];
355
+ screen(0, 'Work directory'); // repaint (selector left its own frame)
356
+ log(` ${wd}`);
357
+ }
358
+ }
359
+ const remoteUrl = shSafe('git', ['-C', wd, 'remote', 'get-url', 'origin']);
360
+ if (!remoteUrl) { console.error(' ERROR: no git origin remote'); process.exit(1); }
361
+ const provider = detectProvider(remoteUrl);
362
+ if (!provider) { console.error(` ERROR: could not detect provider from ${remoteUrl}`); process.exit(1); }
363
+ const pcfg = PROVIDERS[provider];
364
+ const slug = parseSlug(remoteUrl);
365
+ log(` remote: ${remoteUrl}\n provider=${provider} slug=${slug} binary=${pcfg.bin}`);
366
+ if (!pcfg.ready) { console.error(` ERROR: ${provider} not yet supported (macOS+Bitbucket only). Aborting.`); process.exit(1); }
367
+ // identity + token (derived from provider)
368
+ let email = '';
369
+ const accts = keychainAccounts(pcfg.keychainSvc);
370
+ if (accts.length === 1) { email = accts[0]; log(` email (keychain): ${email}`); }
371
+ else if (!flags.yes) { const e = await ask(' Atlassian email: '); email = e || (accts[0] || ''); }
372
+ else if (accts.length) email = accts[0];
373
+ const token = email ? keychainToken(pcfg.keychainSvc, email) : null;
374
+ if (!token) {
375
+ log(` WARN: no keychain token for ${pcfg.keychainSvc}/${email || '(none)'} — reviewers can't auto-fetch`);
376
+ log(` security add-generic-password -s ${pcfg.keychainSvc} -a ${email || 'you@example.com'} -w '<api-token>'`);
377
+ } else log(' token: found');
378
+ if (st.slug !== slug) st.revs = undefined; // invalidate reviewer cache if repo changed
379
+ const mroot = mainRepoRoot(wd) || wd; // shared across worktrees; roster lives here
380
+ if (mroot !== wd) log(` worktree detected → roster shared at main root: ${mroot}`);
381
+ Object.assign(st, { workDir: wd, mainRoot: mroot, remoteUrl, provider, pcfg, slug, email, token });
382
+ };
383
+
384
+ const sReviewers = async () => {
385
+ screen(1, 'Reviewers');
386
+ st.chosen = st.chosen || [];
387
+ if (!(st.token && st.email)) { log(' skipped (no token) — edit the roster later.'); st.chosen = []; return; }
388
+ if (st.selfUuid === undefined) st.selfUuid = await fetchSelfUuid(st.email, st.token);
389
+ if (st.revs === undefined) st.revs = await fetchBitbucketReviewers(st.slug, st.email, st.token, st.selfUuid);
390
+ const revs = st.revs;
391
+ if (!revs) { log(' WARN: could not fetch default-reviewers. Roster left empty.'); st.chosen = []; return; }
392
+ if (revs.length === 0) { log(' repo has no default reviewers. Roster left empty.'); st.chosen = []; return; }
393
+ if (flags.yes) { st.chosen = revs; st.chosenIdx = revs.map((_, i) => i); log(` selected all ${revs.length}`); return; }
394
+ const picked = await multiSelect(
395
+ `Reviewers for ${st.slug} (default: all):`,
396
+ revs.map((r) => ({ label: r.name })), true, st.chosenIdx || null,
397
+ );
398
+ if (picked === BACK) return BACK;
399
+ st.chosenIdx = picked;
400
+ st.chosen = picked.map((i) => revs[i]);
401
+ log(` chosen: ${st.chosen.length}`);
402
+ };
403
+
404
+ const sPreset = async () => {
405
+ screen(2, 'Convention — preset');
406
+ if (flags.yes) {
407
+ st.convention = st.convention || 'trueid';
408
+ st.conv = { ...PRESET_DEFS[st.convention] };
409
+ return;
410
+ }
411
+ const idx = Math.max(0, CONVENTIONS.indexOf(st.convention || 'trueid'));
412
+ const pick = await multiSelectOne('Convention preset:', CONVENTIONS, idx);
413
+ if (pick === BACK) return BACK;
414
+ const chosenConv = CONVENTIONS[pick];
415
+ if (chosenConv !== st.convention) { // reseed fields only when preset actually changes
416
+ st.convention = chosenConv;
417
+ st.conv = { ...PRESET_DEFS[chosenConv] };
418
+ }
419
+ };
420
+
421
+ const editField = (label, key) => async () => {
422
+ screen(3, `Convention — ${key}`);
423
+ printLegend();
424
+ const v = await askPrefill(` ${label} `, st.conv[key] ?? PRESET_DEFS[st.convention][key]);
425
+ if (v === BACK) return BACK;
426
+ st.conv[key] = v;
427
+ };
428
+
429
+ const steps = flags.yes
430
+ ? [sWorkDir, sReviewers, sPreset]
431
+ : [sWorkDir, sReviewers, sPreset,
432
+ editField('title_pattern: ', 'title_pattern'),
433
+ editField('ticket_regex: ', 'ticket_regex'),
434
+ editField('template_sections:', 'template_sections')];
435
+
436
+ let i = 0;
437
+ while (i < steps.length) {
438
+ const r = await steps[i]();
439
+ if (r === BACK) i = Math.max(0, i - 1);
440
+ else i++;
441
+ }
442
+
443
+ // --- install ---
444
+ screen(3, 'Install');
445
+ const { slug, pcfg, email, convention, conv, chosen } = st;
446
+ let cfg = `os=${cfgOs}\ntoken_src=${tokenSrc}\nemail=${email}\nconvention=${convention}\n`;
447
+ if (conv.title_pattern) cfg += `title_pattern=${conv.title_pattern}\n`;
448
+ if (conv.ticket_regex) cfg += `ticket_regex=${conv.ticket_regex}\n`;
449
+ if (conv.template_sections) cfg += `template_sections=${conv.template_sections}\n`;
450
+ writeFile(path.join(HOME, '.config/pr/config'), cfg, 0o600);
451
+ // Roster lives in the work dir (main repo root — shared across worktrees). Editable
452
+ // in place; pr-common.sh finds it first, falling back to the global path.
453
+ const roster = chosen.map((r) => `${r.uuid} # ${r.name}`).join('\n') + (chosen.length ? '\n' : '');
454
+ const header = `# pr reviewers for ${slug} — {uuid} # Name per line; blanks/# ignored. Edit freely.\n`;
455
+ writeFile(path.join(st.mainRoot, '.pr-reviewers.txt'), header + (roster || '# (none selected)\n'));
456
+ const binDir = path.join(HOME, '.local/bin');
457
+ const binsToInstall = flags.all
458
+ ? Object.values(PROVIDERS).filter((p) => p.ready).map((p) => p.bin)
459
+ : [pcfg.bin];
460
+ writeFile(path.join(binDir, 'pr-common.sh'), fs.readFileSync(path.join(ASSETS, 'bin/pr-common.sh')), 0o755);
461
+ for (const b of binsToInstall) {
462
+ const src = path.join(ASSETS, 'bin', b);
463
+ if (!fs.existsSync(src)) { log(` skip ${b} (asset not present yet)`); continue; }
464
+ writeFile(path.join(binDir, b), fs.readFileSync(src), 0o755);
465
+ }
466
+ writeFile(path.join(HOME, '.claude/commands/pr.md'), fs.readFileSync(path.join(ASSETS, 'skill/pr.md')));
467
+ if (!(process.env.PATH || '').split(':').includes(binDir)) {
468
+ log(`\n WARN: ${binDir} not on PATH. Add to your shell rc:`);
469
+ log(` export PATH="$HOME/.local/bin:$PATH"`);
470
+ }
471
+ log('\nDone. Try: /pr help (in Claude Code, from ' + st.workDir + ')');
472
+ })().catch((e) => { console.error(e); process.exit(1); });
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@nvice-r/pr-commands",
3
+ "version": "0.1.0",
4
+ "description": "Provider-agnostic PR helper for Claude Code — npx installer + bbpr/ghpr/glpr binaries + /pr skill",
5
+ "bin": {
6
+ "pr-commands": "install.js"
7
+ },
8
+ "files": [
9
+ "install.js",
10
+ "assets/"
11
+ ],
12
+ "engines": {
13
+ "node": ">=16"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "keywords": ["claude-code", "pull-request", "bitbucket", "github", "gitlab", "cli"],
19
+ "author": "nvice.r",
20
+ "license": "MIT"
21
+ }