bigpowers 2.88.9 → 2.88.10

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.10",
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.10",
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,11 @@
1
+ ## [2.88.10](https://github.com/danielvm-git/bigpowers/compare/v2.88.9...v2.88.10) (2026-10-25)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **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)
7
+ * **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))
8
+
1
9
  ## [2.88.9](https://github.com/danielvm-git/bigpowers/compare/v2.88.8...v2.88.9) (2026-09-21)
2
10
 
3
11
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bigpowers",
3
- "version": "2.88.9",
3
+ "version": "2.88.10",
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": {
@@ -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,6 +5,7 @@ import os
5
5
  import sys
6
6
  import json
7
7
  import glob
8
+ import shutil
8
9
  import subprocess
9
10
 
10
11
  # Ensure scripts/lib is on the path so sibling modules (link_utils, and the
@@ -191,6 +192,20 @@ def render_okf_concept(skill_data, okf_wiki_skills):
191
192
  with open(okf_file, 'w', encoding='utf-8') as f:
192
193
  f.write('\n'.join(lines) + '\n')
193
194
 
195
+ def resolve_bash():
196
+ """Return an explicit path to a usable bash interpreter.
197
+
198
+ On Windows, subprocess(['bash', ...]) goes through the Win32
199
+ CreateProcess search order, which checks C:\\Windows\\System32 before
200
+ PATH. That finds the WSL launcher stub (System32\\bash.exe) instead of
201
+ Git Bash and aborts with 'Windows Subsystem for Linux has no installed
202
+ distributions' on hosts without a WSL distro. shutil.which() honors PATH
203
+ order, so ask it for an explicit path first; fall back to the bare name
204
+ for minimal non-Windows environments where nothing is on PATH.
205
+ """
206
+ return os.environ.get("BIGPOWERS_BASH") or shutil.which("bash") or "bash"
207
+
208
+
194
209
  def dispatch_to_adapter(skill_data, target, repo_root):
195
210
  adapter_path = os.path.join(repo_root, "scripts", "adapters", f"{target}.sh")
196
211
  if not os.path.isfile(adapter_path):
@@ -199,7 +214,7 @@ def dispatch_to_adapter(skill_data, target, repo_root):
199
214
 
200
215
  # Pipe JSON to the adapter
201
216
  proc = subprocess.Popen(
202
- ['bash', adapter_path],
217
+ [resolve_bash(), adapter_path],
203
218
  stdin=subprocess.PIPE,
204
219
  stdout=subprocess.PIPE,
205
220
  stderr=subprocess.PIPE,
@@ -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)'
@@ -6,7 +6,9 @@ Run: python3 -m pytest tests/test_srp_engine.py -v
6
6
  """
7
7
  import os
8
8
  import sys
9
+ import tempfile
9
10
  import unittest
11
+ from unittest import mock
10
12
 
11
13
  # Make scripts/lib importable without installation.
12
14
  _REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -144,5 +146,66 @@ class TestRewriteLinksForPi(unittest.TestCase):
144
146
  self.assertNotIn("](REFERENCE.md)", result) # real link rewritten
145
147
 
146
148
 
149
+ class TestResolveBash(unittest.TestCase):
150
+ """Regression: dispatch must not make subprocess resolve the bare name
151
+ 'bash'. On Windows the Win32 CreateProcess search order finds the WSL
152
+ stub in System32 before Git Bash on PATH, so setup aborted with
153
+ 'Windows Subsystem for Linux has no installed distributions'."""
154
+
155
+ def test_env_override_wins(self):
156
+ with mock.patch.dict(os.environ, {"BIGPOWERS_BASH": "/custom/bash"}):
157
+ self.assertEqual(_srp.resolve_bash(), "/custom/bash")
158
+
159
+ def test_uses_path_resolved_bash(self):
160
+ env = {k: v for k, v in os.environ.items() if k != "BIGPOWERS_BASH"}
161
+ with mock.patch.dict(os.environ, env, clear=True):
162
+ with mock.patch.object(_srp.shutil, "which", return_value="/usr/bin/bash"):
163
+ self.assertEqual(_srp.resolve_bash(), "/usr/bin/bash")
164
+
165
+ def test_falls_back_to_bare_bash(self):
166
+ env = {k: v for k, v in os.environ.items() if k != "BIGPOWERS_BASH"}
167
+ with mock.patch.dict(os.environ, env, clear=True):
168
+ with mock.patch.object(_srp.shutil, "which", return_value=None):
169
+ self.assertEqual(_srp.resolve_bash(), "bash")
170
+
171
+
172
+ class TestDispatchUsesResolvedBash(unittest.TestCase):
173
+ """The adapter subprocess must receive the PATH-resolved bash path, not
174
+ the bare name that Windows resolves to the WSL stub."""
175
+
176
+ def setUp(self):
177
+ self._tmp = tempfile.TemporaryDirectory()
178
+ self.repo_root = self._tmp.name
179
+ adapter_dir = os.path.join(self.repo_root, "scripts", "adapters")
180
+ os.makedirs(adapter_dir)
181
+ self.adapter = os.path.join(adapter_dir, "fake.sh")
182
+ with open(self.adapter, "w", encoding="utf-8") as f:
183
+ f.write("#!/usr/bin/env bash\n")
184
+
185
+ def tearDown(self):
186
+ self._tmp.cleanup()
187
+
188
+ def test_dispatch_passes_resolved_bash_to_popen(self):
189
+ captured = {}
190
+
191
+ class _FakeProc:
192
+ returncode = 0
193
+
194
+ def communicate(self, input=None):
195
+ return ("", "")
196
+
197
+ def _fake_popen(cmd, **kwargs):
198
+ captured["cmd"] = cmd
199
+ return _FakeProc()
200
+
201
+ sentinel = os.path.join(self.repo_root, "git-bash")
202
+ with mock.patch.object(_srp, "resolve_bash", return_value=sentinel):
203
+ with mock.patch.object(_srp.subprocess, "Popen", side_effect=_fake_popen):
204
+ _srp.dispatch_to_adapter({"name": "x"}, "fake", self.repo_root)
205
+
206
+ self.assertEqual(captured["cmd"][0], sentinel)
207
+ self.assertEqual(captured["cmd"][1], self.adapter)
208
+
209
+
147
210
  if __name__ == "__main__":
148
211
  unittest.main()
@@ -1,23 +0,0 @@
1
- [
2
- {
3
- "name": "P0 Risk",
4
- "matchedStatuses": [
5
- "failed"
6
- ],
7
- "messageRegex": ".*risk.*P0.*"
8
- },
9
- {
10
- "name": "Security Review",
11
- "matchedStatuses": [
12
- "failed"
13
- ],
14
- "messageRegex": ".*security.*(?:medium|high).*"
15
- },
16
- {
17
- "name": "Open Bugs",
18
- "matchedStatuses": [
19
- "failed"
20
- ],
21
- "messageRegex": ".*Bug.*"
22
- }
23
- ]
@@ -1,6 +0,0 @@
1
- {
2
- "name": "bigpowers",
3
- "type": "bigpowers",
4
- "buildName": "2.76.2",
5
- "buildOrder": 328
6
- }