@nvice-r/pr-commands 0.1.2 → 0.1.4

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 CHANGED
@@ -23,6 +23,8 @@ The interactive wizard:
23
23
  4. **Identity + token** — reads your API token from the keychain (or tells you how to add one).
24
24
  5. **Reviewers** — fetches the repo's default reviewers into an interactive checklist
25
25
  (`↑/↓` move · `space` toggle · `a` all · `n` none · `b` back · `enter` confirm; all pre-selected).
26
+ On reinstall, an existing roster is kept: its members are pre-selected instead, and entries
27
+ that aren't current default reviewers are carried over as-is.
26
28
  6. **Convention** — pick a preset, then review/edit its title-pattern, ticket-regex, and
27
29
  template-sections (each pre-filled from the preset, editable). A placeholder legend
28
30
  (`<ver>`, `<desc>`, `<ticket>`, …) is shown. Concrete values are written to config — `/pr`
@@ -44,7 +46,9 @@ Flags:
44
46
  | `--provider <p>` | force `bitbucket`\|`github`\|`gitlab` instead of detecting |
45
47
  | `--agent <id>` | force skill install to this agent (repeatable): `claude-code`\|`antigravity`\|`codex`\|`opencode`. Default: auto-detect |
46
48
  | `--all` | install every ready provider binary, not just the detected one |
47
- | `--yes` | non-interactive: reviewers = all, convention = `trueid` |
49
+ | `--agent-dir <dir>` | repo folder for the reviewer roster (default: primary agent's — `.claude`/`.agent`/`.codex`/`.opencode`) |
50
+ | `--yes` | non-interactive: reviewers = existing roster if any, else all; convention = `trueid` |
51
+ | `--reset-reviewers` | ignore an existing roster and rebuild it from the repo's default reviewers |
48
52
  | `--dry-run` | print planned actions, write nothing |
49
53
  | `--help` | usage |
50
54
 
@@ -74,10 +78,11 @@ identically from every clone and every git worktree, no repo-local symlinks.
74
78
 
75
79
  | Artifact | Path |
76
80
  |----------|------|
77
- | Shared library | `~/.local/bin/pr-common.sh` |
78
- | Provider binary | `~/.local/bin/bbpr` (`ghpr`/`glpr` later) |
81
+ | Command (only file on `PATH`) | `~/.local/bin/pr-commands` |
82
+ | Shared library | `~/.local/share/pr-commands/pr-common.sh` |
83
+ | Host provider | `~/.local/share/pr-commands/providers/bitbucket.sh` (`github.sh`/`gitlab.sh` later) |
79
84
  | Config | `~/.config/pr/config` |
80
- | Reviewer roster | `<main-repo-root>/.pr/reviewers.txt` (neutral, agent-agnostic; shared across worktrees) |
85
+ | Reviewer roster | `<main-repo-root>/<agent_dir>/pr-reviewers.txt` (primary agent's folder; shared across worktrees) |
81
86
  | Canonical skill | `~/.config/pr/skill/pr/SKILL.md` (single source) |
82
87
  | Per-agent skill | `<agent-skills-dir>/pr` → symlink to canonical |
83
88
  | Claude `/pr` command | `~/.claude/commands/pr.md` (dual-install, keeps the slash trigger) |
@@ -115,6 +120,30 @@ convention=trueid # see presets
115
120
  Edit by hand any time. **A missing config is a hard error** — `/pr` and the binaries refuse to
116
121
  guess. Re-run the installer to regenerate.
117
122
 
123
+ ### Per-project convention (`.agents/config/pr.yaml`)
124
+
125
+ When the repo has `.agents/config/pr.yaml` (this checkout, or the main checkout for worktrees),
126
+ `/pr` reads the convention from it instead of the user config. One file per project, so teams
127
+ with different conventions share the same skill:
128
+
129
+ ```yaml
130
+ title_pattern: "[<ver>] Feature/<ver>/<ticket> <desc>"
131
+ ticket_regex: "SC-(\\d+|NOTICKET)"
132
+ template_sections: [What Happened, Insight, Proof of Work]
133
+ body_rules: |
134
+ - Free-text project rules the skill follows literally.
135
+ base: develop # default base proposal
136
+ draft: ask # ask | true | false
137
+ push: [origin] # remotes pushed before create
138
+ fork: # optional cross-repo mode (`/pr cross`)
139
+ remote: social-commerce
140
+ repo: workspace/fork-repo
141
+ pre_pr: [] # commands run before confirm; non-zero exit stops /pr
142
+ post_pr: [jira-update] # sibling skills offered after create
143
+ ```
144
+
145
+ Without the file, the convention keys below (written by the wizard) are used.
146
+
118
147
  ### Convention presets
119
148
 
120
149
  | preset | title pattern | ticket regex | template sections |
@@ -135,15 +164,19 @@ template_sections=Context|Changes|Risk
135
164
 
136
165
  ### Reviewers
137
166
 
138
- Roster lives in a **neutral `.pr/` folder** under the main repo root — it's data the bash binary
139
- reads, not an agent artifact, so it's agent-agnostic (survives switching agents). Editable next to
140
- the code. Resolved via `git --git-common-dir/..`, so all worktrees of a repo share one roster:
167
+ Roster lives in the **primary agent's repo folder** under the main repo root — `.claude`,
168
+ `.agent` (Antigravity), `.codex` or `.opencode`, picked from the first agent you install for
169
+ (override with `--agent-dir`). The choice is saved as `agent_dir` in the config so the bash binary
170
+ reads the same path. Resolved via `git --git-common-dir/..`, so all worktrees of a repo share one
171
+ roster:
141
172
 
142
173
  ```
143
- <main-repo-root>/.pr/reviewers.txt
174
+ <main-repo-root>/<agent_dir>/pr-reviewers.txt
144
175
  ```
145
176
 
146
- You (the author) are excluded on install — bbpr also drops self at PR time. One entry per line,
177
+ Switching primary agent = re-run the installer; the old file still loads as a fallback.
178
+
179
+ You (the author) are excluded on install — `pr-commands` also drops self at PR time. One entry per line,
147
180
  trailing `# Name` comment optional:
148
181
 
149
182
  ```
@@ -151,23 +184,23 @@ trailing `# Name` comment optional:
151
184
  {fedcba98-7654-3210-fedc-ba9876543210} # Bob
152
185
  ```
153
186
 
154
- It's plain data — edit freely, no reinstall. Consider gitignoring `.pr/` unless the team wants the
155
- roster committed. Load order: `$BBPR_REVIEWERS` → `.pr/reviewers.txt` → `<agent_dir>/pr-reviewers.txt`
156
- (0.1.1) → root `.pr-reviewers.txt` (pre-0.1.1) → global `~/.config/pr/reviewers/<ws>-<slug>.txt` →
187
+ It's plain data — edit freely, no reinstall. Consider gitignoring the roster unless the team wants
188
+ it committed. Load order: `$PR_REVIEWERS` → `<agent_dir>/pr-reviewers.txt` → `.pr/reviewers.txt`
189
+ (0.1.2) → root `.pr-reviewers.txt` (pre-0.1.1) → global `~/.config/pr/reviewers/<ws>-<slug>.txt` →
157
190
  legacy. Run-time overrides: `--no-reviewers` skips all, `--reviewer <uuid>` appends.
158
191
 
159
192
  ### Auth precedence (email)
160
193
 
161
- `--email` flag → `$BB_EMAIL` → `git config bbpr.email` → config `email` → keychain account.
194
+ `--email` flag → `$BB_EMAIL` → `git config pr.email` → config `email` → keychain account.
162
195
  The token is always read from the keychain (or `$PR_TOKEN` when `token_src=env`).
163
196
 
164
197
  ## Provider support
165
198
 
166
- | provider | binary | status |
167
- |----------|--------|--------|
168
- | Bitbucket Cloud | `bbpr` | ✅ ready |
169
- | GitHub | `ghpr` | 🚧 stub — errors `not-yet-supported` |
170
- | GitLab | `glpr` | 🚧 stub |
199
+ | provider | file | status |
200
+ |----------|------|--------|
201
+ | Bitbucket Cloud | `providers/bitbucket.sh` | ✅ ready |
202
+ | GitHub | `providers/github.sh` | 🚧 not yet — `pr-commands` errors `github not yet supported` |
203
+ | GitLab | `providers/gitlab.sh` | 🚧 not yet |
171
204
 
172
205
  | OS | token source | status |
173
206
  |----|--------------|--------|
@@ -176,15 +209,21 @@ The token is always read from the keychain (or `$PR_TOKEN` when `token_src=env`)
176
209
 
177
210
  ## Architecture
178
211
 
179
- - **Split by provider, not by OS.** Each provider gets its own small binary (`bbpr`/`ghpr`/`glpr`)
180
- because the REST APIs genuinely differ. OS differences (token retrieval) are a single
181
- `get_token()` function switched by `uname`, not a separate binary. Ceiling: 3 binaries, never
182
- 3 × OS.
212
+ - **One command, one file per host.** `pr-commands` is the only thing on `PATH`. It reads
213
+ `git remote get-url origin` (override: `$PR_PROVIDER`) and loads `providers/<host>.sh`.
214
+ Each host keeps its own small file because the REST APIs genuinely differ. OS differences
215
+ (token retrieval) are a single `get_token()` function, not a separate file.
183
216
  - **Shared logic in `pr-common.sh`** — git scan, base save, reviewer load, arg parse, token —
184
- sourced by every binary. A provider binary is ~40 lines: payload shape + endpoint + parse.
217
+ sourced before the provider. A provider file is ~60 lines: payload shape + endpoint + parse.
185
218
  - **One generic `/pr` skill.** Convention lives in config, not the skill, so the skill is
186
- provider- and team-neutral and installs globally. Adding a provider = one binary + one dispatch
187
- line, zero new skill files.
219
+ host- and team-neutral and installs globally. It always calls `pr-commands create`, so adding
220
+ a host = one provider file + `ready: true` in `install.js`, zero skill changes.
221
+
222
+ ```bash
223
+ pr-commands create --title "..." --body-file body.md --dest develop [--draft]
224
+ pr-commands check # which host, and is it supported?
225
+ pr-commands help
226
+ ```
188
227
 
189
228
  Full rationale in [`DECISIONS.md`](./DECISIONS.md).
190
229
 
@@ -195,14 +234,15 @@ Full rationale in [`DECISIONS.md`](./DECISIONS.md).
195
234
  | `no ~/.config/pr/config` | run `npx @nvice-r/pr-commands` |
196
235
  | reviewers not fetched | add the keychain token (see Prerequisites), re-run |
197
236
  | `WARN ~/.local/bin not on PATH` | add the export line above to your shell rc |
198
- | `not-yet-supported: <provider>` | GitHub/GitLab aren't implemented yet |
199
- | worktree runs old behavior | skill/binaries are global, not repo-local; ensure no stale per-repo copy shadows them |
237
+ | `<provider> not yet supported` | GitHub/GitLab aren't implemented yet |
238
+ | worktree runs old behavior | skill/command are global, not repo-local; ensure no stale per-repo copy shadows them |
200
239
  | agent doesn't see the skill | re-run and force it: `npx @nvice-r/pr-commands --agent <id>`; check `<agent-skills-dir>/pr` symlink exists |
201
240
 
202
241
  ## Uninstall
203
242
 
204
243
  ```bash
205
- rm ~/.local/bin/bbpr ~/.local/bin/pr-common.sh
244
+ rm ~/.local/bin/pr-commands
245
+ rm -rf ~/.local/share/pr-commands
206
246
  rm -rf ~/.config/pr
207
247
  rm ~/.claude/commands/pr.md
208
248
  # per-agent skill symlinks (remove those you installed):
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env bash
2
+ # pr-commands — create pull requests on any git host (one entry point).
3
+ #
4
+ # Usage:
5
+ # pr-commands create --title "..." [--body|--body-file f] [--source b] [--dest b]
6
+ # [--repo owner/name] [--draft] [--reviewer id]... [--no-reviewers]
7
+ # [--email a] [--close-source] [--source-repo owner/fork]
8
+ # pr-commands check detect the host from the origin remote; exit 0 if supported
9
+ # pr-commands help this help
10
+ # pr-commands --version
11
+ #
12
+ # The host is detected from `git remote get-url origin` (override: $PR_PROVIDER).
13
+ # Host code lives in <share>/providers/<provider>.sh; shared code in <share>/pr-common.sh,
14
+ # where <share> = $PR_COMMANDS_HOME, else <this dir>/../share/pr-commands.
15
+ # pr-commands-version: 1
16
+
17
+ set -euo pipefail
18
+
19
+ PR_CMD="pr-commands"
20
+ _here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
21
+ SHARE="${PR_COMMANDS_HOME:-$_here/../share/pr-commands}"
22
+
23
+ usage() { sed -n '2,14p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; }
24
+ fail() { echo "$PR_CMD: $*" >&2; exit 1; }
25
+
26
+ # Echo the provider name for the origin remote (or $PR_PROVIDER), or fail.
27
+ detect_provider() {
28
+ if [ -n "${PR_PROVIDER:-}" ]; then echo "$PR_PROVIDER"; return; fi
29
+ local url
30
+ url="$(git remote get-url origin 2>/dev/null)" || fail "no origin remote"
31
+ case "$url" in
32
+ *bitbucket.org*) echo bitbucket ;;
33
+ *github.com*) echo github ;;
34
+ *gitlab*) echo gitlab ;;
35
+ *) fail "unknown git host for remote: $url (set PR_PROVIDER to override)" ;;
36
+ esac
37
+ }
38
+
39
+ # Source the shared lib + the provider file. Fails if the provider is not installed.
40
+ load_provider() {
41
+ PROVIDER="$(detect_provider)"
42
+ local pfile="$SHARE/providers/${PROVIDER}.sh"
43
+ [ -f "$SHARE/pr-common.sh" ] || fail "missing $SHARE/pr-common.sh — run: npx @nvice-r/pr-commands"
44
+ [ -f "$pfile" ] || fail "${PROVIDER} not yet supported (no $pfile)"
45
+ # shellcheck source=../share/pr-commands/pr-common.sh
46
+ . "$SHARE/pr-common.sh"
47
+ # shellcheck disable=SC1090
48
+ . "$pfile"
49
+ }
50
+
51
+ sub="${1:-help}"
52
+ case "$sub" in
53
+ -h|--help|--version) ;;
54
+ --*) set -- create "$@"; sub=create ;; # bare flags = create
55
+ esac
56
+
57
+ case "$sub" in
58
+ create)
59
+ shift
60
+ load_provider
61
+ declare -F provider_create >/dev/null || fail "${PROVIDER}: create not implemented"
62
+ provider_create "$@"
63
+ ;;
64
+ check)
65
+ load_provider
66
+ echo "provider=${PROVIDER} ok"
67
+ ;;
68
+ help|-h|--help) usage ;;
69
+ --version|version) sed -n 's/^# pr-commands-version: //p' "${BASH_SOURCE[0]}" ;;
70
+ *) fail "unknown command: $sub (try: $PR_CMD help)" ;;
71
+ esac
@@ -1,11 +1,11 @@
1
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.
2
+ # pr-common-version: 3
3
+ # pr-common.sh — shared library for the `pr-commands` entry point.
4
+ # NOT executable on its own — sourced by `pr-commands` before providers/<host>.sh, which
5
+ # builds the host-specific payload + POST. Everything here is host-AGNOSTIC.
6
6
  #
7
7
  # Provides (after `pr_common_init "$@"`): global vars
8
- # TITLE BODY SOURCE DEST REPO EMAIL TOKEN DRAFT CLOSE_SOURCE
8
+ # TITLE BODY SOURCE DEST REPO SOURCE_REPO EMAIL TOKEN DRAFT CLOSE_SOURCE
9
9
  # FINAL_REVIEWERS[] (uuids, self already NOT dropped — provider does that)
10
10
  # Provides functions:
11
11
  # die, get_token, pr_load_config, pr_parse_args, pr_git_defaults,
@@ -38,7 +38,7 @@ pr_load_config() {
38
38
  esac
39
39
  done < "$CONFIG_FILE"
40
40
  : "${CFG_TOKEN_SRC:=keychain}"
41
- : "${CFG_AGENT_DIR:=.claude}" # agent folder holding the roster (relative to main repo root)
41
+ : "${CFG_AGENT_DIR:=.claude}" # primary agent's repo folder holding the roster (relative to main repo root)
42
42
  }
43
43
 
44
44
  # --- token retrieval, OS-switched (D1: OS is a function, not a binary axis) ---
@@ -61,10 +61,14 @@ get_token() {
61
61
 
62
62
  # --- arg parse (common flags across all providers) ---
63
63
  pr_parse_args() {
64
- TITLE=""; BODY=""; BODY_FILE=""; SOURCE=""; DEST=""; REPO=""
64
+ TITLE=""; BODY=""; BODY_FILE=""; SOURCE=""; DEST=""; REPO=""; SOURCE_REPO=""
65
65
  DRAFT="false"; CLOSE_SOURCE="false"; NO_REVIEWERS="false"; EMAIL_ARG=""
66
66
  EXTRA_REVIEWERS=()
67
67
  while [ $# -gt 0 ]; do
68
+ case "$1" in
69
+ --title|--body|--body-file|--source|--dest|--target|--repo|--source-repo|--reviewer|--email)
70
+ [ $# -ge 2 ] || die "$1 needs a value" ;;
71
+ esac
68
72
  case "$1" in
69
73
  --title) TITLE="$2"; shift 2 ;;
70
74
  --body) BODY="$2"; shift 2 ;;
@@ -72,12 +76,13 @@ pr_parse_args() {
72
76
  --source) SOURCE="$2"; shift 2 ;;
73
77
  --dest|--target) DEST="$2"; shift 2 ;;
74
78
  --repo) REPO="$2"; shift 2 ;;
79
+ --source-repo) SOURCE_REPO="$2"; shift 2 ;; # fork → cross-repo PR
75
80
  --draft) DRAFT="true"; shift ;;
76
81
  --close-source) CLOSE_SOURCE="true"; shift ;;
77
82
  --reviewer) EXTRA_REVIEWERS+=("$2"); shift 2 ;;
78
83
  --no-reviewers) NO_REVIEWERS="true"; shift ;;
79
84
  --email) EMAIL_ARG="$2"; shift 2 ;;
80
- -h|--help) sed -n '2,20p' "$0"; exit 0 ;;
85
+ -h|--help) if declare -F usage >/dev/null; then usage; else sed -n '2,20p' "$0"; fi; exit 0 ;;
81
86
  *) die "unknown flag: $1" ;;
82
87
  esac
83
88
  done
@@ -96,10 +101,11 @@ pr_git_defaults() {
96
101
  [ "$SOURCE" = "HEAD" ] && die "detached HEAD — pass --source"
97
102
  fi
98
103
  if [ -z "$DEST" ]; then
99
- DEST="$(git config --get "branch.${SOURCE}.bbprBase" 2>/dev/null || true)"
100
- [ -n "$DEST" ] && echo "${PR_CMD:-pr}: using saved base (branch.${SOURCE}.bbprBase = ${DEST})" >&2
104
+ pr_migrate_base
105
+ DEST="$(git config --get "branch.${SOURCE}.base" 2>/dev/null || true)"
106
+ [ -n "$DEST" ] && echo "${PR_CMD:-pr}: using saved base (branch.${SOURCE}.base = ${DEST})" >&2
101
107
  fi
102
- [ -n "$DEST" ] || die "--dest required (no saved branch.${SOURCE}.bbprBase)"
108
+ [ -n "$DEST" ] || die "--dest required (no saved branch.${SOURCE}.base)"
103
109
  if [ -z "$REPO" ]; then
104
110
  local url
105
111
  url="$(git remote get-url origin 2>/dev/null)" || die "no origin remote"
@@ -111,9 +117,11 @@ pr_git_defaults() {
111
117
 
112
118
  # --- creds: email precedence + token via OS ---
113
119
  pr_resolve_creds() {
114
- # --email > $BB_EMAIL > git config bbpr.email > config email > keychain acct
120
+ # --email > $BB_EMAIL > git config pr.email > config email > keychain acct
121
+ # (legacy git config bbpr.email still read as fallback after pr.email)
115
122
  EMAIL="$EMAIL_ARG"
116
123
  [ -n "$EMAIL" ] || EMAIL="${BB_EMAIL:-}"
124
+ [ -n "$EMAIL" ] || EMAIL="$(git config pr.email 2>/dev/null || true)"
117
125
  [ -n "$EMAIL" ] || EMAIL="$(git config bbpr.email 2>/dev/null || true)"
118
126
  [ -n "$EMAIL" ] || EMAIL="${CFG_EMAIL:-}"
119
127
  [ -n "$EMAIL" ] || die "no email resolved (set config email or pass --email)"
@@ -123,17 +131,17 @@ pr_resolve_creds() {
123
131
  # --- reviewer roster: work-dir file (shared across worktrees) then global fallbacks ---
124
132
  pr_load_reviewers() {
125
133
  DEFAULT_REVIEWERS=()
126
- local file="${BBPR_REVIEWERS:-}"
134
+ local file="${PR_REVIEWERS:-${BBPR_REVIEWERS:-}}" # BBPR_REVIEWERS = legacy name
127
135
  if [ -z "$file" ]; then
128
136
  local slug; slug="$(echo "$REPO" | tr '/' '-')" # workspace/slug -> workspace-slug
129
137
  # main repo root, shared across worktrees: git-common-dir/.. (empty if git call fails)
130
138
  local mainroot; mainroot="$(cd "$(git rev-parse --git-common-dir 2>/dev/null)/.." 2>/dev/null && pwd)"
131
139
  for cand in \
132
- ${mainroot:+"$mainroot/.pr/reviewers.txt"} \
133
140
  ${mainroot:+"$mainroot/${CFG_AGENT_DIR:-.claude}/pr-reviewers.txt"} \
141
+ ${mainroot:+"$mainroot/.pr/reviewers.txt"} \
134
142
  ${mainroot:+"$mainroot/.pr-reviewers.txt"} \
135
143
  "$HOME/.config/pr/reviewers/${slug}.txt" \
136
- "$HOME/.config/bbpr/bbpr-reviewers.txt"; do # neutral primary; agent-dir + root + global legacy fallbacks
144
+ "$HOME/.config/bbpr/bbpr-reviewers.txt"; do # agent-dir primary; 0.1.2 .pr/ + root + global legacy fallbacks
137
145
  [ -f "$cand" ] && { file="$cand"; break; }
138
146
  done
139
147
  fi
@@ -156,12 +164,28 @@ pr_load_reviewers() {
156
164
  }
157
165
 
158
166
  # --- persist resolved base for next run ---
167
+ # branch.<b>.base is the shared, host-neutral key (also read by
168
+ # @nvice-r/git-commands git-restack).
159
169
  pr_save_base() {
160
- git config "branch.${SOURCE}.bbprBase" "$DEST" 2>/dev/null \
161
- && echo "${PR_CMD:-pr}: saved branch.${SOURCE}.bbprBase = ${DEST}" >&2
170
+ git config "branch.${SOURCE}.base" "$DEST" 2>/dev/null \
171
+ && echo "${PR_CMD:-pr}: saved branch.${SOURCE}.base = ${DEST}" >&2
162
172
  return 0 # base-save is best-effort; never abort the binary on failure
163
173
  }
164
174
 
175
+ # One-time migration: pr-commands <= 0.1.3 saved the base under the legacy key
176
+ # branch.<b>.bbprBase. Copy it to branch.<b>.base and unset the old key.
177
+ # Remove this function once no branch can still carry the legacy key.
178
+ pr_migrate_base() {
179
+ local old
180
+ git config --get "branch.${SOURCE}.base" >/dev/null 2>&1 && return 0
181
+ old="$(git config --get "branch.${SOURCE}.bbprBase" 2>/dev/null || true)"
182
+ [ -n "$old" ] || return 0
183
+ git config "branch.${SOURCE}.base" "$old" 2>/dev/null \
184
+ && git config --unset "branch.${SOURCE}.bbprBase" 2>/dev/null \
185
+ && echo "${PR_CMD:-pr}: migrated branch.${SOURCE}.bbprBase -> branch.${SOURCE}.base" >&2
186
+ return 0 # best-effort; never abort the binary on failure
187
+ }
188
+
165
189
  # --- orchestrate everything a provider needs before its POST ---
166
190
  pr_common_init() {
167
191
  pr_load_config
@@ -0,0 +1,77 @@
1
+ # bitbucket-provider-version: 10
2
+ # providers/bitbucket.sh — Bitbucket Cloud (REST API v2.0). Sourced by `pr-commands`
3
+ # after pr-common.sh; never run directly. Only Bitbucket-specific code lives here:
4
+ # payload shape, endpoint, self-as-reviewer rule, response parse.
5
+
6
+ KEYCHAIN_SERVICE="bitbucket-api-token"
7
+
8
+ provider_create() {
9
+ pr_common_init "$@"
10
+
11
+ # --- Bitbucket: drop the PR author's own UUID (BB rejects author-as-reviewer) ---
12
+ if [ "${#FINAL_REVIEWERS[@]}" -gt 0 ]; then
13
+ local SELF_UUID
14
+ SELF_UUID="$(curl -sS -u "${EMAIL}:${TOKEN}" "https://api.bitbucket.org/2.0/user" 2>/dev/null \
15
+ | python3 -c 'import json,sys
16
+ try: print(json.load(sys.stdin).get("uuid",""))
17
+ except Exception: print("")' 2>/dev/null || true)"
18
+ if [ -n "$SELF_UUID" ]; then
19
+ local FILTERED=() r
20
+ for r in "${FINAL_REVIEWERS[@]}"; do
21
+ [ "$r" = "$SELF_UUID" ] || FILTERED+=("$r")
22
+ done
23
+ FINAL_REVIEWERS=("${FILTERED[@]:-}")
24
+ [ -z "${FINAL_REVIEWERS[0]:-}" ] && FINAL_REVIEWERS=()
25
+ fi
26
+ fi
27
+
28
+ # --- Bitbucket payload (source/destination keys, draft, reviewers[]) ---
29
+ local PAYLOAD
30
+ PAYLOAD="$(
31
+ TITLE="$TITLE" BODY="$BODY" SOURCE="$SOURCE" DEST="$DEST" SOURCE_REPO="$SOURCE_REPO" \
32
+ DRAFT="$DRAFT" CLOSE_SOURCE="$CLOSE_SOURCE" \
33
+ 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()]))')" \
34
+ python3 -c '
35
+ import json, os
36
+ p = {
37
+ "title": os.environ["TITLE"],
38
+ "description": os.environ["BODY"],
39
+ "source": {"branch": {"name": os.environ["SOURCE"]}},
40
+ "destination": {"branch": {"name": os.environ["DEST"]}},
41
+ "draft": os.environ["DRAFT"] == "true",
42
+ "close_source_branch": os.environ["CLOSE_SOURCE"] == "true",
43
+ }
44
+ if os.environ["SOURCE_REPO"]:
45
+ p["source"]["repository"] = {"full_name": os.environ["SOURCE_REPO"]}
46
+ revs = json.loads(os.environ["REVIEWERS_JSON"])
47
+ if revs: p["reviewers"] = revs
48
+ print(json.dumps(p))
49
+ '
50
+ )"
51
+
52
+ local URL="https://api.bitbucket.org/2.0/repositories/${REPO}/pullrequests"
53
+ echo "POST $URL"
54
+ echo "source=${SOURCE_REPO:+$SOURCE_REPO:}$SOURCE dest=$DEST draft=$DRAFT reviewers=${#FINAL_REVIEWERS[@]}"
55
+
56
+ local RESP PR_INFO
57
+ RESP="$(curl -sS -X POST "$URL" -u "${EMAIL}:${TOKEN}" \
58
+ -H 'Content-Type: application/json' -d "$PAYLOAD")"
59
+
60
+ PR_INFO="$(echo "$RESP" | python3 -c '
61
+ import json, sys
62
+ try: d = json.loads(sys.stdin.read())
63
+ except Exception as e: print("non-JSON response", e); sys.exit(2)
64
+ if "error" in d: print("ERROR:", json.dumps(d["error"], indent=2)); sys.exit(1)
65
+ print(str(d.get("id")) + "\t" + str(d.get("links", {}).get("html", {}).get("href")))
66
+ ')" || { echo "$PR_INFO" >&2; exit 1; } # surface the API error; set -e would swallow it
67
+
68
+ local NEW_PR_ID NEW_PR_URL ATTACHED
69
+ NEW_PR_ID="$(echo "$PR_INFO" | cut -f1)"
70
+ NEW_PR_URL="$(echo "$PR_INFO" | cut -f2)"
71
+ ATTACHED="$(echo "$RESP" | python3 -c 'import json,sys; print(len(json.load(sys.stdin).get("reviewers", [])))')"
72
+ echo "created PR #${NEW_PR_ID} with ${ATTACHED} reviewers"
73
+ echo "$NEW_PR_URL"
74
+
75
+ [ -n "$NEW_PR_ID" ] && pr_save_base
76
+ return 0
77
+ }
@@ -1,6 +1,8 @@
1
1
  Create a pull request for the current branch. Provider (Bitbucket / GitHub / GitLab) is
2
- auto-detected from the git remote; the matching binary (`bbpr` / `ghpr` / `glpr`) is invoked.
3
- Convention (title pattern, ticket regex, PR template) comes from `~/.config/pr/config`.
2
+ auto-detected from the git remote by the `pr-commands` command, which runs that host's provider.
3
+ Convention (title pattern, ticket regex, PR template, body rules, push remotes, hooks) comes
4
+ from the repo's `.agents/config/pr.yaml` (one file per project, usually copied there by
5
+ `/landing`). Without that file, the convention keys in `~/.config/pr/config` are used.
4
6
 
5
7
  Installed by `npx @nvice-r/pr-commands`. If setup is missing, this command STOPS and tells the
6
8
  user to run the installer — it never guesses config (D8: hard fail, no hardcoded fallback).
@@ -16,14 +18,16 @@ skip the create steps entirely.
16
18
  /pr — provider-agnostic PR helper (@nvice-r/pr-commands)
17
19
 
18
20
  USAGE
19
- /pr [base-branch] [draft] Create a PR for the current branch (default).
21
+ /pr [base-branch] [draft] [cross]
22
+ Create a PR for the current branch (default).
20
23
  /pr update [PR#] [short] Rewrite an existing PR's description in place.
21
24
  /pr help Show this help.
22
25
 
23
26
  CREATE
24
27
  base-branch Any arg containing "/". If omitted, inferred from closest ancestor
25
28
  remote branch (saved per-branch after first run).
26
- draft Create as draft. Otherwise opens ready (asks to confirm).
29
+ draft Create as draft. Otherwise follows `draft:` in pr.yaml (default: ask).
30
+ cross Fork mode: open the PR in origin from the fork in pr.yaml `fork:`.
27
31
 
28
32
  UPDATE
29
33
  PR# PR number to rewrite. If omitted, resolved from the current branch's
@@ -31,9 +35,10 @@ UPDATE
31
35
  short Compress harder (caveman brevity): cut throat-clearing, restated ticket
32
36
  goals, filler connectors. Keeps every file + technical fact.
33
37
 
34
- CONFIG ~/.config/pr/config (os, token_src, email, convention)
35
- PROVIDER auto from `git remote get-url origin` -> bbpr | ghpr | glpr
36
- REVIEWERS <main-repo-root>/.pr/reviewers.txt (per-repo, shared across worktrees, agent-agnostic)
38
+ CONVENTION <repo>/.agents/config/pr.yaml (per project; else convention keys in the user config)
39
+ CONFIG ~/.config/pr/config (os, token_src, email, agent_dir; legacy convention keys)
40
+ PROVIDER auto from `git remote get-url origin` (run by `pr-commands`)
41
+ REVIEWERS <main-repo-root>/<agent_dir>/pr-reviewers.txt (per-repo, shared across worktrees; agent_dir from config)
37
42
  SETUP npx @nvice-r/pr-commands
38
43
  ```
39
44
 
@@ -47,33 +52,53 @@ missing piece). Do NOT improvise config or reviewers.
47
52
  CFG="${PR_CONFIG:-$HOME/.config/pr/config}"
48
53
  [ -f "$CFG" ] || { echo "MISSING config: $CFG"; }
49
54
 
50
- # b. detect provider from remote
51
- URL="$(git remote get-url origin 2>/dev/null)" || echo "no origin remote"
52
- case "$URL" in
53
- *bitbucket.org*) PROVIDER=bitbucket; BIN=bbpr ;;
54
- *github.com*) PROVIDER=github; BIN=ghpr ;;
55
- *gitlab.com*) PROVIDER=gitlab; BIN=glpr ;;
56
- *) echo "unknown provider for remote: $URL" ;;
57
- esac
58
-
59
- # c. matching binary installed?
60
- command -v "$BIN" >/dev/null 2>&1 || echo "MISSING binary: $BIN (run installer, or provider not yet supported)"
55
+ # b. command installed?
56
+ command -v pr-commands >/dev/null 2>&1 || echo "MISSING command: pr-commands"
57
+
58
+ # c. host detected + supported? prints "provider=<name> ok", or the reason it is not
59
+ pr-commands check
60
+
61
+ # d. project convention file: this checkout first, then the main checkout (worktrees share it,
62
+ # because .agents/config/ is usually gitignored and only exists in the main checkout)
63
+ TOP="$(git rev-parse --show-toplevel)"
64
+ MAINROOT="$(cd "$(git rev-parse --git-common-dir)/.." && pwd)"
65
+ PR_YAML=""
66
+ for f in "$TOP/.agents/config/pr.yaml" "$MAINROOT/.agents/config/pr.yaml"; do
67
+ [ -f "$f" ] && { PR_YAML="$f"; break; }
68
+ done
69
+ echo "convention: ${PR_YAML:-$CFG (no .agents/config/pr.yaml)}"
61
70
  ```
62
71
 
63
- - config missing → STOP: "run `npx @nvice-r/pr-commands`".
64
- - provider unknown → STOP: only bitbucket/github/gitlab supported.
65
- - `$BIN` = `ghpr`/`glpr` and not installed → STOP: "provider not yet supported (macOS+Bitbucket only for now)". (D6 stub.)
72
+ - config or `pr-commands` missing → STOP: "run `npx @nvice-r/pr-commands`".
73
+ - `pr-commands check` fails → STOP and show its message (unknown host, or
74
+ "<provider> not yet supported" — only Bitbucket for now). (D6 stub.)
66
75
  - all pass → continue.
67
76
 
68
77
  ## Step 1 — gather context (parallel)
69
78
  - `git rev-parse --abbrev-ref HEAD` — source branch (CURR)
70
- - `git config --get branch.<CURR>.bbprBase` — saved base, if any
79
+ - `git config --get branch.<CURR>.base` — saved base, if any
71
80
  - `git log --oneline -20`
72
- - read `convention=` from `$CFG` (default `trueid` if unset — but config presence already enforced in Step 0)
81
+ - read the **convention source**: `$PR_YAML` if Step 0 found one, else the convention keys in `$CFG`.
82
+ Every later step that says "convention" means this source.
83
+
84
+ **Convention keys** (YAML in `pr.yaml`; `key=value` in `$CFG`, where list values are pipe-separated):
85
+
86
+ | key | meaning | if missing |
87
+ |-----|---------|------------|
88
+ | `title_pattern` | title template with `<ver> <desc> <ticket> <type> <scope> <issue>` | ask the user |
89
+ | `ticket_regex` | ticket key in the branch name | no ticket |
90
+ | `template_sections` | body `## ` headings, in order | `Summary`, `Changes` |
91
+ | `body_rules` | extra project rules for the body (free text, follow literally) | none |
92
+ | `base` | default base proposal (Step 2 d) | ask |
93
+ | `draft` | `ask` / `true` / `false` | `ask` |
94
+ | `push` | remotes to push before create (Step 7.5) | none (assume pushed) |
95
+ | `fork` | `{remote, repo}` for cross-repo mode | cross mode unavailable |
96
+ | `pre_pr` | shell commands run before confirm (Step 6.5) | none |
97
+ | `post_pr` | sibling skills offered after create (Step 10) | none |
73
98
 
74
99
  ## Step 2 — determine base branch (priority)
75
100
  a. `$1` (any arg containing `/`) → use it.
76
- b. else `branch.<CURR>.bbprBase` from git config → use silently.
101
+ b. else saved `branch.<CURR>.base` from git config → use silently.
77
102
  c. else infer — smallest non-zero `<ref>..HEAD` distance where `<ref>` is an ancestor of HEAD:
78
103
  ```bash
79
104
  for ref in $(git for-each-ref --format='%(refname:short)' refs/remotes/origin | grep -v "^origin/<CURR>$"); do
@@ -85,9 +110,9 @@ c. else infer — smallest non-zero `<ref>..HEAD` distance where `<ref>` is an a
85
110
  ```
86
111
  Propose smallest-`ahead` candidate; confirm. Do NOT auto-pick a naming-pattern match — a
87
112
  stacked PR on another in-flight ticket branch is common. Ties or all >20 ahead → ask.
88
- d. still ambiguous → ask.
113
+ d. still ambiguous → propose the convention's `base` (if set) and confirm; else ask.
89
114
 
90
- The binary auto-saves the resolved base to `branch.<CURR>.bbprBase` on success.
115
+ `pr-commands` auto-saves the resolved base to `branch.<CURR>.base` on success.
91
116
 
92
117
  ## Step 3 — build diff context
93
118
  `git log <base>..HEAD --oneline` · `git diff --stat <base>..HEAD`. Multiple commits → summarize each.
@@ -99,23 +124,30 @@ diff into seams, detect coupling (shared file/symbol → stacked; disjoint → i
99
124
  → one atomic PR), present metrics + seam table, then `AskUserQuestion`: proceed-as-one /
100
125
  split (`guard_cmd pr-split`, degrade if absent) / adjust-threshold. Never auto-split.
101
126
 
102
- ## Step 4 — draft title (from config, authoritative)
127
+ ## Step 4 — draft title (from the convention, authoritative)
103
128
 
104
- Read `title_pattern=` and `ticket_regex=` directly from `$CFG` — the installer wrote concrete,
105
- possibly user-edited values, so DO NOT reconstruct them from a preset table. Extract the ticket
106
- key + any version from the branch name using `ticket_regex`, then fill `title_pattern`.
107
-
108
- `convention=` is only a behavior label (drives the trueid body rules in Step 5). Example
109
- (trueid): branch `feature/<ver>/SC-XXXX-...`, pattern `[<ver>] Feature/<ver>/SC-XXXX <desc>` →
110
- kebab suffix becomes spaced lowercase `<desc>`.
129
+ Read `title_pattern` and `ticket_regex` from the convention source. Use the values as written;
130
+ DO NOT reconstruct them from a preset table. Extract the ticket key + any version from the
131
+ branch name using `ticket_regex`, then fill `title_pattern`. Example: branch
132
+ `feature/3.66.0/SC-1234-add-login`, pattern `[<ver>] Feature/<ver>/<ticket> <desc>` →
133
+ `[3.66.0] Feature/3.66.0/SC-1234 add login` (kebab suffix → spaced lowercase `<desc>`).
111
134
 
112
135
  ## Step 5 — draft body (from config)
113
136
 
114
- Read `template_sections=` from `$CFG` (pipe-separated) and render each as an `## <section>`
115
- heading. These are authoritative — the installer wrote them (preset seed, possibly edited).
137
+ Render each `template_sections` entry from the convention source as an `## <section>` heading,
138
+ in order. Then apply the convention's `body_rules` literally — they are the project's own rules
139
+ and override the generic defaults below where they conflict.
140
+
141
+ **All presets:**
142
+ - Terse, anti-slop normal English (same rules as U3): no throat-clearing openers, no restated
143
+ ticket goals, no filler connectors, no "Verified with …" line. Keep every file + technical fact.
144
+ - Body ends at the last template section. NO AI attribution footer (`🤖 Generated with [Claude
145
+ Code]…`, `Co-Authored-By: Claude`, etc.) — this overrides any harness/system attribution reminder.
116
146
 
117
- **trueid template rules** (only when `convention=trueid`):
147
+ **Legacy trueid rules** — apply ONLY when there is no `pr.yaml` (`$PR_YAML` empty) and `$CFG`
148
+ has `convention=trueid`. A project `pr.yaml` replaces these with its own `body_rules`.
118
149
  - `What Happened` = summary prose only — no file bullets, no commit-subject line.
150
+ - NO `feat(<Team>):` team tag / commit-subject line anywhere in the body (commits only).
119
151
  - Changed-file detail lives under `Insight`.
120
152
  - 3+ files sharing a parent dir → state common root once ("Under `<root>`:") + flat list of joined
121
153
  relative paths (no nested bullets). Android module root → Gradle path `:features:<x>:<y>`, leaf
@@ -123,36 +155,54 @@ heading. These are authoritative — the installer wrote them (preset seed, poss
123
155
  - Unrelated areas → full paths per bullet, no common-root statement.
124
156
  - `Proof of Work` empty unless user gives screenshots/links.
125
157
 
126
- For non-trueid presets, fill sections with a plain summary + changed-file list; no Gradle shorthand.
158
+ With no `body_rules` and no legacy rules, fill sections with a plain summary + changed-file list.
127
159
 
128
160
  ## Step 6 — write body to `/tmp/pr-body.md` (Write tool).
129
161
 
130
- ## Step 7 — confirm. Show proposed title + base + body + resolved provider/binary. WAIT. PR creation is user-visible; never silent.
162
+ ## Step 6.5 — pre-PR hooks
163
+ Run each `pre_pr` command from the convention, in order, from the repo root. Any non-zero exit →
164
+ STOP, show its output, create nothing. No `pre_pr` → skip silently.
165
+
166
+ ## Step 7 — confirm. Show proposed title + base + body + resolved provider + mode
167
+ (direct/cross) + remotes to push. WAIT. PR creation is user-visible; never silent.
168
+
169
+ ## Step 7.5 — mode + push
170
+ - **Mode.** `cross` arg → cross. Else saved `branch.<CURR>.prMode` → use it. Else, when the
171
+ convention has `fork`: branch exists only on `fork.remote` (`git ls-remote <remote> <CURR>`)
172
+ → cross; otherwise direct. No `fork` → always direct; `cross` arg → STOP ("no fork in pr.yaml").
173
+ - **Direct:** push every remote in `push` (`git push <remote> HEAD`), in order.
174
+ - **Cross:** push `fork.remote` only. Never push to `origin`.
175
+ - Push failure → STOP and show the error. Never pass `--no-verify` (pre-push hooks may lint;
176
+ allow 30–60 s). No `push` key → do not push; warn if HEAD is ahead of its upstream.
131
177
 
132
- ## Step 8 — create. Run the detected binary (append `--draft` only if draft chosen):
178
+ ## Step 8 — create. Run `pr-commands create` (append `--draft` only if draft chosen):
133
179
  ```bash
134
- <BIN> --title "<title>" --body-file /tmp/pr-body.md --dest "<base>" [--draft]
180
+ pr-commands create --title "<title>" --body-file /tmp/pr-body.md --dest "<base>" [--draft] \
181
+ [--source-repo "<fork.repo>"] # cross mode only
135
182
  ```
136
- Always ask open-vs-draft before creating unless `draft` was passed. The binary attaches the
183
+ Draft: `draft` arg → draft. Else convention `draft: true|false` → use it. Else (`ask` or unset)
184
+ ask open-vs-draft before creating. After a cross-mode success, run
185
+ `git config branch.<CURR>.prMode cross` so the next `/pr` reuses the mode. `pr-commands` attaches the
137
186
  per-repo reviewer roster automatically; `--no-reviewers` skips, `--reviewer <id>` appends.
138
187
 
139
- ## Step 9 — print final PR URL + reviewer count from binary output. On error, surface raw response.
188
+ ## Step 9 — print final PR URL + reviewer count from `pr-commands` output. On error, surface raw response.
140
189
 
141
- ## Step 10 — offer Jira sync (guarded, optional).
142
- Reuse ticket key from Step 4. If none, skip. `guard_cmd jira-update`; if absent, just print URL + stop.
143
- If present, `AskUserQuestion`: sync now → `/jira-update <KEY>`, or skip (print reminder).
190
+ ## Step 10 — post-PR skills (guarded, optional).
191
+ For each skill in the convention's `post_pr` (legacy config without `pr.yaml`: `jira-update`):
192
+ `guard_cmd <skill>`; absent → one-line note, skip. Present → `AskUserQuestion`: run now
193
+ (`/<skill> <ticket>` with the Step 4 ticket key) or skip. No ticket key → skip ticket-based skills.
144
194
 
145
195
  ## Update mode (`/pr update [PR#] [short]`)
146
196
 
147
197
  Rewrites an existing open PR's **description** in place — no new branch, no new PR. Same Step 0
148
198
  install check runs first (config + provider + creds). Provider PATCH is currently Bitbucket-only,
149
- matching the create path; `ghpr`/`glpr` providers → STOP with the same "not yet supported" note.
199
+ matching the create path; any other host → STOP with the same "not yet supported" note.
150
200
 
151
201
  **U1 — parse args.** Tokens after `update`: a bare integer = `PR#`; the word `short` (or
152
202
  `caveman`) = compress-hard flag. Missing `PR#` → resolve from the current branch: query the
153
203
  provider for the open PR whose source branch is `HEAD` (Bitbucket: `GET /pullrequests?q=source.branch.name="<CURR>"&state=OPEN`). Zero or multiple matches → ask.
154
204
 
155
- **U2 — fetch current PR.** Resolve creds the same way the binary does — email from `$CFG`
205
+ **U2 — fetch current PR.** Resolve creds the same way `pr-commands` does — email from `$CFG`
156
206
  (`email=`), token from the OS keychain (macOS service `bitbucket-api-token`, account = email).
157
207
  `REPO` = workspace/slug from `git remote get-url origin`. Fetch title + description:
158
208
  ```bash
package/install.js CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  // @nvice-r/pr-commands installer. Zero external deps (Node >=16 builtins only).
3
3
  // Wizard: detect OS + provider, pick reviewers + convention, write config/roster,
4
- // install binaries -> ~/.local/bin, install the portable `pr` SKILL.md into every
4
+ // install the `pr-commands` entry point -> ~/.local/bin (+ shared lib and host providers ->
5
+ // ~/.local/share/pr-commands), install the portable `pr` SKILL.md into every
5
6
  // detected agent (symlinked to one canonical copy) + Claude /pr slash-command.
6
7
  //
7
8
  // Flags: --dir <path> --provider <p> --agent <id> --all --yes --dry-run --help
@@ -16,13 +17,15 @@ const { execFileSync } = require('child_process');
16
17
 
17
18
  const HOME = os.homedir();
18
19
  const PKG = __dirname;
20
+ const PKG_VERSION = require('./package.json').version;
19
21
  const ASSETS = path.join(PKG, 'assets');
20
22
  const BACK = Symbol('back'); // returned by a prompt when the user wants the previous step
21
23
 
22
24
  const PROVIDERS = {
23
- bitbucket: { bin: 'bbpr', host: 'bitbucket.org', keychainSvc: 'bitbucket-api-token', ready: true },
24
- github: { bin: 'ghpr', host: 'github.com', keychainSvc: 'github-api-token', ready: false },
25
- gitlab: { bin: 'glpr', host: 'gitlab.com', keychainSvc: 'gitlab-api-token', ready: false },
25
+ // provider code = assets/share/pr-commands/providers/<name>.sh
26
+ bitbucket: { host: 'bitbucket.org', keychainSvc: 'bitbucket-api-token', ready: true },
27
+ github: { host: 'github.com', keychainSvc: 'github-api-token', ready: false },
28
+ gitlab: { host: 'gitlab.com', keychainSvc: 'gitlab-api-token', ready: false },
26
29
  };
27
30
  const CONVENTIONS = ['trueid', 'conventional', 'github-default', 'custom'];
28
31
  // Concrete, editable defaults per preset. Whatever the user confirms is written to config,
@@ -36,7 +39,7 @@ const PRESET_DEFS = {
36
39
 
37
40
  // ---- arg parse ----
38
41
  const argv = process.argv.slice(2);
39
- const flags = { dir: null, provider: null, all: false, yes: false, dryRun: false, agentDir: null, agents: null };
42
+ const flags = { dir: null, provider: null, all: false, yes: false, dryRun: false, agentDir: null, agents: null, resetReviewers: false };
40
43
  for (let i = 0; i < argv.length; i++) {
41
44
  const a = argv[i];
42
45
  if (a === '--help' || a === '-h') { printHelp(); process.exit(0); }
@@ -47,9 +50,10 @@ for (let i = 0; i < argv.length; i++) {
47
50
  else if (a === '--all') flags.all = true;
48
51
  else if (a === '--yes' || a === '-y') flags.yes = true;
49
52
  else if (a === '--dry-run') flags.dryRun = true;
53
+ else if (a === '--reset-reviewers') flags.resetReviewers = true;
50
54
  else { console.error(`unknown flag: ${a}`); process.exit(1); }
51
55
  }
52
- const AGENT_DIR = (flags.agentDir || '.claude').replace(/^\/+|\/+$/g, ''); // strip stray slashes
56
+ const cleanDir = (d) => d.replace(/^\/+|\/+$/g, ''); // strip stray slashes
53
57
 
54
58
  // ---- portable skill ----
55
59
  // One canonical SKILL.md (~/.config/pr/skill/pr/); every detected agent's global skills
@@ -68,17 +72,18 @@ model: sonnet
68
72
  ---
69
73
 
70
74
  `;
71
- // base = probe dir deciding "installed"; skills = GLOBAL skills dir we link into.
75
+ // base = probe dir deciding "installed"; skills = GLOBAL skills dir we link into;
76
+ // repoDir = the agent's per-repo folder (holds the reviewer roster when it's the primary agent).
72
77
  // Env vars resolved the same way the agents themselves resolve them.
73
78
  function agentTargets() {
74
79
  const claudeBase = process.env.CLAUDE_CONFIG_DIR || path.join(HOME, '.claude');
75
80
  const codexBase = process.env.CODEX_HOME || path.join(HOME, '.codex');
76
81
  const xdg = process.env.XDG_CONFIG_HOME || path.join(HOME, '.config');
77
82
  return [
78
- { id: 'claude-code', base: claudeBase, skills: path.join(claudeBase, 'skills'), claude: true },
79
- { id: 'antigravity', base: path.join(HOME, '.gemini/antigravity'), skills: path.join(HOME, '.gemini/antigravity/skills') },
80
- { id: 'codex', base: codexBase, skills: path.join(codexBase, 'skills') },
81
- { id: 'opencode', base: path.join(xdg, 'opencode'), skills: path.join(xdg, 'opencode/skills') },
83
+ { id: 'claude-code', base: claudeBase, skills: path.join(claudeBase, 'skills'), repoDir: '.claude', claude: true },
84
+ { id: 'antigravity', base: path.join(HOME, '.gemini/antigravity'), skills: path.join(HOME, '.gemini/antigravity/skills'), repoDir: '.agent' },
85
+ { id: 'codex', base: codexBase, skills: path.join(codexBase, 'skills'), repoDir: '.codex' },
86
+ { id: 'opencode', base: path.join(xdg, 'opencode'), skills: path.join(xdg, 'opencode/skills'), repoDir: '.opencode' },
82
87
  ];
83
88
  }
84
89
 
@@ -89,11 +94,13 @@ function printHelp() {
89
94
 
90
95
  --dir <path> Repo to configure (default: cwd)
91
96
  --provider <p> Force bitbucket|github|gitlab (default: detect from remote)
92
- --agent-dir <dir> (deprecated) Legacy roster folder; roster now lives in <root>/.pr/
97
+ --agent-dir <dir> Repo folder holding the reviewer roster (default: primary agent's
98
+ folder — .claude / .agent / .codex / .opencode)
93
99
  --agent <id> Force skill install to this agent (repeatable). Ids: claude-code,
94
100
  antigravity, codex, opencode. Default: auto-detect installed agents.
95
- --all Install all provider binaries (default: only the detected one)
96
- --yes Accept defaults, no prompts (reviewers=all, convention=trueid)
101
+ --all Install every ready host provider (default: only the detected one)
102
+ --yes Accept defaults, no prompts (reviewers=existing roster or all, convention=trueid)
103
+ --reset-reviewers Ignore an existing roster and rebuild it from the repo's default reviewers
97
104
  --dry-run Print planned actions, write nothing
98
105
  --help This help`);
99
106
  }
@@ -259,6 +266,21 @@ function writeFile(p, content, mode) {
259
266
  // Main repo root, shared across worktrees: git-common-dir points at the main .git
260
267
  // even from a linked worktree; its parent is the main checkout root. gitCommonDir
261
268
  // may be relative ('.git') or absolute ('/main/.git') — path.resolve handles both.
269
+ // Existing roster, so a reinstall keeps it: --agent-dir, each agent's repo folder, then the
270
+ // 0.1.2 neutral .pr/ location. Returns { file, entries: [{uuid, name}] } or null.
271
+ function findExistingRoster(mroot) {
272
+ const dirs = [flags.agentDir && cleanDir(flags.agentDir), ...agentTargets().map((t) => t.repoDir)].filter(Boolean);
273
+ const files = [...new Set(dirs)].map((d) => path.join(mroot, d, 'pr-reviewers.txt'))
274
+ .concat(path.join(mroot, '.pr', 'reviewers.txt'));
275
+ const file = files.find((f) => fs.existsSync(f));
276
+ if (!file) return null;
277
+ const entries = [];
278
+ for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
279
+ const m = line.match(/^\s*(\{[^}]+\})\s*(?:#\s*(.*))?$/);
280
+ if (m) entries.push({ uuid: m[1], name: (m[2] || '').trim() });
281
+ }
282
+ return { file, entries };
283
+ }
262
284
  function mainRepoRoot(wd) {
263
285
  const gcd = shSafe('git', ['-C', wd, 'rev-parse', '--git-common-dir']);
264
286
  return gcd ? path.resolve(wd, gcd, '..') : null;
@@ -325,6 +347,20 @@ function scanGitRepos(root, maxDepth = 3, cap = 50) {
325
347
  return found;
326
348
  }
327
349
 
350
+ // Remove binaries from <= 0.1.3 (bbpr + pr-common.sh in ~/.local/bin). Only files that carry
351
+ // our own version marker are deleted, so an unrelated file with the same name is left alone.
352
+ function removeLegacyBins(binDir) {
353
+ for (const [name, marker] of [['bbpr', 'bbpr-version'], ['pr-common.sh', 'pr-common-version']]) {
354
+ const p = path.join(binDir, name);
355
+ let txt;
356
+ try { txt = fs.readFileSync(p, 'utf8'); } catch { continue; }
357
+ if (!txt.includes(marker)) { log(` kept ${p} (not ours)`); continue; }
358
+ if (flags.dryRun) { log(` [dry-run] remove legacy ${p}`); continue; }
359
+ fs.unlinkSync(p);
360
+ log(` removed legacy ${p}`);
361
+ }
362
+ }
363
+
328
364
  // ---- provider detection ----
329
365
  function detectProvider(remoteUrl) {
330
366
  if (flags.provider) return flags.provider;
@@ -406,7 +442,7 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
406
442
  const L = [];
407
443
  if (stage > 0 && st.workDir) {
408
444
  L.push(` \x1b[32m✓\x1b[0m work dir ${st.workDir}`);
409
- L.push(` \x1b[32m✓\x1b[0m provider ${st.provider} (${st.slug}) → ${st.pcfg.bin}`);
445
+ L.push(` \x1b[32m✓\x1b[0m provider ${st.provider} (${st.slug})`);
410
446
  L.push(` \x1b[32m✓\x1b[0m auth ${st.email || '(none)'}${st.token ? ' · token ok' : ' · no token'}`);
411
447
  }
412
448
  if (stage > 1 && st.chosen) L.push(` \x1b[32m✓\x1b[0m reviewers ${st.chosen.length} selected`);
@@ -461,7 +497,7 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
461
497
  if (!provider) { console.error(` ERROR: could not detect provider from ${remoteUrl}`); process.exit(1); }
462
498
  const pcfg = PROVIDERS[provider];
463
499
  const slug = parseSlug(remoteUrl);
464
- log(` remote: ${remoteUrl}\n provider=${provider} slug=${slug} binary=${pcfg.bin}`);
500
+ log(` remote: ${remoteUrl}\n provider=${provider} slug=${slug}`);
465
501
  if (!pcfg.ready) { console.error(` ERROR: ${provider} not yet supported (macOS+Bitbucket only). Aborting.`); process.exit(1); }
466
502
  // identity + token (derived from provider)
467
503
  let email = '';
@@ -474,7 +510,7 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
474
510
  log(` WARN: no keychain token for ${pcfg.keychainSvc}/${email || '(none)'} — reviewers can't auto-fetch`);
475
511
  log(` security add-generic-password -s ${pcfg.keychainSvc} -a ${email || 'you@example.com'} -w '<api-token>'`);
476
512
  } else log(' token: found');
477
- if (st.slug !== slug) st.revs = undefined; // invalidate reviewer cache if repo changed
513
+ if (st.slug !== slug) { st.revs = undefined; st.existing = undefined; st.chosenIdx = undefined; } // repo changed → drop reviewer caches
478
514
  const mroot = mainRepoRoot(wd) || wd; // shared across worktrees; roster lives here
479
515
  if (mroot !== wd) log(` worktree detected → roster shared at main root: ${mroot}`);
480
516
  Object.assign(st, { workDir: wd, mainRoot: mroot, remoteUrl, provider, pcfg, slug, email, token });
@@ -483,6 +519,13 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
483
519
  const sReviewers = async () => {
484
520
  screen(1, 'Reviewers');
485
521
  st.chosen = st.chosen || [];
522
+ if (st.existing === undefined) st.existing = flags.resetReviewers ? null : findExistingRoster(st.mainRoot);
523
+ const ex = st.existing;
524
+ if (ex) log(` existing roster: ${ex.file} (${ex.entries.length})`);
525
+ // --yes (or no token to fetch with) + existing roster → keep it untouched, skip the fetch.
526
+ if (ex && (flags.yes || !(st.token && st.email))) {
527
+ st.chosen = ex.entries; st.extra = []; log(' kept existing roster (--reset-reviewers to rebuild)'); return;
528
+ }
486
529
  if (!(st.token && st.email)) { log(' skipped (no token) — edit the roster later.'); st.chosen = []; return; }
487
530
  if (st.selfUuid === undefined) st.selfUuid = await fetchSelfUuid(st.email, st.token);
488
531
  if (st.revs === undefined) st.revs = await fetchBitbucketReviewers(st.slug, st.email, st.token, st.selfUuid);
@@ -490,14 +533,18 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
490
533
  if (!revs) { log(' WARN: could not fetch default-reviewers. Roster left empty.'); st.chosen = []; return; }
491
534
  if (revs.length === 0) { log(' repo has no default reviewers. Roster left empty.'); st.chosen = []; return; }
492
535
  if (flags.yes) { st.chosen = revs; st.chosenIdx = revs.map((_, i) => i); log(` selected all ${revs.length}`); return; }
536
+ // Existing roster → pre-check its members; entries not among the defaults are kept as-is.
537
+ const exIds = ex ? new Set(ex.entries.map((e) => e.uuid)) : null;
538
+ st.extra = ex ? ex.entries.filter((e) => !revs.some((r) => r.uuid === e.uuid)) : [];
539
+ const preIdx = st.chosenIdx || (exIds ? revs.map((r, i) => (exIds.has(r.uuid) ? i : -1)).filter((i) => i >= 0) : null);
493
540
  const picked = await multiSelect(
494
- `Reviewers for ${st.slug} (default: all):`,
495
- revs.map((r) => ({ label: r.name })), true, st.chosenIdx || null,
541
+ `Reviewers for ${st.slug} (default: ${ex ? 'existing roster' : 'all'}):`,
542
+ revs.map((r) => ({ label: r.name })), true, preIdx,
496
543
  );
497
544
  if (picked === BACK) return BACK;
498
545
  st.chosenIdx = picked;
499
- st.chosen = picked.map((i) => revs[i]);
500
- log(` chosen: ${st.chosen.length}`);
546
+ st.chosen = picked.map((i) => revs[i]).concat(st.extra);
547
+ log(` chosen: ${st.chosen.length}${st.extra.length ? ` (incl. ${st.extra.length} kept from existing roster, not in defaults)` : ''}`);
501
548
  };
502
549
 
503
550
  const sPreset = async () => {
@@ -561,29 +608,45 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
561
608
 
562
609
  // --- install ---
563
610
  screen(4, 'Install');
564
- const { slug, pcfg, email, convention, conv, chosen } = st;
565
- let cfg = `os=${cfgOs}\ntoken_src=${tokenSrc}\nemail=${email}\nconvention=${convention}\nagent_dir=${AGENT_DIR}\n`;
611
+ const { slug, email, convention, conv, chosen } = st;
612
+ // Roster folder = --agent-dir, else the first chosen agent's repo folder, else .claude.
613
+ const agentIds = st.agents || detectedAgentIds();
614
+ const primary = agentTargets().find((t) => t.id === agentIds[0]);
615
+ const agentDir = cleanDir(flags.agentDir || (primary && primary.repoDir) || '.claude');
616
+ let cfg = `pkg_version=${PKG_VERSION}\nos=${cfgOs}\ntoken_src=${tokenSrc}\nemail=${email}\nconvention=${convention}\nagent_dir=${agentDir}\n`;
566
617
  if (conv.title_pattern) cfg += `title_pattern=${conv.title_pattern}\n`;
567
618
  if (conv.ticket_regex) cfg += `ticket_regex=${conv.ticket_regex}\n`;
568
619
  if (conv.template_sections) cfg += `template_sections=${conv.template_sections}\n`;
569
620
  writeFile(path.join(HOME, '.config/pr/config'), cfg, 0o600);
570
- // Roster: neutral <main-repo-root>/.pr/reviewers.txt — agent-agnostic (bash reads it, not the
571
- // agent), shared across worktrees, survives switching agents. pr-common.sh finds it first,
572
- // falling back to the legacy agent-dir/root/global paths.
573
- const roster = chosen.map((r) => `${r.uuid} # ${r.name}`).join('\n') + (chosen.length ? '\n' : '');
574
- const header = `# pr reviewers for ${slug} — {uuid} # Name per line; blanks/# ignored. Edit freely.\n`;
575
- writeFile(path.join(st.mainRoot, '.pr', 'reviewers.txt'), header + (roster || '# (none selected)\n'));
621
+ // Roster: <main-repo-root>/<agent_dir>/pr-reviewers.txt — lives with the primary agent's repo
622
+ // folder, shared across worktrees. agent_dir is recorded in the config so the bash binary
623
+ // (which can't know the calling agent) reads the same path; legacy .pr/ + root + global paths
624
+ // remain load fallbacks.
625
+ const rosterFile = path.join(st.mainRoot, agentDir, 'pr-reviewers.txt');
626
+ const kept = st.existing && chosen === st.existing.entries; // untouched existing roster
627
+ if (kept && st.existing.file === rosterFile) log(` kept ${rosterFile}`);
628
+ else if (kept) writeFile(rosterFile, fs.readFileSync(st.existing.file)); // move to new agent_dir; old file left as fallback
629
+ else {
630
+ const roster = chosen.map((r) => `${r.uuid} # ${r.name}`).join('\n') + (chosen.length ? '\n' : '');
631
+ const header = `# pr reviewers for ${slug} — {uuid} # Name per line; blanks/# ignored. Edit freely.\n`;
632
+ writeFile(rosterFile, header + (roster || '# (none selected)\n'));
633
+ }
634
+ // One entry point on PATH; shared lib + host providers under ~/.local/share/pr-commands.
576
635
  const binDir = path.join(HOME, '.local/bin');
577
- const binsToInstall = flags.all
578
- ? Object.values(PROVIDERS).filter((p) => p.ready).map((p) => p.bin)
579
- : [pcfg.bin];
580
- writeFile(path.join(binDir, 'pr-common.sh'), fs.readFileSync(path.join(ASSETS, 'bin/pr-common.sh')), 0o755);
581
- for (const b of binsToInstall) {
582
- const src = path.join(ASSETS, 'bin', b);
583
- if (!fs.existsSync(src)) { log(` skip ${b} (asset not present yet)`); continue; }
584
- writeFile(path.join(binDir, b), fs.readFileSync(src), 0o755);
636
+ const shareDir = path.join(HOME, '.local/share/pr-commands');
637
+ const shareSrc = path.join(ASSETS, 'share/pr-commands');
638
+ writeFile(path.join(binDir, 'pr-commands'), fs.readFileSync(path.join(ASSETS, 'bin/pr-commands')), 0o755);
639
+ writeFile(path.join(shareDir, 'pr-common.sh'), fs.readFileSync(path.join(shareSrc, 'pr-common.sh')), 0o644);
640
+ const provNames = flags.all
641
+ ? Object.keys(PROVIDERS).filter((n) => PROVIDERS[n].ready)
642
+ : [st.provider];
643
+ for (const n of provNames) {
644
+ const src = path.join(shareSrc, 'providers', `${n}.sh`);
645
+ if (!fs.existsSync(src)) { log(` skip ${n} (provider not present yet)`); continue; }
646
+ writeFile(path.join(shareDir, 'providers', `${n}.sh`), fs.readFileSync(src), 0o644);
585
647
  }
586
- installSkill(st.agents || detectedAgentIds());
648
+ removeLegacyBins(binDir);
649
+ installSkill(agentIds);
587
650
  if (!(process.env.PATH || '').split(':').includes(binDir)) {
588
651
  log(`\n WARN: ${binDir} not on PATH. Add to your shell rc:`);
589
652
  log(` export PATH="$HOME/.local/bin:$PATH"`);
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@nvice-r/pr-commands",
3
- "version": "0.1.2",
4
- "description": "Provider-agnostic PR helper for Claude Code — npx installer + bbpr/ghpr/glpr binaries + /pr skill",
3
+ "version": "0.1.4",
4
+ "description": "Provider-agnostic PR helper for Claude Code — npx installer + one `pr-commands` command (per-host providers) + /pr skill",
5
5
  "bin": {
6
- "pr-commands": "install.js"
6
+ "pr-commands-setup": "install.js"
7
7
  },
8
8
  "files": [
9
9
  "install.js",
package/assets/bin/bbpr DELETED
@@ -1,78 +0,0 @@
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