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.
@@ -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 kih tools/wk-bin-acquire.sh, shipped byte-identical
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 kih checkout is found (--web-harness forces it) skip: --no-harness
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 kih checkout; "none" = ignore every checkout on this machine)
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 kih CHECKOUT (never the web harness): explicit dirs first, then the usual checkout places.
90
- // WK_KIH_DIR=none: act as on a machine with no checkout (the selftest's isolation from this VM's kih).
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
- if (process.env.WK_KIH_DIR === "none") return o.kihDir && hasHook(o.kihDir) ? resolve(o.kihDir) : "";
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 kih root whose .claude/hooks/wkharness-run.sh the plugin calls: a checkout, else the web harness
99
- // (~/.wk-harness). Empty = plugin fails open.
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 kih checkout" };
149
- // A kih checkout runs its own copy (same bytes, plus the MANIFEST-verified repo bin/ fallback); the package
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: process.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: kih checkout " + found + " found; the web harness is not needed (--web-harness forces it)");
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: { ...process.env, WK_HARNESS_BASE: HARNESS_BASE(), WK_HARNESS_DIR: shPath(HARNESS_DIR()) } });
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
- return { ok, mode: "web", dir: HARNESS_DIR(), note: ok ? "" : "harness install rc " + r.status };
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 copy imports nothing from the package, but mark its folder ESM so Node never re-parses it.
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 || "kih checkout" });
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 st = { version: PKG.version, at: new Date().toISOString() };
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" : "kih checkout");
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 fails open until --kih-dir or WK_KIH_DIR points at a kih checkout"));
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", hk, k ? k + (st.harness && st.harness.mode ? " (" + st.harness.mode + ")" : "") : "not found", !!(st.harness && st.harness.mode === "web"));
398
- // guards: which guard set the plugin can run. The full set needs PowerShell; without it the plugin falls back to
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
- add("skills", n > 0 && printed, n + " skills in " + hq + (printed ? "; wkappbot skill read on-load prints" : why), !!(st.skills && !st.skills.skipped));
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 = (() => { try { return execFileSync("git", ["config", "--global", "--get", "core.hooksPath"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); } catch { return ""; } })();
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) ? "kih checkout" : "web harness"); process.exit(0);
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
- if (!out.provider) out.provider = template.provider;
101
- else if (template.provider) for (const [k, v] of Object.entries(template.provider)) if (!(k in out.provider)) out.provider[k] = v;
102
- if (!out.permission) out.permission = template.permission;
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
  }