wkappbot-code 0.3.23 → 0.3.26
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/bin/wkappbot-code.js +6 -6
- package/lib/doctor-path.js +293 -0
- package/lib/hook-wire.js +22 -1
- package/package.json +1 -1
package/bin/wkappbot-code.js
CHANGED
|
@@ -40,7 +40,7 @@ import {
|
|
|
40
40
|
import { homedir, tmpdir } from "node:os";
|
|
41
41
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
42
42
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
43
|
-
import { quietCheck, runUpdate, updateRow } from "../lib/update.js"; import { rmTreeHard } from "../lib/rmtree.js"; import { plainFileUrl } from "../lib/fileurl.js"; import { removeStaleWkskill } from "../lib/stale-wkskill.js";
|
|
43
|
+
import { quietCheck, runUpdate, updateRow } from "../lib/update.js"; import { rmTreeHard } from "../lib/rmtree.js"; import { plainFileUrl } from "../lib/fileurl.js"; import { removeStaleWkskill } from "../lib/stale-wkskill.js"; import { checkPath, installBothLinks, removeCreatedLinks } from "../lib/doctor-path.js";
|
|
44
44
|
import { applySets, askEach, buildConfig, configRow, detect, lineDiff, printDetected } from "../lib/config.js";
|
|
45
45
|
import { aiCliRows, installAiClis, removeAiClis } from "../lib/ai-clis.js";
|
|
46
46
|
import { hooksRow, wireClaudeHooks } from "../lib/hook-wire.js";
|
|
@@ -455,9 +455,8 @@ function install(o) {
|
|
|
455
455
|
: ((prev.aiClis && (prev.aiClis.installed || []).length) ? prev.aiClis : { skipped: true }); // a skip still owns what an earlier install added
|
|
456
456
|
const dest = binDest(o);
|
|
457
457
|
st.bin = o.bin ? installBin(o) : { skipped: true, dest };
|
|
458
|
-
|
|
459
|
-
// road
|
|
460
|
-
// refused, but the install exits 1 and the banner says so until a rerun fetches the core.
|
|
458
|
+
if (o.bin) st.bin.links = installBothLinks({ dest, home: homedir(), env: process.env, log }).created; // both bare names, recorded for uninstall
|
|
459
|
+
// 0.3.13: NO core is a failed part on every road (exit 1 until a rerun fetches it); the hooks and lite harness still install.
|
|
461
460
|
if (o.bin && !st.bin.ok) { bad++; log("bin: FAILED -- no wkappbot core installed: " + st.bin.note); }
|
|
462
461
|
st.harness = o.harness ? installHarness(o) : { skipped: true };
|
|
463
462
|
if (o.harness && !st.harness.ok) { bad++; log("harness: FAILED -- " + st.harness.note); }
|
|
@@ -578,6 +577,7 @@ function uninstall(o) {
|
|
|
578
577
|
if (body.includes(core)) { rmSync(cmdShim, { force: true }); linkRemoved = cmdShim; log("shim: removed " + cmdShim + " (it called the core this install placed)"); }
|
|
579
578
|
else log("shim: kept " + cmdShim + " (it no longer calls " + core + ")");
|
|
580
579
|
}
|
|
580
|
+
removeCreatedLinks(st.bin?.links || []).forEach((p) => log("links: removed " + p)); // only the links install recorded
|
|
581
581
|
// Undo only our npm license marker; do not alter a marker owned by another installer.
|
|
582
582
|
if (IS_WIN && st.bin?.ok) {
|
|
583
583
|
const marker = join(dirname(core), ".wkappbot-code-install");
|
|
@@ -657,8 +657,8 @@ function doctor(o) {
|
|
|
657
657
|
core = r.status === 0 ? (r.stdout || "").trim().split("\n")[0] : "";
|
|
658
658
|
add("bin", !!core, core ? dest + " " + core + (st.bin && st.bin.link ? " (PATH link " + st.bin.link + ")" : "") : dest + " does not run", !!(st.bin && !st.bin.skipped));
|
|
659
659
|
}
|
|
660
|
-
|
|
661
|
-
//
|
|
660
|
+
const pr = checkPath({ home: homedir(), env: process.env, platform: process.platform }); add("path", pr.ok, pr.text, true);
|
|
661
|
+
// kih-hook: the harness the plugin calls and its verdict road; no pwsh and no lite = NO verdict = FAIL whatever the install mode.
|
|
662
662
|
const k = (entry && Array.isArray(entry) && entry[1] && entry[1].kihDir) || kihDir(o);
|
|
663
663
|
const hk = hasHook(k);
|
|
664
664
|
const ps = pwshPath();
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// doctor-path.js -- the REQUIRED doctor row "path": are `wkappbot` AND `wkappbot-core` found by their BARE names
|
|
2
|
+
// in a FRESH shell? Proto for wkappbot-code/lib/doctor-path.js. ES module, node built-ins only.
|
|
3
|
+
// Linux/macOS: a login bash (bash -lc) with HOME as given and the OS persistent PATH.
|
|
4
|
+
// Windows: a new cmd.exe whose PATH is machine PATH + user PATH read from the registry.
|
|
5
|
+
// PASS only when each bare-name run prints "wkappbot v". Any other outcome is FAIL for that name.
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { dirname, join, resolve } from "node:path";
|
|
9
|
+
|
|
10
|
+
const OK_MARK = "wkappbot v";
|
|
11
|
+
const BINS = ["wkappbot", "wkappbot-core"];
|
|
12
|
+
|
|
13
|
+
// One REG_SZ/REG_EXPAND_SZ Path value from the registry; "" when absent or reg is unavailable.
|
|
14
|
+
function winRegPath(key) {
|
|
15
|
+
try {
|
|
16
|
+
const out = execFileSync("reg", ["query", key, "/v", "Path"], {
|
|
17
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 15000,
|
|
18
|
+
});
|
|
19
|
+
const m = out.match(/^\s*Path\s+REG_(?:EXPAND_)?SZ\s+(.*?)\s*$/m);
|
|
20
|
+
return m ? m[1] : "";
|
|
21
|
+
} catch {
|
|
22
|
+
return "";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// %VAR% expansion for registry values such as %USERPROFILE%\bin; unknown names are kept as written.
|
|
27
|
+
function expandVars(s, env) {
|
|
28
|
+
return s.replace(/%([^%]+)%/g, (all, name) => (env[name] !== undefined ? env[name] : all));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The real POSIX road: a login bash, as a user's new terminal starts. /etc/profile may reset PATH.
|
|
32
|
+
export function loginShell(h, env, bin) {
|
|
33
|
+
try {
|
|
34
|
+
return execFileSync("bash", ["-lc", `command -v ${bin} && ${bin} --version`], {
|
|
35
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 60000,
|
|
36
|
+
env: { HOME: h, PATH: env.PATH || "/usr/local/bin:/usr/bin:/bin" },
|
|
37
|
+
});
|
|
38
|
+
} catch (e) {
|
|
39
|
+
return typeof e.stdout === "string" ? e.stdout : "";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// checkPath({home, env, platform, readRegPath, runShell}) -> {ok, text}
|
|
44
|
+
// home HOME (or USERPROFILE on Windows) the fresh shell starts in
|
|
45
|
+
// env carries the OS persistent PATH (PATH key; default process.env)
|
|
46
|
+
// platform process.platform by default; "win32" selects the registry road
|
|
47
|
+
// readRegPath injectable registry reader (tests pass a stub)
|
|
48
|
+
// runShell injectable POSIX fresh-shell runner (default loginShell; the selftest injects a hermetic one)
|
|
49
|
+
export function checkPath({ home, env = process.env, platform = process.platform, readRegPath = winRegPath, runShell = loginShell } = {}) {
|
|
50
|
+
const h = home || env.HOME || env.USERPROFILE || "";
|
|
51
|
+
let winPath = null;
|
|
52
|
+
const fails = [];
|
|
53
|
+
for (const bin of BINS) {
|
|
54
|
+
let out = "";
|
|
55
|
+
if (platform === "win32") {
|
|
56
|
+
if (winPath === null) {
|
|
57
|
+
const machine = expandVars(readRegPath("HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment"), env);
|
|
58
|
+
const user = expandVars(readRegPath("HKCU\\Environment"), env);
|
|
59
|
+
winPath = [machine, user].filter(Boolean).join(";");
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
out = execFileSync("cmd.exe", ["/d", "/s", "/c", `${bin} --version`], {
|
|
63
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 60000,
|
|
64
|
+
env: { SystemRoot: env.SystemRoot || "C:\\Windows", USERPROFILE: h, HOME: h, PATH: winPath },
|
|
65
|
+
});
|
|
66
|
+
} catch (e) {
|
|
67
|
+
out = typeof e.stdout === "string" ? e.stdout : "";
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
out = runShell(h, env, bin);
|
|
71
|
+
}
|
|
72
|
+
if (!out.includes(OK_MARK)) fails.push(bin);
|
|
73
|
+
}
|
|
74
|
+
if (fails.length) return { ok: false, text: fails.map((b) => `path: FAIL ${b} not found by bare name in a fresh shell`).join("; ") };
|
|
75
|
+
return { ok: true, text: "path: PASS wkappbot and wkappbot-core found by bare name in a fresh shell" };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---- installer helper: create the missing links on the persistent PATH ----------------------------------------
|
|
79
|
+
|
|
80
|
+
const WIN_MACHINE_KEY = "HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment";
|
|
81
|
+
const WIN_USER_KEY = "HKCU\\Environment";
|
|
82
|
+
|
|
83
|
+
// The PERSISTENT PATH entries. win32: machine Path then user Path from the registry (%VAR% expanded), the same
|
|
84
|
+
// values a new cmd.exe is built from; the process PATH is never consulted there. POSIX: the PATH given in env.
|
|
85
|
+
function persistentEntries(env, platform, readRegPath) {
|
|
86
|
+
if (platform !== "win32") return (env.PATH || "").split(":");
|
|
87
|
+
const machine = expandVars(readRegPath(WIN_MACHINE_KEY), env);
|
|
88
|
+
const user = expandVars(readRegPath(WIN_USER_KEY), env);
|
|
89
|
+
return [machine, user].join(";").split(";");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const onPathList = (env, platform, dir, readRegPath = winRegPath) => {
|
|
93
|
+
const norm = (p) => p.replace(/[\\/]+$/, "").toLowerCase();
|
|
94
|
+
return persistentEntries(env, platform, readRegPath).some((p) => p && norm(p) === norm(dir));
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
function occupied(p) {
|
|
98
|
+
try { lstatSync(p); return true; } catch { return false; }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function defaultNpmPrefix(platform) {
|
|
102
|
+
try {
|
|
103
|
+
return execFileSync(platform === "win32" ? "npm.cmd" : "npm", ["prefix", "-g"], {
|
|
104
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 20000, shell: platform === "win32",
|
|
105
|
+
}).trim();
|
|
106
|
+
} catch {
|
|
107
|
+
return "";
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The folder the links go in: npm global bin first, then ~/.local/bin. Prefers a folder already on PATH.
|
|
112
|
+
// needsPath is true when neither is on the persistent PATH (then ensureLinks adds the chosen folder).
|
|
113
|
+
export function pickBinDir({ home, env = process.env, platform = process.platform, npmPrefix = defaultNpmPrefix(platform), readRegPath = winRegPath } = {}) {
|
|
114
|
+
const npmBin = npmPrefix ? (platform === "win32" ? npmPrefix : join(npmPrefix, "bin")) : "";
|
|
115
|
+
const local = join(home, ".local", "bin");
|
|
116
|
+
const candidates = [npmBin, local].filter(Boolean);
|
|
117
|
+
const hit = candidates.find((d) => onPathList(env, platform, d, readRegPath));
|
|
118
|
+
if (hit) return { dir: hit, needsPath: false };
|
|
119
|
+
return { dir: candidates[0] || local, needsPath: true };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const exportLine = (dir) => `export PATH="${dir}:$PATH"`;
|
|
123
|
+
const trimSlash = (p) => p.replace(/[\\/]+$/, "").toLowerCase();
|
|
124
|
+
|
|
125
|
+
// PowerShell does the user-Path write: [Environment]::SetEnvironmentVariable broadcasts the settings change.
|
|
126
|
+
// The folder arrives in an env var, never in the script text. Prints ADDED, or PRESENT when it is already there.
|
|
127
|
+
const WIN_ADD_SCRIPT = [
|
|
128
|
+
"$ErrorActionPreference = 'Stop'",
|
|
129
|
+
"$d = $env:WK_PATH_DIR",
|
|
130
|
+
"$cur = [Environment]::GetEnvironmentVariable('Path', 'User')",
|
|
131
|
+
"if ($null -eq $cur) { $cur = '' }",
|
|
132
|
+
"$want = $d.TrimEnd([char[]]@('\\', '/')).ToLowerInvariant()",
|
|
133
|
+
"$hit = @($cur.Split(';') | Where-Object { $_ -and ($_.TrimEnd([char[]]@('\\', '/')).ToLowerInvariant() -eq $want) })",
|
|
134
|
+
"if ($hit.Count -gt 0) { Write-Output 'PRESENT'; exit 0 }",
|
|
135
|
+
"$new = if ($cur.Trim() -eq '') { $d } else { $cur.TrimEnd(';') + ';' + $d }",
|
|
136
|
+
"[Environment]::SetEnvironmentVariable('Path', $new, 'User')",
|
|
137
|
+
"Write-Output 'ADDED'",
|
|
138
|
+
].join("\n");
|
|
139
|
+
|
|
140
|
+
// Removes exactly the folder's entries from the user Path (untouched entries keep their order and text).
|
|
141
|
+
const WIN_REMOVE_SCRIPT = [
|
|
142
|
+
"$ErrorActionPreference = 'Stop'",
|
|
143
|
+
"$d = $env:WK_PATH_DIR",
|
|
144
|
+
"$cur = [Environment]::GetEnvironmentVariable('Path', 'User')",
|
|
145
|
+
"if ($null -eq $cur) { Write-Output 'ABSENT'; exit 0 }",
|
|
146
|
+
"$want = $d.TrimEnd([char[]]@('\\', '/')).ToLowerInvariant()",
|
|
147
|
+
"$keep = @($cur.Split(';') | Where-Object { -not ($_ -and ($_.TrimEnd([char[]]@('\\', '/')).ToLowerInvariant() -eq $want)) })",
|
|
148
|
+
"$new = $keep -join ';'",
|
|
149
|
+
"if ($new -eq $cur) { Write-Output 'ABSENT'; exit 0 }",
|
|
150
|
+
"[Environment]::SetEnvironmentVariable('Path', $new, 'User')",
|
|
151
|
+
"Write-Output 'REMOVED'",
|
|
152
|
+
].join("\n");
|
|
153
|
+
|
|
154
|
+
function psRun(script, dir) {
|
|
155
|
+
return execFileSync("powershell", ["-NoProfile", "-NonInteractive", "-Command", script], {
|
|
156
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 30000,
|
|
157
|
+
env: { ...process.env, WK_PATH_DIR: dir },
|
|
158
|
+
}).trim();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Windows: append the folder to the USER Path. PowerShell first; the reg add road only when PowerShell fails.
|
|
162
|
+
// Returns true only when this call wrote the entry (so uninstall removes only what install added).
|
|
163
|
+
function addUserPathWin(dir, readRegPath) {
|
|
164
|
+
try {
|
|
165
|
+
return psRun(WIN_ADD_SCRIPT, dir) === "ADDED";
|
|
166
|
+
} catch {
|
|
167
|
+
const cur = readRegPath(WIN_USER_KEY);
|
|
168
|
+
if (cur.split(";").some((p) => p && trimSlash(p) === trimSlash(dir))) return false;
|
|
169
|
+
const next = cur ? cur + ";" + dir : dir;
|
|
170
|
+
execFileSync("reg", ["add", WIN_USER_KEY, "/v", "Path", "/t", "REG_EXPAND_SZ", "/d", next, "/f"], { stdio: "ignore", timeout: 15000 });
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Default PATH addition: POSIX appends an export line to ~/.profile; Windows appends to the user Path value.
|
|
176
|
+
// Returns true only when this call wrote the PATH entry.
|
|
177
|
+
function defaultAddToPath({ dir, home, platform, readRegPath = winRegPath }) {
|
|
178
|
+
try {
|
|
179
|
+
if (platform === "win32") return addUserPathWin(dir, readRegPath);
|
|
180
|
+
const profile = join(home, ".profile");
|
|
181
|
+
const text = existsSync(profile) ? readFileSync(profile, "utf8") : "";
|
|
182
|
+
if (text.includes(dir)) return false;
|
|
183
|
+
appendFileSync(profile, `\n${exportLine(dir)}\n`);
|
|
184
|
+
return true;
|
|
185
|
+
} catch {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Uninstall side of defaultAddToPath: removes exactly the entry install wrote. Returns true when something was removed.
|
|
191
|
+
function defaultRemovePath({ dir, home, platform }) {
|
|
192
|
+
try {
|
|
193
|
+
if (platform === "win32") return psRun(WIN_REMOVE_SCRIPT, dir) === "REMOVED";
|
|
194
|
+
const profile = join(home, ".profile");
|
|
195
|
+
if (!existsSync(profile)) return false;
|
|
196
|
+
const lines = readFileSync(profile, "utf8").split("\n");
|
|
197
|
+
const keep = lines.filter((l) => l !== exportLine(dir));
|
|
198
|
+
if (keep.length === lines.length) return false;
|
|
199
|
+
writeFileSync(profile, keep.join("\n"));
|
|
200
|
+
return true;
|
|
201
|
+
} catch {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ensureLinks({home, platform, binDir, env, targets, npmPrefix, addToPath}) -> {created, dir, pathAdded}
|
|
207
|
+
// targets REQUIRED {wkappbot: <core path>, "wkappbot-core": <core path>}; the installer passes the real files.
|
|
208
|
+
// Creates a link (POSIX symlink) or a .cmd shim (Windows) only where NOTHING exists yet: a user's own file is
|
|
209
|
+
// never overwritten and never listed in created. created is exactly what removeCreatedLinks may delete.
|
|
210
|
+
export function ensureLinks({ home, platform = process.platform, binDir, env = process.env, targets, npmPrefix, addToPath = defaultAddToPath, readRegPath = winRegPath } = {}) {
|
|
211
|
+
if (!targets || !targets.wkappbot || !targets["wkappbot-core"]) throw new Error("ensureLinks: targets {wkappbot, wkappbot-core} required");
|
|
212
|
+
let dir = binDir, needsPath = false;
|
|
213
|
+
if (dir) needsPath = !onPathList(env, platform, dir, readRegPath);
|
|
214
|
+
else ({ dir, needsPath } = pickBinDir({ home, env, platform, npmPrefix, readRegPath }));
|
|
215
|
+
mkdirSync(dir, { recursive: true });
|
|
216
|
+
const created = [];
|
|
217
|
+
for (const bin of BINS) {
|
|
218
|
+
const dest = join(dir, platform === "win32" ? bin + ".cmd" : bin);
|
|
219
|
+
if (occupied(dest)) continue;
|
|
220
|
+
if (platform === "win32") writeFileSync(dest, `@"${targets[bin]}" %*\r\n`);
|
|
221
|
+
else symlinkSync(targets[bin], dest);
|
|
222
|
+
created.push(dest);
|
|
223
|
+
}
|
|
224
|
+
// Windows runs the PATH logic even when both shims already existed: the doctor row reads the persistent PATH,
|
|
225
|
+
// so a missing entry is a FAIL whether or not this run wrote a shim. POSIX keeps its old rule (a link was written).
|
|
226
|
+
const pathWanted = needsPath && (created.length > 0 || platform === "win32");
|
|
227
|
+
// POSIX: note whether ~/.profile existed BEFORE addToPath, so uninstall deletes only a profile install created.
|
|
228
|
+
const profilePath = platform === "win32" ? "" : join(home, ".profile");
|
|
229
|
+
const profileExisted = !!profilePath && existsSync(profilePath);
|
|
230
|
+
const pathAdded = pathWanted ? !!addToPath({ dir, home, platform, readRegPath }) : false;
|
|
231
|
+
const profileCreated = pathAdded && !profileExisted && !!profilePath && existsSync(profilePath) ? profilePath : "";
|
|
232
|
+
return { created, dir, pathAdded, profileCreated };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// removeCreatedLinks(created, opts) -> the entries actually removed. Deletes only the listed entries, nothing else.
|
|
236
|
+
// A "PATH:<folder>" entry is the PATH entry install added: it is removed by removePath (exactly that folder),
|
|
237
|
+
// never by unlink. A "PROFILE:<path>" entry is the ~/.profile install created: it is deleted only when its text is
|
|
238
|
+
// blank again (trim() empty), so a profile the user wrote into is never removed. opts: platform, home, and an
|
|
239
|
+
// injectable removePath({dir, home, platform}) -> boolean.
|
|
240
|
+
export function removeCreatedLinks(created = [], { platform = process.platform, home = process.env.HOME || process.env.USERPROFILE || "", removePath = defaultRemovePath } = {}) {
|
|
241
|
+
const removed = [];
|
|
242
|
+
for (const p of created) {
|
|
243
|
+
if (p.startsWith("PATH:")) {
|
|
244
|
+
try { if (removePath({ dir: p.slice(5), home, platform })) removed.push(p); } catch { /* not removed */ }
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (p.startsWith("PROFILE:")) {
|
|
248
|
+
try {
|
|
249
|
+
const f = p.slice(8);
|
|
250
|
+
if (existsSync(f) && readFileSync(f, "utf8").trim() === "") { unlinkSync(f); removed.push(p); }
|
|
251
|
+
} catch { /* not removed */ }
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
try { unlinkSync(p); removed.push(p); } catch { /* already gone */ }
|
|
255
|
+
}
|
|
256
|
+
return removed;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// installBothLinks({dest, home, platform, binDir, env, log}) -> {created, pathAdded, kept, dir}
|
|
260
|
+
// ALWAYS makes both bare names, wkappbot and wkappbot-core, in binDir (env WK_LINK_DIR, else ~/.local/bin).
|
|
261
|
+
// dest is the core file; Windows wkappbot-core.cmd points at wkappbot-core.exe beside it. A user's own file
|
|
262
|
+
// stays and is listed in kept (and logged), never in created. A path already holding THIS install's link (a
|
|
263
|
+
// re-install) is ours: it is recorded in created again, so uninstall removes it and the folder is left empty.
|
|
264
|
+
export function installBothLinks({ dest, home, platform = process.platform, binDir, env = process.env, log = () => {}, addToPath = defaultAddToPath, readRegPath = winRegPath } = {}) {
|
|
265
|
+
const dir = binDir || env.WK_LINK_DIR || join(home, ".local", "bin");
|
|
266
|
+
const coreExe = platform === "win32" ? join(dirname(dest), "wkappbot-core.exe") : dest;
|
|
267
|
+
const targets = { wkappbot: dest, "wkappbot-core": coreExe };
|
|
268
|
+
const pathOf = (bin) => join(dir, platform === "win32" ? bin + ".cmd" : bin);
|
|
269
|
+
const ours = BINS.filter((bin) => occupied(pathOf(bin)) && isOurLink(pathOf(bin), targets[bin], platform));
|
|
270
|
+
const kept = BINS.filter((bin) => occupied(pathOf(bin)) && !ours.includes(bin));
|
|
271
|
+
for (const bin of kept) log("links: kept " + join(dir, bin) + " (not ours)");
|
|
272
|
+
const r = ensureLinks({ home, platform, binDir: dir, env, targets, addToPath, readRegPath });
|
|
273
|
+
const created = BINS.map(pathOf).filter((p) => r.created.includes(p) || ours.some((bin) => pathOf(bin) === p));
|
|
274
|
+
if (r.pathAdded) created.push("PATH:" + dir); // recorded only when this install wrote the PATH entry
|
|
275
|
+
if (r.profileCreated) created.push("PROFILE:" + r.profileCreated); // right after PATH: the profile install made
|
|
276
|
+
return { created, pathAdded: r.pathAdded, kept, dir };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// An occupied link path is OURS when a POSIX symlink resolves to the target this install would create, or a
|
|
280
|
+
// Windows .cmd shim names that target. Anything else (a user's own file or symlink) is not ours.
|
|
281
|
+
function isOurLink(p, target, platform) {
|
|
282
|
+
try {
|
|
283
|
+
if (platform === "win32") return readFileSync(p, "utf8").includes(`"${target}"`);
|
|
284
|
+
return lstatSync(p).isSymbolicLink() && resolve(dirname(p), readlinkSync(p)) === resolve(target);
|
|
285
|
+
} catch {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// removeRecorded(created): the uninstall side of installBothLinks; removes only what that install recorded.
|
|
291
|
+
export function removeRecorded(created) {
|
|
292
|
+
return removeCreatedLinks(Array.isArray(created) ? created : []);
|
|
293
|
+
}
|
package/lib/hook-wire.js
CHANGED
|
@@ -61,11 +61,19 @@ function route(cmd, lite) {
|
|
|
61
61
|
return null;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
// A dev seat: a harness checkout beside this lite (its .claude/hooks/wkharness-run.sh exists) that is not a web user install
|
|
65
|
+
// (no .wk-user-install marker in the harness folder). Python --wire uses the same two tests (tools/wkharness-lite.sh).
|
|
66
|
+
export function isDevSeat(lite) {
|
|
67
|
+
const kih = dirname(dirname(lite));
|
|
68
|
+
return existsSync(join(kih, ".claude", "hooks", "wkharness-run.sh")) && !existsSync(join(kih, ".wk-user-install"));
|
|
69
|
+
}
|
|
70
|
+
|
|
64
71
|
// The node port of --wire, plus the create step of the web installer. Throws on a settings file that is not JSON.
|
|
65
72
|
export function wireSettings(path, lite) {
|
|
66
73
|
const r = readSettings(path);
|
|
67
74
|
if (!r.ok) throw new Error(path + " is not plain JSON (" + r.err + ")");
|
|
68
75
|
const d = r.json || {};
|
|
76
|
+
const dev = isDevSeat(lite) && harnessHooks(d).some((h) => /wkharness-run\.sh/.test(h.command));
|
|
69
77
|
const out = {};
|
|
70
78
|
for (const [ev, groups] of Object.entries(d.hooks && typeof d.hooks === "object" ? d.hooks : {})) {
|
|
71
79
|
const keep = [];
|
|
@@ -75,6 +83,8 @@ export function wireSettings(path, lite) {
|
|
|
75
83
|
const seen = new Set(), hs = [];
|
|
76
84
|
for (const h of asHooks(g)) {
|
|
77
85
|
const cmd = String((h && h.command) || "");
|
|
86
|
+
// a dev seat runs its full harness and never also the lite one (owner 2026-11-12): lite entries go, the rest stay verbatim
|
|
87
|
+
if (dev) { if (!/wkharness-lite\.sh/.test(cmd)) hs.push(h); continue; }
|
|
78
88
|
const c = HARNESS_HOOK.test(cmd) ? route(cmd, lite) : null; // only a harness hook is ever rewritten
|
|
79
89
|
if (c && !seen.has(c)) { seen.add(c); hs.push({ type: "command", command: c, timeout: 10 }); }
|
|
80
90
|
else if (!c && !HARNESS_HOOK.test(cmd)) hs.push(h); // a user's own hook stays verbatim
|
|
@@ -83,7 +93,8 @@ export function wireSettings(path, lite) {
|
|
|
83
93
|
}
|
|
84
94
|
if (keep.length) out[ev] = keep;
|
|
85
95
|
}
|
|
86
|
-
|
|
96
|
+
// a dev seat gets no lite arm added: its full harness is the only chain
|
|
97
|
+
if (!dev) for (const [ev, arm, matcher] of ARMS) {
|
|
87
98
|
const c = '"' + lite + '" ' + arm;
|
|
88
99
|
if (!(out[ev] || []).some((g) => (g.hooks || []).some((h) => h.command === c)))
|
|
89
100
|
(out[ev] = out[ev] || []).push({ ...(matcher ? { matcher } : {}), hooks: [{ type: "command", command: c, timeout: 10 }] });
|
|
@@ -124,6 +135,16 @@ function wireClaudeHooksInner(o) {
|
|
|
124
135
|
log("hooks: WARN " + o.settings + " is not plain JSON (" + before.err + "); left untouched -- please fix it, then run: wkappbot-code install");
|
|
125
136
|
return res;
|
|
126
137
|
}
|
|
138
|
+
// A dev seat with both its full hook and a lite entry (owner 2026-11-12: two Stop chains fired): drop the lite entries
|
|
139
|
+
// and keep the full hook, whatever state the settings reports as.
|
|
140
|
+
const liteOnDisk = join(o.kih, "tools", "wkharness-lite.sh");
|
|
141
|
+
if (before.hooks.some((h) => /wkharness-run\.sh/.test(h.command)) && before.hooks.some((h) => /wkharness-lite\.sh/.test(h.command)) && isDevSeat(liteOnDisk)) {
|
|
142
|
+
wireSettings(o.settings, liteOnDisk);
|
|
143
|
+
const dev = analyzeHooks(o.settings, o.kih);
|
|
144
|
+
res.after = dev.state; res.changed = true;
|
|
145
|
+
log("hooks: " + o.settings + " runs the full harness on this dev seat; its lite hook entries were removed");
|
|
146
|
+
return res;
|
|
147
|
+
}
|
|
127
148
|
if (before.state === "ours") { log("hooks: " + o.settings + " already runs every harness hook from " + o.kih); return res; }
|
|
128
149
|
// A FULL harness hook (wkharness-run.sh, the pwsh hub of a dev seat) is never rewritten into the lite one where pwsh
|
|
129
150
|
// runs: the rewrite would downgrade the owner's own seats. Leave it and say what to do.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wkappbot-code",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.26",
|
|
4
4
|
"description": "wkappbot-code -- Wisdom Kernel: the pinned OpenCode bridge plus the reflex harness plugin, the wkappbot core, the web harness and the latest public skills in one install",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|