@nvice-r/pr-commands 0.1.1 → 0.1.3

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,11 +23,16 @@ 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`
29
31
  reads those, not a hard-coded table.
30
- 7. **Install** — writes config + roster, drops the binaries and the `/pr` skill.
32
+ 7. **Agents** — checklist of supported agents (claude-code / antigravity / codex / opencode) with
33
+ installed ones pre-checked; toggle which get the `pr` skill. (`--agent` skips this; `--yes` = detected.)
34
+ 8. **Install** — writes config + roster + binaries, then installs the portable `pr` skill into
35
+ the chosen agents (symlinked to one canonical copy) + Claude's `/pr` command.
31
36
 
32
37
  Every prompt is reversible: in a list press `b` to step back; in a text field, append `:b` to
33
38
  the value and press enter (the field is pre-filled, so `:b` at the end triggers back). Back
@@ -39,8 +44,11 @@ Flags:
39
44
  |------|--------|
40
45
  | `--dir <path>` | repo to configure (default: cwd) |
41
46
  | `--provider <p>` | force `bitbucket`\|`github`\|`gitlab` instead of detecting |
47
+ | `--agent <id>` | force skill install to this agent (repeatable): `claude-code`\|`antigravity`\|`codex`\|`opencode`. Default: auto-detect |
42
48
  | `--all` | install every ready provider binary, not just the detected one |
43
- | `--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 |
44
52
  | `--dry-run` | print planned actions, write nothing |
45
53
  | `--help` | usage |
46
54
 
@@ -65,16 +73,23 @@ npx @nvice-r/pr-commands --dry-run --dir /path/to/repo
65
73
 
66
74
  ## What gets installed
67
75
 
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.
76
+ Everything user-global is **global** — nothing lands in a repo's agent dir, so it works
77
+ identically from every clone and every git worktree, no repo-local symlinks.
70
78
 
71
79
  | Artifact | Path |
72
80
  |----------|------|
73
81
  | Shared library | `~/.local/bin/pr-common.sh` |
74
82
  | Provider binary | `~/.local/bin/bbpr` (`ghpr`/`glpr` later) |
75
83
  | Config | `~/.config/pr/config` |
76
- | Reviewer roster | `<main-repo-root>/<agent_dir>/pr-reviewers.txt` (default `.claude`; shared across worktrees) |
77
- | `/pr` skill | `~/.claude/commands/pr.md` |
84
+ | Reviewer roster | `<main-repo-root>/<agent_dir>/pr-reviewers.txt` (primary agent's folder; shared across worktrees) |
85
+ | Canonical skill | `~/.config/pr/skill/pr/SKILL.md` (single source) |
86
+ | Per-agent skill | `<agent-skills-dir>/pr` → symlink to canonical |
87
+ | Claude `/pr` command | `~/.claude/commands/pr.md` (dual-install, keeps the slash trigger) |
88
+
89
+ The skill is a **portable `SKILL.md`** installed into every detected agent — **claude-code,
90
+ antigravity, codex, opencode** — via one canonical copy each agent symlinks to (`npx skills`
91
+ single-source model). Force a target with `--agent <id>`. A pre-existing non-symlink `pr` skill
92
+ dir is never overwritten (the installer skips and warns).
78
93
 
79
94
  ## Usage
80
95
 
@@ -104,6 +119,30 @@ convention=trueid # see presets
104
119
  Edit by hand any time. **A missing config is a hard error** — `/pr` and the binaries refuse to
105
120
  guess. Re-run the installer to regenerate.
106
121
 
122
+ ### Per-project convention (`.agents/config/pr.yaml`)
123
+
124
+ When the repo has `.agents/config/pr.yaml` (this checkout, or the main checkout for worktrees),
125
+ `/pr` reads the convention from it instead of the user config. One file per project, so teams
126
+ with different conventions share the same skill:
127
+
128
+ ```yaml
129
+ title_pattern: "[<ver>] Feature/<ver>/<ticket> <desc>"
130
+ ticket_regex: "SC-(\\d+|NOTICKET)"
131
+ template_sections: [What Happened, Insight, Proof of Work]
132
+ body_rules: |
133
+ - Free-text project rules the skill follows literally.
134
+ base: develop # default base proposal
135
+ draft: ask # ask | true | false
136
+ push: [origin] # remotes pushed before create
137
+ fork: # optional cross-repo mode (`/pr cross`)
138
+ remote: social-commerce
139
+ repo: workspace/fork-repo
140
+ pre_pr: [] # commands run before confirm; non-zero exit stops /pr
141
+ post_pr: [jira-update] # sibling skills offered after create
142
+ ```
143
+
144
+ Without the file, the convention keys below (written by the wizard) are used.
145
+
107
146
  ### Convention presets
108
147
 
109
148
  | preset | title pattern | ticket regex | template sections |
@@ -124,14 +163,18 @@ template_sections=Context|Changes|Risk
124
163
 
125
164
  ### Reviewers
126
165
 
127
- Roster lives **in the agent folder** under the main repo root (`agent_dir` in config, default
128
- `.claude`), so it's editable next to the code. Resolved via `git --git-common-dir/..`, so all
129
- worktrees of a repo share one roster:
166
+ Roster lives in the **primary agent's repo folder** under the main repo root — `.claude`,
167
+ `.agent` (Antigravity), `.codex` or `.opencode`, picked from the first agent you install for
168
+ (override with `--agent-dir`). The choice is saved as `agent_dir` in the config so the bash binary
169
+ reads the same path. Resolved via `git --git-common-dir/..`, so all worktrees of a repo share one
170
+ roster:
130
171
 
131
172
  ```
132
- <main-repo-root>/.claude/pr-reviewers.txt # or <agent_dir>/… — .cursor, .aider, …
173
+ <main-repo-root>/<agent_dir>/pr-reviewers.txt
133
174
  ```
134
175
 
176
+ Switching primary agent = re-run the installer; the old file still loads as a fallback.
177
+
135
178
  You (the author) are excluded on install — bbpr also drops self at PR time. One entry per line,
136
179
  trailing `# Name` comment optional:
137
180
 
@@ -140,10 +183,10 @@ trailing `# Name` comment optional:
140
183
  {fedcba98-7654-3210-fedc-ba9876543210} # Bob
141
184
  ```
142
185
 
143
- It's plain data — edit freely, no reinstall. Consider gitignoring it unless the team wants the
144
- roster committed. Load order: `$BBPR_REVIEWERS` → `<agent_dir>/pr-reviewers.txt` → root
145
- `.pr-reviewers.txt` (pre-0.1.1) → global `~/.config/pr/reviewers/<ws>-<slug>.txt` → legacy.
146
- Run-time overrides: `--no-reviewers` skips all, `--reviewer <uuid>` appends.
186
+ It's plain data — edit freely, no reinstall. Consider gitignoring the roster unless the team wants
187
+ it committed. Load order: `$BBPR_REVIEWERS` → `<agent_dir>/pr-reviewers.txt` → `.pr/reviewers.txt`
188
+ (0.1.2) → root `.pr-reviewers.txt` (pre-0.1.1) → global `~/.config/pr/reviewers/<ws>-<slug>.txt` →
189
+ legacy. Run-time overrides: `--no-reviewers` skips all, `--reviewer <uuid>` appends.
147
190
 
148
191
  ### Auth precedence (email)
149
192
 
@@ -185,7 +228,8 @@ Full rationale in [`DECISIONS.md`](./DECISIONS.md).
185
228
  | reviewers not fetched | add the keychain token (see Prerequisites), re-run |
186
229
  | `WARN ~/.local/bin not on PATH` | add the export line above to your shell rc |
187
230
  | `not-yet-supported: <provider>` | GitHub/GitLab aren't implemented yet |
188
- | worktree runs old behavior | nothing is repo-local here; ensure `pr.md` is at `~/.claude/commands/pr.md`, not a stale repo copy |
231
+ | worktree runs old behavior | skill/binaries are global, not repo-local; ensure no stale per-repo copy shadows them |
232
+ | 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 |
189
233
 
190
234
  ## Uninstall
191
235
 
@@ -193,16 +237,18 @@ Full rationale in [`DECISIONS.md`](./DECISIONS.md).
193
237
  rm ~/.local/bin/bbpr ~/.local/bin/pr-common.sh
194
238
  rm -rf ~/.config/pr
195
239
  rm ~/.claude/commands/pr.md
240
+ # per-agent skill symlinks (remove those you installed):
241
+ rm ~/.claude/skills/pr ~/.gemini/antigravity/skills/pr ~/.codex/skills/pr ~/.config/opencode/skills/pr
196
242
  # roster lives in each repo — remove per repo if wanted:
197
- rm <main-repo-root>/.claude/pr-reviewers.txt
243
+ rm -rf <main-repo-root>/.pr
198
244
  ```
199
245
 
200
246
  ## Development
201
247
 
202
248
  ```
203
- install.js npx entry (wizard)
204
- assets/bin/* provider binaries + shared lib (source of truth; copied on install)
205
- assets/skill/pr.md generic /pr skill (source of truth; copied on install)
249
+ install.js npx entry (wizard)
250
+ assets/bin/* provider binaries + shared lib (source of truth; copied on install)
251
+ assets/skill/pr.body.md shared /pr skill body (frontmatter added per-target on install)
206
252
  ```
207
253
 
208
254
  `assets/` is the single source of truth — both the installer (`install.js`)
package/assets/bin/bbpr CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env bash
2
- # bbpr-version: 8
2
+ # bbpr-version: 9
3
3
  # bbpr — create a Bitbucket Cloud pull request (REST API v2.0).
4
4
  # Provider-specific layer; all git/config/creds/reviewer logic lives in pr-common.sh.
5
5
  # Usage: bbpr --title "..." [--body|--body-file] [--source b] [--dest b] [--repo w/s]
6
6
  # [--draft] [--reviewer uuid]... [--no-reviewers] [--email a] [--close-source]
7
+ # [--source-repo ws/fork] (cross-repo: PR opens in --repo/origin, from the fork)
7
8
 
8
9
  set -euo pipefail
9
10
 
@@ -35,7 +36,7 @@ fi
35
36
 
36
37
  # --- Bitbucket payload (source/destination keys, draft, reviewers[]) ---
37
38
  PAYLOAD="$(
38
- TITLE="$TITLE" BODY="$BODY" SOURCE="$SOURCE" DEST="$DEST" \
39
+ TITLE="$TITLE" BODY="$BODY" SOURCE="$SOURCE" DEST="$DEST" SOURCE_REPO="$SOURCE_REPO" \
39
40
  DRAFT="$DRAFT" CLOSE_SOURCE="$CLOSE_SOURCE" \
40
41
  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
42
  python3 -c '
@@ -48,6 +49,8 @@ p = {
48
49
  "draft": os.environ["DRAFT"] == "true",
49
50
  "close_source_branch": os.environ["CLOSE_SOURCE"] == "true",
50
51
  }
52
+ if os.environ["SOURCE_REPO"]:
53
+ p["source"]["repository"] = {"full_name": os.environ["SOURCE_REPO"]}
51
54
  revs = json.loads(os.environ["REVIEWERS_JSON"])
52
55
  if revs: p["reviewers"] = revs
53
56
  print(json.dumps(p))
@@ -56,7 +59,7 @@ print(json.dumps(p))
56
59
 
57
60
  URL="https://api.bitbucket.org/2.0/repositories/${REPO}/pullrequests"
58
61
  echo "POST $URL"
59
- echo "source=$SOURCE dest=$DEST draft=$DRAFT reviewers=${#FINAL_REVIEWERS[@]}"
62
+ echo "source=${SOURCE_REPO:+$SOURCE_REPO:}$SOURCE dest=$DEST draft=$DRAFT reviewers=${#FINAL_REVIEWERS[@]}"
60
63
 
61
64
  RESP="$(curl -sS -X POST "$URL" -u "${EMAIL}:${TOKEN}" \
62
65
  -H 'Content-Type: application/json' -d "$PAYLOAD")"
@@ -67,7 +70,7 @@ try: d = json.loads(sys.stdin.read())
67
70
  except Exception as e: print("non-JSON response", e); sys.exit(2)
68
71
  if "error" in d: print("ERROR:", json.dumps(d["error"], indent=2)); sys.exit(1)
69
72
  print(str(d.get("id")) + "\t" + str(d.get("links", {}).get("html", {}).get("href")))
70
- ')"
73
+ ')" || { echo "$PR_INFO" >&2; exit 1; } # surface the API error; set -e would swallow it
71
74
 
72
75
  NEW_PR_ID="$(echo "$PR_INFO" | cut -f1)"
73
76
  NEW_PR_URL="$(echo "$PR_INFO" | cut -f2)"
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env bash
2
- # pr-common-version: 1
2
+ # pr-common-version: 2
3
3
  # pr-common.sh — shared library for provider PR binaries (bbpr/ghpr/glpr).
4
4
  # NOT executable on its own — sourced by a provider binary, which then builds the
5
5
  # provider-specific payload + POST. Everything here is provider-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,6 +76,7 @@ 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 ;;
@@ -130,9 +135,10 @@ pr_load_reviewers() {
130
135
  local mainroot; mainroot="$(cd "$(git rev-parse --git-common-dir 2>/dev/null)/.." 2>/dev/null && pwd)"
131
136
  for cand in \
132
137
  ${mainroot:+"$mainroot/${CFG_AGENT_DIR:-.claude}/pr-reviewers.txt"} \
138
+ ${mainroot:+"$mainroot/.pr/reviewers.txt"} \
133
139
  ${mainroot:+"$mainroot/.pr-reviewers.txt"} \
134
140
  "$HOME/.config/pr/reviewers/${slug}.txt" \
135
- "$HOME/.config/bbpr/bbpr-reviewers.txt"; do # global + legacy fallbacks
141
+ "$HOME/.config/bbpr/bbpr-reviewers.txt"; do # agent-dir primary; 0.1.2 .pr/ + root + global legacy fallbacks
136
142
  [ -f "$cand" ] && { file="$cand"; break; }
137
143
  done
138
144
  fi
@@ -0,0 +1,278 @@
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, 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.
6
+
7
+ Installed by `npx @nvice-r/pr-commands`. If setup is missing, this command STOPS and tells the
8
+ user to run the installer — it never guesses config (D8: hard fail, no hardcoded fallback).
9
+
10
+ If `$ARGUMENTS` starts with `help`/`-help`/`--help` (case-insensitive), print the **Help** block and stop.
11
+
12
+ If `$ARGUMENTS` starts with `update` (case-insensitive), run **Update mode** (below) and stop —
13
+ skip the create steps entirely.
14
+
15
+ ## Help (`/pr help`)
16
+
17
+ ```
18
+ /pr — provider-agnostic PR helper (@nvice-r/pr-commands)
19
+
20
+ USAGE
21
+ /pr [base-branch] [draft] [cross]
22
+ Create a PR for the current branch (default).
23
+ /pr update [PR#] [short] Rewrite an existing PR's description in place.
24
+ /pr help Show this help.
25
+
26
+ CREATE
27
+ base-branch Any arg containing "/". If omitted, inferred from closest ancestor
28
+ remote branch (saved per-branch after first run).
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:`.
31
+
32
+ UPDATE
33
+ PR# PR number to rewrite. If omitted, resolved from the current branch's
34
+ open PR. Re-drafts the body per convention, then PATCHes it in place.
35
+ short Compress harder (caveman brevity): cut throat-clearing, restated ticket
36
+ goals, filler connectors. Keeps every file + technical fact.
37
+
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` -> bbpr | ghpr | glpr
41
+ REVIEWERS <main-repo-root>/<agent_dir>/pr-reviewers.txt (per-repo, shared across worktrees; agent_dir from config)
42
+ SETUP npx @nvice-r/pr-commands
43
+ ```
44
+
45
+ ## Step 0 — verify install (HARD FAIL, no fallback)
46
+
47
+ Run the checks; if any fails, STOP and tell the user to run `npx @nvice-r/pr-commands` (name the
48
+ missing piece). Do NOT improvise config or reviewers.
49
+
50
+ ```bash
51
+ # a. config present?
52
+ CFG="${PR_CONFIG:-$HOME/.config/pr/config}"
53
+ [ -f "$CFG" ] || { echo "MISSING config: $CFG"; }
54
+
55
+ # b. detect provider from remote
56
+ URL="$(git remote get-url origin 2>/dev/null)" || echo "no origin remote"
57
+ case "$URL" in
58
+ *bitbucket.org*) PROVIDER=bitbucket; BIN=bbpr ;;
59
+ *github.com*) PROVIDER=github; BIN=ghpr ;;
60
+ *gitlab.com*) PROVIDER=gitlab; BIN=glpr ;;
61
+ *) echo "unknown provider for remote: $URL" ;;
62
+ esac
63
+
64
+ # c. matching binary installed?
65
+ command -v "$BIN" >/dev/null 2>&1 || echo "MISSING binary: $BIN (run installer, or provider not yet supported)"
66
+
67
+ # d. project convention file: this checkout first, then the main checkout (worktrees share it,
68
+ # because .agents/config/ is usually gitignored and only exists in the main checkout)
69
+ TOP="$(git rev-parse --show-toplevel)"
70
+ MAINROOT="$(cd "$(git rev-parse --git-common-dir)/.." && pwd)"
71
+ PR_YAML=""
72
+ for f in "$TOP/.agents/config/pr.yaml" "$MAINROOT/.agents/config/pr.yaml"; do
73
+ [ -f "$f" ] && { PR_YAML="$f"; break; }
74
+ done
75
+ echo "convention: ${PR_YAML:-$CFG (no .agents/config/pr.yaml)}"
76
+ ```
77
+
78
+ - config missing → STOP: "run `npx @nvice-r/pr-commands`".
79
+ - provider unknown → STOP: only bitbucket/github/gitlab supported.
80
+ - `$BIN` = `ghpr`/`glpr` and not installed → STOP: "provider not yet supported (macOS+Bitbucket only for now)". (D6 stub.)
81
+ - all pass → continue.
82
+
83
+ ## Step 1 — gather context (parallel)
84
+ - `git rev-parse --abbrev-ref HEAD` — source branch (CURR)
85
+ - `git config --get branch.<CURR>.bbprBase` — saved base, if any
86
+ - `git log --oneline -20`
87
+ - read the **convention source**: `$PR_YAML` if Step 0 found one, else the convention keys in `$CFG`.
88
+ Every later step that says "convention" means this source.
89
+
90
+ **Convention keys** (YAML in `pr.yaml`; `key=value` in `$CFG`, where list values are pipe-separated):
91
+
92
+ | key | meaning | if missing |
93
+ |-----|---------|------------|
94
+ | `title_pattern` | title template with `<ver> <desc> <ticket> <type> <scope> <issue>` | ask the user |
95
+ | `ticket_regex` | ticket key in the branch name | no ticket |
96
+ | `template_sections` | body `## ` headings, in order | `Summary`, `Changes` |
97
+ | `body_rules` | extra project rules for the body (free text, follow literally) | none |
98
+ | `base` | default base proposal (Step 2 d) | ask |
99
+ | `draft` | `ask` / `true` / `false` | `ask` |
100
+ | `push` | remotes to push before create (Step 7.5) | none (assume pushed) |
101
+ | `fork` | `{remote, repo}` for cross-repo mode | cross mode unavailable |
102
+ | `pre_pr` | shell commands run before confirm (Step 6.5) | none |
103
+ | `post_pr` | sibling skills offered after create (Step 10) | none |
104
+
105
+ ## Step 2 — determine base branch (priority)
106
+ a. `$1` (any arg containing `/`) → use it.
107
+ b. else `branch.<CURR>.bbprBase` from git config → use silently.
108
+ c. else infer — smallest non-zero `<ref>..HEAD` distance where `<ref>` is an ancestor of HEAD:
109
+ ```bash
110
+ for ref in $(git for-each-ref --format='%(refname:short)' refs/remotes/origin | grep -v "^origin/<CURR>$"); do
111
+ if git merge-base --is-ancestor "$ref" HEAD 2>/dev/null; then
112
+ ahead=$(git rev-list --count "$ref"..HEAD)
113
+ [ "$ahead" -gt 0 ] && echo "$ahead $ref"
114
+ fi
115
+ done | sort -n | head -5
116
+ ```
117
+ Propose smallest-`ahead` candidate; confirm. Do NOT auto-pick a naming-pattern match — a
118
+ stacked PR on another in-flight ticket branch is common. Ties or all >20 ahead → ask.
119
+ d. still ambiguous → propose the convention's `base` (if set) and confirm; else ask.
120
+
121
+ The binary auto-saves the resolved base to `branch.<CURR>.bbprBase` on success.
122
+
123
+ ## Step 3 — build diff context
124
+ `git log <base>..HEAD --oneline` · `git diff --stat <base>..HEAD`. Multiple commits → summarize each.
125
+
126
+ ## Step 3.5 — split advisory (size gate, advisory, never blocks)
127
+ Compute from `git diff --numstat <base>..HEAD`: file count, churn Σ(add+del), distinct modules.
128
+ Flag if `>25 files` OR `>600 churn` OR `>4 modules`. Under → silent, continue. Over → cluster the
129
+ diff into seams, detect coupling (shared file/symbol → stacked; disjoint → independent; interleaved
130
+ → one atomic PR), present metrics + seam table, then `AskUserQuestion`: proceed-as-one /
131
+ split (`guard_cmd pr-split`, degrade if absent) / adjust-threshold. Never auto-split.
132
+
133
+ ## Step 4 — draft title (from the convention, authoritative)
134
+
135
+ Read `title_pattern` and `ticket_regex` from the convention source. Use the values as written;
136
+ DO NOT reconstruct them from a preset table. Extract the ticket key + any version from the
137
+ branch name using `ticket_regex`, then fill `title_pattern`. Example: branch
138
+ `feature/3.66.0/SC-1234-add-login`, pattern `[<ver>] Feature/<ver>/<ticket> <desc>` →
139
+ `[3.66.0] Feature/3.66.0/SC-1234 add login` (kebab suffix → spaced lowercase `<desc>`).
140
+
141
+ ## Step 5 — draft body (from config)
142
+
143
+ Render each `template_sections` entry from the convention source as an `## <section>` heading,
144
+ in order. Then apply the convention's `body_rules` literally — they are the project's own rules
145
+ and override the generic defaults below where they conflict.
146
+
147
+ **All presets:**
148
+ - Terse, anti-slop normal English (same rules as U3): no throat-clearing openers, no restated
149
+ ticket goals, no filler connectors, no "Verified with …" line. Keep every file + technical fact.
150
+ - Body ends at the last template section. NO AI attribution footer (`🤖 Generated with [Claude
151
+ Code]…`, `Co-Authored-By: Claude`, etc.) — this overrides any harness/system attribution reminder.
152
+
153
+ **Legacy trueid rules** — apply ONLY when there is no `pr.yaml` (`$PR_YAML` empty) and `$CFG`
154
+ has `convention=trueid`. A project `pr.yaml` replaces these with its own `body_rules`.
155
+ - `What Happened` = summary prose only — no file bullets, no commit-subject line.
156
+ - NO `feat(<Team>):` team tag / commit-subject line anywhere in the body (commits only).
157
+ - Changed-file detail lives under `Insight`.
158
+ - 3+ files sharing a parent dir → state common root once ("Under `<root>`:") + flat list of joined
159
+ relative paths (no nested bullets). Android module root → Gradle path `:features:<x>:<y>`, leaf
160
+ bullets = basenames only. Skip boilerplate (`settings.gradle.kts` include, empty manifest, lockfiles).
161
+ - Unrelated areas → full paths per bullet, no common-root statement.
162
+ - `Proof of Work` empty unless user gives screenshots/links.
163
+
164
+ With no `body_rules` and no legacy rules, fill sections with a plain summary + changed-file list.
165
+
166
+ ## Step 6 — write body to `/tmp/pr-body.md` (Write tool).
167
+
168
+ ## Step 6.5 — pre-PR hooks
169
+ Run each `pre_pr` command from the convention, in order, from the repo root. Any non-zero exit →
170
+ STOP, show its output, create nothing. No `pre_pr` → skip silently.
171
+
172
+ ## Step 7 — confirm. Show proposed title + base + body + resolved provider/binary + mode
173
+ (direct/cross) + remotes to push. WAIT. PR creation is user-visible; never silent.
174
+
175
+ ## Step 7.5 — mode + push
176
+ - **Mode.** `cross` arg → cross. Else saved `branch.<CURR>.prMode` → use it. Else, when the
177
+ convention has `fork`: branch exists only on `fork.remote` (`git ls-remote <remote> <CURR>`)
178
+ → cross; otherwise direct. No `fork` → always direct; `cross` arg → STOP ("no fork in pr.yaml").
179
+ - **Direct:** push every remote in `push` (`git push <remote> HEAD`), in order.
180
+ - **Cross:** push `fork.remote` only. Never push to `origin`.
181
+ - Push failure → STOP and show the error. Never pass `--no-verify` (pre-push hooks may lint;
182
+ allow 30–60 s). No `push` key → do not push; warn if HEAD is ahead of its upstream.
183
+
184
+ ## Step 8 — create. Run the detected binary (append `--draft` only if draft chosen):
185
+ ```bash
186
+ <BIN> --title "<title>" --body-file /tmp/pr-body.md --dest "<base>" [--draft] \
187
+ [--source-repo "<fork.repo>"] # cross mode only
188
+ ```
189
+ Draft: `draft` arg → draft. Else convention `draft: true|false` → use it. Else (`ask` or unset)
190
+ ask open-vs-draft before creating. After a cross-mode success, run
191
+ `git config branch.<CURR>.prMode cross` so the next `/pr` reuses the mode. The binary attaches the
192
+ per-repo reviewer roster automatically; `--no-reviewers` skips, `--reviewer <id>` appends.
193
+
194
+ ## Step 9 — print final PR URL + reviewer count from binary output. On error, surface raw response.
195
+
196
+ ## Step 10 — post-PR skills (guarded, optional).
197
+ For each skill in the convention's `post_pr` (legacy config without `pr.yaml`: `jira-update`):
198
+ `guard_cmd <skill>`; absent → one-line note, skip. Present → `AskUserQuestion`: run now
199
+ (`/<skill> <ticket>` with the Step 4 ticket key) or skip. No ticket key → skip ticket-based skills.
200
+
201
+ ## Update mode (`/pr update [PR#] [short]`)
202
+
203
+ Rewrites an existing open PR's **description** in place — no new branch, no new PR. Same Step 0
204
+ install check runs first (config + provider + creds). Provider PATCH is currently Bitbucket-only,
205
+ matching the create path; `ghpr`/`glpr` providers → STOP with the same "not yet supported" note.
206
+
207
+ **U1 — parse args.** Tokens after `update`: a bare integer = `PR#`; the word `short` (or
208
+ `caveman`) = compress-hard flag. Missing `PR#` → resolve from the current branch: query the
209
+ 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.
210
+
211
+ **U2 — fetch current PR.** Resolve creds the same way the binary does — email from `$CFG`
212
+ (`email=`), token from the OS keychain (macOS service `bitbucket-api-token`, account = email).
213
+ `REPO` = workspace/slug from `git remote get-url origin`. Fetch title + description:
214
+ ```bash
215
+ curl -sS -u "$EMAIL:$TOKEN" \
216
+ "https://api.bitbucket.org/2.0/repositories/$REPO/pullrequests/$PRNUM"
217
+ ```
218
+
219
+ **U3 — re-draft the body.** Rebuild the description with the **Step 5** rules (trueid template
220
+ when `convention=trueid`): `What Happened` prose, `Insight` file detail, `Proof of Work`. Prefer
221
+ the diff/commits as the source of truth over the old text; carry forward Proof-of-Work links the
222
+ old body already had. Default output = terse, anti-slop normal English:
223
+ - Cut throat-clearing openers ("The X feature needs to…", "This adds…"), restated ticket goals,
224
+ and filler connectors ("as a consequence", "in order to").
225
+ - Keep every changed file, every behavioural fact, and any bug-fix detail — brevity, not omission.
226
+
227
+ With the `short` flag, compress harder toward caveman brevity (fragments, dropped articles) while
228
+ keeping the section headings and every technical fact. Title stays unchanged unless the user asks.
229
+
230
+ **U4 — confirm (diff-style).** Write the new body to `/tmp/pr-body.md`. Show a short before/after
231
+ summary (what was cut) + the full new body. WAIT — editing a live PR is user-visible; never silent.
232
+
233
+ **U5 — PATCH in place** (Bitbucket `PUT`, description only):
234
+ ```bash
235
+ python3 - "$REPO" "$PRNUM" "$EMAIL" "$TOKEN" /tmp/pr-body.md <<'PY'
236
+ import sys, json, base64, urllib.request, urllib.error
237
+ repo, num, email, token, bodyfile = sys.argv[1:6]
238
+ desc = open(bodyfile).read()
239
+ data = json.dumps({"description": desc}).encode()
240
+ url = f"https://api.bitbucket.org/2.0/repositories/{repo}/pullrequests/{num}"
241
+ req = urllib.request.Request(url, data=data, method="PUT", headers={"Content-Type": "application/json"})
242
+ req.add_header("Authorization", "Basic " + base64.b64encode(f"{email}:{token}".encode()).decode())
243
+ try:
244
+ r = urllib.request.urlopen(req)
245
+ print(r.status, json.loads(r.read())["links"]["html"]["href"])
246
+ except urllib.error.HTTPError as e:
247
+ print("ERR", e.code, e.read().decode()[:500])
248
+ PY
249
+ ```
250
+ On non-2xx, surface the raw response body. Reviewers/base/title are untouched — description only.
251
+
252
+ **U6 — print PR URL.** No Jira sync offer on update (status unchanged).
253
+
254
+ ## Cross-command guard
255
+ Sibling commands (`/pr-split`, `/jira-update`) are optional — a teammate may not have them, and
256
+ they may live under any agent's skills/commands dir. Before invoking any, confirm it exists; if
257
+ absent, degrade gracefully (one-line note). Reusable check (scans Claude command dir + every known
258
+ agent skills dir):
259
+ ```bash
260
+ guard_cmd() { # usage: guard_cmd pr-split → prints path or "no-<cmd>"
261
+ local c="$1"
262
+ ls "$PWD/.claude/commands/$c.md" "$HOME/.claude/commands/$c.md" \
263
+ "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/$c/SKILL.md" \
264
+ "$HOME/.gemini/antigravity/skills/$c/SKILL.md" \
265
+ "${CODEX_HOME:-$HOME/.codex}/skills/$c/SKILL.md" \
266
+ "${XDG_CONFIG_HOME:-$HOME/.config}/opencode/skills/$c/SKILL.md" \
267
+ "$PWD/.claude/skills/$c/SKILL.md" "$PWD/.agents/skills/$c/SKILL.md" 2>/dev/null | head -1 \
268
+ || echo "no-$c"
269
+ }
270
+ ```
271
+
272
+ ## Notes
273
+ - Does NOT push — assumes branch pushed. Unpushed commits ahead of upstream → warn + ask.
274
+ - Provider/token/convention all from config + remote — NO hardcoded team values here (D3/D4).
275
+ - Verify worktree before any split branch work (`git symbolic-ref --short HEAD` + `git worktree list`);
276
+ background restacks can move branches mid-turn.
277
+ - Caveman mode applies to replies — PR title/body default to normal English. Exception: `/pr update
278
+ … short` (a.k.a. `caveman`) is an explicit user opt-in to compress the body toward caveman brevity.
package/install.js CHANGED
@@ -1,9 +1,10 @@
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, copy /pr skill -> ~/.claude/commands.
4
+ // install binaries -> ~/.local/bin, install the portable `pr` SKILL.md into every
5
+ // detected agent (symlinked to one canonical copy) + Claude /pr slash-command.
5
6
  //
6
- // Flags: --dir <path> --provider <p> --all --yes --dry-run --help
7
+ // Flags: --dir <path> --provider <p> --agent <id> --all --yes --dry-run --help
7
8
 
8
9
  'use strict';
9
10
  const fs = require('fs');
@@ -15,6 +16,7 @@ const { execFileSync } = require('child_process');
15
16
 
16
17
  const HOME = os.homedir();
17
18
  const PKG = __dirname;
19
+ const PKG_VERSION = require('./package.json').version;
18
20
  const ASSETS = path.join(PKG, 'assets');
19
21
  const BACK = Symbol('back'); // returned by a prompt when the user wants the previous step
20
22
 
@@ -35,19 +37,53 @@ const PRESET_DEFS = {
35
37
 
36
38
  // ---- arg parse ----
37
39
  const argv = process.argv.slice(2);
38
- const flags = { dir: null, provider: null, all: false, yes: false, dryRun: false, agentDir: null };
40
+ const flags = { dir: null, provider: null, all: false, yes: false, dryRun: false, agentDir: null, agents: null, resetReviewers: false };
39
41
  for (let i = 0; i < argv.length; i++) {
40
42
  const a = argv[i];
41
43
  if (a === '--help' || a === '-h') { printHelp(); process.exit(0); }
42
44
  else if (a === '--dir') flags.dir = argv[++i];
43
45
  else if (a === '--provider') flags.provider = argv[++i];
44
46
  else if (a === '--agent-dir') flags.agentDir = argv[++i];
47
+ else if (a === '--agent') (flags.agents ||= []).push(argv[++i]);
45
48
  else if (a === '--all') flags.all = true;
46
49
  else if (a === '--yes' || a === '-y') flags.yes = true;
47
50
  else if (a === '--dry-run') flags.dryRun = true;
51
+ else if (a === '--reset-reviewers') flags.resetReviewers = true;
48
52
  else { console.error(`unknown flag: ${a}`); process.exit(1); }
49
53
  }
50
- const AGENT_DIR = (flags.agentDir || '.claude').replace(/^\/+|\/+$/g, ''); // strip stray slashes
54
+ const cleanDir = (d) => d.replace(/^\/+|\/+$/g, ''); // strip stray slashes
55
+
56
+ // ---- portable skill ----
57
+ // One canonical SKILL.md (~/.config/pr/skill/pr/); every detected agent's global skills
58
+ // dir gets a symlink `pr` -> that dir (skills-CLI single-source model). Claude additionally
59
+ // gets a /pr slash-command. Frontmatter differs per target; the body is shared (pr.body.md).
60
+ const SKILL_FM = `---
61
+ name: pr
62
+ description: Create a pull request for the current branch, with the title and body drafted in the team's convention and the provider (Bitbucket/GitHub/GitLab) auto-detected from the git remote. Use when the user asks to open, create, raise, or draft a pull request / PR / merge request for the current branch, or types /pr.
63
+ ---
64
+
65
+ `;
66
+ const CMD_FM = `---
67
+ description: Create a pull request for the current branch (provider auto-detected)
68
+ argument-hint: [base-branch] [draft]
69
+ model: sonnet
70
+ ---
71
+
72
+ `;
73
+ // base = probe dir deciding "installed"; skills = GLOBAL skills dir we link into;
74
+ // repoDir = the agent's per-repo folder (holds the reviewer roster when it's the primary agent).
75
+ // Env vars resolved the same way the agents themselves resolve them.
76
+ function agentTargets() {
77
+ const claudeBase = process.env.CLAUDE_CONFIG_DIR || path.join(HOME, '.claude');
78
+ const codexBase = process.env.CODEX_HOME || path.join(HOME, '.codex');
79
+ const xdg = process.env.XDG_CONFIG_HOME || path.join(HOME, '.config');
80
+ return [
81
+ { id: 'claude-code', base: claudeBase, skills: path.join(claudeBase, 'skills'), repoDir: '.claude', claude: true },
82
+ { id: 'antigravity', base: path.join(HOME, '.gemini/antigravity'), skills: path.join(HOME, '.gemini/antigravity/skills'), repoDir: '.agent' },
83
+ { id: 'codex', base: codexBase, skills: path.join(codexBase, 'skills'), repoDir: '.codex' },
84
+ { id: 'opencode', base: path.join(xdg, 'opencode'), skills: path.join(xdg, 'opencode/skills'), repoDir: '.opencode' },
85
+ ];
86
+ }
51
87
 
52
88
  function printHelp() {
53
89
  console.log(`@nvice-r/pr-commands installer
@@ -56,9 +92,13 @@ function printHelp() {
56
92
 
57
93
  --dir <path> Repo to configure (default: cwd)
58
94
  --provider <p> Force bitbucket|github|gitlab (default: detect from remote)
59
- --agent-dir <dir> Agent folder for the roster, relative to main repo root (default: .claude)
95
+ --agent-dir <dir> Repo folder holding the reviewer roster (default: primary agent's
96
+ folder — .claude / .agent / .codex / .opencode)
97
+ --agent <id> Force skill install to this agent (repeatable). Ids: claude-code,
98
+ antigravity, codex, opencode. Default: auto-detect installed agents.
60
99
  --all Install all provider binaries (default: only the detected one)
61
- --yes Accept defaults, no prompts (reviewers=all, convention=trueid)
100
+ --yes Accept defaults, no prompts (reviewers=existing roster or all, convention=trueid)
101
+ --reset-reviewers Ignore an existing roster and rebuild it from the repo's default reviewers
62
102
  --dry-run Print planned actions, write nothing
63
103
  --help This help`);
64
104
  }
@@ -73,8 +113,8 @@ const ask = (q) => new Promise((res) => {
73
113
  });
74
114
  // Editable pre-filled default: edit the shown value or hit enter to keep it.
75
115
  // `:b` (or `:back`) returns BACK; empty keeps the default.
76
- const askPrefill = (q, def, allowBack = true) => new Promise((res) => {
77
- const r = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
116
+ const askPrefill = (q, def, allowBack = true, completer = undefined) => new Promise((res) => {
117
+ const r = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true, completer });
78
118
  r.question(q, (a) => {
79
119
  r.close();
80
120
  const t = a.trim();
@@ -84,6 +124,22 @@ const askPrefill = (q, def, allowBack = true) => new Promise((res) => {
84
124
  });
85
125
  if (def && process.stdin.isTTY) r.write(def); // inject editable text
86
126
  });
127
+ // Tab-completion for filesystem paths (directories only — work dir is always a dir).
128
+ // Preserves the user's typed prefix style (~, relative, absolute); appends "/" to each match.
129
+ function pathCompleter(line) {
130
+ const cut = line.lastIndexOf('/');
131
+ const dirPart = cut >= 0 ? line.slice(0, cut + 1) : ''; // keeps trailing slash, e.g. "~/Doc" -> "~/"
132
+ const base = cut >= 0 ? line.slice(cut + 1) : line;
133
+ let realDir = dirPart || '.';
134
+ if (realDir.startsWith('~')) realDir = HOME + realDir.slice(1);
135
+ let ents;
136
+ try { ents = fs.readdirSync(realDir, { withFileTypes: true }); } catch { return [[], line]; }
137
+ const hits = ents
138
+ .filter((e) => e.isDirectory() && e.name.startsWith(base))
139
+ .map((e) => dirPart + e.name + '/')
140
+ .sort();
141
+ return [hits, line];
142
+ }
87
143
 
88
144
  // Interactive checklist (↑/↓ move · space toggle · a all · n none · enter confirm).
89
145
  // Zero-dep raw-mode TTY. Returns array of selected indices. items: [{label}].
@@ -208,10 +264,69 @@ function writeFile(p, content, mode) {
208
264
  // Main repo root, shared across worktrees: git-common-dir points at the main .git
209
265
  // even from a linked worktree; its parent is the main checkout root. gitCommonDir
210
266
  // may be relative ('.git') or absolute ('/main/.git') — path.resolve handles both.
267
+ // Existing roster, so a reinstall keeps it: --agent-dir, each agent's repo folder, then the
268
+ // 0.1.2 neutral .pr/ location. Returns { file, entries: [{uuid, name}] } or null.
269
+ function findExistingRoster(mroot) {
270
+ const dirs = [flags.agentDir && cleanDir(flags.agentDir), ...agentTargets().map((t) => t.repoDir)].filter(Boolean);
271
+ const files = [...new Set(dirs)].map((d) => path.join(mroot, d, 'pr-reviewers.txt'))
272
+ .concat(path.join(mroot, '.pr', 'reviewers.txt'));
273
+ const file = files.find((f) => fs.existsSync(f));
274
+ if (!file) return null;
275
+ const entries = [];
276
+ for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
277
+ const m = line.match(/^\s*(\{[^}]+\})\s*(?:#\s*(.*))?$/);
278
+ if (m) entries.push({ uuid: m[1], name: (m[2] || '').trim() });
279
+ }
280
+ return { file, entries };
281
+ }
211
282
  function mainRepoRoot(wd) {
212
283
  const gcd = shSafe('git', ['-C', wd, 'rev-parse', '--git-common-dir']);
213
284
  return gcd ? path.resolve(wd, gcd, '..') : null;
214
285
  }
286
+ // Write the canonical SKILL.md, then link/copy it into each targeted agent. Claude also
287
+ // gets the /pr slash-command. --agent forces a set; otherwise install to every detected
288
+ // agent (claude-code is always included — it's this tool's native home).
289
+ // Agents whose config dir exists (claude-code always included — this tool's native home).
290
+ function detectedAgentIds() {
291
+ return agentTargets().filter((t) => t.id === 'claude-code' || fs.existsSync(t.base)).map((t) => t.id);
292
+ }
293
+ // Install the skill into exactly the agents in `ids`.
294
+ function installSkill(ids) {
295
+ const set = new Set(ids || []);
296
+ const body = fs.readFileSync(path.join(ASSETS, 'skill/pr.body.md'), 'utf8');
297
+ const canonDir = path.join(HOME, '.config/pr/skill/pr');
298
+ writeFile(path.join(canonDir, 'SKILL.md'), SKILL_FM + body);
299
+
300
+ const hits = [];
301
+ for (const t of agentTargets()) {
302
+ if (!set.has(t.id)) continue;
303
+ linkSkill(t, canonDir);
304
+ if (t.claude) writeFile(path.join(t.base, 'commands/pr.md'), CMD_FM + body); // dual: keep /pr
305
+ hits.push(t.id);
306
+ }
307
+ log(` skill targeted: ${hits.join(', ') || '(none)'}`);
308
+ }
309
+ // Symlink <agent>/skills/pr -> canonical dir. Replace only OUR symlink; never clobber a
310
+ // pre-existing real dir/file (could be the user's own `pr` skill). Copy if symlinks fail.
311
+ function linkSkill(t, canonDir) {
312
+ const dst = path.join(t.skills, 'pr');
313
+ if (flags.dryRun) { log(` [dry-run] link ${dst} -> ${canonDir}`); return; }
314
+ fs.mkdirSync(t.skills, { recursive: true });
315
+ let cur = null;
316
+ try { cur = fs.lstatSync(dst); } catch { /* absent */ }
317
+ if (cur) {
318
+ if (cur.isSymbolicLink()) fs.unlinkSync(dst);
319
+ else { log(` SKIP ${dst} — exists and is not our symlink; remove it and re-run`); return; }
320
+ }
321
+ try {
322
+ fs.symlinkSync(canonDir, dst, 'dir');
323
+ log(` linked ${dst} -> ${canonDir}`);
324
+ } catch (e) {
325
+ fs.mkdirSync(dst, { recursive: true });
326
+ fs.copyFileSync(path.join(canonDir, 'SKILL.md'), path.join(dst, 'SKILL.md'));
327
+ log(` copied ${dst}/SKILL.md (symlink unavailable: ${e.code})`);
328
+ }
329
+ }
215
330
  // Depth-limited scan for git repos beneath a non-repo dir. Stops descending once a
216
331
  // repo is found (no nested-repo noise); skips dotdirs + node_modules; caps results.
217
332
  function scanGitRepos(root, maxDepth = 3, cap = 50) {
@@ -316,6 +431,7 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
316
431
  }
317
432
  if (stage > 1 && st.chosen) L.push(` \x1b[32m✓\x1b[0m reviewers ${st.chosen.length} selected`);
318
433
  if (stage > 2 && st.convention) L.push(` \x1b[32m✓\x1b[0m convention ${st.convention}`);
434
+ if (stage > 3 && st.agents) L.push(` \x1b[32m✓\x1b[0m agents ${st.agents.join(', ') || '(none)'}`);
319
435
  return L;
320
436
  };
321
437
  // Full-screen redraw: wipe, reprint header + breadcrumb + active-step marker. No accumulation on back.
@@ -336,7 +452,7 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
336
452
  let wd;
337
453
  if (flags.dir || flags.yes) wd = path.resolve(flags.dir || process.cwd());
338
454
  else {
339
- const a = await askPrefill(' repo path: ', st.workDir || process.cwd());
455
+ const a = await askPrefill(' repo path: ', st.workDir || process.cwd(), true, pathCompleter);
340
456
  if (a === BACK) return BACK; // first step — driver just stays here
341
457
  wd = path.resolve(a);
342
458
  }
@@ -378,7 +494,7 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
378
494
  log(` WARN: no keychain token for ${pcfg.keychainSvc}/${email || '(none)'} — reviewers can't auto-fetch`);
379
495
  log(` security add-generic-password -s ${pcfg.keychainSvc} -a ${email || 'you@example.com'} -w '<api-token>'`);
380
496
  } else log(' token: found');
381
- if (st.slug !== slug) st.revs = undefined; // invalidate reviewer cache if repo changed
497
+ if (st.slug !== slug) { st.revs = undefined; st.existing = undefined; st.chosenIdx = undefined; } // repo changed → drop reviewer caches
382
498
  const mroot = mainRepoRoot(wd) || wd; // shared across worktrees; roster lives here
383
499
  if (mroot !== wd) log(` worktree detected → roster shared at main root: ${mroot}`);
384
500
  Object.assign(st, { workDir: wd, mainRoot: mroot, remoteUrl, provider, pcfg, slug, email, token });
@@ -387,6 +503,13 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
387
503
  const sReviewers = async () => {
388
504
  screen(1, 'Reviewers');
389
505
  st.chosen = st.chosen || [];
506
+ if (st.existing === undefined) st.existing = flags.resetReviewers ? null : findExistingRoster(st.mainRoot);
507
+ const ex = st.existing;
508
+ if (ex) log(` existing roster: ${ex.file} (${ex.entries.length})`);
509
+ // --yes (or no token to fetch with) + existing roster → keep it untouched, skip the fetch.
510
+ if (ex && (flags.yes || !(st.token && st.email))) {
511
+ st.chosen = ex.entries; st.extra = []; log(' kept existing roster (--reset-reviewers to rebuild)'); return;
512
+ }
390
513
  if (!(st.token && st.email)) { log(' skipped (no token) — edit the roster later.'); st.chosen = []; return; }
391
514
  if (st.selfUuid === undefined) st.selfUuid = await fetchSelfUuid(st.email, st.token);
392
515
  if (st.revs === undefined) st.revs = await fetchBitbucketReviewers(st.slug, st.email, st.token, st.selfUuid);
@@ -394,14 +517,18 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
394
517
  if (!revs) { log(' WARN: could not fetch default-reviewers. Roster left empty.'); st.chosen = []; return; }
395
518
  if (revs.length === 0) { log(' repo has no default reviewers. Roster left empty.'); st.chosen = []; return; }
396
519
  if (flags.yes) { st.chosen = revs; st.chosenIdx = revs.map((_, i) => i); log(` selected all ${revs.length}`); return; }
520
+ // Existing roster → pre-check its members; entries not among the defaults are kept as-is.
521
+ const exIds = ex ? new Set(ex.entries.map((e) => e.uuid)) : null;
522
+ st.extra = ex ? ex.entries.filter((e) => !revs.some((r) => r.uuid === e.uuid)) : [];
523
+ const preIdx = st.chosenIdx || (exIds ? revs.map((r, i) => (exIds.has(r.uuid) ? i : -1)).filter((i) => i >= 0) : null);
397
524
  const picked = await multiSelect(
398
- `Reviewers for ${st.slug} (default: all):`,
399
- revs.map((r) => ({ label: r.name })), true, st.chosenIdx || null,
525
+ `Reviewers for ${st.slug} (default: ${ex ? 'existing roster' : 'all'}):`,
526
+ revs.map((r) => ({ label: r.name })), true, preIdx,
400
527
  );
401
528
  if (picked === BACK) return BACK;
402
529
  st.chosenIdx = picked;
403
- st.chosen = picked.map((i) => revs[i]);
404
- log(` chosen: ${st.chosen.length}`);
530
+ st.chosen = picked.map((i) => revs[i]).concat(st.extra);
531
+ log(` chosen: ${st.chosen.length}${st.extra.length ? ` (incl. ${st.extra.length} kept from existing roster, not in defaults)` : ''}`);
405
532
  };
406
533
 
407
534
  const sPreset = async () => {
@@ -429,12 +556,32 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
429
556
  st.conv[key] = v;
430
557
  };
431
558
 
559
+ // Pick which agents get the /pr skill. --agent overrides (non-interactive); --yes auto-detects;
560
+ // else an interactive checklist with detected agents pre-checked.
561
+ const sAgents = async () => {
562
+ screen(3, 'Agents');
563
+ const targets = agentTargets();
564
+ if (flags.agents) { st.agents = flags.agents; log(` forced (--agent): ${st.agents.join(', ')}`); return; }
565
+ const detected = new Set(detectedAgentIds());
566
+ if (flags.yes) { st.agents = [...detected]; log(` auto-detected: ${st.agents.join(', ')}`); return; }
567
+ const preIdx = targets.map((t, i) => (detected.has(t.id) ? i : -1)).filter((i) => i >= 0);
568
+ const picked = await multiSelect(
569
+ 'Install the /pr skill into which agents? (detected pre-checked):',
570
+ targets.map((t) => ({ label: `${t.id}${detected.has(t.id) ? ' \x1b[2m(detected)\x1b[0m' : ''}` })),
571
+ false, preIdx,
572
+ );
573
+ if (picked === BACK) return BACK;
574
+ st.agents = picked.map((i) => targets[i].id);
575
+ log(` agents: ${st.agents.join(', ') || '(none)'}`);
576
+ };
577
+
432
578
  const steps = flags.yes
433
- ? [sWorkDir, sReviewers, sPreset]
579
+ ? [sWorkDir, sReviewers, sPreset, sAgents]
434
580
  : [sWorkDir, sReviewers, sPreset,
435
581
  editField('title_pattern: ', 'title_pattern'),
436
582
  editField('ticket_regex: ', 'ticket_regex'),
437
- editField('template_sections:', 'template_sections')];
583
+ editField('template_sections:', 'template_sections'),
584
+ sAgents];
438
585
 
439
586
  let i = 0;
440
587
  while (i < steps.length) {
@@ -444,18 +591,30 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
444
591
  }
445
592
 
446
593
  // --- install ---
447
- screen(3, 'Install');
594
+ screen(4, 'Install');
448
595
  const { slug, pcfg, email, convention, conv, chosen } = st;
449
- let cfg = `os=${cfgOs}\ntoken_src=${tokenSrc}\nemail=${email}\nconvention=${convention}\nagent_dir=${AGENT_DIR}\n`;
596
+ // Roster folder = --agent-dir, else the first chosen agent's repo folder, else .claude.
597
+ const agentIds = st.agents || detectedAgentIds();
598
+ const primary = agentTargets().find((t) => t.id === agentIds[0]);
599
+ const agentDir = cleanDir(flags.agentDir || (primary && primary.repoDir) || '.claude');
600
+ let cfg = `pkg_version=${PKG_VERSION}\nos=${cfgOs}\ntoken_src=${tokenSrc}\nemail=${email}\nconvention=${convention}\nagent_dir=${agentDir}\n`;
450
601
  if (conv.title_pattern) cfg += `title_pattern=${conv.title_pattern}\n`;
451
602
  if (conv.ticket_regex) cfg += `ticket_regex=${conv.ticket_regex}\n`;
452
603
  if (conv.template_sections) cfg += `template_sections=${conv.template_sections}\n`;
453
604
  writeFile(path.join(HOME, '.config/pr/config'), cfg, 0o600);
454
- // Roster lives in the agent dir under the main repo root (shared across worktrees). Editable
455
- // in place; pr-common.sh finds it first, falling back to root/global/legacy paths.
456
- const roster = chosen.map((r) => `${r.uuid} # ${r.name}`).join('\n') + (chosen.length ? '\n' : '');
457
- const header = `# pr reviewers for ${slug} — {uuid} # Name per line; blanks/# ignored. Edit freely.\n`;
458
- writeFile(path.join(st.mainRoot, AGENT_DIR, 'pr-reviewers.txt'), header + (roster || '# (none selected)\n'));
605
+ // Roster: <main-repo-root>/<agent_dir>/pr-reviewers.txt — lives with the primary agent's repo
606
+ // folder, shared across worktrees. agent_dir is recorded in the config so the bash binary
607
+ // (which can't know the calling agent) reads the same path; legacy .pr/ + root + global paths
608
+ // remain load fallbacks.
609
+ const rosterFile = path.join(st.mainRoot, agentDir, 'pr-reviewers.txt');
610
+ const kept = st.existing && chosen === st.existing.entries; // untouched existing roster
611
+ if (kept && st.existing.file === rosterFile) log(` kept ${rosterFile}`);
612
+ else if (kept) writeFile(rosterFile, fs.readFileSync(st.existing.file)); // move to new agent_dir; old file left as fallback
613
+ else {
614
+ const roster = chosen.map((r) => `${r.uuid} # ${r.name}`).join('\n') + (chosen.length ? '\n' : '');
615
+ const header = `# pr reviewers for ${slug} — {uuid} # Name per line; blanks/# ignored. Edit freely.\n`;
616
+ writeFile(rosterFile, header + (roster || '# (none selected)\n'));
617
+ }
459
618
  const binDir = path.join(HOME, '.local/bin');
460
619
  const binsToInstall = flags.all
461
620
  ? Object.values(PROVIDERS).filter((p) => p.ready).map((p) => p.bin)
@@ -466,7 +625,7 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
466
625
  if (!fs.existsSync(src)) { log(` skip ${b} (asset not present yet)`); continue; }
467
626
  writeFile(path.join(binDir, b), fs.readFileSync(src), 0o755);
468
627
  }
469
- writeFile(path.join(HOME, '.claude/commands/pr.md'), fs.readFileSync(path.join(ASSETS, 'skill/pr.md')));
628
+ installSkill(agentIds);
470
629
  if (!(process.env.PATH || '').split(':').includes(binDir)) {
471
630
  log(`\n WARN: ${binDir} not on PATH. Add to your shell rc:`);
472
631
  log(` export PATH="$HOME/.local/bin:$PATH"`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nvice-r/pr-commands",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Provider-agnostic PR helper for Claude Code — npx installer + bbpr/ghpr/glpr binaries + /pr skill",
5
5
  "bin": {
6
6
  "pr-commands": "install.js"
@@ -1,157 +0,0 @@
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.