@nvice-r/pr-commands 0.1.1 → 0.1.2

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
@@ -27,7 +27,10 @@ The interactive wizard:
27
27
  template-sections (each pre-filled from the preset, editable). A placeholder legend
28
28
  (`<ver>`, `<desc>`, `<ticket>`, …) is shown. Concrete values are written to config — `/pr`
29
29
  reads those, not a hard-coded table.
30
- 7. **Install** — writes config + roster, drops the binaries and the `/pr` skill.
30
+ 7. **Agents** — checklist of supported agents (claude-code / antigravity / codex / opencode) with
31
+ installed ones pre-checked; toggle which get the `pr` skill. (`--agent` skips this; `--yes` = detected.)
32
+ 8. **Install** — writes config + roster + binaries, then installs the portable `pr` skill into
33
+ the chosen agents (symlinked to one canonical copy) + Claude's `/pr` command.
31
34
 
32
35
  Every prompt is reversible: in a list press `b` to step back; in a text field, append `:b` to
33
36
  the value and press enter (the field is pre-filled, so `:b` at the end triggers back). Back
@@ -39,6 +42,7 @@ Flags:
39
42
  |------|--------|
40
43
  | `--dir <path>` | repo to configure (default: cwd) |
41
44
  | `--provider <p>` | force `bitbucket`\|`github`\|`gitlab` instead of detecting |
45
+ | `--agent <id>` | force skill install to this agent (repeatable): `claude-code`\|`antigravity`\|`codex`\|`opencode`. Default: auto-detect |
42
46
  | `--all` | install every ready provider binary, not just the detected one |
43
47
  | `--yes` | non-interactive: reviewers = all, convention = `trueid` |
44
48
  | `--dry-run` | print planned actions, write nothing |
@@ -65,16 +69,23 @@ npx @nvice-r/pr-commands --dry-run --dir /path/to/repo
65
69
 
66
70
  ## What gets installed
67
71
 
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.
72
+ Everything user-global is **global** — nothing lands in a repo's agent dir, so it works
73
+ identically from every clone and every git worktree, no repo-local symlinks.
70
74
 
71
75
  | Artifact | Path |
72
76
  |----------|------|
73
77
  | Shared library | `~/.local/bin/pr-common.sh` |
74
78
  | Provider binary | `~/.local/bin/bbpr` (`ghpr`/`glpr` later) |
75
79
  | 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` |
80
+ | Reviewer roster | `<main-repo-root>/.pr/reviewers.txt` (neutral, agent-agnostic; shared across worktrees) |
81
+ | Canonical skill | `~/.config/pr/skill/pr/SKILL.md` (single source) |
82
+ | Per-agent skill | `<agent-skills-dir>/pr` → symlink to canonical |
83
+ | Claude `/pr` command | `~/.claude/commands/pr.md` (dual-install, keeps the slash trigger) |
84
+
85
+ The skill is a **portable `SKILL.md`** installed into every detected agent — **claude-code,
86
+ antigravity, codex, opencode** — via one canonical copy each agent symlinks to (`npx skills`
87
+ single-source model). Force a target with `--agent <id>`. A pre-existing non-symlink `pr` skill
88
+ dir is never overwritten (the installer skips and warns).
78
89
 
79
90
  ## Usage
80
91
 
@@ -124,12 +135,12 @@ template_sections=Context|Changes|Risk
124
135
 
125
136
  ### Reviewers
126
137
 
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:
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:
130
141
 
131
142
  ```
132
- <main-repo-root>/.claude/pr-reviewers.txt # or <agent_dir>/… — .cursor, .aider, …
143
+ <main-repo-root>/.pr/reviewers.txt
133
144
  ```
134
145
 
135
146
  You (the author) are excluded on install — bbpr also drops self at PR time. One entry per line,
@@ -140,10 +151,10 @@ trailing `# Name` comment optional:
140
151
  {fedcba98-7654-3210-fedc-ba9876543210} # Bob
141
152
  ```
142
153
 
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.
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` →
157
+ legacy. Run-time overrides: `--no-reviewers` skips all, `--reviewer <uuid>` appends.
147
158
 
148
159
  ### Auth precedence (email)
149
160
 
@@ -185,7 +196,8 @@ Full rationale in [`DECISIONS.md`](./DECISIONS.md).
185
196
  | reviewers not fetched | add the keychain token (see Prerequisites), re-run |
186
197
  | `WARN ~/.local/bin not on PATH` | add the export line above to your shell rc |
187
198
  | `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 |
199
+ | worktree runs old behavior | skill/binaries are global, not repo-local; ensure no stale per-repo copy shadows them |
200
+ | 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
201
 
190
202
  ## Uninstall
191
203
 
@@ -193,16 +205,18 @@ Full rationale in [`DECISIONS.md`](./DECISIONS.md).
193
205
  rm ~/.local/bin/bbpr ~/.local/bin/pr-common.sh
194
206
  rm -rf ~/.config/pr
195
207
  rm ~/.claude/commands/pr.md
208
+ # per-agent skill symlinks (remove those you installed):
209
+ rm ~/.claude/skills/pr ~/.gemini/antigravity/skills/pr ~/.codex/skills/pr ~/.config/opencode/skills/pr
196
210
  # roster lives in each repo — remove per repo if wanted:
197
- rm <main-repo-root>/.claude/pr-reviewers.txt
211
+ rm -rf <main-repo-root>/.pr
198
212
  ```
199
213
 
200
214
  ## Development
201
215
 
202
216
  ```
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)
217
+ install.js npx entry (wizard)
218
+ assets/bin/* provider binaries + shared lib (source of truth; copied on install)
219
+ assets/skill/pr.body.md shared /pr skill body (frontmatter added per-target on install)
206
220
  ```
207
221
 
208
222
  `assets/` is the single source of truth — both the installer (`install.js`)
@@ -129,10 +129,11 @@ pr_load_reviewers() {
129
129
  # main repo root, shared across worktrees: git-common-dir/.. (empty if git call fails)
130
130
  local mainroot; mainroot="$(cd "$(git rev-parse --git-common-dir 2>/dev/null)/.." 2>/dev/null && pwd)"
131
131
  for cand in \
132
+ ${mainroot:+"$mainroot/.pr/reviewers.txt"} \
132
133
  ${mainroot:+"$mainroot/${CFG_AGENT_DIR:-.claude}/pr-reviewers.txt"} \
133
134
  ${mainroot:+"$mainroot/.pr-reviewers.txt"} \
134
135
  "$HOME/.config/pr/reviewers/${slug}.txt" \
135
- "$HOME/.config/bbpr/bbpr-reviewers.txt"; do # global + legacy fallbacks
136
+ "$HOME/.config/bbpr/bbpr-reviewers.txt"; do # neutral primary; agent-dir + root + global legacy fallbacks
136
137
  [ -f "$cand" ] && { file="$cand"; break; }
137
138
  done
138
139
  fi
@@ -1,9 +1,3 @@
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
1
  Create a pull request for the current branch. Provider (Bitbucket / GitHub / GitLab) is
8
2
  auto-detected from the git remote; the matching binary (`bbpr` / `ghpr` / `glpr`) is invoked.
9
3
  Convention (title pattern, ticket regex, PR template) comes from `~/.config/pr/config`.
@@ -13,6 +7,9 @@ user to run the installer — it never guesses config (D8: hard fail, no hardcod
13
7
 
14
8
  If `$ARGUMENTS` starts with `help`/`-help`/`--help` (case-insensitive), print the **Help** block and stop.
15
9
 
10
+ If `$ARGUMENTS` starts with `update` (case-insensitive), run **Update mode** (below) and stop —
11
+ skip the create steps entirely.
12
+
16
13
  ## Help (`/pr help`)
17
14
 
18
15
  ```
@@ -20,6 +17,7 @@ If `$ARGUMENTS` starts with `help`/`-help`/`--help` (case-insensitive), print th
20
17
 
21
18
  USAGE
22
19
  /pr [base-branch] [draft] Create a PR for the current branch (default).
20
+ /pr update [PR#] [short] Rewrite an existing PR's description in place.
23
21
  /pr help Show this help.
24
22
 
25
23
  CREATE
@@ -27,9 +25,15 @@ CREATE
27
25
  remote branch (saved per-branch after first run).
28
26
  draft Create as draft. Otherwise opens ready (asks to confirm).
29
27
 
28
+ UPDATE
29
+ PR# PR number to rewrite. If omitted, resolved from the current branch's
30
+ open PR. Re-drafts the body per convention, then PATCHes it in place.
31
+ short Compress harder (caveman brevity): cut throat-clearing, restated ticket
32
+ goals, filler connectors. Keeps every file + technical fact.
33
+
30
34
  CONFIG ~/.config/pr/config (os, token_src, email, convention)
31
35
  PROVIDER auto from `git remote get-url origin` -> bbpr | ghpr | glpr
32
- REVIEWERS ~/.config/pr/reviewers/<workspace>-<slug>.txt (per-repo, global location)
36
+ REVIEWERS <main-repo-root>/.pr/reviewers.txt (per-repo, shared across worktrees, agent-agnostic)
33
37
  SETUP npx @nvice-r/pr-commands
34
38
  ```
35
39
 
@@ -138,14 +142,74 @@ per-repo reviewer roster automatically; `--no-reviewers` skips, `--reviewer <id>
138
142
  Reuse ticket key from Step 4. If none, skip. `guard_cmd jira-update`; if absent, just print URL + stop.
139
143
  If present, `AskUserQuestion`: sync now → `/jira-update <KEY>`, or skip (print reminder).
140
144
 
145
+ ## Update mode (`/pr update [PR#] [short]`)
146
+
147
+ Rewrites an existing open PR's **description** in place — no new branch, no new PR. Same Step 0
148
+ 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.
150
+
151
+ **U1 — parse args.** Tokens after `update`: a bare integer = `PR#`; the word `short` (or
152
+ `caveman`) = compress-hard flag. Missing `PR#` → resolve from the current branch: query the
153
+ 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
+
155
+ **U2 — fetch current PR.** Resolve creds the same way the binary does — email from `$CFG`
156
+ (`email=`), token from the OS keychain (macOS service `bitbucket-api-token`, account = email).
157
+ `REPO` = workspace/slug from `git remote get-url origin`. Fetch title + description:
158
+ ```bash
159
+ curl -sS -u "$EMAIL:$TOKEN" \
160
+ "https://api.bitbucket.org/2.0/repositories/$REPO/pullrequests/$PRNUM"
161
+ ```
162
+
163
+ **U3 — re-draft the body.** Rebuild the description with the **Step 5** rules (trueid template
164
+ when `convention=trueid`): `What Happened` prose, `Insight` file detail, `Proof of Work`. Prefer
165
+ the diff/commits as the source of truth over the old text; carry forward Proof-of-Work links the
166
+ old body already had. Default output = terse, anti-slop normal English:
167
+ - Cut throat-clearing openers ("The X feature needs to…", "This adds…"), restated ticket goals,
168
+ and filler connectors ("as a consequence", "in order to").
169
+ - Keep every changed file, every behavioural fact, and any bug-fix detail — brevity, not omission.
170
+
171
+ With the `short` flag, compress harder toward caveman brevity (fragments, dropped articles) while
172
+ keeping the section headings and every technical fact. Title stays unchanged unless the user asks.
173
+
174
+ **U4 — confirm (diff-style).** Write the new body to `/tmp/pr-body.md`. Show a short before/after
175
+ summary (what was cut) + the full new body. WAIT — editing a live PR is user-visible; never silent.
176
+
177
+ **U5 — PATCH in place** (Bitbucket `PUT`, description only):
178
+ ```bash
179
+ python3 - "$REPO" "$PRNUM" "$EMAIL" "$TOKEN" /tmp/pr-body.md <<'PY'
180
+ import sys, json, base64, urllib.request, urllib.error
181
+ repo, num, email, token, bodyfile = sys.argv[1:6]
182
+ desc = open(bodyfile).read()
183
+ data = json.dumps({"description": desc}).encode()
184
+ url = f"https://api.bitbucket.org/2.0/repositories/{repo}/pullrequests/{num}"
185
+ req = urllib.request.Request(url, data=data, method="PUT", headers={"Content-Type": "application/json"})
186
+ req.add_header("Authorization", "Basic " + base64.b64encode(f"{email}:{token}".encode()).decode())
187
+ try:
188
+ r = urllib.request.urlopen(req)
189
+ print(r.status, json.loads(r.read())["links"]["html"]["href"])
190
+ except urllib.error.HTTPError as e:
191
+ print("ERR", e.code, e.read().decode()[:500])
192
+ PY
193
+ ```
194
+ On non-2xx, surface the raw response body. Reviewers/base/title are untouched — description only.
195
+
196
+ **U6 — print PR URL.** No Jira sync offer on update (status unchanged).
197
+
141
198
  ## 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:
199
+ Sibling commands (`/pr-split`, `/jira-update`) are optional — a teammate may not have them, and
200
+ they may live under any agent's skills/commands dir. Before invoking any, confirm it exists; if
201
+ absent, degrade gracefully (one-line note). Reusable check (scans Claude command dir + every known
202
+ agent skills dir):
144
203
  ```bash
145
204
  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"
205
+ local c="$1"
206
+ ls "$PWD/.claude/commands/$c.md" "$HOME/.claude/commands/$c.md" \
207
+ "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/$c/SKILL.md" \
208
+ "$HOME/.gemini/antigravity/skills/$c/SKILL.md" \
209
+ "${CODEX_HOME:-$HOME/.codex}/skills/$c/SKILL.md" \
210
+ "${XDG_CONFIG_HOME:-$HOME/.config}/opencode/skills/$c/SKILL.md" \
211
+ "$PWD/.claude/skills/$c/SKILL.md" "$PWD/.agents/skills/$c/SKILL.md" 2>/dev/null | head -1 \
212
+ || echo "no-$c"
149
213
  }
150
214
  ```
151
215
 
@@ -154,4 +218,5 @@ guard_cmd() { # usage: guard_cmd pr-split → prints path or "no-<cmd>"
154
218
  - Provider/token/convention all from config + remote — NO hardcoded team values here (D3/D4).
155
219
  - Verify worktree before any split branch work (`git symbolic-ref --short HEAD` + `git worktree list`);
156
220
  background restacks can move branches mid-turn.
157
- - Caveman mode applies to replies — but PR title/body must be normal English.
221
+ - Caveman mode applies to replies — PR title/body default to normal English. Exception: `/pr update
222
+ … 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');
@@ -35,13 +36,14 @@ const PRESET_DEFS = {
35
36
 
36
37
  // ---- arg parse ----
37
38
  const argv = process.argv.slice(2);
38
- const flags = { dir: null, provider: null, all: false, yes: false, dryRun: false, agentDir: null };
39
+ const flags = { dir: null, provider: null, all: false, yes: false, dryRun: false, agentDir: null, agents: null };
39
40
  for (let i = 0; i < argv.length; i++) {
40
41
  const a = argv[i];
41
42
  if (a === '--help' || a === '-h') { printHelp(); process.exit(0); }
42
43
  else if (a === '--dir') flags.dir = argv[++i];
43
44
  else if (a === '--provider') flags.provider = argv[++i];
44
45
  else if (a === '--agent-dir') flags.agentDir = argv[++i];
46
+ else if (a === '--agent') (flags.agents ||= []).push(argv[++i]);
45
47
  else if (a === '--all') flags.all = true;
46
48
  else if (a === '--yes' || a === '-y') flags.yes = true;
47
49
  else if (a === '--dry-run') flags.dryRun = true;
@@ -49,6 +51,37 @@ for (let i = 0; i < argv.length; i++) {
49
51
  }
50
52
  const AGENT_DIR = (flags.agentDir || '.claude').replace(/^\/+|\/+$/g, ''); // strip stray slashes
51
53
 
54
+ // ---- portable skill ----
55
+ // One canonical SKILL.md (~/.config/pr/skill/pr/); every detected agent's global skills
56
+ // dir gets a symlink `pr` -> that dir (skills-CLI single-source model). Claude additionally
57
+ // gets a /pr slash-command. Frontmatter differs per target; the body is shared (pr.body.md).
58
+ const SKILL_FM = `---
59
+ name: pr
60
+ 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.
61
+ ---
62
+
63
+ `;
64
+ const CMD_FM = `---
65
+ description: Create a pull request for the current branch (provider auto-detected)
66
+ argument-hint: [base-branch] [draft]
67
+ model: sonnet
68
+ ---
69
+
70
+ `;
71
+ // base = probe dir deciding "installed"; skills = GLOBAL skills dir we link into.
72
+ // Env vars resolved the same way the agents themselves resolve them.
73
+ function agentTargets() {
74
+ const claudeBase = process.env.CLAUDE_CONFIG_DIR || path.join(HOME, '.claude');
75
+ const codexBase = process.env.CODEX_HOME || path.join(HOME, '.codex');
76
+ const xdg = process.env.XDG_CONFIG_HOME || path.join(HOME, '.config');
77
+ 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') },
82
+ ];
83
+ }
84
+
52
85
  function printHelp() {
53
86
  console.log(`@nvice-r/pr-commands installer
54
87
 
@@ -56,7 +89,9 @@ function printHelp() {
56
89
 
57
90
  --dir <path> Repo to configure (default: cwd)
58
91
  --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)
92
+ --agent-dir <dir> (deprecated) Legacy roster folder; roster now lives in <root>/.pr/
93
+ --agent <id> Force skill install to this agent (repeatable). Ids: claude-code,
94
+ antigravity, codex, opencode. Default: auto-detect installed agents.
60
95
  --all Install all provider binaries (default: only the detected one)
61
96
  --yes Accept defaults, no prompts (reviewers=all, convention=trueid)
62
97
  --dry-run Print planned actions, write nothing
@@ -73,8 +108,8 @@ const ask = (q) => new Promise((res) => {
73
108
  });
74
109
  // Editable pre-filled default: edit the shown value or hit enter to keep it.
75
110
  // `: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 });
111
+ const askPrefill = (q, def, allowBack = true, completer = undefined) => new Promise((res) => {
112
+ const r = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true, completer });
78
113
  r.question(q, (a) => {
79
114
  r.close();
80
115
  const t = a.trim();
@@ -84,6 +119,22 @@ const askPrefill = (q, def, allowBack = true) => new Promise((res) => {
84
119
  });
85
120
  if (def && process.stdin.isTTY) r.write(def); // inject editable text
86
121
  });
122
+ // Tab-completion for filesystem paths (directories only — work dir is always a dir).
123
+ // Preserves the user's typed prefix style (~, relative, absolute); appends "/" to each match.
124
+ function pathCompleter(line) {
125
+ const cut = line.lastIndexOf('/');
126
+ const dirPart = cut >= 0 ? line.slice(0, cut + 1) : ''; // keeps trailing slash, e.g. "~/Doc" -> "~/"
127
+ const base = cut >= 0 ? line.slice(cut + 1) : line;
128
+ let realDir = dirPart || '.';
129
+ if (realDir.startsWith('~')) realDir = HOME + realDir.slice(1);
130
+ let ents;
131
+ try { ents = fs.readdirSync(realDir, { withFileTypes: true }); } catch { return [[], line]; }
132
+ const hits = ents
133
+ .filter((e) => e.isDirectory() && e.name.startsWith(base))
134
+ .map((e) => dirPart + e.name + '/')
135
+ .sort();
136
+ return [hits, line];
137
+ }
87
138
 
88
139
  // Interactive checklist (↑/↓ move · space toggle · a all · n none · enter confirm).
89
140
  // Zero-dep raw-mode TTY. Returns array of selected indices. items: [{label}].
@@ -212,6 +263,50 @@ function mainRepoRoot(wd) {
212
263
  const gcd = shSafe('git', ['-C', wd, 'rev-parse', '--git-common-dir']);
213
264
  return gcd ? path.resolve(wd, gcd, '..') : null;
214
265
  }
266
+ // Write the canonical SKILL.md, then link/copy it into each targeted agent. Claude also
267
+ // gets the /pr slash-command. --agent forces a set; otherwise install to every detected
268
+ // agent (claude-code is always included — it's this tool's native home).
269
+ // Agents whose config dir exists (claude-code always included — this tool's native home).
270
+ function detectedAgentIds() {
271
+ return agentTargets().filter((t) => t.id === 'claude-code' || fs.existsSync(t.base)).map((t) => t.id);
272
+ }
273
+ // Install the skill into exactly the agents in `ids`.
274
+ function installSkill(ids) {
275
+ const set = new Set(ids || []);
276
+ const body = fs.readFileSync(path.join(ASSETS, 'skill/pr.body.md'), 'utf8');
277
+ const canonDir = path.join(HOME, '.config/pr/skill/pr');
278
+ writeFile(path.join(canonDir, 'SKILL.md'), SKILL_FM + body);
279
+
280
+ const hits = [];
281
+ for (const t of agentTargets()) {
282
+ if (!set.has(t.id)) continue;
283
+ linkSkill(t, canonDir);
284
+ if (t.claude) writeFile(path.join(t.base, 'commands/pr.md'), CMD_FM + body); // dual: keep /pr
285
+ hits.push(t.id);
286
+ }
287
+ log(` skill targeted: ${hits.join(', ') || '(none)'}`);
288
+ }
289
+ // Symlink <agent>/skills/pr -> canonical dir. Replace only OUR symlink; never clobber a
290
+ // pre-existing real dir/file (could be the user's own `pr` skill). Copy if symlinks fail.
291
+ function linkSkill(t, canonDir) {
292
+ const dst = path.join(t.skills, 'pr');
293
+ if (flags.dryRun) { log(` [dry-run] link ${dst} -> ${canonDir}`); return; }
294
+ fs.mkdirSync(t.skills, { recursive: true });
295
+ let cur = null;
296
+ try { cur = fs.lstatSync(dst); } catch { /* absent */ }
297
+ if (cur) {
298
+ if (cur.isSymbolicLink()) fs.unlinkSync(dst);
299
+ else { log(` SKIP ${dst} — exists and is not our symlink; remove it and re-run`); return; }
300
+ }
301
+ try {
302
+ fs.symlinkSync(canonDir, dst, 'dir');
303
+ log(` linked ${dst} -> ${canonDir}`);
304
+ } catch (e) {
305
+ fs.mkdirSync(dst, { recursive: true });
306
+ fs.copyFileSync(path.join(canonDir, 'SKILL.md'), path.join(dst, 'SKILL.md'));
307
+ log(` copied ${dst}/SKILL.md (symlink unavailable: ${e.code})`);
308
+ }
309
+ }
215
310
  // Depth-limited scan for git repos beneath a non-repo dir. Stops descending once a
216
311
  // repo is found (no nested-repo noise); skips dotdirs + node_modules; caps results.
217
312
  function scanGitRepos(root, maxDepth = 3, cap = 50) {
@@ -316,6 +411,7 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
316
411
  }
317
412
  if (stage > 1 && st.chosen) L.push(` \x1b[32m✓\x1b[0m reviewers ${st.chosen.length} selected`);
318
413
  if (stage > 2 && st.convention) L.push(` \x1b[32m✓\x1b[0m convention ${st.convention}`);
414
+ if (stage > 3 && st.agents) L.push(` \x1b[32m✓\x1b[0m agents ${st.agents.join(', ') || '(none)'}`);
319
415
  return L;
320
416
  };
321
417
  // Full-screen redraw: wipe, reprint header + breadcrumb + active-step marker. No accumulation on back.
@@ -336,7 +432,7 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
336
432
  let wd;
337
433
  if (flags.dir || flags.yes) wd = path.resolve(flags.dir || process.cwd());
338
434
  else {
339
- const a = await askPrefill(' repo path: ', st.workDir || process.cwd());
435
+ const a = await askPrefill(' repo path: ', st.workDir || process.cwd(), true, pathCompleter);
340
436
  if (a === BACK) return BACK; // first step — driver just stays here
341
437
  wd = path.resolve(a);
342
438
  }
@@ -429,12 +525,32 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
429
525
  st.conv[key] = v;
430
526
  };
431
527
 
528
+ // Pick which agents get the /pr skill. --agent overrides (non-interactive); --yes auto-detects;
529
+ // else an interactive checklist with detected agents pre-checked.
530
+ const sAgents = async () => {
531
+ screen(3, 'Agents');
532
+ const targets = agentTargets();
533
+ if (flags.agents) { st.agents = flags.agents; log(` forced (--agent): ${st.agents.join(', ')}`); return; }
534
+ const detected = new Set(detectedAgentIds());
535
+ if (flags.yes) { st.agents = [...detected]; log(` auto-detected: ${st.agents.join(', ')}`); return; }
536
+ const preIdx = targets.map((t, i) => (detected.has(t.id) ? i : -1)).filter((i) => i >= 0);
537
+ const picked = await multiSelect(
538
+ 'Install the /pr skill into which agents? (detected pre-checked):',
539
+ targets.map((t) => ({ label: `${t.id}${detected.has(t.id) ? ' \x1b[2m(detected)\x1b[0m' : ''}` })),
540
+ false, preIdx,
541
+ );
542
+ if (picked === BACK) return BACK;
543
+ st.agents = picked.map((i) => targets[i].id);
544
+ log(` agents: ${st.agents.join(', ') || '(none)'}`);
545
+ };
546
+
432
547
  const steps = flags.yes
433
- ? [sWorkDir, sReviewers, sPreset]
548
+ ? [sWorkDir, sReviewers, sPreset, sAgents]
434
549
  : [sWorkDir, sReviewers, sPreset,
435
550
  editField('title_pattern: ', 'title_pattern'),
436
551
  editField('ticket_regex: ', 'ticket_regex'),
437
- editField('template_sections:', 'template_sections')];
552
+ editField('template_sections:', 'template_sections'),
553
+ sAgents];
438
554
 
439
555
  let i = 0;
440
556
  while (i < steps.length) {
@@ -444,18 +560,19 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
444
560
  }
445
561
 
446
562
  // --- install ---
447
- screen(3, 'Install');
563
+ screen(4, 'Install');
448
564
  const { slug, pcfg, email, convention, conv, chosen } = st;
449
565
  let cfg = `os=${cfgOs}\ntoken_src=${tokenSrc}\nemail=${email}\nconvention=${convention}\nagent_dir=${AGENT_DIR}\n`;
450
566
  if (conv.title_pattern) cfg += `title_pattern=${conv.title_pattern}\n`;
451
567
  if (conv.ticket_regex) cfg += `ticket_regex=${conv.ticket_regex}\n`;
452
568
  if (conv.template_sections) cfg += `template_sections=${conv.template_sections}\n`;
453
569
  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.
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.
456
573
  const roster = chosen.map((r) => `${r.uuid} # ${r.name}`).join('\n') + (chosen.length ? '\n' : '');
457
574
  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'));
575
+ writeFile(path.join(st.mainRoot, '.pr', 'reviewers.txt'), header + (roster || '# (none selected)\n'));
459
576
  const binDir = path.join(HOME, '.local/bin');
460
577
  const binsToInstall = flags.all
461
578
  ? Object.values(PROVIDERS).filter((p) => p.ready).map((p) => p.bin)
@@ -466,7 +583,7 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
466
583
  if (!fs.existsSync(src)) { log(` skip ${b} (asset not present yet)`); continue; }
467
584
  writeFile(path.join(binDir, b), fs.readFileSync(src), 0o755);
468
585
  }
469
- writeFile(path.join(HOME, '.claude/commands/pr.md'), fs.readFileSync(path.join(ASSETS, 'skill/pr.md')));
586
+ installSkill(st.agents || detectedAgentIds());
470
587
  if (!(process.env.PATH || '').split(':').includes(binDir)) {
471
588
  log(`\n WARN: ${binDir} not on PATH. Add to your shell rc:`);
472
589
  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.2",
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"