wkappbot-code 0.3.3 → 0.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -34
- package/bin/postinstall.js +72 -0
- package/bin/wkappbot-code.js +198 -45
- package/lib/config.js +7 -3
- package/lib/wk-hook.js +281 -0
- package/opencode.json +1 -26
- package/package.json +8 -4
- package/plugin/wisdom-kernel.js +65 -187
package/bin/wkappbot-code.js
CHANGED
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
// wkappbot-code --version package version and the pinned OpenCode release
|
|
4
4
|
// wkappbot-code install [opts] ONE COMMAND lays down every part (idempotent):
|
|
5
5
|
// opencode pinned opencode-ai (npm -g) skip: --no-npm
|
|
6
|
-
// plugin plugin/wisdom-kernel.js + the opencode.json plugin entry (always)
|
|
6
|
+
// plugin plugin/wisdom-kernel.js + lib/wk-hook.js + the opencode.json plugin entry (always)
|
|
7
7
|
// bin the wkappbot core for this platform, from the SDK latest release asset verified by
|
|
8
|
-
// SHA256SUMS -- the SAME road as
|
|
9
|
-
// in lib/ (the selftest pins the two copies equal) skip: --no-bin
|
|
8
|
+
// SHA256SUMS -- the SAME road as tools/wk-bin-acquire.sh of the harness repo, shipped
|
|
9
|
+
// byte-identical in lib/ (the selftest pins the two copies equal) skip: --no-bin
|
|
10
10
|
// harness the scrubbed web harness to ~/.wk-harness through its own published install.sh,
|
|
11
|
-
// only when no
|
|
11
|
+
// only when no harness checkout is found (--web-harness forces it) skip: --no-harness
|
|
12
12
|
// skills the latest public skills from the same published tarball (sha256-checked) into the
|
|
13
13
|
// core's HQ (<core dir>/wkappbot.hq/skills), so `wkappbot skill read on-load` prints;
|
|
14
14
|
// a READ-ONLY copy with no git remote. A user's own skill edits go to the private
|
|
@@ -16,12 +16,16 @@
|
|
|
16
16
|
// wkappbot-code doctor [opts] one PASS/WARN/FAIL/SKIP row per part (exit 1 if a required part fails)
|
|
17
17
|
// wkappbot-code update [opts] newer version.json -> tgz sha256-checked -> npm i -g -> install --update (lib/update.js)
|
|
18
18
|
// wkappbot-code config [opts] detect provider/model/plugin/harness, edit at a TTY, merge into opencode.json (lib/config.js)
|
|
19
|
+
// wkappbot-code uninstall [opts] undo install: the recorded pre-install global core.hooksPath comes back, the
|
|
20
|
+
// harness hook arms leave ~/.claude/settings.json (your own entries stay), the recorded shims
|
|
21
|
+
// leave the bin dirs, ~/.wk-harness and ~/.wk-harness-hooks go, the plugin entry leaves opencode.json
|
|
19
22
|
// install/doctor/config check for a newer version at most once a day (WKAPPBOT_CODE_NO_UPDATE_CHECK=1 turns it off;
|
|
20
23
|
// --auto-update or WKAPPBOT_CODE_AUTO_UPDATE=1 applies it). config opts: --yes, --project, --set key=value
|
|
21
|
-
// opts: --config-dir DIR, --kih-dir DIR, --bin-dest FILE, --hq-dir DIR
|
|
24
|
+
// opts: --config-dir DIR, --kih-dir DIR, --bin-dest FILE, --hq-dir DIR, --dev-seat (the full pwsh harness and its
|
|
25
|
+
// emergency-edit door on this seat; a user install never gets them)
|
|
22
26
|
// env (mock and relocation): WK_BIN_BASE (SDK release base), WK_HARNESS_BASE (web harness base),
|
|
23
|
-
// WK_HARNESS_DIR (~/.wk-harness), WK_BIN_DEST, WK_APPBOT_HQ, WK_LINK_DIR, WK_PRIVATE_SKILLS,
|
|
24
|
-
// WK_KIH_DIR (a
|
|
27
|
+
// WK_HARNESS_DIR (~/.wk-harness), WK_BIN_DEST, WK_APPBOT_HQ, WK_LINK_DIR, WK_PRIVATE_SKILLS, WK_SETTINGS,
|
|
28
|
+
// WK_KIH_DIR (a harness checkout; "none" = ignore every checkout on this machine)
|
|
25
29
|
// Installer owns all setup, surgically: an existing user opencode.json is BACKED UP and only the plugin entry is
|
|
26
30
|
// merged in; every other key is kept. Spec: wkappbot skill read agent-harness-landscape-2026-howto
|
|
27
31
|
// and wkappbot skill read harness-installer-owns-all-setup (step 9 ONE-COMMAND APPBOT CODE INSTALL)
|
|
@@ -43,10 +47,19 @@ const OPENCODE_VERSION = PKG.wkAppbotCode.opencodeVersion;
|
|
|
43
47
|
const OWNER_LOGIN = "kiexpert";
|
|
44
48
|
const HARNESS_BASE = () => (process.env.WK_HARNESS_BASE || "https://kiexpert.github.io/wkappbot-kih/webvm/x64/harness").replace(/\/+$/, "");
|
|
45
49
|
const HARNESS_DIR = () => process.env.WK_HARNESS_DIR || join(homedir(), ".wk-harness");
|
|
50
|
+
const HOOKS_DIR = () => join(homedir(), ".wk-harness-hooks"); // tools/wk-global-hooks.sh install default
|
|
51
|
+
const SETTINGS = () => process.env.WK_SETTINGS || join(homedir(), ".claude", "settings.json");
|
|
52
|
+
const HARNESS_LINKS = () => process.env.WK_LINK_DIR || join(homedir(), ".local", "bin"); // harness install.sh
|
|
53
|
+
const STATE_HOME = () => join(homedir(), ".wkappbot-code");
|
|
54
|
+
const USER_MARK = ".wk-user-install";
|
|
46
55
|
const IS_WIN = process.platform === "win32";
|
|
47
56
|
// Windows: the sh and tar this CLI starts are Git Bash's (MSYS). They read "D:\a\x" as host "D" plus a remote path
|
|
48
57
|
// ("tar: Cannot connect to C: resolve failed", SDK all-OS QA 2026-11-06), so a path handed to them is the MSYS form /d/a/x.
|
|
49
58
|
const shPath = (p) => (IS_WIN && /^[A-Za-z]:[\\/]/.test(p) ? "/" + p[0].toLowerCase() + p.slice(2).replace(/\\/g, "/") : p);
|
|
59
|
+
// The env for an MSYS child: HOME in MSYS form too. Node hands HOME through as "D:\\a\\home", and every "$HOME/..." path
|
|
60
|
+
// the harness scripts build then fails their "/*" absolute-path test, so wk-verify ran the hooks from a wrong
|
|
61
|
+
// directory (windows-latest "hooks FAIL 1/6" in the installer, PASS 6/6 when the same verify ran from Git Bash).
|
|
62
|
+
const shEnv = (extra = {}) => (IS_WIN ? { ...process.env, HOME: shPath(homedir()), ...extra } : { ...process.env, ...extra });
|
|
50
63
|
let _gnuTar = null;
|
|
51
64
|
const tarArgs = (tgz, dir) => {
|
|
52
65
|
if (!IS_WIN) return ["-xzf", tgz, "-C", dir];
|
|
@@ -56,7 +69,7 @@ const tarArgs = (tgz, dir) => {
|
|
|
56
69
|
};
|
|
57
70
|
|
|
58
71
|
function parseArgs(argv) {
|
|
59
|
-
const o = { cmd: argv[0] || "help", npm: true, bin: true, harness: true, skills: true, webHarness: false,
|
|
72
|
+
const o = { cmd: argv[0] || "help", npm: true, bin: true, harness: true, skills: true, webHarness: false, devSeat: false,
|
|
60
73
|
configDir: "", kihDir: "", binDest: "", hqDir: "", update: false, autoUpdate: false, yes: false, project: false, sets: [] };
|
|
61
74
|
for (let i = 1; i < argv.length; i++) {
|
|
62
75
|
const a = argv[i];
|
|
@@ -65,6 +78,7 @@ function parseArgs(argv) {
|
|
|
65
78
|
else if (a === "--no-harness") o.harness = false;
|
|
66
79
|
else if (a === "--no-skills") o.skills = false;
|
|
67
80
|
else if (a === "--web-harness") o.webHarness = true;
|
|
81
|
+
else if (a === "--dev-seat") o.devSeat = true;
|
|
68
82
|
else if (a === "--config-dir") o.configDir = argv[++i] || "";
|
|
69
83
|
else if (a === "--kih-dir") o.kihDir = argv[++i] || "";
|
|
70
84
|
else if (a === "--bin-dest") o.binDest = argv[++i] || "";
|
|
@@ -86,17 +100,19 @@ function configDir(o) {
|
|
|
86
100
|
}
|
|
87
101
|
|
|
88
102
|
const hasHook = (d) => !!d && existsSync(join(d, ".claude", "hooks", "wkharness-run.sh"));
|
|
89
|
-
// A
|
|
90
|
-
//
|
|
103
|
+
// A harness CHECKOUT (never the web harness): explicit dirs first, then this package's parent (a checkout's
|
|
104
|
+
// wkappbot-code/ folder) and the HOME-relative clone. No machine-specific absolute path is ever searched.
|
|
105
|
+
// WK_KIH_DIR=none: act as on a machine with no checkout (the selftest's isolation from this VM's checkout).
|
|
106
|
+
export function checkoutCandidates(o = {}, env = process.env, home = homedir()) {
|
|
107
|
+
if (env.WK_KIH_DIR === "none") return o.kihDir ? [o.kihDir] : [];
|
|
108
|
+
return [o.kihDir, env.WK_KIH_DIR, resolve(PKG_DIR, ".."), join(home, "wkappbot-kih")].filter(Boolean);
|
|
109
|
+
}
|
|
91
110
|
function kihCheckout(o) {
|
|
92
|
-
|
|
93
|
-
const tries = [o.kihDir, process.env.WK_KIH_DIR, resolve(PKG_DIR, ".."),
|
|
94
|
-
join(homedir(), "wkappbot-kih"), "/home/user/wkappbot-kih", "D:/GitHub/wkappbot-kih"];
|
|
95
|
-
for (const d of tries) if (hasHook(d)) return resolve(d);
|
|
111
|
+
for (const d of checkoutCandidates(o)) if (hasHook(d)) return resolve(d);
|
|
96
112
|
return "";
|
|
97
113
|
}
|
|
98
|
-
// The
|
|
99
|
-
// (~/.wk-harness). Empty = plugin
|
|
114
|
+
// The harness root whose .claude/hooks/wkharness-run.sh the plugin calls: a checkout, else the web harness
|
|
115
|
+
// (~/.wk-harness). Empty = no hook: the plugin then refuses the risky classes and lets the rest through.
|
|
100
116
|
function kihDir(o) {
|
|
101
117
|
return kihCheckout(o) || (hasHook(HARNESS_DIR()) ? resolve(HARNESS_DIR()) : "");
|
|
102
118
|
}
|
|
@@ -105,12 +121,16 @@ function opencodeVersion() {
|
|
|
105
121
|
try { return execFileSync("opencode", ["--version"], { shell: process.platform === "win32", encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); }
|
|
106
122
|
catch { return ""; }
|
|
107
123
|
}
|
|
124
|
+
const which = (cmd) => { const r = spawnSync("sh", ["-c", "command -v " + cmd], { encoding: "utf8" }); return r.status === 0 ? r.stdout.trim() : ""; };
|
|
125
|
+
const pwshPath = () => which("pwsh") || which("powershell");
|
|
108
126
|
|
|
109
127
|
const stamp = () => new Date().toISOString().replace(/[-:]/g, "").replace(/\..*/, "");
|
|
110
128
|
const log = (s) => console.log("[wkappbot-code] " + s);
|
|
111
129
|
const sha256File = (f) => createHash("sha256").update(readFileSync(f)).digest("hex");
|
|
112
130
|
const statePath = (o) => join(configDir(o), "wkappbot-code", "install-state.json");
|
|
113
131
|
function readState(o) { try { return JSON.parse(readFileSync(statePath(o), "utf8")); } catch { return {}; } }
|
|
132
|
+
const gitGlobal = (...a) => spawnSync("git", ["config", "--global", ...a], { encoding: "utf8" });
|
|
133
|
+
const globalHooksPath = () => { const r = gitGlobal("--get", "core.hooksPath"); return r.status === 0 ? r.stdout.trim() : null; };
|
|
114
134
|
|
|
115
135
|
// Same rule as wk-bin-acquire.sh platform_detect (linux-x64, osx-arm64, osx-x64, win-x64).
|
|
116
136
|
export function platformId(platform = process.platform, arch = process.arch) {
|
|
@@ -125,6 +145,18 @@ export function mergeConfig(user, template, url, kih) {
|
|
|
125
145
|
return buildConfig(user, { plugin: { value: url }, harness: { value: kih } }, template);
|
|
126
146
|
}
|
|
127
147
|
|
|
148
|
+
// The advisory verify line the harness install prints (scripts/wk-harness-verify.sh): a miss is NOT a broken install.
|
|
149
|
+
const VERIFY_MEANING = { c1: "wkappbot not on PATH", c2: "wkhippo not on PATH (the memory tool ships with a harness checkout only)",
|
|
150
|
+
c3: "no PreToolUse hook names wkharness in settings.json", c4: "no Stop hook names wkharness in settings.json",
|
|
151
|
+
c5: "wkappbot skill read on-load did not exit 0 (no core yet, or --no-bin)", c6: "no harness git checkout (expected for the web harness)" };
|
|
152
|
+
export function verifyLine(line) {
|
|
153
|
+
const m = /\[wk-verify\] FAIL (\d)\/6:\s*(.*)$/.exec(line);
|
|
154
|
+
if (!m) return line;
|
|
155
|
+
const ids = m[2].trim().split(/\s+/).filter(Boolean);
|
|
156
|
+
return line.slice(0, m.index) + "[wk-verify] WARN " + m[1] + "/6 advisory, not a failure -- " +
|
|
157
|
+
ids.map((c) => c + " " + (VERIFY_MEANING[c] || "missed")).join("; ") + " (verdict: " + join(STATE_HOME(), "verify.json") + ")";
|
|
158
|
+
}
|
|
159
|
+
|
|
128
160
|
// ---- part: bin ------------------------------------------------------------------------------------
|
|
129
161
|
function binDest(o) {
|
|
130
162
|
if (o.binDest) return resolve(o.binDest);
|
|
@@ -145,13 +177,13 @@ function installBin(o) {
|
|
|
145
177
|
const dest = binDest(o);
|
|
146
178
|
const plat = platformId();
|
|
147
179
|
if (!plat) return { ok: false, dest, note: "no wkappbot build for " + process.platform + "-" + process.arch };
|
|
148
|
-
if (plat === "win-x64") return { ok: false, dest, note: "win-x64 has no single SDK asset; run install.cmd from a
|
|
149
|
-
// A
|
|
180
|
+
if (plat === "win-x64") return { ok: false, dest, note: "win-x64 has no single SDK asset; run install.cmd from a harness checkout" };
|
|
181
|
+
// A checkout runs its own copy (same bytes, plus the MANIFEST-verified repo bin/ fallback); the package
|
|
150
182
|
// copy outside a checkout takes only the SDK release asset verified by SHA256SUMS.
|
|
151
183
|
const co = kihCheckout(o);
|
|
152
184
|
const script = co && existsSync(join(co, "tools", "wk-bin-acquire.sh")) ? join(co, "tools", "wk-bin-acquire.sh") : join(PKG_DIR, "lib", "wk-bin-acquire.sh");
|
|
153
185
|
log("bin: bash " + script + " acquire --dest " + dest);
|
|
154
|
-
const r = spawnSync("bash", [shPath(script), "acquire", "--dest", shPath(dest)], { encoding: "utf8", env:
|
|
186
|
+
const r = spawnSync("bash", [shPath(script), "acquire", "--dest", shPath(dest)], { encoding: "utf8", env: shEnv() });
|
|
155
187
|
for (const line of ((r.stdout || "") + (r.stderr || "")).split("\n").filter(Boolean)) log(" " + line);
|
|
156
188
|
if (r.status !== 0) return { ok: false, dest, note: "wk-bin-acquire rc " + r.status };
|
|
157
189
|
// Put `wkappbot` on PATH beside wkappbot-code itself, unless that name is already taken by someone else.
|
|
@@ -182,7 +214,7 @@ function curl(url, out) {
|
|
|
182
214
|
function installHarness(o) {
|
|
183
215
|
const found = kihCheckout(o);
|
|
184
216
|
if (found && !o.webHarness) {
|
|
185
|
-
log("harness:
|
|
217
|
+
log("harness: checkout " + found + " found; the web harness is not needed (--web-harness forces it)");
|
|
186
218
|
return { ok: true, mode: "checkout", dir: found };
|
|
187
219
|
}
|
|
188
220
|
const tmp = mkdtempSync(join(tmpdir(), "wk-appbot-harness-"));
|
|
@@ -190,11 +222,17 @@ function installHarness(o) {
|
|
|
190
222
|
const err = curl(HARNESS_BASE() + "/install.sh", join(tmp, "install.sh"));
|
|
191
223
|
if (err) return { ok: false, mode: "web", dir: HARNESS_DIR(), note: err };
|
|
192
224
|
log("harness: sh " + HARNESS_BASE() + "/install.sh (to " + HARNESS_DIR() + ")");
|
|
225
|
+
mkdirSync(STATE_HOME(), { recursive: true });
|
|
193
226
|
const r = spawnSync("sh", [shPath(join(tmp, "install.sh"))], { encoding: "utf8",
|
|
194
|
-
env: {
|
|
195
|
-
for (const line of ((r.stdout || "") + (r.stderr || "")).split("\n").filter(Boolean)) log(" " + line);
|
|
227
|
+
env: shEnv({ WK_HARNESS_BASE: HARNESS_BASE(), WK_HARNESS_DIR: shPath(HARNESS_DIR()), WK_VERIFY_OUT: shPath(join(STATE_HOME(), "verify.json")) }) });
|
|
228
|
+
for (const line of ((r.stdout || "") + (r.stderr || "")).split("\n").filter(Boolean)) log(" " + verifyLine(line));
|
|
196
229
|
const ok = r.status === 0 && hasHook(HARNESS_DIR());
|
|
197
|
-
|
|
230
|
+
// USER INSTALL MARK: the launcher then takes the sh lite harness as the whole verdict and the emergency-edit door
|
|
231
|
+
// never grants or prints (wkharness-run.sh FULL, Test-WkEmergencyEditAllow.ps1). --dev-seat keeps the full harness.
|
|
232
|
+
const mark = join(HARNESS_DIR(), USER_MARK);
|
|
233
|
+
if (ok && !o.devSeat) writeFileSync(mark, "user install by wkappbot-code " + PKG.version + "\n");
|
|
234
|
+
else if (ok && o.devSeat) rmSync(mark, { force: true });
|
|
235
|
+
return { ok, mode: "web", dir: HARNESS_DIR(), userInstall: ok && !o.devSeat, note: ok ? "" : "harness install rc " + r.status };
|
|
198
236
|
} finally { rmSync(tmp, { recursive: true, force: true }); }
|
|
199
237
|
}
|
|
200
238
|
|
|
@@ -284,7 +322,8 @@ function installPlugin(o, kih, kihSource) {
|
|
|
284
322
|
mkdirSync(join(cfg, "wkappbot-code"), { recursive: true });
|
|
285
323
|
const dest = join(cfg, "wkappbot-code", "wisdom-kernel.js");
|
|
286
324
|
copyFileSync(join(PKG_DIR, "plugin", "wisdom-kernel.js"), dest);
|
|
287
|
-
// The
|
|
325
|
+
// The plugin file exports ONLY the plugin (OpenCode calls every named export as one); its helpers ride beside it.
|
|
326
|
+
copyFileSync(join(PKG_DIR, "lib", "wk-hook.js"), join(cfg, "wkappbot-code", "wk-hook.js"));
|
|
288
327
|
writeFileSync(join(cfg, "wkappbot-code", "package.json"), '{ "type": "module" }\n');
|
|
289
328
|
const url = pathToFileURL(dest).href;
|
|
290
329
|
const tmpl = JSON.parse(readFileSync(join(PKG_DIR, "opencode.json"), "utf8"));
|
|
@@ -294,7 +333,7 @@ function installPlugin(o, kih, kihSource) {
|
|
|
294
333
|
raw = readFileSync(target, "utf8");
|
|
295
334
|
try { user = JSON.parse(raw); } catch { user = null; }
|
|
296
335
|
}
|
|
297
|
-
const it = detect({ user: user || {}, pluginUrl: url, kih, kihSource: kihSource || "
|
|
336
|
+
const it = detect({ user: user || {}, pluginUrl: url, kih, kihSource: kihSource || "harness checkout" });
|
|
298
337
|
for (const k of applySets(it, o.sets)) log("config: unknown --set key " + k + " (provider|model|plugin|harness)");
|
|
299
338
|
printDetected(it, log);
|
|
300
339
|
if (process.stdin.isTTY && process.stdout.isTTY && !o.yes) askEach(it);
|
|
@@ -322,10 +361,29 @@ function installPlugin(o, kih, kihSource) {
|
|
|
322
361
|
log("config: " + target + note);
|
|
323
362
|
}
|
|
324
363
|
|
|
364
|
+
// The machine state an uninstall must give back, recorded ONCE (a re-install never overwrites the first record, or
|
|
365
|
+
// it would record our own dispatcher as the "prior" hooksPath). Shim paths are those install may create.
|
|
366
|
+
function shimCandidates() {
|
|
367
|
+
return [join(linkDir(), "wkappbot"), join(HARNESS_LINKS(), "wkskill"), join(HARNESS_LINKS(), "wk-verify")];
|
|
368
|
+
}
|
|
369
|
+
const PRE_SETTINGS = () => join(STATE_HOME(), "settings.pre-install.json");
|
|
370
|
+
function recordPrior(prev) {
|
|
371
|
+
if (prev && prev.prior) return prev.prior;
|
|
372
|
+
// The user's settings.json as it was, byte for byte: uninstall gives these bytes back when nothing else changed.
|
|
373
|
+
if (existsSync(SETTINGS()) && !existsSync(PRE_SETTINGS())) { mkdirSync(STATE_HOME(), { recursive: true }); copyFileSync(SETTINGS(), PRE_SETTINGS()); }
|
|
374
|
+
return { hooksPath: globalHooksPath(), settingsExisted: existsSync(SETTINGS()), harnessDirExisted: existsSync(HARNESS_DIR()),
|
|
375
|
+
hooksDirExisted: existsSync(HOOKS_DIR()), shimsExisting: shimCandidates().filter((p) => existsSync(p) || isSymlink(p)) };
|
|
376
|
+
}
|
|
377
|
+
|
|
325
378
|
function install(o) {
|
|
326
|
-
const
|
|
379
|
+
const prev = readState(o);
|
|
380
|
+
const st = { version: PKG.version, at: new Date().toISOString(), prior: recordPrior(prev) };
|
|
327
381
|
let bad = 0;
|
|
328
382
|
if (o.update) log("install --update: refreshing the core, the harness and the public skills");
|
|
383
|
+
const ps = pwshPath();
|
|
384
|
+
log("pwsh: " + (ps || "not found") + " -- the FULL harness (PowerShell 7) runs only on a dev seat (a harness checkout, or --dev-seat);" +
|
|
385
|
+
" a user install and every seat without pwsh take the sh lite harness, which still refuses git --no-verify/--force/core.hooksPath," +
|
|
386
|
+
" rm -rf of HOME or the project, and writes under .git");
|
|
329
387
|
if (o.npm) {
|
|
330
388
|
const have = opencodeVersion();
|
|
331
389
|
if (have === OPENCODE_VERSION) log("opencode " + have + " already installed");
|
|
@@ -345,20 +403,107 @@ function install(o) {
|
|
|
345
403
|
// A user's own absolute global core.hooksPath is kept (the script WARNs). Skipped with --no-harness.
|
|
346
404
|
st.pushGate = { skipped: true };
|
|
347
405
|
if (o.harness && kih && existsSync(join(kih, "tools", "wk-global-hooks.sh"))) {
|
|
348
|
-
const r = spawnSync("bash", [shPath(join(kih, "tools", "wk-global-hooks.sh")), "install", "--gate-dir", shPath(join(kih, ".githooks", "lib"))], { encoding: "utf8" });
|
|
406
|
+
const r = spawnSync("bash", [shPath(join(kih, "tools", "wk-global-hooks.sh")), "install", "--gate-dir", shPath(join(kih, ".githooks", "lib"))], { encoding: "utf8", env: shEnv() });
|
|
349
407
|
for (const line of ((r.stdout || "") + (r.stderr || "")).split("\n").filter(Boolean)) log("push-gate: " + line);
|
|
350
408
|
st.pushGate = { ok: r.status === 0, kih };
|
|
351
409
|
}
|
|
352
410
|
st.skills = o.skills ? installSkills(o, dest) : { skipped: true };
|
|
353
411
|
if (o.skills && !st.skills.ok) { bad++; log("skills: FAILED -- " + (st.skills.note || "on-load missing from the HQ")); }
|
|
354
|
-
installPlugin(o, kih, (st.harness.mode === "web" && st.harness.ok) ? "web harness" : "
|
|
412
|
+
installPlugin(o, kih, (st.harness.mode === "web" && st.harness.ok) ? "web harness" : "harness checkout");
|
|
355
413
|
st.kihDir = kih;
|
|
414
|
+
// The shims this install created (absent before, present now): uninstall removes exactly these.
|
|
415
|
+
st.shims = [...new Set([...(prev.shims || []), ...shimCandidates().filter((p) => (existsSync(p) || isSymlink(p)) && !st.prior.shimsExisting.includes(p))])];
|
|
356
416
|
writeFileSync(statePath(o), JSON.stringify(st, null, 2) + "\n");
|
|
357
|
-
log("kih: " + (kih || "NOT FOUND -- plugin
|
|
417
|
+
log("kih: " + (kih || "NOT FOUND -- no hook runs; the plugin still refuses the risky classes. Use --kih-dir or WK_KIH_DIR"));
|
|
358
418
|
log((bad ? "done with " + bad + " failed part(s)" : "done") + " -- check: wkappbot-code doctor then run: opencode");
|
|
359
419
|
return bad ? 1 : 0;
|
|
360
420
|
}
|
|
361
421
|
|
|
422
|
+
// ---- uninstall ------------------------------------------------------------------------------------
|
|
423
|
+
// Surgical: our hook arms leave settings.json, every other entry stays. A hook is ours when its command names the
|
|
424
|
+
// lite harness or a file under the harness dir; a settings.json that did not exist before and is left empty goes.
|
|
425
|
+
export function stripOurHooks(d, harnessDir) {
|
|
426
|
+
const ours = (c) => /wkharness-lite\.sh|wkharness-run\.sh/.test(c) || (!!harnessDir && c.includes(harnessDir));
|
|
427
|
+
let removed = 0;
|
|
428
|
+
const hooks = d && d.hooks;
|
|
429
|
+
if (!hooks || typeof hooks !== "object") return { d, removed };
|
|
430
|
+
for (const ev of Object.keys(hooks)) {
|
|
431
|
+
const groups = (hooks[ev] || []).map((g) => {
|
|
432
|
+
const hs = (g.hooks || []).filter((h) => { const o = ours(String(h.command || "")); if (o) removed++; return !o; });
|
|
433
|
+
return { ...g, hooks: hs };
|
|
434
|
+
}).filter((g) => g.hooks.length);
|
|
435
|
+
if (groups.length) hooks[ev] = groups; else delete hooks[ev];
|
|
436
|
+
}
|
|
437
|
+
if (!Object.keys(hooks).length) delete d.hooks;
|
|
438
|
+
return { d, removed };
|
|
439
|
+
}
|
|
440
|
+
const sortKeys = (v) => Array.isArray(v) ? v.map(sortKeys) : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortKeys(v[k])])) : v;
|
|
441
|
+
function uninstall(o) {
|
|
442
|
+
const st = readState(o);
|
|
443
|
+
const pr = st.prior || {};
|
|
444
|
+
let rc = 0;
|
|
445
|
+
if (!st.version) log("no install record at " + statePath(o) + "; removing only what is ours by name");
|
|
446
|
+
// 1. global core.hooksPath back to its recorded value (only when it still points at a dir we wrote)
|
|
447
|
+
const cur = globalHooksPath();
|
|
448
|
+
const oursHp = (p) => !!p && [HOOKS_DIR(), join(HARNESS_DIR(), ".githooks"), st.kihDir && join(st.kihDir, ".githooks")]
|
|
449
|
+
.filter(Boolean).some((x) => resolve(p.replace(/^~(?=\/|$)/, homedir())) === resolve(x));
|
|
450
|
+
if (cur !== (pr.hooksPath ?? null) && (cur === null || oursHp(cur))) {
|
|
451
|
+
const r = pr.hooksPath ? gitGlobal("core.hooksPath", pr.hooksPath) : gitGlobal("--unset", "core.hooksPath");
|
|
452
|
+
log("hooksPath: " + (cur || "unset") + " -> " + (pr.hooksPath || "unset") + (r.status === 0 ? "" : " (git rc " + r.status + ")"));
|
|
453
|
+
} else log("hooksPath: " + (cur || "unset") + (cur === (pr.hooksPath ?? null) ? " (as before install)" : " (set by you, kept)"));
|
|
454
|
+
// 2. settings.json: our arms out, yours kept
|
|
455
|
+
const sp = SETTINGS();
|
|
456
|
+
if (existsSync(sp)) {
|
|
457
|
+
try {
|
|
458
|
+
const raw = readFileSync(sp, "utf8");
|
|
459
|
+
const { d, removed } = stripOurHooks(JSON.parse(raw), HARNESS_DIR());
|
|
460
|
+
// A hook of yours that an older harness --wire dropped comes back from the pre-install snapshot; when the result is
|
|
461
|
+
// then the same JSON as before install, the snapshot's own bytes are written (formatting kept).
|
|
462
|
+
let preRaw = "", pre = null, restored = 0;
|
|
463
|
+
try { preRaw = readFileSync(PRE_SETTINGS(), "utf8"); pre = JSON.parse(preRaw); } catch {}
|
|
464
|
+
if (pre && pre.hooks) {
|
|
465
|
+
d.hooks = d.hooks || {};
|
|
466
|
+
for (const [ev, groups] of Object.entries(pre.hooks)) for (const g of groups || []) {
|
|
467
|
+
const have = JSON.stringify(d.hooks[ev] || []);
|
|
468
|
+
if (!have.includes(JSON.stringify(g))) { (d.hooks[ev] = d.hooks[ev] || []).push(g); restored++; }
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
if (removed || restored) {
|
|
472
|
+
copyFileSync(sp, sp + ".bak-" + stamp());
|
|
473
|
+
if (pr.settingsExisted === false && !Object.keys(d).length) { rmSync(sp); log("settings: removed " + sp + " (it did not exist before install)"); }
|
|
474
|
+
else {
|
|
475
|
+
const same = pre && JSON.stringify(sortKeys(pre)) === JSON.stringify(sortKeys(d));
|
|
476
|
+
writeFileSync(sp, same ? preRaw : JSON.stringify(d, null, 2) + "\n");
|
|
477
|
+
log("settings: removed " + removed + " harness hook entr" + (removed === 1 ? "y" : "ies") + (restored ? ", restored " + restored + " of yours" : "") +
|
|
478
|
+
" in " + sp + (same ? " (now byte-identical to before install)" : "; your other entries stay"));
|
|
479
|
+
}
|
|
480
|
+
} else log("settings: no harness hook in " + sp);
|
|
481
|
+
} catch (e) { rc = 1; log("settings: " + sp + " is not plain JSON, left untouched (" + e.message + ")"); }
|
|
482
|
+
}
|
|
483
|
+
// 3. the shims install created
|
|
484
|
+
for (const p of st.shims || []) {
|
|
485
|
+
if (isSymlink(p) || existsSync(p)) { rmSync(p, { force: true }); log("shim: removed " + p); }
|
|
486
|
+
}
|
|
487
|
+
// 4. the harness dirs we created, the plugin entry and our config folder
|
|
488
|
+
if (st.harness && st.harness.mode === "web" && pr.harnessDirExisted !== true && existsSync(HARNESS_DIR())) { rmSync(HARNESS_DIR(), { recursive: true, force: true }); log("harness: removed " + HARNESS_DIR()); }
|
|
489
|
+
if (pr.hooksDirExisted !== true && existsSync(HOOKS_DIR())) { rmSync(HOOKS_DIR(), { recursive: true, force: true }); log("push-gate: removed " + HOOKS_DIR()); }
|
|
490
|
+
const cfgFile = join(configDir(o), "opencode.json");
|
|
491
|
+
try {
|
|
492
|
+
const c = JSON.parse(readFileSync(cfgFile, "utf8"));
|
|
493
|
+
const keep = (c.plugin || []).filter((p) => !String(Array.isArray(p) ? p[0] : p).includes("wisdom-kernel"));
|
|
494
|
+
if (keep.length !== (c.plugin || []).length) {
|
|
495
|
+
copyFileSync(cfgFile, cfgFile + ".bak-" + stamp());
|
|
496
|
+
if (keep.length) c.plugin = keep; else delete c.plugin;
|
|
497
|
+
writeFileSync(cfgFile, JSON.stringify(c, null, 2) + "\n");
|
|
498
|
+
log("config: plugin entry removed from " + cfgFile);
|
|
499
|
+
}
|
|
500
|
+
} catch {}
|
|
501
|
+
rmSync(STATE_HOME(), { recursive: true, force: true });
|
|
502
|
+
rmSync(join(configDir(o), "wkappbot-code"), { recursive: true, force: true });
|
|
503
|
+
log("uninstall done. Kept: opencode-ai, the wkappbot core (" + ((st.bin && st.bin.dest) || binDest(o)) + ") and its skills, and this CLI (npm rm -g wkappbot-code)");
|
|
504
|
+
return rc;
|
|
505
|
+
}
|
|
506
|
+
|
|
362
507
|
// ---- doctor ---------------------------------------------------------------------------------------
|
|
363
508
|
// The GitHub login a skill push would use: gh when present, else "none". Push is the owner's alone.
|
|
364
509
|
export function pushLogin() {
|
|
@@ -367,6 +512,15 @@ export function pushLogin() {
|
|
|
367
512
|
return r.status === 0 && r.stdout.trim() ? r.stdout.trim() : "none";
|
|
368
513
|
}
|
|
369
514
|
|
|
515
|
+
// The verdict road a pre call takes in harness dir k: "full" (pwsh hub, lite first for OpenCode), "lite", or "" (none).
|
|
516
|
+
export function harnessMode(k, ps = pwshPath()) {
|
|
517
|
+
if (!hasHook(k)) return "";
|
|
518
|
+
const lite = existsSync(join(k, "tools", "wkharness-lite.sh"));
|
|
519
|
+
const user = existsSync(join(k, USER_MARK));
|
|
520
|
+
if (ps && !user) return "full";
|
|
521
|
+
return lite ? "lite" : "";
|
|
522
|
+
}
|
|
523
|
+
|
|
370
524
|
function doctor(o) {
|
|
371
525
|
const cfg = configDir(o);
|
|
372
526
|
const st = readState(o);
|
|
@@ -375,7 +529,7 @@ function doctor(o) {
|
|
|
375
529
|
const v = opencodeVersion();
|
|
376
530
|
add("opencode", v === OPENCODE_VERSION, v ? v + (v === OPENCODE_VERSION ? "" : " (pinned " + OPENCODE_VERSION + ")") : "not on PATH", false);
|
|
377
531
|
const pdest = join(cfg, "wkappbot-code", "wisdom-kernel.js");
|
|
378
|
-
add("plugin", existsSync(pdest), pdest, true);
|
|
532
|
+
add("plugin", existsSync(pdest) && existsSync(join(cfg, "wkappbot-code", "wk-hook.js")), pdest + " + wk-hook.js", true);
|
|
379
533
|
let entry = null;
|
|
380
534
|
try { entry = (JSON.parse(readFileSync(join(cfg, "opencode.json"), "utf8")).plugin || []).find((p) => String(Array.isArray(p) ? p[0] : p).includes("wisdom-kernel")); } catch {}
|
|
381
535
|
add("config", !!entry, join(cfg, "opencode.json"), true);
|
|
@@ -390,19 +544,15 @@ function doctor(o) {
|
|
|
390
544
|
core = r.status === 0 ? (r.stdout || "").trim().split("\n")[0] : "";
|
|
391
545
|
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));
|
|
392
546
|
}
|
|
393
|
-
// kih-hook: the harness the plugin calls
|
|
547
|
+
// kih-hook: the harness the plugin calls, and the verdict road it takes. No pwsh and no lite = NO verdict at all:
|
|
548
|
+
// that is a FAIL whatever the install mode, since the plugin then only refuses its own risky classes.
|
|
394
549
|
const k = (entry && Array.isArray(entry) && entry[1] && entry[1].kihDir) || kihDir(o);
|
|
395
550
|
const hk = hasHook(k);
|
|
551
|
+
const ps = pwshPath();
|
|
552
|
+
const mode = harnessMode(k, ps);
|
|
396
553
|
if (st.harness && st.harness.skipped && !hk) add("kih-hook", false, "skipped (--no-harness)", false, true);
|
|
397
|
-
else add("kih-hook",
|
|
398
|
-
|
|
399
|
-
// the bash lite harness (intent lock, study lock), so this row WARNs instead of passing silently (QA 2026-11-06).
|
|
400
|
-
if (hk) {
|
|
401
|
-
const psDirs = String(process.env.PATH || "").split(IS_WIN ? ";" : ":");
|
|
402
|
-
const ps = ["pwsh", "pwsh.exe", "powershell.exe", "powershell"].find((n) => psDirs.some((d) => d && existsSync(join(d, n))));
|
|
403
|
-
const lite = existsSync(join(k, "tools", "wkharness-lite.sh"));
|
|
404
|
-
add("guards", !!ps, ps ? "full guard set (" + ps + ")" : lite ? "no PowerShell on PATH: lite guards only (intent lock, study lock); install pwsh 7 for the full set" : "no PowerShell and no lite harness: every tool call is let through", !ps && !lite);
|
|
405
|
-
}
|
|
554
|
+
else if (hk && !mode) add("kih-hook", false, k + ": pwsh is missing and the lite harness (tools/wkharness-lite.sh) is not there -- no hook verdict; reinstall the harness", true);
|
|
555
|
+
else add("kih-hook", hk, k ? k + (st.harness && st.harness.mode ? " (" + st.harness.mode + ")" : "") + (mode ? " verdict: " + (mode === "full" ? "full pwsh hub (" + ps + ") after lite" : "lite sh harness" + (ps ? " (user install)" : " (no pwsh)")) : "") : "not found", !!(st.harness && st.harness.mode === "web"));
|
|
406
556
|
// skills: the HQ holds on-load and the core prints it
|
|
407
557
|
const hq = (st.skills && st.skills.hq) || hqDir(o, dest);
|
|
408
558
|
if (st.skills && st.skills.skipped) add("skills", false, "skipped (--no-skills)", false, true);
|
|
@@ -417,10 +567,12 @@ function doctor(o) {
|
|
|
417
567
|
if (!printed) why = " skill read on-load rc " + r.status;
|
|
418
568
|
rmSync(cwd, { recursive: true, force: true });
|
|
419
569
|
} else why = " (no core to read with)";
|
|
420
|
-
|
|
570
|
+
// Without a core (--no-bin, or win-x64 where no single core asset exists yet) the skills cannot be read back:
|
|
571
|
+
// that is a WARN about the missing core, not a FAIL of the skills that did install (SDK all-OS QA 2026-11-06).
|
|
572
|
+
add("skills", n > 0 && printed, n + " skills in " + hq + (printed ? "; wkappbot skill read on-load prints" : why), !!(st.skills && !st.skills.skipped) && !!core);
|
|
421
573
|
}
|
|
422
574
|
// push-gate: the global core.hooksPath runs the push-owner gate (directly, or through the global dispatcher).
|
|
423
|
-
const ghp = (
|
|
575
|
+
const ghp = globalHooksPath() || "";
|
|
424
576
|
let gated = false;
|
|
425
577
|
try { gated = /push-owner-gate|wk-global-hooks\.sh/.test(readFileSync(join(ghp.replace(/^~(?=\/|$)/, homedir()), "pre-push"), "utf8")); } catch {}
|
|
426
578
|
add("push-gate", gated, "global core.hooksPath " + (ghp || "unset") + (gated ? " runs the push-owner gate" : " does not run the push-owner gate"), false);
|
|
@@ -452,12 +604,13 @@ switch (o.cmd) {
|
|
|
452
604
|
case "--version": case "-v": case "version":
|
|
453
605
|
console.log("wkappbot-code " + PKG.version + " (opencode-ai " + OPENCODE_VERSION + ")"); process.exit(0);
|
|
454
606
|
case "install": process.exit(install(o));
|
|
607
|
+
case "uninstall": process.exit(uninstall(o));
|
|
455
608
|
case "doctor": process.exit(doctor(o));
|
|
456
609
|
case "update": process.exit(upd());
|
|
457
|
-
case "config": installPlugin(o, kihDir(o), kihCheckout(o) ? "
|
|
610
|
+
case "config": installPlugin(o, kihDir(o), kihCheckout(o) ? "harness checkout" : "web harness"); process.exit(0);
|
|
458
611
|
default:
|
|
459
|
-
console.log("usage: wkappbot-code --version | install [--no-npm] [--no-bin] [--no-harness] [--no-skills] [--web-harness]\n" +
|
|
612
|
+
console.log("usage: wkappbot-code --version | install [--no-npm] [--no-bin] [--no-harness] [--no-skills] [--web-harness] [--dev-seat]\n" +
|
|
460
613
|
" [--config-dir DIR] [--kih-dir DIR] [--bin-dest FILE] [--hq-dir DIR] [--update] [--auto-update] | doctor [--config-dir DIR]\n" +
|
|
461
|
-
" | update [install opts] | config [--yes] [--project] [--set key=value] [--config-dir DIR]");
|
|
614
|
+
" | uninstall [--config-dir DIR] | update [install opts] | config [--yes] [--project] [--set key=value] [--config-dir DIR]");
|
|
462
615
|
process.exit(o.cmd === "help" || o.cmd === "--help" ? 0 : 64);
|
|
463
616
|
}
|
package/lib/config.js
CHANGED
|
@@ -97,9 +97,13 @@ export function buildConfig(user, it, template) {
|
|
|
97
97
|
const ours = [it.plugin ? it.plugin.value : "", { kihDir: it.harness ? it.harness.value : "" }];
|
|
98
98
|
const keep = (Array.isArray(user.plugin) ? user.plugin : []).filter((p) => !String(Array.isArray(p) ? p[0] : p).includes("wisdom-kernel"));
|
|
99
99
|
out.plugin = [...keep, ours];
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if (
|
|
100
|
+
// 0.3.1 (owner: minimum items only): the shipped template carries no provider and no permission, so nothing is
|
|
101
|
+
// added for them; a template that does carry one still only fills what the user has not set.
|
|
102
|
+
if (template.provider) {
|
|
103
|
+
if (!out.provider) out.provider = template.provider;
|
|
104
|
+
else for (const [k, v] of Object.entries(template.provider)) if (!(k in out.provider)) out.provider[k] = v;
|
|
105
|
+
}
|
|
106
|
+
if (template.permission && !out.permission) out.permission = template.permission;
|
|
103
107
|
if (it.model && (it.model.forced || !user.model)) out.model = it.model.value;
|
|
104
108
|
return out;
|
|
105
109
|
}
|