@nvice-r/pr-commands 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -78,8 +78,9 @@ identically from every clone and every git worktree, no repo-local symlinks.
78
78
 
79
79
  | Artifact | Path |
80
80
  |----------|------|
81
- | Shared library | `~/.local/bin/pr-common.sh` |
82
- | Provider binary | `~/.local/bin/bbpr` (`ghpr`/`glpr` later) |
81
+ | Command (only file on `PATH`) | `~/.local/bin/pr-commands` |
82
+ | Shared library | `~/.local/share/pr-commands/pr-common.sh` |
83
+ | Host provider | `~/.local/share/pr-commands/providers/bitbucket.sh` (`github.sh`/`gitlab.sh` later) |
83
84
  | Config | `~/.config/pr/config` |
84
85
  | Reviewer roster | `<main-repo-root>/<agent_dir>/pr-reviewers.txt` (primary agent's folder; shared across worktrees) |
85
86
  | Canonical skill | `~/.config/pr/skill/pr/SKILL.md` (single source) |
@@ -175,7 +176,7 @@ roster:
175
176
 
176
177
  Switching primary agent = re-run the installer; the old file still loads as a fallback.
177
178
 
178
- You (the author) are excluded on install — bbpr also drops self at PR time. One entry per line,
179
+ You (the author) are excluded on install — `pr-commands` also drops self at PR time. One entry per line,
179
180
  trailing `# Name` comment optional:
180
181
 
181
182
  ```
@@ -184,22 +185,22 @@ trailing `# Name` comment optional:
184
185
  ```
185
186
 
186
187
  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
+ it committed. Load order: `$PR_REVIEWERS` → `<agent_dir>/pr-reviewers.txt` → `.pr/reviewers.txt`
188
189
  (0.1.2) → root `.pr-reviewers.txt` (pre-0.1.1) → global `~/.config/pr/reviewers/<ws>-<slug>.txt` →
189
190
  legacy. Run-time overrides: `--no-reviewers` skips all, `--reviewer <uuid>` appends.
190
191
 
191
192
  ### Auth precedence (email)
192
193
 
193
- `--email` flag → `$BB_EMAIL` → `git config bbpr.email` → config `email` → keychain account.
194
+ `--email` flag → `$BB_EMAIL` → `git config pr.email` → config `email` → keychain account.
194
195
  The token is always read from the keychain (or `$PR_TOKEN` when `token_src=env`).
195
196
 
196
197
  ## Provider support
197
198
 
198
- | provider | binary | status |
199
- |----------|--------|--------|
200
- | Bitbucket Cloud | `bbpr` | ✅ ready |
201
- | GitHub | `ghpr` | 🚧 stub — errors `not-yet-supported` |
202
- | GitLab | `glpr` | 🚧 stub |
199
+ | provider | file | status |
200
+ |----------|------|--------|
201
+ | Bitbucket Cloud | `providers/bitbucket.sh` | ✅ ready |
202
+ | GitHub | `providers/github.sh` | 🚧 not yet — `pr-commands` errors `github not yet supported` |
203
+ | GitLab | `providers/gitlab.sh` | 🚧 not yet |
203
204
 
204
205
  | OS | token source | status |
205
206
  |----|--------------|--------|
@@ -208,15 +209,21 @@ The token is always read from the keychain (or `$PR_TOKEN` when `token_src=env`)
208
209
 
209
210
  ## Architecture
210
211
 
211
- - **Split by provider, not by OS.** Each provider gets its own small binary (`bbpr`/`ghpr`/`glpr`)
212
- because the REST APIs genuinely differ. OS differences (token retrieval) are a single
213
- `get_token()` function switched by `uname`, not a separate binary. Ceiling: 3 binaries, never
214
- 3 × OS.
212
+ - **One command, one file per host.** `pr-commands` is the only thing on `PATH`. It reads
213
+ `git remote get-url origin` (override: `$PR_PROVIDER`) and loads `providers/<host>.sh`.
214
+ Each host keeps its own small file because the REST APIs genuinely differ. OS differences
215
+ (token retrieval) are a single `get_token()` function, not a separate file.
215
216
  - **Shared logic in `pr-common.sh`** — git scan, base save, reviewer load, arg parse, token —
216
- sourced by every binary. A provider binary is ~40 lines: payload shape + endpoint + parse.
217
+ sourced before the provider. A provider file is ~60 lines: payload shape + endpoint + parse.
217
218
  - **One generic `/pr` skill.** Convention lives in config, not the skill, so the skill is
218
- provider- and team-neutral and installs globally. Adding a provider = one binary + one dispatch
219
- line, zero new skill files.
219
+ host- and team-neutral and installs globally. It always calls `pr-commands create`, so adding
220
+ a host = one provider file + `ready: true` in `install.js`, zero skill changes.
221
+
222
+ ```bash
223
+ pr-commands create --title "..." --body-file body.md --dest develop [--draft]
224
+ pr-commands check # which host, and is it supported?
225
+ pr-commands help
226
+ ```
220
227
 
221
228
  Full rationale in [`DECISIONS.md`](./DECISIONS.md).
222
229
 
@@ -227,14 +234,15 @@ Full rationale in [`DECISIONS.md`](./DECISIONS.md).
227
234
  | `no ~/.config/pr/config` | run `npx @nvice-r/pr-commands` |
228
235
  | reviewers not fetched | add the keychain token (see Prerequisites), re-run |
229
236
  | `WARN ~/.local/bin not on PATH` | add the export line above to your shell rc |
230
- | `not-yet-supported: <provider>` | GitHub/GitLab aren't implemented yet |
231
- | worktree runs old behavior | skill/binaries are global, not repo-local; ensure no stale per-repo copy shadows them |
237
+ | `<provider> not yet supported` | GitHub/GitLab aren't implemented yet |
238
+ | worktree runs old behavior | skill/command are global, not repo-local; ensure no stale per-repo copy shadows them |
232
239
  | agent doesn't see the skill | re-run and force it: `npx @nvice-r/pr-commands --agent <id>`; check `<agent-skills-dir>/pr` symlink exists |
233
240
 
234
241
  ## Uninstall
235
242
 
236
243
  ```bash
237
- rm ~/.local/bin/bbpr ~/.local/bin/pr-common.sh
244
+ rm ~/.local/bin/pr-commands
245
+ rm -rf ~/.local/share/pr-commands
238
246
  rm -rf ~/.config/pr
239
247
  rm ~/.claude/commands/pr.md
240
248
  # per-agent skill symlinks (remove those you installed):
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env bash
2
+ # pr-commands — create pull requests on any git host (one entry point).
3
+ #
4
+ # Usage:
5
+ # pr-commands create --title "..." [--body|--body-file f] [--source b] [--dest b]
6
+ # [--repo owner/name] [--draft] [--reviewer id]... [--no-reviewers]
7
+ # [--email a] [--close-source] [--source-repo owner/fork]
8
+ # pr-commands check detect the host from the origin remote; exit 0 if supported
9
+ # pr-commands help this help
10
+ # pr-commands --version
11
+ #
12
+ # The host is detected from `git remote get-url origin` (override: $PR_PROVIDER).
13
+ # Host code lives in <share>/providers/<provider>.sh; shared code in <share>/pr-common.sh,
14
+ # where <share> = $PR_COMMANDS_HOME, else <this dir>/../share/pr-commands.
15
+ # pr-commands-version: 1
16
+
17
+ set -euo pipefail
18
+
19
+ PR_CMD="pr-commands"
20
+ _here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
21
+ SHARE="${PR_COMMANDS_HOME:-$_here/../share/pr-commands}"
22
+
23
+ usage() { sed -n '2,14p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; }
24
+ fail() { echo "$PR_CMD: $*" >&2; exit 1; }
25
+
26
+ # Echo the provider name for the origin remote (or $PR_PROVIDER), or fail.
27
+ detect_provider() {
28
+ if [ -n "${PR_PROVIDER:-}" ]; then echo "$PR_PROVIDER"; return; fi
29
+ local url
30
+ url="$(git remote get-url origin 2>/dev/null)" || fail "no origin remote"
31
+ case "$url" in
32
+ *bitbucket.org*) echo bitbucket ;;
33
+ *github.com*) echo github ;;
34
+ *gitlab*) echo gitlab ;;
35
+ *) fail "unknown git host for remote: $url (set PR_PROVIDER to override)" ;;
36
+ esac
37
+ }
38
+
39
+ # Source the shared lib + the provider file. Fails if the provider is not installed.
40
+ load_provider() {
41
+ PROVIDER="$(detect_provider)"
42
+ local pfile="$SHARE/providers/${PROVIDER}.sh"
43
+ [ -f "$SHARE/pr-common.sh" ] || fail "missing $SHARE/pr-common.sh — run: npx @nvice-r/pr-commands"
44
+ [ -f "$pfile" ] || fail "${PROVIDER} not yet supported (no $pfile)"
45
+ # shellcheck source=../share/pr-commands/pr-common.sh
46
+ . "$SHARE/pr-common.sh"
47
+ # shellcheck disable=SC1090
48
+ . "$pfile"
49
+ }
50
+
51
+ sub="${1:-help}"
52
+ case "$sub" in
53
+ -h|--help|--version) ;;
54
+ --*) set -- create "$@"; sub=create ;; # bare flags = create
55
+ esac
56
+
57
+ case "$sub" in
58
+ create)
59
+ shift
60
+ load_provider
61
+ declare -F provider_create >/dev/null || fail "${PROVIDER}: create not implemented"
62
+ provider_create "$@"
63
+ ;;
64
+ check)
65
+ load_provider
66
+ echo "provider=${PROVIDER} ok"
67
+ ;;
68
+ help|-h|--help) usage ;;
69
+ --version|version) sed -n 's/^# pr-commands-version: //p' "${BASH_SOURCE[0]}" ;;
70
+ *) fail "unknown command: $sub (try: $PR_CMD help)" ;;
71
+ esac
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env bash
2
- # pr-common-version: 2
3
- # pr-common.sh — shared library for provider PR binaries (bbpr/ghpr/glpr).
4
- # NOT executable on its own — sourced by a provider binary, which then builds the
5
- # provider-specific payload + POST. Everything here is provider-AGNOSTIC.
2
+ # pr-common-version: 3
3
+ # pr-common.sh — shared library for the `pr-commands` entry point.
4
+ # NOT executable on its own — sourced by `pr-commands` before providers/<host>.sh, which
5
+ # builds the host-specific payload + POST. Everything here is host-AGNOSTIC.
6
6
  #
7
7
  # Provides (after `pr_common_init "$@"`): global vars
8
8
  # TITLE BODY SOURCE DEST REPO SOURCE_REPO EMAIL TOKEN DRAFT CLOSE_SOURCE
@@ -82,7 +82,7 @@ pr_parse_args() {
82
82
  --reviewer) EXTRA_REVIEWERS+=("$2"); shift 2 ;;
83
83
  --no-reviewers) NO_REVIEWERS="true"; shift ;;
84
84
  --email) EMAIL_ARG="$2"; shift 2 ;;
85
- -h|--help) sed -n '2,20p' "$0"; exit 0 ;;
85
+ -h|--help) if declare -F usage >/dev/null; then usage; else sed -n '2,20p' "$0"; fi; exit 0 ;;
86
86
  *) die "unknown flag: $1" ;;
87
87
  esac
88
88
  done
@@ -101,10 +101,11 @@ pr_git_defaults() {
101
101
  [ "$SOURCE" = "HEAD" ] && die "detached HEAD — pass --source"
102
102
  fi
103
103
  if [ -z "$DEST" ]; then
104
- DEST="$(git config --get "branch.${SOURCE}.bbprBase" 2>/dev/null || true)"
105
- [ -n "$DEST" ] && echo "${PR_CMD:-pr}: using saved base (branch.${SOURCE}.bbprBase = ${DEST})" >&2
104
+ pr_migrate_base
105
+ DEST="$(git config --get "branch.${SOURCE}.base" 2>/dev/null || true)"
106
+ [ -n "$DEST" ] && echo "${PR_CMD:-pr}: using saved base (branch.${SOURCE}.base = ${DEST})" >&2
106
107
  fi
107
- [ -n "$DEST" ] || die "--dest required (no saved branch.${SOURCE}.bbprBase)"
108
+ [ -n "$DEST" ] || die "--dest required (no saved branch.${SOURCE}.base)"
108
109
  if [ -z "$REPO" ]; then
109
110
  local url
110
111
  url="$(git remote get-url origin 2>/dev/null)" || die "no origin remote"
@@ -116,9 +117,11 @@ pr_git_defaults() {
116
117
 
117
118
  # --- creds: email precedence + token via OS ---
118
119
  pr_resolve_creds() {
119
- # --email > $BB_EMAIL > git config bbpr.email > config email > keychain acct
120
+ # --email > $BB_EMAIL > git config pr.email > config email > keychain acct
121
+ # (legacy git config bbpr.email still read as fallback after pr.email)
120
122
  EMAIL="$EMAIL_ARG"
121
123
  [ -n "$EMAIL" ] || EMAIL="${BB_EMAIL:-}"
124
+ [ -n "$EMAIL" ] || EMAIL="$(git config pr.email 2>/dev/null || true)"
122
125
  [ -n "$EMAIL" ] || EMAIL="$(git config bbpr.email 2>/dev/null || true)"
123
126
  [ -n "$EMAIL" ] || EMAIL="${CFG_EMAIL:-}"
124
127
  [ -n "$EMAIL" ] || die "no email resolved (set config email or pass --email)"
@@ -128,7 +131,7 @@ pr_resolve_creds() {
128
131
  # --- reviewer roster: work-dir file (shared across worktrees) then global fallbacks ---
129
132
  pr_load_reviewers() {
130
133
  DEFAULT_REVIEWERS=()
131
- local file="${BBPR_REVIEWERS:-}"
134
+ local file="${PR_REVIEWERS:-${BBPR_REVIEWERS:-}}" # BBPR_REVIEWERS = legacy name
132
135
  if [ -z "$file" ]; then
133
136
  local slug; slug="$(echo "$REPO" | tr '/' '-')" # workspace/slug -> workspace-slug
134
137
  # main repo root, shared across worktrees: git-common-dir/.. (empty if git call fails)
@@ -161,12 +164,28 @@ pr_load_reviewers() {
161
164
  }
162
165
 
163
166
  # --- persist resolved base for next run ---
167
+ # branch.<b>.base is the shared, host-neutral key (also read by
168
+ # @nvice-r/git-commands git-restack).
164
169
  pr_save_base() {
165
- git config "branch.${SOURCE}.bbprBase" "$DEST" 2>/dev/null \
166
- && echo "${PR_CMD:-pr}: saved branch.${SOURCE}.bbprBase = ${DEST}" >&2
170
+ git config "branch.${SOURCE}.base" "$DEST" 2>/dev/null \
171
+ && echo "${PR_CMD:-pr}: saved branch.${SOURCE}.base = ${DEST}" >&2
167
172
  return 0 # base-save is best-effort; never abort the binary on failure
168
173
  }
169
174
 
175
+ # One-time migration: pr-commands <= 0.1.3 saved the base under the legacy key
176
+ # branch.<b>.bbprBase. Copy it to branch.<b>.base and unset the old key.
177
+ # Remove this function once no branch can still carry the legacy key.
178
+ pr_migrate_base() {
179
+ local old
180
+ git config --get "branch.${SOURCE}.base" >/dev/null 2>&1 && return 0
181
+ old="$(git config --get "branch.${SOURCE}.bbprBase" 2>/dev/null || true)"
182
+ [ -n "$old" ] || return 0
183
+ git config "branch.${SOURCE}.base" "$old" 2>/dev/null \
184
+ && git config --unset "branch.${SOURCE}.bbprBase" 2>/dev/null \
185
+ && echo "${PR_CMD:-pr}: migrated branch.${SOURCE}.bbprBase -> branch.${SOURCE}.base" >&2
186
+ return 0 # best-effort; never abort the binary on failure
187
+ }
188
+
170
189
  # --- orchestrate everything a provider needs before its POST ---
171
190
  pr_common_init() {
172
191
  pr_load_config
@@ -0,0 +1,77 @@
1
+ # bitbucket-provider-version: 10
2
+ # providers/bitbucket.sh — Bitbucket Cloud (REST API v2.0). Sourced by `pr-commands`
3
+ # after pr-common.sh; never run directly. Only Bitbucket-specific code lives here:
4
+ # payload shape, endpoint, self-as-reviewer rule, response parse.
5
+
6
+ KEYCHAIN_SERVICE="bitbucket-api-token"
7
+
8
+ provider_create() {
9
+ pr_common_init "$@"
10
+
11
+ # --- Bitbucket: drop the PR author's own UUID (BB rejects author-as-reviewer) ---
12
+ if [ "${#FINAL_REVIEWERS[@]}" -gt 0 ]; then
13
+ local SELF_UUID
14
+ SELF_UUID="$(curl -sS -u "${EMAIL}:${TOKEN}" "https://api.bitbucket.org/2.0/user" 2>/dev/null \
15
+ | python3 -c 'import json,sys
16
+ try: print(json.load(sys.stdin).get("uuid",""))
17
+ except Exception: print("")' 2>/dev/null || true)"
18
+ if [ -n "$SELF_UUID" ]; then
19
+ local FILTERED=() r
20
+ for r in "${FINAL_REVIEWERS[@]}"; do
21
+ [ "$r" = "$SELF_UUID" ] || FILTERED+=("$r")
22
+ done
23
+ FINAL_REVIEWERS=("${FILTERED[@]:-}")
24
+ [ -z "${FINAL_REVIEWERS[0]:-}" ] && FINAL_REVIEWERS=()
25
+ fi
26
+ fi
27
+
28
+ # --- Bitbucket payload (source/destination keys, draft, reviewers[]) ---
29
+ local PAYLOAD
30
+ PAYLOAD="$(
31
+ TITLE="$TITLE" BODY="$BODY" SOURCE="$SOURCE" DEST="$DEST" SOURCE_REPO="$SOURCE_REPO" \
32
+ DRAFT="$DRAFT" CLOSE_SOURCE="$CLOSE_SOURCE" \
33
+ REVIEWERS_JSON="$(printf '%s\n' "${FINAL_REVIEWERS[@]:-}" | python3 -c 'import json,sys; print(json.dumps([{"uuid": l.strip()} for l in sys.stdin if l.strip()]))')" \
34
+ python3 -c '
35
+ import json, os
36
+ p = {
37
+ "title": os.environ["TITLE"],
38
+ "description": os.environ["BODY"],
39
+ "source": {"branch": {"name": os.environ["SOURCE"]}},
40
+ "destination": {"branch": {"name": os.environ["DEST"]}},
41
+ "draft": os.environ["DRAFT"] == "true",
42
+ "close_source_branch": os.environ["CLOSE_SOURCE"] == "true",
43
+ }
44
+ if os.environ["SOURCE_REPO"]:
45
+ p["source"]["repository"] = {"full_name": os.environ["SOURCE_REPO"]}
46
+ revs = json.loads(os.environ["REVIEWERS_JSON"])
47
+ if revs: p["reviewers"] = revs
48
+ print(json.dumps(p))
49
+ '
50
+ )"
51
+
52
+ local URL="https://api.bitbucket.org/2.0/repositories/${REPO}/pullrequests"
53
+ echo "POST $URL"
54
+ echo "source=${SOURCE_REPO:+$SOURCE_REPO:}$SOURCE dest=$DEST draft=$DRAFT reviewers=${#FINAL_REVIEWERS[@]}"
55
+
56
+ local RESP PR_INFO
57
+ RESP="$(curl -sS -X POST "$URL" -u "${EMAIL}:${TOKEN}" \
58
+ -H 'Content-Type: application/json' -d "$PAYLOAD")"
59
+
60
+ PR_INFO="$(echo "$RESP" | python3 -c '
61
+ import json, sys
62
+ try: d = json.loads(sys.stdin.read())
63
+ except Exception as e: print("non-JSON response", e); sys.exit(2)
64
+ if "error" in d: print("ERROR:", json.dumps(d["error"], indent=2)); sys.exit(1)
65
+ print(str(d.get("id")) + "\t" + str(d.get("links", {}).get("html", {}).get("href")))
66
+ ')" || { echo "$PR_INFO" >&2; exit 1; } # surface the API error; set -e would swallow it
67
+
68
+ local NEW_PR_ID NEW_PR_URL ATTACHED
69
+ NEW_PR_ID="$(echo "$PR_INFO" | cut -f1)"
70
+ NEW_PR_URL="$(echo "$PR_INFO" | cut -f2)"
71
+ ATTACHED="$(echo "$RESP" | python3 -c 'import json,sys; print(len(json.load(sys.stdin).get("reviewers", [])))')"
72
+ echo "created PR #${NEW_PR_ID} with ${ATTACHED} reviewers"
73
+ echo "$NEW_PR_URL"
74
+
75
+ [ -n "$NEW_PR_ID" ] && pr_save_base
76
+ return 0
77
+ }
@@ -1,5 +1,5 @@
1
1
  Create a pull request for the current branch. Provider (Bitbucket / GitHub / GitLab) is
2
- auto-detected from the git remote; the matching binary (`bbpr` / `ghpr` / `glpr`) is invoked.
2
+ auto-detected from the git remote by the `pr-commands` command, which runs that host's provider.
3
3
  Convention (title pattern, ticket regex, PR template, body rules, push remotes, hooks) comes
4
4
  from the repo's `.agents/config/pr.yaml` (one file per project, usually copied there by
5
5
  `/landing`). Without that file, the convention keys in `~/.config/pr/config` are used.
@@ -37,7 +37,7 @@ UPDATE
37
37
 
38
38
  CONVENTION <repo>/.agents/config/pr.yaml (per project; else convention keys in the user config)
39
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
40
+ PROVIDER auto from `git remote get-url origin` (run by `pr-commands`)
41
41
  REVIEWERS <main-repo-root>/<agent_dir>/pr-reviewers.txt (per-repo, shared across worktrees; agent_dir from config)
42
42
  SETUP npx @nvice-r/pr-commands
43
43
  ```
@@ -52,17 +52,11 @@ missing piece). Do NOT improvise config or reviewers.
52
52
  CFG="${PR_CONFIG:-$HOME/.config/pr/config}"
53
53
  [ -f "$CFG" ] || { echo "MISSING config: $CFG"; }
54
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
55
+ # b. command installed?
56
+ command -v pr-commands >/dev/null 2>&1 || echo "MISSING command: pr-commands"
63
57
 
64
- # c. matching binary installed?
65
- command -v "$BIN" >/dev/null 2>&1 || echo "MISSING binary: $BIN (run installer, or provider not yet supported)"
58
+ # c. host detected + supported? prints "provider=<name> ok", or the reason it is not
59
+ pr-commands check
66
60
 
67
61
  # d. project convention file: this checkout first, then the main checkout (worktrees share it,
68
62
  # because .agents/config/ is usually gitignored and only exists in the main checkout)
@@ -75,14 +69,14 @@ done
75
69
  echo "convention: ${PR_YAML:-$CFG (no .agents/config/pr.yaml)}"
76
70
  ```
77
71
 
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.)
72
+ - config or `pr-commands` missing → STOP: "run `npx @nvice-r/pr-commands`".
73
+ - `pr-commands check` fails → STOP and show its message (unknown host, or
74
+ "<provider> not yet supported" — only Bitbucket for now). (D6 stub.)
81
75
  - all pass → continue.
82
76
 
83
77
  ## Step 1 — gather context (parallel)
84
78
  - `git rev-parse --abbrev-ref HEAD` — source branch (CURR)
85
- - `git config --get branch.<CURR>.bbprBase` — saved base, if any
79
+ - `git config --get branch.<CURR>.base` — saved base, if any
86
80
  - `git log --oneline -20`
87
81
  - read the **convention source**: `$PR_YAML` if Step 0 found one, else the convention keys in `$CFG`.
88
82
  Every later step that says "convention" means this source.
@@ -104,7 +98,7 @@ echo "convention: ${PR_YAML:-$CFG (no .agents/config/pr.yaml)}"
104
98
 
105
99
  ## Step 2 — determine base branch (priority)
106
100
  a. `$1` (any arg containing `/`) → use it.
107
- b. else `branch.<CURR>.bbprBase` from git config → use silently.
101
+ b. else saved `branch.<CURR>.base` from git config → use silently.
108
102
  c. else infer — smallest non-zero `<ref>..HEAD` distance where `<ref>` is an ancestor of HEAD:
109
103
  ```bash
110
104
  for ref in $(git for-each-ref --format='%(refname:short)' refs/remotes/origin | grep -v "^origin/<CURR>$"); do
@@ -118,7 +112,7 @@ c. else infer — smallest non-zero `<ref>..HEAD` distance where `<ref>` is an a
118
112
  stacked PR on another in-flight ticket branch is common. Ties or all >20 ahead → ask.
119
113
  d. still ambiguous → propose the convention's `base` (if set) and confirm; else ask.
120
114
 
121
- The binary auto-saves the resolved base to `branch.<CURR>.bbprBase` on success.
115
+ `pr-commands` auto-saves the resolved base to `branch.<CURR>.base` on success.
122
116
 
123
117
  ## Step 3 — build diff context
124
118
  `git log <base>..HEAD --oneline` · `git diff --stat <base>..HEAD`. Multiple commits → summarize each.
@@ -169,7 +163,7 @@ With no `body_rules` and no legacy rules, fill sections with a plain summary + c
169
163
  Run each `pre_pr` command from the convention, in order, from the repo root. Any non-zero exit →
170
164
  STOP, show its output, create nothing. No `pre_pr` → skip silently.
171
165
 
172
- ## Step 7 — confirm. Show proposed title + base + body + resolved provider/binary + mode
166
+ ## Step 7 — confirm. Show proposed title + base + body + resolved provider + mode
173
167
  (direct/cross) + remotes to push. WAIT. PR creation is user-visible; never silent.
174
168
 
175
169
  ## Step 7.5 — mode + push
@@ -181,17 +175,17 @@ STOP, show its output, create nothing. No `pre_pr` → skip silently.
181
175
  - Push failure → STOP and show the error. Never pass `--no-verify` (pre-push hooks may lint;
182
176
  allow 30–60 s). No `push` key → do not push; warn if HEAD is ahead of its upstream.
183
177
 
184
- ## Step 8 — create. Run the detected binary (append `--draft` only if draft chosen):
178
+ ## Step 8 — create. Run `pr-commands create` (append `--draft` only if draft chosen):
185
179
  ```bash
186
- <BIN> --title "<title>" --body-file /tmp/pr-body.md --dest "<base>" [--draft] \
187
- [--source-repo "<fork.repo>"] # cross mode only
180
+ pr-commands create --title "<title>" --body-file /tmp/pr-body.md --dest "<base>" [--draft] \
181
+ [--source-repo "<fork.repo>"] # cross mode only
188
182
  ```
189
183
  Draft: `draft` arg → draft. Else convention `draft: true|false` → use it. Else (`ask` or unset)
190
184
  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
185
+ `git config branch.<CURR>.prMode cross` so the next `/pr` reuses the mode. `pr-commands` attaches the
192
186
  per-repo reviewer roster automatically; `--no-reviewers` skips, `--reviewer <id>` appends.
193
187
 
194
- ## Step 9 — print final PR URL + reviewer count from binary output. On error, surface raw response.
188
+ ## Step 9 — print final PR URL + reviewer count from `pr-commands` output. On error, surface raw response.
195
189
 
196
190
  ## Step 10 — post-PR skills (guarded, optional).
197
191
  For each skill in the convention's `post_pr` (legacy config without `pr.yaml`: `jira-update`):
@@ -202,13 +196,13 @@ For each skill in the convention's `post_pr` (legacy config without `pr.yaml`: `
202
196
 
203
197
  Rewrites an existing open PR's **description** in place — no new branch, no new PR. Same Step 0
204
198
  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.
199
+ matching the create path; any other host → STOP with the same "not yet supported" note.
206
200
 
207
201
  **U1 — parse args.** Tokens after `update`: a bare integer = `PR#`; the word `short` (or
208
202
  `caveman`) = compress-hard flag. Missing `PR#` → resolve from the current branch: query the
209
203
  provider for the open PR whose source branch is `HEAD` (Bitbucket: `GET /pullrequests?q=source.branch.name="<CURR>"&state=OPEN`). Zero or multiple matches → ask.
210
204
 
211
- **U2 — fetch current PR.** Resolve creds the same way the binary does — email from `$CFG`
205
+ **U2 — fetch current PR.** Resolve creds the same way `pr-commands` does — email from `$CFG`
212
206
  (`email=`), token from the OS keychain (macOS service `bitbucket-api-token`, account = email).
213
207
  `REPO` = workspace/slug from `git remote get-url origin`. Fetch title + description:
214
208
  ```bash
package/install.js CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  // @nvice-r/pr-commands installer. Zero external deps (Node >=16 builtins only).
3
3
  // Wizard: detect OS + provider, pick reviewers + convention, write config/roster,
4
- // install binaries -> ~/.local/bin, install the portable `pr` SKILL.md into every
4
+ // install the `pr-commands` entry point -> ~/.local/bin (+ shared lib and host providers ->
5
+ // ~/.local/share/pr-commands), install the portable `pr` SKILL.md into every
5
6
  // detected agent (symlinked to one canonical copy) + Claude /pr slash-command.
6
7
  //
7
8
  // Flags: --dir <path> --provider <p> --agent <id> --all --yes --dry-run --help
@@ -21,9 +22,10 @@ const ASSETS = path.join(PKG, 'assets');
21
22
  const BACK = Symbol('back'); // returned by a prompt when the user wants the previous step
22
23
 
23
24
  const PROVIDERS = {
24
- bitbucket: { bin: 'bbpr', host: 'bitbucket.org', keychainSvc: 'bitbucket-api-token', ready: true },
25
- github: { bin: 'ghpr', host: 'github.com', keychainSvc: 'github-api-token', ready: false },
26
- gitlab: { bin: 'glpr', host: 'gitlab.com', keychainSvc: 'gitlab-api-token', ready: false },
25
+ // provider code = assets/share/pr-commands/providers/<name>.sh
26
+ bitbucket: { host: 'bitbucket.org', keychainSvc: 'bitbucket-api-token', ready: true },
27
+ github: { host: 'github.com', keychainSvc: 'github-api-token', ready: false },
28
+ gitlab: { host: 'gitlab.com', keychainSvc: 'gitlab-api-token', ready: false },
27
29
  };
28
30
  const CONVENTIONS = ['trueid', 'conventional', 'github-default', 'custom'];
29
31
  // Concrete, editable defaults per preset. Whatever the user confirms is written to config,
@@ -96,7 +98,7 @@ function printHelp() {
96
98
  folder — .claude / .agent / .codex / .opencode)
97
99
  --agent <id> Force skill install to this agent (repeatable). Ids: claude-code,
98
100
  antigravity, codex, opencode. Default: auto-detect installed agents.
99
- --all Install all provider binaries (default: only the detected one)
101
+ --all Install every ready host provider (default: only the detected one)
100
102
  --yes Accept defaults, no prompts (reviewers=existing roster or all, convention=trueid)
101
103
  --reset-reviewers Ignore an existing roster and rebuild it from the repo's default reviewers
102
104
  --dry-run Print planned actions, write nothing
@@ -345,6 +347,20 @@ function scanGitRepos(root, maxDepth = 3, cap = 50) {
345
347
  return found;
346
348
  }
347
349
 
350
+ // Remove binaries from <= 0.1.3 (bbpr + pr-common.sh in ~/.local/bin). Only files that carry
351
+ // our own version marker are deleted, so an unrelated file with the same name is left alone.
352
+ function removeLegacyBins(binDir) {
353
+ for (const [name, marker] of [['bbpr', 'bbpr-version'], ['pr-common.sh', 'pr-common-version']]) {
354
+ const p = path.join(binDir, name);
355
+ let txt;
356
+ try { txt = fs.readFileSync(p, 'utf8'); } catch { continue; }
357
+ if (!txt.includes(marker)) { log(` kept ${p} (not ours)`); continue; }
358
+ if (flags.dryRun) { log(` [dry-run] remove legacy ${p}`); continue; }
359
+ fs.unlinkSync(p);
360
+ log(` removed legacy ${p}`);
361
+ }
362
+ }
363
+
348
364
  // ---- provider detection ----
349
365
  function detectProvider(remoteUrl) {
350
366
  if (flags.provider) return flags.provider;
@@ -426,7 +442,7 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
426
442
  const L = [];
427
443
  if (stage > 0 && st.workDir) {
428
444
  L.push(` \x1b[32m✓\x1b[0m work dir ${st.workDir}`);
429
- L.push(` \x1b[32m✓\x1b[0m provider ${st.provider} (${st.slug}) → ${st.pcfg.bin}`);
445
+ L.push(` \x1b[32m✓\x1b[0m provider ${st.provider} (${st.slug})`);
430
446
  L.push(` \x1b[32m✓\x1b[0m auth ${st.email || '(none)'}${st.token ? ' · token ok' : ' · no token'}`);
431
447
  }
432
448
  if (stage > 1 && st.chosen) L.push(` \x1b[32m✓\x1b[0m reviewers ${st.chosen.length} selected`);
@@ -481,7 +497,7 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
481
497
  if (!provider) { console.error(` ERROR: could not detect provider from ${remoteUrl}`); process.exit(1); }
482
498
  const pcfg = PROVIDERS[provider];
483
499
  const slug = parseSlug(remoteUrl);
484
- log(` remote: ${remoteUrl}\n provider=${provider} slug=${slug} binary=${pcfg.bin}`);
500
+ log(` remote: ${remoteUrl}\n provider=${provider} slug=${slug}`);
485
501
  if (!pcfg.ready) { console.error(` ERROR: ${provider} not yet supported (macOS+Bitbucket only). Aborting.`); process.exit(1); }
486
502
  // identity + token (derived from provider)
487
503
  let email = '';
@@ -592,7 +608,7 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
592
608
 
593
609
  // --- install ---
594
610
  screen(4, 'Install');
595
- const { slug, pcfg, email, convention, conv, chosen } = st;
611
+ const { slug, email, convention, conv, chosen } = st;
596
612
  // Roster folder = --agent-dir, else the first chosen agent's repo folder, else .claude.
597
613
  const agentIds = st.agents || detectedAgentIds();
598
614
  const primary = agentTargets().find((t) => t.id === agentIds[0]);
@@ -615,16 +631,21 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
615
631
  const header = `# pr reviewers for ${slug} — {uuid} # Name per line; blanks/# ignored. Edit freely.\n`;
616
632
  writeFile(rosterFile, header + (roster || '# (none selected)\n'));
617
633
  }
634
+ // One entry point on PATH; shared lib + host providers under ~/.local/share/pr-commands.
618
635
  const binDir = path.join(HOME, '.local/bin');
619
- const binsToInstall = flags.all
620
- ? Object.values(PROVIDERS).filter((p) => p.ready).map((p) => p.bin)
621
- : [pcfg.bin];
622
- writeFile(path.join(binDir, 'pr-common.sh'), fs.readFileSync(path.join(ASSETS, 'bin/pr-common.sh')), 0o755);
623
- for (const b of binsToInstall) {
624
- const src = path.join(ASSETS, 'bin', b);
625
- if (!fs.existsSync(src)) { log(` skip ${b} (asset not present yet)`); continue; }
626
- writeFile(path.join(binDir, b), fs.readFileSync(src), 0o755);
636
+ const shareDir = path.join(HOME, '.local/share/pr-commands');
637
+ const shareSrc = path.join(ASSETS, 'share/pr-commands');
638
+ writeFile(path.join(binDir, 'pr-commands'), fs.readFileSync(path.join(ASSETS, 'bin/pr-commands')), 0o755);
639
+ writeFile(path.join(shareDir, 'pr-common.sh'), fs.readFileSync(path.join(shareSrc, 'pr-common.sh')), 0o644);
640
+ const provNames = flags.all
641
+ ? Object.keys(PROVIDERS).filter((n) => PROVIDERS[n].ready)
642
+ : [st.provider];
643
+ for (const n of provNames) {
644
+ const src = path.join(shareSrc, 'providers', `${n}.sh`);
645
+ if (!fs.existsSync(src)) { log(` skip ${n} (provider not present yet)`); continue; }
646
+ writeFile(path.join(shareDir, 'providers', `${n}.sh`), fs.readFileSync(src), 0o644);
627
647
  }
648
+ removeLegacyBins(binDir);
628
649
  installSkill(agentIds);
629
650
  if (!(process.env.PATH || '').split(':').includes(binDir)) {
630
651
  log(`\n WARN: ${binDir} not on PATH. Add to your shell rc:`);
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@nvice-r/pr-commands",
3
- "version": "0.1.3",
4
- "description": "Provider-agnostic PR helper for Claude Code — npx installer + bbpr/ghpr/glpr binaries + /pr skill",
3
+ "version": "0.1.4",
4
+ "description": "Provider-agnostic PR helper for Claude Code — npx installer + one `pr-commands` command (per-host providers) + /pr skill",
5
5
  "bin": {
6
- "pr-commands": "install.js"
6
+ "pr-commands-setup": "install.js"
7
7
  },
8
8
  "files": [
9
9
  "install.js",
package/assets/bin/bbpr DELETED
@@ -1,81 +0,0 @@
1
- #!/usr/bin/env bash
2
- # bbpr-version: 9
3
- # bbpr — create a Bitbucket Cloud pull request (REST API v2.0).
4
- # Provider-specific layer; all git/config/creds/reviewer logic lives in pr-common.sh.
5
- # Usage: bbpr --title "..." [--body|--body-file] [--source b] [--dest b] [--repo w/s]
6
- # [--draft] [--reviewer uuid]... [--no-reviewers] [--email a] [--close-source]
7
- # [--source-repo ws/fork] (cross-repo: PR opens in --repo/origin, from the fork)
8
-
9
- set -euo pipefail
10
-
11
- PR_CMD="bbpr"
12
- KEYCHAIN_SERVICE="bitbucket-api-token"
13
-
14
- # locate + source the shared lib (same dir as this binary)
15
- _here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
16
- # shellcheck source=pr-common.sh
17
- . "$_here/pr-common.sh"
18
-
19
- pr_common_init "$@"
20
-
21
- # --- Bitbucket: drop the PR author's own UUID (BB rejects author-as-reviewer) ---
22
- if [ "${#FINAL_REVIEWERS[@]}" -gt 0 ]; then
23
- SELF_UUID="$(curl -sS -u "${EMAIL}:${TOKEN}" "https://api.bitbucket.org/2.0/user" 2>/dev/null \
24
- | python3 -c 'import json,sys
25
- try: print(json.load(sys.stdin).get("uuid",""))
26
- except Exception: print("")' 2>/dev/null || true)"
27
- if [ -n "$SELF_UUID" ]; then
28
- FILTERED=()
29
- for r in "${FINAL_REVIEWERS[@]}"; do
30
- [ "$r" = "$SELF_UUID" ] || FILTERED+=("$r")
31
- done
32
- FINAL_REVIEWERS=("${FILTERED[@]:-}")
33
- [ -z "${FINAL_REVIEWERS[0]:-}" ] && FINAL_REVIEWERS=()
34
- fi
35
- fi
36
-
37
- # --- Bitbucket payload (source/destination keys, draft, reviewers[]) ---
38
- PAYLOAD="$(
39
- TITLE="$TITLE" BODY="$BODY" SOURCE="$SOURCE" DEST="$DEST" SOURCE_REPO="$SOURCE_REPO" \
40
- DRAFT="$DRAFT" CLOSE_SOURCE="$CLOSE_SOURCE" \
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()]))')" \
42
- python3 -c '
43
- import json, os
44
- p = {
45
- "title": os.environ["TITLE"],
46
- "description": os.environ["BODY"],
47
- "source": {"branch": {"name": os.environ["SOURCE"]}},
48
- "destination": {"branch": {"name": os.environ["DEST"]}},
49
- "draft": os.environ["DRAFT"] == "true",
50
- "close_source_branch": os.environ["CLOSE_SOURCE"] == "true",
51
- }
52
- if os.environ["SOURCE_REPO"]:
53
- p["source"]["repository"] = {"full_name": os.environ["SOURCE_REPO"]}
54
- revs = json.loads(os.environ["REVIEWERS_JSON"])
55
- if revs: p["reviewers"] = revs
56
- print(json.dumps(p))
57
- '
58
- )"
59
-
60
- URL="https://api.bitbucket.org/2.0/repositories/${REPO}/pullrequests"
61
- echo "POST $URL"
62
- echo "source=${SOURCE_REPO:+$SOURCE_REPO:}$SOURCE dest=$DEST draft=$DRAFT reviewers=${#FINAL_REVIEWERS[@]}"
63
-
64
- RESP="$(curl -sS -X POST "$URL" -u "${EMAIL}:${TOKEN}" \
65
- -H 'Content-Type: application/json' -d "$PAYLOAD")"
66
-
67
- PR_INFO="$(echo "$RESP" | python3 -c '
68
- import json, sys
69
- try: d = json.loads(sys.stdin.read())
70
- except Exception as e: print("non-JSON response", e); sys.exit(2)
71
- if "error" in d: print("ERROR:", json.dumps(d["error"], indent=2)); sys.exit(1)
72
- print(str(d.get("id")) + "\t" + str(d.get("links", {}).get("html", {}).get("href")))
73
- ')" || { echo "$PR_INFO" >&2; exit 1; } # surface the API error; set -e would swallow it
74
-
75
- NEW_PR_ID="$(echo "$PR_INFO" | cut -f1)"
76
- NEW_PR_URL="$(echo "$PR_INFO" | cut -f2)"
77
- ATTACHED="$(echo "$RESP" | python3 -c 'import json,sys; print(len(json.load(sys.stdin).get("reviewers", [])))')"
78
- echo "created PR #${NEW_PR_ID} with ${ATTACHED} reviewers"
79
- echo "$NEW_PR_URL"
80
-
81
- [ -n "$NEW_PR_ID" ] && pr_save_base