bigpowers 2.88.9 → 2.88.11

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bigpowers",
3
- "version": "2.88.9",
3
+ "version": "2.88.11",
4
4
  "description": "81 skills — 73 agent skills synthesizing 17 years of software engineering discipline into a prescriptive methodology for solo developers",
5
5
  "keywords": [
6
6
  "gemini-extension"
package/.pi/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bigpowers",
3
- "version": "2.88.9",
3
+ "version": "2.88.11",
4
4
  "description": "81 skills — 73 agent skills synthesizing 17 years of software engineering discipline into a prescriptive methodology for solo developers",
5
5
  "keywords": [
6
6
  "pi-package"
package/CHANGELOG.md CHANGED
@@ -1,3 +1,25 @@
1
+ ## [2.88.11](https://github.com/danielvm-git/bigpowers/compare/v2.88.10...v2.88.11) (2026-10-25)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **guard:** resolve git branch per command target, not process cwd ([#154](https://github.com/danielvm-git/bigpowers/issues/154)) ([07c7498](https://github.com/danielvm-git/bigpowers/commit/07c749803c4d5d1c2499c01255fad138ce88e870))
7
+ * **lockfile:** add missing slash in skill path + self-contained resolve_repo_root ([#156](https://github.com/danielvm-git/bigpowers/issues/156)) ([04bda15](https://github.com/danielvm-git/bigpowers/commit/04bda157195f39816cd388a2fb4b0829ca19b20f))
8
+ * **omp-hooks:** inGitRepo bail-early, accept type!(scope) form, echo subject ([#157](https://github.com/danielvm-git/bigpowers/issues/157)) ([9e1b11a](https://github.com/danielvm-git/bigpowers/commit/9e1b11aaf4c63fa951c6916fabda13a836b56f2d)), closes [#147](https://github.com/danielvm-git/bigpowers/issues/147) [#151](https://github.com/danielvm-git/bigpowers/issues/151) [#151](https://github.com/danielvm-git/bigpowers/issues/151)
9
+
10
+
11
+ ### Performance Improvements
12
+
13
+ * **sync:** render all skills in one bash process per adapter ([#155](https://github.com/danielvm-git/bigpowers/issues/155)) ([1b56463](https://github.com/danielvm-git/bigpowers/commit/1b564639771c39c3d4bc712f09912751a56ab8ee)), closes [#137](https://github.com/danielvm-git/bigpowers/issues/137)
14
+
15
+ ## [2.88.10](https://github.com/danielvm-git/bigpowers/compare/v2.88.9...v2.88.10) (2026-10-25)
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * **install:** support Windows without symlink privilege ([#135](https://github.com/danielvm-git/bigpowers/issues/135)) ([c47e3c5](https://github.com/danielvm-git/bigpowers/commit/c47e3c5465679046fe980be0c5593a489efee793)), closes [#134](https://github.com/danielvm-git/bigpowers/issues/134)
21
+ * **srp:** resolve bash via PATH so adapters run on Windows ([#137](https://github.com/danielvm-git/bigpowers/issues/137)) ([c89f4f5](https://github.com/danielvm-git/bigpowers/commit/c89f4f563ed981a312f84cc6ac38e82a9826cd08))
22
+
1
23
  ## [2.88.9](https://github.com/danielvm-git/bigpowers/compare/v2.88.8...v2.88.9) (2026-09-21)
2
24
 
3
25
 
@@ -7,7 +7,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
7
  import { Type } from "typebox";
8
8
  import { execSync } from "node:child_process";
9
9
  import { readdirSync, readFileSync, statSync } from "node:fs";
10
- import { dirname, join } from "node:path";
10
+ import { dirname, join, resolve } from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
12
 
13
13
  // ---------------------------------------------------------------------------
@@ -28,8 +28,10 @@ const DANGEROUS_PATTERNS = [
28
28
  /push\s+-f(?:\s|$)/,
29
29
  ];
30
30
 
31
+ // Conventional Commits specifies `type(scope)!:`, but many repositories write
32
+ // `type!(scope):`. Accept both; the leading type is what matters.
31
33
  const CONVENTIONAL_RE =
32
- /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([^)]+\))?!?:\s.+/;
34
+ /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)!?(?:\([^)]+\))?!?:\s.+/;
33
35
 
34
36
  const PROTECTED_BRANCH_RE = /(?:^|\s|[:])(?:main|master)(?:\s|$)/;
35
37
  const PROTECTED_BRANCHES = ["main", "master"] as const;
@@ -157,11 +159,57 @@ function pluginRoot(): string {
157
159
  // Git safety helpers
158
160
  // ---------------------------------------------------------------------------
159
161
 
160
- function currentBranch(): string {
162
+ // Command-aware branch resolution: `git commit|push` in a bash command must
163
+ // be judged against the repo it will actually run in, not the agent process
164
+ // cwd (which is the session root — often a different repository).
165
+ // Resolve the directory: the last `cd <path>` before it (within the command
166
+ // prefix), overridden by the last `git -C <path>` anywhere in the command.
167
+ // Fail-closed: unresolvable paths fall back to the process cwd — previous
168
+ // behavior, over-blocks, never under-blocks.
169
+ function commandGitCwd(command: string, upTo: number, fallback: string): string {
170
+ const head = command.slice(0, upTo);
171
+ const cdMatches = [
172
+ ...head.matchAll(/\bcd\s+(?:"([^"]+)"|'([^']+)'|([^&|;<(`$][^\s&|;<>]*))/g),
173
+ ];
174
+ const cMatches = [
175
+ ...command.matchAll(/\bgit\s+-C\s+(?:"([^"]+)"|'([^']+)'|([^&|;<(`$][^\s&|;<>]*))/g),
176
+ ];
177
+ const m = cMatches.length
178
+ ? cMatches[cMatches.length - 1]
179
+ : cdMatches.length
180
+ ? cdMatches[cdMatches.length - 1]
181
+ : null;
182
+ if (!m) return fallback;
183
+ const p = m[1] ?? m[2] ?? m[3];
184
+ if (!p || p.includes("$") || p.includes("`")) return fallback;
185
+ try {
186
+ const abs = resolve(fallback, p);
187
+ return statSync(abs).isDirectory() ? abs : fallback;
188
+ } catch {
189
+ return fallback;
190
+ }
191
+ }
192
+
193
+ function inGitRepo(cwd?: string): boolean {
194
+ try {
195
+ execSync("git rev-parse --git-dir", {
196
+ cwd,
197
+ encoding: "utf-8",
198
+ timeout: 3000,
199
+ stdio: "ignore",
200
+ });
201
+ return true;
202
+ } catch {
203
+ return false;
204
+ }
205
+ }
206
+
207
+ function currentBranch(cwd: string): string {
161
208
  try {
162
209
  return execSync("git rev-parse --abbrev-ref HEAD", {
163
210
  encoding: "utf-8",
164
211
  timeout: 3000,
212
+ cwd,
165
213
  }).trim();
166
214
  } catch {
167
215
  return "unknown";
@@ -367,9 +415,11 @@ export default function bigpowers(pi: ExtensionAPI) {
367
415
  }
368
416
  }
369
417
 
370
- const branch = currentBranch();
418
+ const sessionRoot = process.cwd();
419
+ if (!inGitRepo(sessionRoot)) return;
420
+ const pushBranch = currentBranch(commandGitCwd(command, command.length, sessionRoot));
371
421
 
372
- if (/git\s+push/.test(command) && protectedPushTarget(command, branch)) {
422
+ if (/git\s+push/.test(command) && protectedPushTarget(command, pushBranch)) {
373
423
  return {
374
424
  block: true,
375
425
  reason:
@@ -378,10 +428,16 @@ export default function bigpowers(pi: ExtensionAPI) {
378
428
  }
379
429
 
380
430
  if (/git\s+commit/.test(command)) {
381
- if ((PROTECTED_BRANCHES as readonly string[]).includes(branch)) {
431
+ const commitBranches = [...command.matchAll(/\bgit\s+commit\b/g)].map((m) =>
432
+ currentBranch(commandGitCwd(command, m.index ?? 0, sessionRoot)),
433
+ );
434
+ const hit = commitBranches.find((b) =>
435
+ (PROTECTED_BRANCHES as readonly string[]).includes(b),
436
+ );
437
+ if (hit) {
382
438
  return {
383
439
  block: true,
384
- reason: `BLOCKED: Direct commits to '${branch}' are forbidden. Use a feature branch.`,
440
+ reason: `BLOCKED: Direct commits to '${hit}' are forbidden. Use a feature branch.`,
385
441
  };
386
442
  }
387
443
  const msg = extractCommitMessage(command);
@@ -389,8 +445,7 @@ export default function bigpowers(pi: ExtensionAPI) {
389
445
  if (!CONVENTIONAL_RE.test(msg)) {
390
446
  return {
391
447
  block: true,
392
- reason:
393
- "BLOCKED: Commit message must follow Conventional Commits: <type>(<scope>): <subject>.",
448
+ reason: `BLOCKED: Commit subject '${msg.split("\n")[0]}' must follow Conventional Commits: <type>(<scope>): <subject>.`,
394
449
  };
395
450
  }
396
451
  if (msg.split("\n")[0].length > 72) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bigpowers",
3
- "version": "2.88.9",
3
+ "version": "2.88.11",
4
4
  "description": "73 agent skills synthesizing 17 years of software engineering discipline into a prescriptive methodology for solo developers",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env bash
2
+ # story: #138
3
+ # adapter-render-all.sh — render every skill through one adapter in a single
4
+ # bash process.
5
+ #
6
+ # srp-engine.py --all used to spawn one `bash` (plus three `jq`) per
7
+ # skill×adapter — ~1,539 bash and ~4,600 jq processes from one Python process.
8
+ # On constrained Windows sessions that exhausts process-creation resources: the
9
+ # run dies with STATUS_DLL_INIT_FAILED (0xC0000142) and then hangs on orphaned
10
+ # children. Sourcing the adapter once and calling render_skill() in a loop keeps
11
+ # the exact same rendering code while collapsing the process count to one per
12
+ # adapter and removing the per-render jq calls.
13
+ #
14
+ # Input is a NUL-delimited stream written by srp-engine.py, five fields per
15
+ # skill in order — name, description, model, body, pi body — each NUL-terminated
16
+ # so field content may contain newlines.
17
+ #
18
+ # Adapters without a stdin entrypoint (continue, omp, zed) define render_skill
19
+ # but were never driven by srp-engine; they are skipped so behaviour is
20
+ # unchanged.
21
+ #
22
+ # Usage: adapter-render-all.sh <path-to-adapter.sh> <ir-stream-file>
23
+ set -u
24
+
25
+ adapter="$1"
26
+ stream="$2"
27
+
28
+ if [[ ! -f "$adapter" ]]; then
29
+ echo "adapter-render-all: adapter not found: $adapter" >&2
30
+ exit 1
31
+ fi
32
+ if [[ ! -f "$stream" ]]; then
33
+ echo "adapter-render-all: IR stream not found: $stream" >&2
34
+ exit 1
35
+ fi
36
+
37
+ # A spawned adapter with no stdin entrypoint is a no-op; match that.
38
+ if ! grep -q '\[\[ ! -t 0 \]\]' "$adapter"; then
39
+ exit 0
40
+ fi
41
+
42
+ adapter_id="$(basename "$adapter" .sh)"
43
+
44
+ # Sourcing the adapter runs its stdin entrypoint; point stdin at /dev/null so
45
+ # `[[ ! -t 0 ]]` is true but `cat` yields nothing and the entrypoint no-ops.
46
+ # shellcheck disable=SC1090
47
+ source "$adapter" < /dev/null
48
+
49
+ if ! declare -f render_skill >/dev/null 2>&1; then
50
+ exit 0
51
+ fi
52
+
53
+ exec 3< "$stream"
54
+ status=0
55
+ while IFS= read -r -d '' IR_NAME <&3; do
56
+ IFS= read -r -d '' IR_DESCRIPTION <&3
57
+ IFS= read -r -d '' IR_MODEL <&3
58
+ IFS= read -r -d '' IR_BODY <&3
59
+ IFS= read -r -d '' IR_BODY_PI <&3
60
+
61
+ IR_DESC_ESCAPED=$(echo "$IR_DESCRIPTION" | sed 's/\\/\\\\/g; s/"/\\"/g')
62
+ if [[ "$adapter_id" == "pi" ]]; then
63
+ IR_BODY_SKILL="$IR_BODY_PI"
64
+ else
65
+ IR_BODY_SKILL="$IR_BODY"
66
+ fi
67
+
68
+ if ! render_skill; then
69
+ status=1
70
+ break
71
+ fi
72
+ done
73
+ exec 3<&-
74
+ exit "$status"
@@ -14,6 +14,7 @@
14
14
  // story: e72s02
15
15
  // story: e68s02
16
16
  // story: e65s02
17
+ // story: #134
17
18
  // install-helpers.js — symlink helpers for bigpowers setup
18
19
 
19
20
  const fs = require('fs');
@@ -23,6 +24,50 @@ const path = require('path');
23
24
  // directory, which is how "managed" is detected (see assertReplaceable).
24
25
  const REPO_ROOT = path.resolve(__dirname, '..', '..');
25
26
 
27
+ // Windows denies symlink creation unless the shell is elevated or Developer
28
+ // Mode is on (EPERM). Directory *junctions* need no privilege, and Node reports
29
+ // a junction through lstat/readlink exactly like a symlink — so install,
30
+ // detectExistingInstall, and uninstall keep working. A file cannot be junctioned
31
+ // (the result is a broken reparse point), so a file link on Windows becomes a
32
+ // copy, tracked here so a reinstall replaces it and uninstall removes it.
33
+ const copiesPath = () => path.join(require('os').homedir(), '.bigpowers', 'managed-copies.json');
34
+
35
+ function managedCopies() {
36
+ try {
37
+ return new Set(JSON.parse(fs.readFileSync(copiesPath(), 'utf8')));
38
+ } catch {
39
+ return new Set();
40
+ }
41
+ }
42
+
43
+ function saveCopies(copies) {
44
+ fs.mkdirSync(path.dirname(copiesPath()), { recursive: true });
45
+ fs.writeFileSync(copiesPath(), JSON.stringify([...copies], null, 2));
46
+ }
47
+
48
+ // Symlink on POSIX; on Windows junction directories and copy (tracked) files.
49
+ function createLink(src, dst) {
50
+ if (process.platform !== 'win32') {
51
+ fs.symlinkSync(src, dst);
52
+ return;
53
+ }
54
+ if (!fs.existsSync(src)) return; // source absent from this build (e.g. AGENTS.md)
55
+ if (fs.statSync(src).isDirectory()) {
56
+ fs.symlinkSync(src, dst, 'junction');
57
+ return;
58
+ }
59
+ try {
60
+ fs.symlinkSync(src, dst);
61
+ const copies = managedCopies();
62
+ if (copies.delete(dst)) saveCopies(copies); // was a copy; now a real link
63
+ } catch {
64
+ fs.copyFileSync(src, dst);
65
+ const copies = managedCopies();
66
+ copies.add(dst);
67
+ saveCopies(copies);
68
+ }
69
+ }
70
+
26
71
  // Refuse to replace a destination that is not already a bigpowers-managed
27
72
  // symlink. Prevents setup from silently destroying a user's own file or
28
73
  // directory (e.g. an existing ~/.claude/hooks/rtk-rewrite.sh installed by
@@ -42,6 +87,9 @@ function assertReplaceable(dst) {
42
87
  return; // managed by bigpowers — safe to re-link
43
88
  }
44
89
  }
90
+ if (st.isFile() && managedCopies().has(dst)) {
91
+ return; // file copy placed by the Windows fallback — safe to re-link
92
+ }
45
93
  throw new Error(
46
94
  `Refusing to replace ${dst}: it exists and is not a bigpowers-managed symlink. ` +
47
95
  `Back it up or remove it manually, then re-run setup.`
@@ -58,7 +106,7 @@ function linkSkills(skillsDir, targetDir) {
58
106
 
59
107
  assertReplaceable(dst);
60
108
  fs.rmSync(dst, { force: true });
61
- fs.symlinkSync(src, dst);
109
+ createLink(src, dst);
62
110
  }
63
111
  }
64
112
 
@@ -70,13 +118,13 @@ function linkDir(src, dst) {
70
118
  }
71
119
  assertReplaceable(dst);
72
120
  fs.rmSync(dst, { force: true });
73
- fs.symlinkSync(src, dst);
121
+ createLink(src, dst);
74
122
  }
75
123
 
76
124
  function linkFile(src, dst) {
77
125
  assertReplaceable(dst);
78
126
  fs.rmSync(dst, { force: true });
79
- fs.symlinkSync(src, dst);
127
+ createLink(src, dst);
80
128
  }
81
129
 
82
130
  function linkRenderedSkills(renderedDir, targetDir) {
@@ -106,7 +154,7 @@ function linkHook(src, dst) {
106
154
  }
107
155
  assertReplaceable(dst);
108
156
  fs.rmSync(dst, { force: true });
109
- fs.symlinkSync(src, dst);
157
+ createLink(src, dst);
110
158
  try { fs.chmodSync(src, 0o755); } catch {}
111
159
  }
112
160
 
@@ -667,6 +715,13 @@ function removeSymlink(p) {
667
715
  fs.unlinkSync(p);
668
716
  return true;
669
717
  }
718
+ const copies = managedCopies();
719
+ if (stat.isFile() && copies.has(p)) {
720
+ fs.unlinkSync(p);
721
+ copies.delete(p);
722
+ saveCopies(copies);
723
+ return true;
724
+ }
670
725
  } catch {}
671
726
  return false;
672
727
  }
@@ -5,7 +5,9 @@ import os
5
5
  import sys
6
6
  import json
7
7
  import glob
8
+ import shutil
8
9
  import subprocess
10
+ import tempfile
9
11
 
10
12
  # Ensure scripts/lib is on the path so sibling modules (link_utils, and the
11
13
  # simple_yaml fallback below) resolve regardless of cwd.
@@ -191,6 +193,20 @@ def render_okf_concept(skill_data, okf_wiki_skills):
191
193
  with open(okf_file, 'w', encoding='utf-8') as f:
192
194
  f.write('\n'.join(lines) + '\n')
193
195
 
196
+ def resolve_bash():
197
+ """Return an explicit path to a usable bash interpreter.
198
+
199
+ On Windows, subprocess(['bash', ...]) goes through the Win32
200
+ CreateProcess search order, which checks C:\\Windows\\System32 before
201
+ PATH. That finds the WSL launcher stub (System32\\bash.exe) instead of
202
+ Git Bash and aborts with 'Windows Subsystem for Linux has no installed
203
+ distributions' on hosts without a WSL distro. shutil.which() honors PATH
204
+ order, so ask it for an explicit path first; fall back to the bare name
205
+ for minimal non-Windows environments where nothing is on PATH.
206
+ """
207
+ return os.environ.get("BIGPOWERS_BASH") or shutil.which("bash") or "bash"
208
+
209
+
194
210
  def dispatch_to_adapter(skill_data, target, repo_root):
195
211
  adapter_path = os.path.join(repo_root, "scripts", "adapters", f"{target}.sh")
196
212
  if not os.path.isfile(adapter_path):
@@ -199,7 +215,7 @@ def dispatch_to_adapter(skill_data, target, repo_root):
199
215
 
200
216
  # Pipe JSON to the adapter
201
217
  proc = subprocess.Popen(
202
- ['bash', adapter_path],
218
+ [resolve_bash(), adapter_path],
203
219
  stdin=subprocess.PIPE,
204
220
  stdout=subprocess.PIPE,
205
221
  stderr=subprocess.PIPE,
@@ -216,6 +232,50 @@ def dispatch_to_adapter(skill_data, target, repo_root):
216
232
  print(f"Error: adapter {target} failed with exit code {proc.returncode}", file=sys.stderr)
217
233
  sys.exit(proc.returncode)
218
234
 
235
+ def _write_ir_stream(records):
236
+ fd, stream_path = tempfile.mkstemp(prefix="bigpowers-ir-", suffix=".bin")
237
+ with os.fdopen(fd, "wb") as stream:
238
+ for data in records:
239
+ pi_body = rewrite_links_for_pi(data['body'], data['name'])
240
+ for field in (data['name'], data['description'], data['model'], data['body'], pi_body):
241
+ stream.write(field.encode("utf-8"))
242
+ stream.write(b"\x00")
243
+ return stream_path
244
+
245
+
246
+ def render_all(records, adapters, repo_root):
247
+ """Render every skill through each adapter with one bash process per adapter.
248
+
249
+ The previous path spawned one bash (plus three jq) per skill×adapter — about
250
+ 1,539 bash and 4,600 jq processes from a single Python process. On constrained
251
+ Windows sessions that exhausts process-creation resources, so the run dies
252
+ with STATUS_DLL_INIT_FAILED (0xC0000142) and then hangs on orphaned children.
253
+ Writing the IR once and letting a small bash driver source the adapter keeps
254
+ the same rendering code while dropping to one process per adapter.
255
+ """
256
+ if not records:
257
+ return
258
+
259
+ driver = os.path.join(repo_root, "scripts", "lib", "adapter-render-all.sh")
260
+ bash = resolve_bash()
261
+ stream_path = _write_ir_stream(records)
262
+ try:
263
+ for adapter in adapters:
264
+ adapter_path = os.path.join(repo_root, "scripts", "adapters", f"{adapter}.sh")
265
+ if not os.path.isfile(adapter_path):
266
+ print(f"Error: adapter not found: {adapter_path}", file=sys.stderr)
267
+ sys.exit(1)
268
+ proc = subprocess.run([bash, driver, adapter_path, stream_path])
269
+ if proc.returncode != 0:
270
+ print(f"Error: adapter {adapter} failed with exit code {proc.returncode}", file=sys.stderr)
271
+ sys.exit(1)
272
+ finally:
273
+ try:
274
+ os.unlink(stream_path)
275
+ except OSError:
276
+ pass
277
+
278
+
219
279
  def main():
220
280
  repo_root = resolve_repo_root()
221
281
 
@@ -226,19 +286,17 @@ def main():
226
286
 
227
287
  okf_wiki_skills = os.environ.get("OKF_WIKI_SKILLS", os.path.join(repo_root, "specs", "skills-wiki", "skills"))
228
288
 
289
+ records = []
229
290
  for skill_md in skills:
230
291
  skill_data = parse_skill(skill_md)
231
292
  if not skill_data['name']:
232
293
  continue
294
+ records.append(skill_data)
233
295
 
234
- for adapter in adapters:
235
- data = skill_data
236
- if adapter == "pi":
237
- data = dict(skill_data)
238
- data["body_pi_skill"] = rewrite_links_for_pi(skill_data['body'], skill_data['name'])
239
- dispatch_to_adapter(data, adapter, repo_root)
296
+ render_all(records, adapters, repo_root)
240
297
 
241
- if okf_mode:
298
+ if okf_mode:
299
+ for skill_data in records:
242
300
  render_okf_concept(skill_data, okf_wiki_skills)
243
301
  return
244
302
 
@@ -3,6 +3,7 @@
3
3
  # regenerate-lockfile.sh — Scan all SKILL.md files and regenerate skills-lock.json
4
4
  set -euo pipefail
5
5
  source "$(dirname "${BASH_SOURCE[0]}")/lib/skill-common.sh"
6
+ resolve_repo_root
6
7
  LOCKFILE="$REPO_ROOT/skills-lock.json"
7
8
  records=()
8
9
  while IFS= read -r skill_dir; do
@@ -12,7 +13,7 @@ while IFS= read -r skill_dir; do
12
13
  description="$_PF_DESCRIPTION"
13
14
  if command -v sha256sum &>/dev/null; then sha256=$(sha256sum "$skill_md" | cut -c1-16)
14
15
  else sha256=$(shasum -a 256 "$skill_md" | cut -c1-16); fi
15
- relpath="${skill_dir#$REPO_ROOT/}SKILL.md"
16
+ relpath="${skill_dir#$REPO_ROOT/}/SKILL.md"
16
17
  records+=("$(jq -n --arg name "$name" --arg desc "$description" --arg sha "$sha256" --arg path "$relpath" \
17
18
  '{($name): {description: $desc, sha256: $sha, path: $path}}')")
18
19
  done < <(iterate_skills)
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // story: e60s01
3
+ // story: #134
3
4
  // Regression selftest for scripts/lib/install-helpers.js (Wave 0 closeout).
4
5
  'use strict';
5
6
 
@@ -12,9 +13,25 @@ const { mkdtempSync, rmSync } = fs;
12
13
  const ROOT = path.resolve(__dirname, '..');
13
14
  const tmpHome = mkdtempSync(path.join(os.tmpdir(), 'bp-install-helpers-'));
14
15
  process.env.HOME = tmpHome;
16
+ process.env.USERPROFILE = tmpHome; // os.homedir() prefers USERPROFILE on Windows
15
17
 
16
18
  const { installGlobal, linkHook } = require('../scripts/lib/install-helpers.js');
17
19
 
20
+ const isWindows = process.platform === 'win32';
21
+
22
+ // Directories become junctions on Windows, which lstat reports as symlinks.
23
+ // Files cannot be symlinked without Developer Mode, so createLink copies them;
24
+ // accept either shape and prove a copy matches the source byte-for-byte.
25
+ function assertLinkedFile(dst, src, msg) {
26
+ const st = fs.lstatSync(dst);
27
+ if (st.isSymbolicLink()) {
28
+ assert.strictEqual(fs.readlinkSync(dst), src, msg);
29
+ return;
30
+ }
31
+ assert.ok(isWindows && st.isFile(), `${msg} (expected a symlink, or a Windows copy)`);
32
+ assert.strictEqual(fs.readFileSync(dst, 'utf8'), fs.readFileSync(src, 'utf8'), msg);
33
+ }
34
+
18
35
  try {
19
36
  const hookSrc = path.join(ROOT, 'skills', 'guard-git', 'scripts', 'block-dangerous-git.sh');
20
37
  assert.ok(fs.existsSync(hookSrc), `Claude guard-git hook source must exist: ${hookSrc}`);
@@ -40,8 +57,7 @@ try {
40
57
  installGlobal({ id: 'claude', name: 'Claude Code' }, ROOT);
41
58
 
42
59
  const linked = path.join(tmpHome, '.claude', 'hooks', 'block-dangerous-git.sh');
43
- assert.ok(fs.lstatSync(linked).isSymbolicLink(), 'block-dangerous-git.sh must be symlinked');
44
- assert.strictEqual(fs.readlinkSync(linked), hookSrc, 'symlink must point at skills/guard-git path');
60
+ assertLinkedFile(linked, hookSrc, 'block-dangerous-git.sh must link to skills/guard-git');
45
61
 
46
62
  const libLinked = path.join(tmpHome, '.claude', 'hooks', 'lib');
47
63
  const libSrc = path.join(ROOT, 'skills', 'guard-git', 'scripts', 'lib');
@@ -53,8 +69,29 @@ try {
53
69
  );
54
70
 
55
71
  const rtkLinked = path.join(tmpHome, '.claude', 'hooks', 'rtk-rewrite.sh');
56
- assert.ok(fs.lstatSync(rtkLinked).isSymbolicLink(), 'rtk-rewrite.sh must be symlinked');
57
- assert.strictEqual(fs.readlinkSync(rtkLinked), rtkSrc, 'rtk-rewrite symlink must point at scripts/hooks source');
72
+ assertLinkedFile(rtkLinked, rtkSrc, 'rtk-rewrite.sh must link to scripts/hooks source');
73
+
74
+ if (isWindows) {
75
+ // Windows EPERM regression: without Developer Mode the hooks above are
76
+ // copies. A reinstall must not trip the "Refusing to replace" guard, and
77
+ // uninstall must remove the copies and clear them from the manifest.
78
+ const manifest = path.join(tmpHome, '.bigpowers', 'managed-copies.json');
79
+ const tracked = () => (fs.existsSync(manifest) ? JSON.parse(fs.readFileSync(manifest, 'utf8')) : []);
80
+ for (const p of [linked, rtkLinked]) {
81
+ if (!fs.lstatSync(p).isSymbolicLink()) {
82
+ assert.ok(tracked().includes(p), `Windows copy must be tracked in the manifest: ${p}`);
83
+ }
84
+ }
85
+ installGlobal({ id: 'claude', name: 'Claude Code' }, ROOT); // idempotent reinstall
86
+ uninstallTool('claude', ROOT);
87
+ assert.ok(!fs.existsSync(linked), 'uninstall must remove the block-dangerous-git hook');
88
+ assert.ok(!fs.existsSync(rtkLinked), 'uninstall must remove the rtk-rewrite hook');
89
+ assert.deepStrictEqual(
90
+ tracked().filter((p) => p === linked || p === rtkLinked),
91
+ [],
92
+ 'uninstall must clear removed copies from the manifest'
93
+ );
94
+ }
58
95
 
59
96
  installGlobal({ id: 'pi', name: 'pi' }, ROOT);
60
97
  const piSkill = path.join(tmpHome, '.pi', 'agent', 'skills');
@@ -173,9 +210,10 @@ try {
173
210
 
174
211
  installLocal({ id: 'codex', name: 'Codex' }, ROOT);
175
212
  const localCodexAgents = path.join(tmpCwd, '.codex', 'AGENTS.md');
176
- assert.ok(fs.lstatSync(localCodexAgents).isSymbolicLink(), 'local codex install must symlink AGENTS.md');
213
+ assertLinkedFile(localCodexAgents, path.join(ROOT, 'templates', 'codex', 'AGENTS.md'),
214
+ 'local codex install must link AGENTS.md');
177
215
  uninstallTool('codex', ROOT);
178
- assert.ok(!fs.existsSync(localCodexAgents), 'uninstallTool(codex) must remove LOCAL AGENTS.md symlink');
216
+ assert.ok(!fs.existsSync(localCodexAgents), 'uninstallTool(codex) must remove LOCAL AGENTS.md');
179
217
  } finally {
180
218
  process.chdir(savedCwd);
181
219
  rmSync(tmpCwd, { recursive: true, force: true });
@@ -353,8 +391,12 @@ try {
353
391
  // package root (e.g. <ROOT>-evil) as managed — needs a path-separator boundary.
354
392
  {
355
393
  const dst = path.join(tmpHome, 'prefix-boundary-hook.sh');
356
- fs.symlinkSync(path.join(`${ROOT}-evil`, 'x.sh'), dst); // dangling foreign link
357
- assert.throws(
394
+ if (isWindows) {
395
+ fs.writeFileSync(dst, 'foreign\n'); // Windows cannot make this link unprivileged
396
+ } else {
397
+ fs.symlinkSync(path.join(`${ROOT}-evil`, 'x.sh'), dst); // dangling foreign link
398
+ }
399
+ assert.throws(
358
400
  () => linkHook(rtkSrc, dst),
359
401
  /Refusing to replace/,
360
402
  'managed-symlink check must require a path-separator boundary (prefix sibling is NOT managed)'