@nvice-r/pr-commands 0.1.0 → 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 +32 -17
- package/assets/bin/pr-common.sh +7 -3
- package/assets/skill/{pr.md → pr.body.md} +78 -13
- package/install.js +134 -14
- package/package.json +9 -2
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. **
|
|
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
|
|
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>/.pr
|
|
77
|
-
|
|
|
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,11 +135,12 @@ template_sections=Context|Changes|Risk
|
|
|
124
135
|
|
|
125
136
|
### Reviewers
|
|
126
137
|
|
|
127
|
-
Roster lives
|
|
128
|
-
|
|
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:
|
|
129
141
|
|
|
130
142
|
```
|
|
131
|
-
<main-repo-root>/.pr
|
|
143
|
+
<main-repo-root>/.pr/reviewers.txt
|
|
132
144
|
```
|
|
133
145
|
|
|
134
146
|
You (the author) are excluded on install — bbpr also drops self at PR time. One entry per line,
|
|
@@ -139,10 +151,10 @@ trailing `# Name` comment optional:
|
|
|
139
151
|
{fedcba98-7654-3210-fedc-ba9876543210} # Bob
|
|
140
152
|
```
|
|
141
153
|
|
|
142
|
-
It's plain data — edit freely, no reinstall. Consider gitignoring
|
|
143
|
-
roster committed. Load order: `$BBPR_REVIEWERS` →
|
|
144
|
-
`~/.config/pr/reviewers/<ws>-<slug>.txt` →
|
|
145
|
-
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.
|
|
146
158
|
|
|
147
159
|
### Auth precedence (email)
|
|
148
160
|
|
|
@@ -184,7 +196,8 @@ Full rationale in [`DECISIONS.md`](./DECISIONS.md).
|
|
|
184
196
|
| reviewers not fetched | add the keychain token (see Prerequisites), re-run |
|
|
185
197
|
| `WARN ~/.local/bin not on PATH` | add the export line above to your shell rc |
|
|
186
198
|
| `not-yet-supported: <provider>` | GitHub/GitLab aren't implemented yet |
|
|
187
|
-
| worktree runs old behavior |
|
|
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 |
|
|
188
201
|
|
|
189
202
|
## Uninstall
|
|
190
203
|
|
|
@@ -192,16 +205,18 @@ Full rationale in [`DECISIONS.md`](./DECISIONS.md).
|
|
|
192
205
|
rm ~/.local/bin/bbpr ~/.local/bin/pr-common.sh
|
|
193
206
|
rm -rf ~/.config/pr
|
|
194
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
|
|
195
210
|
# roster lives in each repo — remove per repo if wanted:
|
|
196
|
-
rm <main-repo-root>/.pr
|
|
211
|
+
rm -rf <main-repo-root>/.pr
|
|
197
212
|
```
|
|
198
213
|
|
|
199
214
|
## Development
|
|
200
215
|
|
|
201
216
|
```
|
|
202
|
-
install.js
|
|
203
|
-
assets/bin/*
|
|
204
|
-
assets/skill/pr.md
|
|
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)
|
|
205
220
|
```
|
|
206
221
|
|
|
207
222
|
`assets/` is the single source of truth — both the installer (`install.js`)
|
package/assets/bin/pr-common.sh
CHANGED
|
@@ -20,11 +20,11 @@ CONFIG_FILE="${PR_CONFIG:-$HOME/.config/pr/config}"
|
|
|
20
20
|
die() { echo "${PR_CMD:-pr}: $*" >&2; exit 1; }
|
|
21
21
|
|
|
22
22
|
# --- config load (safe parse, no `source` of untrusted file) ---
|
|
23
|
-
# Sets: CFG_OS CFG_TOKEN_SRC CFG_EMAIL CFG_CONVENTION (+ optional custom_* keys)
|
|
23
|
+
# Sets: CFG_OS CFG_TOKEN_SRC CFG_EMAIL CFG_CONVENTION CFG_AGENT_DIR (+ optional custom_* keys)
|
|
24
24
|
pr_load_config() {
|
|
25
25
|
[ -f "$CONFIG_FILE" ] || die "no $CONFIG_FILE — run: npx @nvice-r/pr-commands"
|
|
26
26
|
local k v
|
|
27
|
-
CFG_OS=""; CFG_TOKEN_SRC=""; CFG_EMAIL=""; CFG_CONVENTION=""
|
|
27
|
+
CFG_OS=""; CFG_TOKEN_SRC=""; CFG_EMAIL=""; CFG_CONVENTION=""; CFG_AGENT_DIR=""
|
|
28
28
|
while IFS='=' read -r k v; do
|
|
29
29
|
k="${k%%#*}"; k="${k//[[:space:]]/}" # strip comment + whitespace from key
|
|
30
30
|
[ -z "$k" ] && continue
|
|
@@ -34,9 +34,11 @@ pr_load_config() {
|
|
|
34
34
|
token_src) CFG_TOKEN_SRC="$v" ;;
|
|
35
35
|
email) CFG_EMAIL="$v" ;;
|
|
36
36
|
convention) CFG_CONVENTION="$v" ;;
|
|
37
|
+
agent_dir) CFG_AGENT_DIR="$v" ;;
|
|
37
38
|
esac
|
|
38
39
|
done < "$CONFIG_FILE"
|
|
39
40
|
: "${CFG_TOKEN_SRC:=keychain}"
|
|
41
|
+
: "${CFG_AGENT_DIR:=.claude}" # agent folder holding the roster (relative to main repo root)
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
# --- token retrieval, OS-switched (D1: OS is a function, not a binary axis) ---
|
|
@@ -127,9 +129,11 @@ pr_load_reviewers() {
|
|
|
127
129
|
# main repo root, shared across worktrees: git-common-dir/.. (empty if git call fails)
|
|
128
130
|
local mainroot; mainroot="$(cd "$(git rev-parse --git-common-dir 2>/dev/null)/.." 2>/dev/null && pwd)"
|
|
129
131
|
for cand in \
|
|
132
|
+
${mainroot:+"$mainroot/.pr/reviewers.txt"} \
|
|
133
|
+
${mainroot:+"$mainroot/${CFG_AGENT_DIR:-.claude}/pr-reviewers.txt"} \
|
|
130
134
|
${mainroot:+"$mainroot/.pr-reviewers.txt"} \
|
|
131
135
|
"$HOME/.config/pr/reviewers/${slug}.txt" \
|
|
132
|
-
"$HOME/.config/bbpr/bbpr-reviewers.txt"; do # legacy
|
|
136
|
+
"$HOME/.config/bbpr/bbpr-reviewers.txt"; do # neutral primary; agent-dir + root + global legacy fallbacks
|
|
133
137
|
[ -f "$cand" ] && { file="$cand"; break; }
|
|
134
138
|
done
|
|
135
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
|
|
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
|
|
143
|
-
invoking any, confirm it exists; if
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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 —
|
|
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,
|
|
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,17 +36,51 @@ 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 };
|
|
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];
|
|
45
|
+
else if (a === '--agent-dir') flags.agentDir = argv[++i];
|
|
46
|
+
else if (a === '--agent') (flags.agents ||= []).push(argv[++i]);
|
|
44
47
|
else if (a === '--all') flags.all = true;
|
|
45
48
|
else if (a === '--yes' || a === '-y') flags.yes = true;
|
|
46
49
|
else if (a === '--dry-run') flags.dryRun = true;
|
|
47
50
|
else { console.error(`unknown flag: ${a}`); process.exit(1); }
|
|
48
51
|
}
|
|
52
|
+
const AGENT_DIR = (flags.agentDir || '.claude').replace(/^\/+|\/+$/g, ''); // strip stray slashes
|
|
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
|
+
}
|
|
49
84
|
|
|
50
85
|
function printHelp() {
|
|
51
86
|
console.log(`@nvice-r/pr-commands installer
|
|
@@ -54,6 +89,9 @@ function printHelp() {
|
|
|
54
89
|
|
|
55
90
|
--dir <path> Repo to configure (default: cwd)
|
|
56
91
|
--provider <p> Force bitbucket|github|gitlab (default: detect from remote)
|
|
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.
|
|
57
95
|
--all Install all provider binaries (default: only the detected one)
|
|
58
96
|
--yes Accept defaults, no prompts (reviewers=all, convention=trueid)
|
|
59
97
|
--dry-run Print planned actions, write nothing
|
|
@@ -70,8 +108,8 @@ const ask = (q) => new Promise((res) => {
|
|
|
70
108
|
});
|
|
71
109
|
// Editable pre-filled default: edit the shown value or hit enter to keep it.
|
|
72
110
|
// `:b` (or `:back`) returns BACK; empty keeps the default.
|
|
73
|
-
const askPrefill = (q, def, allowBack = true) => new Promise((res) => {
|
|
74
|
-
const r = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
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 });
|
|
75
113
|
r.question(q, (a) => {
|
|
76
114
|
r.close();
|
|
77
115
|
const t = a.trim();
|
|
@@ -81,6 +119,22 @@ const askPrefill = (q, def, allowBack = true) => new Promise((res) => {
|
|
|
81
119
|
});
|
|
82
120
|
if (def && process.stdin.isTTY) r.write(def); // inject editable text
|
|
83
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
|
+
}
|
|
84
138
|
|
|
85
139
|
// Interactive checklist (↑/↓ move · space toggle · a all · n none · enter confirm).
|
|
86
140
|
// Zero-dep raw-mode TTY. Returns array of selected indices. items: [{label}].
|
|
@@ -209,6 +263,50 @@ function mainRepoRoot(wd) {
|
|
|
209
263
|
const gcd = shSafe('git', ['-C', wd, 'rev-parse', '--git-common-dir']);
|
|
210
264
|
return gcd ? path.resolve(wd, gcd, '..') : null;
|
|
211
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
|
+
}
|
|
212
310
|
// Depth-limited scan for git repos beneath a non-repo dir. Stops descending once a
|
|
213
311
|
// repo is found (no nested-repo noise); skips dotdirs + node_modules; caps results.
|
|
214
312
|
function scanGitRepos(root, maxDepth = 3, cap = 50) {
|
|
@@ -313,6 +411,7 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
|
|
|
313
411
|
}
|
|
314
412
|
if (stage > 1 && st.chosen) L.push(` \x1b[32m✓\x1b[0m reviewers ${st.chosen.length} selected`);
|
|
315
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)'}`);
|
|
316
415
|
return L;
|
|
317
416
|
};
|
|
318
417
|
// Full-screen redraw: wipe, reprint header + breadcrumb + active-step marker. No accumulation on back.
|
|
@@ -333,7 +432,7 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
|
|
|
333
432
|
let wd;
|
|
334
433
|
if (flags.dir || flags.yes) wd = path.resolve(flags.dir || process.cwd());
|
|
335
434
|
else {
|
|
336
|
-
const a = await askPrefill(' repo path: ', st.workDir || process.cwd());
|
|
435
|
+
const a = await askPrefill(' repo path: ', st.workDir || process.cwd(), true, pathCompleter);
|
|
337
436
|
if (a === BACK) return BACK; // first step — driver just stays here
|
|
338
437
|
wd = path.resolve(a);
|
|
339
438
|
}
|
|
@@ -426,12 +525,32 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
|
|
|
426
525
|
st.conv[key] = v;
|
|
427
526
|
};
|
|
428
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
|
+
|
|
429
547
|
const steps = flags.yes
|
|
430
|
-
? [sWorkDir, sReviewers, sPreset]
|
|
548
|
+
? [sWorkDir, sReviewers, sPreset, sAgents]
|
|
431
549
|
: [sWorkDir, sReviewers, sPreset,
|
|
432
550
|
editField('title_pattern: ', 'title_pattern'),
|
|
433
551
|
editField('ticket_regex: ', 'ticket_regex'),
|
|
434
|
-
editField('template_sections:', 'template_sections')
|
|
552
|
+
editField('template_sections:', 'template_sections'),
|
|
553
|
+
sAgents];
|
|
435
554
|
|
|
436
555
|
let i = 0;
|
|
437
556
|
while (i < steps.length) {
|
|
@@ -441,18 +560,19 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
|
|
|
441
560
|
}
|
|
442
561
|
|
|
443
562
|
// --- install ---
|
|
444
|
-
screen(
|
|
563
|
+
screen(4, 'Install');
|
|
445
564
|
const { slug, pcfg, email, convention, conv, chosen } = st;
|
|
446
|
-
let cfg = `os=${cfgOs}\ntoken_src=${tokenSrc}\nemail=${email}\nconvention=${convention}\n`;
|
|
565
|
+
let cfg = `os=${cfgOs}\ntoken_src=${tokenSrc}\nemail=${email}\nconvention=${convention}\nagent_dir=${AGENT_DIR}\n`;
|
|
447
566
|
if (conv.title_pattern) cfg += `title_pattern=${conv.title_pattern}\n`;
|
|
448
567
|
if (conv.ticket_regex) cfg += `ticket_regex=${conv.ticket_regex}\n`;
|
|
449
568
|
if (conv.template_sections) cfg += `template_sections=${conv.template_sections}\n`;
|
|
450
569
|
writeFile(path.join(HOME, '.config/pr/config'), cfg, 0o600);
|
|
451
|
-
// Roster
|
|
452
|
-
//
|
|
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.
|
|
453
573
|
const roster = chosen.map((r) => `${r.uuid} # ${r.name}`).join('\n') + (chosen.length ? '\n' : '');
|
|
454
574
|
const header = `# pr reviewers for ${slug} — {uuid} # Name per line; blanks/# ignored. Edit freely.\n`;
|
|
455
|
-
writeFile(path.join(st.mainRoot, '.pr
|
|
575
|
+
writeFile(path.join(st.mainRoot, '.pr', 'reviewers.txt'), header + (roster || '# (none selected)\n'));
|
|
456
576
|
const binDir = path.join(HOME, '.local/bin');
|
|
457
577
|
const binsToInstall = flags.all
|
|
458
578
|
? Object.values(PROVIDERS).filter((p) => p.ready).map((p) => p.bin)
|
|
@@ -463,7 +583,7 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
|
|
|
463
583
|
if (!fs.existsSync(src)) { log(` skip ${b} (asset not present yet)`); continue; }
|
|
464
584
|
writeFile(path.join(binDir, b), fs.readFileSync(src), 0o755);
|
|
465
585
|
}
|
|
466
|
-
|
|
586
|
+
installSkill(st.agents || detectedAgentIds());
|
|
467
587
|
if (!(process.env.PATH || '').split(':').includes(binDir)) {
|
|
468
588
|
log(`\n WARN: ${binDir} not on PATH. Add to your shell rc:`);
|
|
469
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.
|
|
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"
|
|
@@ -15,7 +15,14 @@
|
|
|
15
15
|
"publishConfig": {
|
|
16
16
|
"access": "public"
|
|
17
17
|
},
|
|
18
|
-
"keywords": [
|
|
18
|
+
"keywords": [
|
|
19
|
+
"claude-code",
|
|
20
|
+
"pull-request",
|
|
21
|
+
"bitbucket",
|
|
22
|
+
"github",
|
|
23
|
+
"gitlab",
|
|
24
|
+
"cli"
|
|
25
|
+
],
|
|
19
26
|
"author": "nvice.r",
|
|
20
27
|
"license": "MIT"
|
|
21
28
|
}
|