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.
- package/.gemini/extensions/bigpowers/gemini-extension.json +1 -1
- package/.pi/package.json +1 -1
- package/CHANGELOG.md +22 -0
- package/extensions/omp-hooks.ts +64 -9
- package/package.json +1 -1
- package/scripts/lib/__pycache__/link_utils.cpython-314.pyc +0 -0
- package/scripts/lib/__pycache__/srp_engine_links.cpython-314.pyc +0 -0
- package/scripts/lib/adapter-render-all.sh +74 -0
- package/scripts/lib/install-helpers.js +59 -4
- package/scripts/lib/srp-engine.py +66 -8
- package/scripts/regenerate-lockfile.sh +2 -1
- package/scripts/test-install-helpers.js +50 -8
- package/skills-lock.json +81 -81
- package/tests/test_srp_engine.py +150 -0
- package/allure-results/categories.json +0 -23
- package/allure-results/executor.json +0 -6
- package/allure-results/junit-results.xml +0 -2743
- package/scripts/lib/__pycache__/link_utils.cpython-312.pyc +0 -0
- package/scripts/lib/__pycache__/srp_engine_links.cpython-312.pyc +0 -0
package/.pi/package.json
CHANGED
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
|
|
package/extensions/omp-hooks.ts
CHANGED
|
@@ -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)(
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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 '${
|
|
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
|
Binary file
|
|
Binary file
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
[
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
357
|
-
|
|
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)'
|