wkappbot-code 0.3.0 → 0.3.1

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 CHANGED
@@ -26,13 +26,12 @@ bash wkappbot-code/selftest.sh # RESULT N/N; --synthetic-only where no
26
26
 
27
27
  ### One command installs everything
28
28
 
29
- Get the CLI by one of four roads, then run `wkappbot-code install` once:
29
+ Get the CLI by one of three roads, then run `wkappbot-code install` once:
30
30
 
31
31
  ```bash
32
32
  npm i -g https://kiexpert.github.io/wkappbot-kih/wkappbot-code/wkappbot-code.tgz # web: the scrub-gated tgz the SDK workflow ships daily (SHA256SUMS + version.json beside it)
33
33
  npm i -g wkappbot-code # registry (published only when the SDK has NPM_TOKEN)
34
- npm i -g --install-links git+https://github.com/kiexpert/wkappbot-kih.git # git: the repo ROOT package exposes the same bin
35
- npm pack ./wkappbot-code && npm i -g ./wkappbot-code-0.3.0.tgz # from a checkout
34
+ npm pack ./wkappbot-code && npm i -g ./wkappbot-code-0.3.1.tgz # from a checkout
36
35
 
37
36
  wkappbot-code install # every part below, idempotent
38
37
  wkappbot-code doctor # one PASS/WARN/FAIL/SKIP row per part
@@ -40,7 +39,6 @@ wkappbot-code --version
40
39
  # the old command name wk-appbot-code (0.2.x) still works: it is a second bin entry for the same file
41
40
  ```
42
41
 
43
- The git road needs `--install-links`: without it npm 10 links the bin to a temp clone it then deletes.
44
42
 
45
43
  | part | what `install` lays down | skip with |
46
44
  |---|---|---|
@@ -72,4 +70,5 @@ The kih lite hooks stay live in the guest. Each question is a prompt event, and
72
70
 
73
71
  ## Floor
74
72
 
75
- Signing, submitting and paying stay human-only. Never install kih or anything built from it onto the SK laptop.
73
+
74
+ Signing, submitting and paying stay human-only.
@@ -14,6 +14,10 @@
14
14
  // a READ-ONLY copy with no git remote. A user's own skill edits go to the private
15
15
  // home ~/.wkappbot/private-skills (a local git repo, no remote) skip: --no-skills
16
16
  // wkappbot-code doctor [opts] one PASS/WARN/FAIL/SKIP row per part (exit 1 if a required part fails)
17
+ // wkappbot-code update [opts] newer version.json -> tgz sha256-checked -> npm i -g -> install --update (lib/update.js)
18
+ // wkappbot-code config [opts] detect provider/model/plugin/harness, edit at a TTY, merge into opencode.json (lib/config.js)
19
+ // install/doctor/config check for a newer version at most once a day (WKAPPBOT_CODE_NO_UPDATE_CHECK=1 turns it off;
20
+ // --auto-update or WKAPPBOT_CODE_AUTO_UPDATE=1 applies it). config opts: --yes, --project, --set key=value
17
21
  // opts: --config-dir DIR, --kih-dir DIR, --bin-dest FILE, --hq-dir DIR
18
22
  // env (mock and relocation): WK_BIN_BASE (SDK release base), WK_HARNESS_BASE (web harness base),
19
23
  // WK_HARNESS_DIR (~/.wk-harness), WK_BIN_DEST, WK_APPBOT_HQ, WK_LINK_DIR, WK_PRIVATE_SKILLS,
@@ -30,6 +34,8 @@ import {
30
34
  import { homedir, tmpdir } from "node:os";
31
35
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
32
36
  import { fileURLToPath, pathToFileURL } from "node:url";
37
+ import { quietCheck, runUpdate, updateRow } from "../lib/update.js";
38
+ import { applySets, askEach, buildConfig, configRow, detect, lineDiff, printDetected } from "../lib/config.js";
33
39
 
34
40
  const PKG_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
35
41
  const PKG = JSON.parse(readFileSync(join(PKG_DIR, "package.json"), "utf8"));
@@ -41,7 +47,7 @@ const IS_WIN = process.platform === "win32";
41
47
 
42
48
  function parseArgs(argv) {
43
49
  const o = { cmd: argv[0] || "help", npm: true, bin: true, harness: true, skills: true, webHarness: false,
44
- configDir: "", kihDir: "", binDest: "", hqDir: "" };
50
+ configDir: "", kihDir: "", binDest: "", hqDir: "", update: false, autoUpdate: false, yes: false, project: false, sets: [] };
45
51
  for (let i = 1; i < argv.length; i++) {
46
52
  const a = argv[i];
47
53
  if (a === "--no-npm") o.npm = false;
@@ -53,6 +59,11 @@ function parseArgs(argv) {
53
59
  else if (a === "--kih-dir") o.kihDir = argv[++i] || "";
54
60
  else if (a === "--bin-dest") o.binDest = argv[++i] || "";
55
61
  else if (a === "--hq-dir") o.hqDir = argv[++i] || "";
62
+ else if (a === "--update") o.update = true;
63
+ else if (a === "--auto-update") o.autoUpdate = true;
64
+ else if (a === "--yes" || a === "-y") o.yes = true;
65
+ else if (a === "--project") o.project = true;
66
+ else if (a === "--set") o.sets.push(argv[++i] || "");
56
67
  else { console.error("unknown option: " + a); process.exit(64); }
57
68
  }
58
69
  return o;
@@ -101,15 +112,7 @@ export function platformId(platform = process.platform, arch = process.arch) {
101
112
 
102
113
  // Merge our plugin entry into a user config: drop any older wisdom-kernel entry, append the new one.
103
114
  export function mergeConfig(user, template, url, kih) {
104
- const out = { ...user };
105
- const ours = [url, { kihDir: kih }];
106
- const keep = (Array.isArray(user.plugin) ? user.plugin : []).filter((p) => !String(Array.isArray(p) ? p[0] : p).includes("wisdom-kernel"));
107
- out.plugin = [...keep, ours];
108
- if (!out.$schema && template.$schema) out.$schema = template.$schema;
109
- if (!out.provider) out.provider = template.provider;
110
- else if (template.provider) for (const [k, v] of Object.entries(template.provider)) if (!(k in out.provider)) out.provider[k] = v;
111
- if (!out.permission) out.permission = template.permission;
112
- return out;
115
+ return buildConfig(user, { plugin: { value: url }, harness: { value: kih } }, template);
113
116
  }
114
117
 
115
118
  // ---- part: bin ------------------------------------------------------------------------------------
@@ -266,7 +269,7 @@ function installSkills(o, dest) {
266
269
  }
267
270
 
268
271
  // ---- install --------------------------------------------------------------------------------------
269
- function installPlugin(o, kih) {
272
+ function installPlugin(o, kih, kihSource) {
270
273
  const cfg = configDir(o);
271
274
  mkdirSync(join(cfg, "wkappbot-code"), { recursive: true });
272
275
  const dest = join(cfg, "wkappbot-code", "wisdom-kernel.js");
@@ -275,29 +278,36 @@ function installPlugin(o, kih) {
275
278
  writeFileSync(join(cfg, "wkappbot-code", "package.json"), '{ "type": "module" }\n');
276
279
  const url = pathToFileURL(dest).href;
277
280
  const tmpl = JSON.parse(readFileSync(join(PKG_DIR, "opencode.json"), "utf8"));
278
- const target = join(cfg, "opencode.json");
279
- let next, note = "";
281
+ const target = o.project ? join(process.cwd(), "opencode.json") : join(cfg, "opencode.json");
282
+ let user = {}, raw = "";
280
283
  if (existsSync(target)) {
281
- const raw = readFileSync(target, "utf8");
282
- let user;
284
+ raw = readFileSync(target, "utf8");
283
285
  try { user = JSON.parse(raw); } catch { user = null; }
284
- if (!user) {
285
- // JSONC or broken: never rewrite what we cannot parse; leave a sidecar to merge by hand.
286
- const side = join(cfg, "wkappbot-code.json");
287
- writeFileSync(side, JSON.stringify(mergeConfig({}, tmpl, url, kih), null, 2) + "\n");
288
- log(target + " is not plain JSON; left untouched, wrote " + side + " -- merge its plugin entry by hand");
289
- return;
290
- }
291
- next = mergeConfig(user, tmpl, url, kih);
292
- if (JSON.stringify(next, null, 2) + "\n" === raw) note = " (unchanged)";
293
- else {
286
+ }
287
+ const it = detect({ user: user || {}, pluginUrl: url, kih, kihSource: kihSource || "kih checkout" });
288
+ for (const k of applySets(it, o.sets)) log("config: unknown --set key " + k + " (provider|model|plugin|harness)");
289
+ printDetected(it, log);
290
+ if (process.stdin.isTTY && process.stdout.isTTY && !o.yes) askEach(it);
291
+ if (!user) {
292
+ // JSONC or broken: never rewrite what we cannot parse; leave a sidecar to merge by hand.
293
+ const side = join(dirname(target), "wkappbot-code.json");
294
+ writeFileSync(side, JSON.stringify(buildConfig({}, it, tmpl), null, 2) + "\n");
295
+ log(target + " is not plain JSON; left untouched, wrote " + side + " -- merge its plugin entry by hand");
296
+ return;
297
+ }
298
+ const text = JSON.stringify(buildConfig(user, it, tmpl), null, 2) + "\n";
299
+ let note = "";
300
+ if (raw && text === raw) note = " (unchanged)";
301
+ else {
302
+ if (raw) {
294
303
  const bak = target + ".bak-" + stamp();
295
304
  copyFileSync(target, bak);
296
305
  note = " (merged; backup " + bak + ")";
297
306
  }
298
- } else next = mergeConfig({}, tmpl, url, kih);
299
- writeFileSync(target + ".tmp", JSON.stringify(next, null, 2) + "\n");
300
- renameSync(target + ".tmp", target);
307
+ for (const l of lineDiff(raw, text)) log("diff " + l);
308
+ writeFileSync(target + ".tmp", text);
309
+ renameSync(target + ".tmp", target);
310
+ }
301
311
  log("plugin: " + url);
302
312
  log("config: " + target + note);
303
313
  }
@@ -305,6 +315,7 @@ function installPlugin(o, kih) {
305
315
  function install(o) {
306
316
  const st = { version: PKG.version, at: new Date().toISOString() };
307
317
  let bad = 0;
318
+ if (o.update) log("install --update: refreshing the core, the harness and the public skills");
308
319
  if (o.npm) {
309
320
  const have = opencodeVersion();
310
321
  if (have === OPENCODE_VERSION) log("opencode " + have + " already installed");
@@ -330,7 +341,7 @@ function install(o) {
330
341
  }
331
342
  st.skills = o.skills ? installSkills(o, dest) : { skipped: true };
332
343
  if (o.skills && !st.skills.ok) { bad++; log("skills: FAILED -- " + (st.skills.note || "on-load missing from the HQ")); }
333
- installPlugin(o, kih);
344
+ installPlugin(o, kih, (st.harness.mode === "web" && st.harness.ok) ? "web harness" : "kih checkout");
334
345
  st.kihDir = kih;
335
346
  writeFileSync(statePath(o), JSON.stringify(st, null, 2) + "\n");
336
347
  log("kih: " + (kih || "NOT FOUND -- plugin fails open until --kih-dir or WK_KIH_DIR points at a kih checkout"));
@@ -358,6 +369,8 @@ function doctor(o) {
358
369
  let entry = null;
359
370
  try { entry = (JSON.parse(readFileSync(join(cfg, "opencode.json"), "utf8")).plugin || []).find((p) => String(Array.isArray(p) ? p[0] : p).includes("wisdom-kernel")); } catch {}
360
371
  add("config", !!entry, join(cfg, "opencode.json"), true);
372
+ const oc = configRow(join(cfg, "opencode.json"));
373
+ add("opencode-config", oc.ok, oc.detail, false);
361
374
  // bin: the core answers --version
362
375
  const dest = o.binDest ? resolve(o.binDest) : (st.bin && st.bin.dest) || binDest(o);
363
376
  let core = "";
@@ -372,6 +385,14 @@ function doctor(o) {
372
385
  const hk = hasHook(k);
373
386
  if (st.harness && st.harness.skipped && !hk) add("kih-hook", false, "skipped (--no-harness)", false, true);
374
387
  else add("kih-hook", hk, k ? k + (st.harness && st.harness.mode ? " (" + st.harness.mode + ")" : "") : "not found", !!(st.harness && st.harness.mode === "web"));
388
+ // guards: which guard set the plugin can run. The full set needs PowerShell; without it the plugin falls back to
389
+ // the bash lite harness (intent lock, study lock), so this row WARNs instead of passing silently (QA 2026-11-06).
390
+ if (hk) {
391
+ const psDirs = String(process.env.PATH || "").split(IS_WIN ? ";" : ":");
392
+ const ps = ["pwsh", "pwsh.exe", "powershell.exe", "powershell"].find((n) => psDirs.some((d) => d && existsSync(join(d, n))));
393
+ const lite = existsSync(join(k, "tools", "wkharness-lite.sh"));
394
+ 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);
395
+ }
375
396
  // skills: the HQ holds on-load and the core prints it
376
397
  const hq = (st.skills && st.skills.hq) || hqDir(o, dest);
377
398
  if (st.skills && st.skills.skipped) add("skills", false, "skipped (--no-skills)", false, true);
@@ -399,6 +420,8 @@ function doctor(o) {
399
420
  const rem = existsSync(join(ph, ".git")) ? gitRemotes(ph) : [];
400
421
  const remNote = rem && rem.length ? "; private home " + ph + " HAS remote(s) " + rem.join(",") : "; private home " + ph + " has no remote";
401
422
  add("skill-push", login === OWNER_LOGIN && !(rem && rem.length), "= owner-only; a push would use github login " + login + remNote, false);
423
+ const ur = updateRow(PKG.version);
424
+ add("update", !!ur.ok, ur.detail, false, !!ur.skip);
402
425
  let bad = 0;
403
426
  for (const r of rows) {
404
427
  console.log((r.skip ? "SKIP " : r.ok ? "PASS " : r.required ? "FAIL " : "WARN ") + r.name + " " + r.detail);
@@ -408,13 +431,23 @@ function doctor(o) {
408
431
  }
409
432
 
410
433
  const o = parseArgs(process.argv.slice(2));
434
+ // install --update (from update) and these flags are forwarded to the re-run install of a fresh version.
435
+ const fwd = process.argv.slice(3).filter((a) => a !== "--auto-update");
436
+ const upd = () => runUpdate({ current: PKG.version, cliPath: process.argv[1], installArgs: fwd, log: console.log });
437
+ if (["install", "doctor", "config"].includes(o.cmd) && !o.update) {
438
+ const nv = quietCheck(PKG.version, console.log);
439
+ if (nv && (o.autoUpdate || process.env.WKAPPBOT_CODE_AUTO_UPDATE === "1")) upd();
440
+ }
411
441
  switch (o.cmd) {
412
442
  case "--version": case "-v": case "version":
413
443
  console.log("wkappbot-code " + PKG.version + " (opencode-ai " + OPENCODE_VERSION + ")"); process.exit(0);
414
444
  case "install": process.exit(install(o));
415
445
  case "doctor": process.exit(doctor(o));
446
+ case "update": process.exit(upd());
447
+ case "config": installPlugin(o, kihDir(o), kihCheckout(o) ? "kih checkout" : "web harness"); process.exit(0);
416
448
  default:
417
449
  console.log("usage: wkappbot-code --version | install [--no-npm] [--no-bin] [--no-harness] [--no-skills] [--web-harness]\n" +
418
- " [--config-dir DIR] [--kih-dir DIR] [--bin-dest FILE] [--hq-dir DIR] | doctor [--config-dir DIR]");
450
+ " [--config-dir DIR] [--kih-dir DIR] [--bin-dest FILE] [--hq-dir DIR] [--update] [--auto-update] | doctor [--config-dir DIR]\n" +
451
+ " | update [install opts] | config [--yes] [--project] [--set key=value] [--config-dir DIR]");
419
452
  process.exit(o.cmd === "help" || o.cmd === "--help" ? 0 : 64);
420
453
  }
package/lib/config.js ADDED
@@ -0,0 +1,122 @@
1
+ // config.js -- the minimum opencode config for wkappbot-code (owner 2026-11-06: detect the needed items, let the user
2
+ // edit them, then generate). Four items only: provider and model (from which API-key env var is PRESENT, or an
3
+ // existing `opencode auth` login -- values are never read or printed), the wisdom-kernel plugin URL, the harness dir.
4
+ // The file is MERGED: a backup first, every user key kept in place, no secret written (providers read env vars).
5
+ // Spec: wkappbot skill read agent-harness-landscape-2026-howto
6
+ import { existsSync, readFileSync, readSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { join } from "node:path";
9
+
10
+ export const KEYS = ["provider", "model", "plugin", "harness"];
11
+ // env var -> opencode provider id -> default model ("provider/model", editable with --set model=...)
12
+ export const PROVIDERS = [
13
+ ["ANTHROPIC_API_KEY", "anthropic", "anthropic/claude-sonnet-5-5"],
14
+ ["OPENAI_API_KEY", "openai", "openai/gpt-5"],
15
+ ["GEMINI_API_KEY", "google", "google/gemini-2.5-pro"],
16
+ ["GROQ_API_KEY", "groq", "groq/llama-3.3-70b-versatile"],
17
+ ["OPENROUTER_API_KEY", "openrouter", "openrouter/anthropic/claude-sonnet-4.5"],
18
+ ];
19
+ const defaultModel = (prov) => { const d = PROVIDERS.find((p) => p[1] === prov); return d ? d[2] : prov + "/default"; };
20
+
21
+ // Provider ids of an existing `opencode auth login` (key names only).
22
+ export function authProviders(env = process.env) {
23
+ const p = join(env.XDG_DATA_HOME || join(homedir(), ".local", "share"), "opencode", "auth.json");
24
+ try { return Object.keys(JSON.parse(readFileSync(p, "utf8")) || {}); } catch { return []; }
25
+ }
26
+
27
+ // Detect the four items. user = the existing config object ({} when none).
28
+ export function detect({ env = process.env, user = {}, pluginUrl = "", kih = "", kihSource = "kih checkout" } = {}) {
29
+ const it = {};
30
+ if (user && user.model) it.model = { value: String(user.model), source: "existing config" };
31
+ else {
32
+ const hit = PROVIDERS.find(([k]) => env[k]);
33
+ if (hit) it.model = { value: hit[2], source: "env " + hit[0] + " present" };
34
+ else {
35
+ const a = authProviders(env)[0];
36
+ if (a) it.model = { value: defaultModel(a), source: "opencode auth login (" + a + ")" };
37
+ }
38
+ }
39
+ if (it.model) it.provider = { value: it.model.value.split("/")[0], source: it.model.source };
40
+ if (pluginUrl) it.plugin = { value: pluginUrl, source: "installed package" };
41
+ if (kih) it.harness = { value: kih, source: kihSource };
42
+ return it;
43
+ }
44
+
45
+ export function printDetected(it, log) {
46
+ for (const k of KEYS) log(it[k] ? "detected " + k + " = " + it[k].value + " (" + it[k].source + ")" : "missing " + k);
47
+ }
48
+
49
+ // Keep provider and model consistent after an override.
50
+ function reconcile(it, changed) {
51
+ if (changed === "model" && it.model) it.provider = { value: it.model.value.split("/")[0], source: it.model.source, forced: true };
52
+ if (changed === "provider" && it.provider && (!it.model || it.model.value.split("/")[0] !== it.provider.value))
53
+ it.model = { value: defaultModel(it.provider.value), source: it.provider.source, forced: true };
54
+ }
55
+
56
+ // --set key=value overrides (provider|model|plugin|harness). Returns the unknown keys.
57
+ export function applySets(it, sets) {
58
+ const bad = [];
59
+ for (const s of sets) {
60
+ const i = s.indexOf("=");
61
+ const k = i > 0 ? s.slice(0, i) : s;
62
+ if (!KEYS.includes(k)) { bad.push(k); continue; }
63
+ it[k] = { value: s.slice(i + 1), source: "--set", forced: true };
64
+ reconcile(it, k);
65
+ }
66
+ return bad;
67
+ }
68
+
69
+ function readLineSync() {
70
+ const b = Buffer.alloc(1);
71
+ let s = "";
72
+ for (;;) {
73
+ let n = 0;
74
+ try { n = readSync(0, b, 0, 1, null); } catch (e) { if (e.code === "EAGAIN") continue; break; }
75
+ if (n === 0) break;
76
+ const c = b.toString("utf8");
77
+ if (c === "\n") break;
78
+ s += c;
79
+ }
80
+ return s.replace(/\r$/, "");
81
+ }
82
+
83
+ // At a TTY: one question per item; Enter keeps the detected value.
84
+ export function askEach(it, out = process.stdout) {
85
+ for (const k of KEYS) {
86
+ out.write(k + " [" + (it[k] ? it[k].value : "none") + "]: ");
87
+ const a = readLineSync().trim();
88
+ if (a) { it[k] = { value: a, source: "typed", forced: true }; reconcile(it, k); }
89
+ }
90
+ }
91
+
92
+ // Merge the items into the user's config: user keys stay where they are; the wisdom-kernel plugin entry is replaced;
93
+ // the template's provider/permission are added only where the user has none; model only when absent or overridden.
94
+ export function buildConfig(user, it, template) {
95
+ const out = { ...user };
96
+ if (!out.$schema && template.$schema) out.$schema = template.$schema;
97
+ const ours = [it.plugin ? it.plugin.value : "", { kihDir: it.harness ? it.harness.value : "" }];
98
+ const keep = (Array.isArray(user.plugin) ? user.plugin : []).filter((p) => !String(Array.isArray(p) ? p[0] : p).includes("wisdom-kernel"));
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;
103
+ if (it.model && (it.model.forced || !user.model)) out.model = it.model.value;
104
+ return out;
105
+ }
106
+
107
+ // A small line diff for the printout: removed lines first, then added lines.
108
+ export function lineDiff(before, after) {
109
+ const A = before.split("\n"), B = after.split("\n");
110
+ const sa = new Set(A), sb = new Set(B);
111
+ return [...A.filter((l) => l && !sb.has(l)).map((l) => "- " + l), ...B.filter((l) => l && !sa.has(l)).map((l) => "+ " + l)];
112
+ }
113
+
114
+ // The doctor row for an opencode.json path: { ok, detail }.
115
+ export function configRow(target) {
116
+ let c = null;
117
+ try { c = JSON.parse(readFileSync(target, "utf8")); } catch { return { ok: false, detail: existsSync(target) ? target + " is not plain JSON" : "missing " + target }; }
118
+ const wired = (c.plugin || []).some((p) => String(Array.isArray(p) ? p[0] : p).includes("wisdom-kernel"));
119
+ const model = c.model ? String(c.model) : "";
120
+ if (model && wired) return { ok: true, detail: "provider " + model.split("/")[0] + ", model " + model + ", plugin wired" };
121
+ return { ok: false, detail: [model ? "" : "missing model", wired ? "" : "plugin not wired"].filter(Boolean).join(", ") + " in " + target };
122
+ }
package/lib/update.js ADDED
@@ -0,0 +1,127 @@
1
+ // update.js -- wkappbot-code self-update (owner 2026-11-06: an auto-update in the launcher, with QA).
2
+ // Remote: <base>/version.json = {"name":"wkappbot-code","version":"X.Y.Z","tgz":"wkappbot-code.tgz","sha256":"<64 hex>"}
3
+ // as the SDK Pages workflow publishes it; "tgz" (or "url"/"file") is resolved relative to version.json, and a missing
4
+ // sha256 is read from SHA256SUMS beside it. WKAPPBOT_CODE_UPDATE_URL overrides the address (tests);
5
+ // WKAPPBOT_CODE_NO_UPDATE_CHECK=1 turns the quiet check off. Spec: wkappbot skill read agent-harness-landscape-2026-howto
6
+ import { spawnSync } from "node:child_process";
7
+ import { createHash } from "node:crypto";
8
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
9
+ import { homedir, tmpdir } from "node:os";
10
+ import { dirname, join } from "node:path";
11
+
12
+ export const DEFAULT_UPDATE_URL = "https://kiexpert.github.io/wkappbot-kih/wkappbot-code/version.json";
13
+ export const updateUrl = () => process.env.WKAPPBOT_CODE_UPDATE_URL || DEFAULT_UPDATE_URL;
14
+ export const stampPath = () => join(homedir(), ".wkappbot-code", "last-update-check");
15
+ const DAY_MS = 24 * 3600 * 1000;
16
+
17
+ // -1 / 0 / 1 on the first three numeric parts ("0.3.10" > "0.3.9").
18
+ export function cmpVer(a, b) {
19
+ const p = (v) => String(v || "").replace(/^v/, "").split(/[.+-]/).slice(0, 3).map((n) => parseInt(n, 10) || 0);
20
+ const x = p(a), y = p(b);
21
+ for (let i = 0; i < 3; i++) if ((x[i] || 0) !== (y[i] || 0)) return (x[i] || 0) > (y[i] || 0) ? 1 : -1;
22
+ return 0;
23
+ }
24
+
25
+ function curl(url, out, secs) {
26
+ const r = spawnSync("curl", ["-fsSL", "-m", String(secs), url, "-o", out], { encoding: "utf8" });
27
+ return r.status === 0 ? "" : "curl " + url + " rc " + r.status + (r.stderr ? " " + r.stderr.trim() : "");
28
+ }
29
+ const rel = (base, p) => { try { return new URL(p, base).href; } catch { return p; } };
30
+
31
+ // Read the remote version.json: { ok, version, sha256, tgzUrl } or { ok: false, err }.
32
+ export function readRemote(secs = 20) {
33
+ const url = updateUrl();
34
+ const tmp = mkdtempSync(join(tmpdir(), "wkappbot-code-upd-"));
35
+ try {
36
+ const err = curl(url, join(tmp, "v.json"), secs);
37
+ if (err) return { ok: false, err };
38
+ let v = null;
39
+ try { v = JSON.parse(readFileSync(join(tmp, "v.json"), "utf8")); } catch { return { ok: false, err: "version.json is not JSON" }; }
40
+ if (!v || !v.version) return { ok: false, err: "version.json has no version" };
41
+ // A page for another package (or an unnamed one) is never an update: no downgrade, no cross-install.
42
+ if (!["wkappbot-code", "wk-appbot-code"].includes(String(v.name || ""))) return { ok: false, foreign: true, err: "version.json names package " + JSON.stringify(v.name || "") + ", not wkappbot-code" };
43
+ const tgzUrl = rel(url, v.url || v.tgz || v.file || "wkappbot-code.tgz");
44
+ let sha = String(v.sha256 || "").toLowerCase();
45
+ if (!/^[0-9a-f]{64}$/.test(sha) && !curl(rel(url, "SHA256SUMS"), join(tmp, "sums"), secs)) {
46
+ const name = tgzUrl.split("/").pop();
47
+ for (const l of readFileSync(join(tmp, "sums"), "utf8").split("\n")) {
48
+ const m = /^([0-9a-f]{64})\s+\*?(\S+)$/i.exec(l.trim());
49
+ if (m && m[2].split("/").pop() === name) sha = m[1].toLowerCase();
50
+ }
51
+ }
52
+ return { ok: true, version: String(v.version), sha256: sha, tgzUrl, source: "pages" };
53
+ } finally { rmSync(tmp, { recursive: true, force: true }); }
54
+ }
55
+
56
+ // The second source (owner 2026-11-06: NPM_TOKEN added, the SDK publishes to the registry): npm view, silent offline.
57
+ // WKAPPBOT_CODE_NO_REGISTRY=1 skips it (tests).
58
+ export function readRegistry(secs = 15) {
59
+ if (process.env.WKAPPBOT_CODE_NO_REGISTRY === "1") return { ok: false, err: "registry check off" };
60
+ const r = spawnSync("npm", ["view", "wkappbot-code", "version"], { encoding: "utf8", timeout: secs * 1000 });
61
+ const v = (r.stdout || "").trim().split("\n").pop();
62
+ return r.status === 0 && /^\d+\.\d+\.\d+/.test(v) ? { ok: true, version: v, source: "registry" } : { ok: false, err: "npm view rc " + r.status };
63
+ }
64
+ const readAny = (secs) => { const p = readRemote(secs); if (p.ok || p.foreign) return p; const g = readRegistry(secs); return g.ok ? g : p; };
65
+
66
+ // `wkappbot-code update`: a newer remote is downloaded, sha256-checked, installed with npm i -g, then the NEW CLI
67
+ // re-runs `install --update` (core, harness, public skills). A sha mismatch changes nothing. Returns the exit code.
68
+ export function runUpdate({ current, cliPath, installArgs = [], log = console.log }) {
69
+ const r = readAny(30);
70
+ if (r.foreign) { log("PASS update: no update (" + r.err + ")"); return 0; }
71
+ if (!r.ok) { log("FAIL update: cannot read " + updateUrl() + " -- " + r.err + " (registry: none)"); return 1; }
72
+ if (cmpVer(r.version, current) <= 0) { log("PASS update: up to date (installed " + current + ", latest " + r.version + " from " + r.source + ")"); return 0; }
73
+ if (r.source === "registry") {
74
+ // npm verifies the registry tarball's own integrity hash.
75
+ log("PASS update: " + r.version + " available (installed " + current + ") on the npm registry");
76
+ const g = spawnSync("npm", ["install", "-g", "wkappbot-code@" + r.version, "--no-audit", "--no-fund", "--silent"], { encoding: "utf8" });
77
+ if (g.status !== 0) { log("FAIL update: npm install -g wkappbot-code@" + r.version + " rc " + g.status); return 1; }
78
+ log("PASS update: npm install -g wkappbot-code " + r.version);
79
+ return reinstall(cliPath, installArgs, log);
80
+ }
81
+ log("PASS update: " + r.version + " available (installed " + current + ") at " + r.tgzUrl);
82
+ if (!/^[0-9a-f]{64}$/.test(r.sha256)) { log("FAIL update: no sha256 published for " + r.version + "; nothing changed"); return 1; }
83
+ const tmp = mkdtempSync(join(tmpdir(), "wkappbot-code-upd-"));
84
+ try {
85
+ const tgz = join(tmp, "wkappbot-code-" + r.version + ".tgz");
86
+ const err = curl(r.tgzUrl, tgz, 300);
87
+ if (err) { log("FAIL update: download -- " + err + "; nothing changed"); return 1; }
88
+ const got = createHash("sha256").update(readFileSync(tgz)).digest("hex");
89
+ if (got !== r.sha256) { log("FAIL update: sha256 MISMATCH (expected " + r.sha256 + ", got " + got + "); nothing changed"); return 1; }
90
+ log("PASS update: sha256 ok " + got);
91
+ const n = spawnSync("npm", ["install", "-g", tgz, "--no-audit", "--no-fund", "--silent"], { encoding: "utf8" });
92
+ if (n.status !== 0) { log("FAIL update: npm install -g rc " + n.status + " " + (n.stderr || "").trim()); return 1; }
93
+ log("PASS update: npm install -g wkappbot-code " + r.version);
94
+ } finally { rmSync(tmp, { recursive: true, force: true }); }
95
+ return reinstall(cliPath, installArgs, log);
96
+ }
97
+
98
+ // The NEW CLI (npm's bin link now points at it) refreshes the core, the harness and the public skills.
99
+ function reinstall(cliPath, installArgs, log) {
100
+ const i = spawnSync(process.execPath, [cliPath, "install", "--update", ...installArgs],
101
+ { stdio: "inherit", env: { ...process.env, WKAPPBOT_CODE_NO_UPDATE_CHECK: "1" } });
102
+ log((i.status === 0 ? "PASS" : "FAIL") + " update: install --update rc " + i.status);
103
+ return i.status === 0 ? 0 : 1;
104
+ }
105
+
106
+ // The quiet check every other subcommand runs: at most once per 24 h (stamp written first), silent on any failure.
107
+ // Returns the newer remote version, else "".
108
+ export function quietCheck(current, log = console.log) {
109
+ if (process.env.WKAPPBOT_CODE_NO_UPDATE_CHECK === "1") return "";
110
+ const sp = stampPath();
111
+ try { if (Date.now() - statSync(sp).mtimeMs < DAY_MS) return ""; } catch {}
112
+ try { mkdirSync(dirname(sp), { recursive: true }); writeFileSync(sp, new Date().toISOString() + "\n"); } catch {}
113
+ let r = null;
114
+ try { r = readAny(5); } catch { return ""; }
115
+ if (!r || !r.ok || cmpVer(r.version, current) <= 0) return "";
116
+ log("wkappbot-code " + r.version + " available -- run wkappbot-code update");
117
+ return r.version;
118
+ }
119
+
120
+ // The doctor row: { ok, skip, detail } -- PASS when current is the latest (or newer), WARN when older, SKIP offline.
121
+ export function updateRow(current) {
122
+ if (process.env.WKAPPBOT_CODE_NO_UPDATE_CHECK === "1") return { skip: true, detail: "current " + current + " (check off: WKAPPBOT_CODE_NO_UPDATE_CHECK=1)" };
123
+ let r = null;
124
+ try { r = readAny(10); } catch (e) { r = { ok: false, err: String(e) }; }
125
+ if (!r.ok) return { skip: true, detail: "current " + current + " / latest ? (offline: " + r.err + ")" };
126
+ return { ok: cmpVer(current, r.version) >= 0, detail: "current " + current + " / latest " + r.version + " (" + r.source + ")" };
127
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "wkappbot-code",
3
- "version": "0.3.0",
4
- "description": "wkappbot-code -- Wisdom Kernel: the pinned OpenCode bridge plus the kih harness plugin, the wkappbot core, the web harness and the latest public skills in one install",
3
+ "version": "0.3.1",
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
  "bin": {
7
7
  "wkappbot-code": "bin/wkappbot-code.js",
@@ -17,17 +17,17 @@ import { dirname, join, resolve } from "node:path";
17
17
  import { fileURLToPath } from "node:url";
18
18
 
19
19
  // OpenCode tool id -> Claude tool name the kih guards already know.
20
- export const TOOL_MAP = {
20
+ const TOOL_MAP = {
21
21
  bash: "Bash", edit: "Edit", write: "Write", read: "Read",
22
22
  glob: "Glob", grep: "Grep", list: "LS", patch: "Edit", multiedit: "MultiEdit",
23
23
  webfetch: "WebFetch", websearch: "WebSearch", task: "Agent", todowrite: "TodoWrite",
24
24
  };
25
25
 
26
26
  // pre arm budget: a hung hook is killed and the call fails OPEN, like a Claude hook timeout.
27
- export const PRE_TIMEOUT_MS = Number(process.env.WK_APPBOT_PRE_TIMEOUT_MS || 60000);
27
+ const PRE_TIMEOUT_MS = Number(process.env.WK_APPBOT_PRE_TIMEOUT_MS || 60000);
28
28
 
29
29
  // OpenCode args (camelCase) -> Claude tool_input (snake_case) for the tools the guards inspect.
30
- export function toClaudeInput(tool, args) {
30
+ function toClaudeInput(tool, args) {
31
31
  const a = args || {};
32
32
  switch (tool) {
33
33
  case "bash": {
@@ -55,12 +55,12 @@ export function toClaudeInput(tool, args) {
55
55
  }
56
56
  }
57
57
 
58
- export function claudeToolName(tool) {
58
+ function claudeToolName(tool) {
59
59
  return TOOL_MAP[tool] || tool;
60
60
  }
61
61
 
62
62
  // The Claude-shaped hook payload for one OpenCode tool call.
63
- export function buildPreToolUsePayload(input, args, cwd) {
63
+ function buildPreToolUsePayload(input, args, cwd) {
64
64
  return {
65
65
  hook_event_name: "PreToolUse",
66
66
  session_id: input.sessionID,
@@ -72,7 +72,7 @@ export function buildPreToolUsePayload(input, args, cwd) {
72
72
  };
73
73
  }
74
74
 
75
- export function buildPostToolUsePayload(input, output, cwd) {
75
+ function buildPostToolUsePayload(input, output, cwd) {
76
76
  const p = buildPreToolUsePayload(input, input.args, cwd);
77
77
  p.hook_event_name = "PostToolUse";
78
78
  p.tool_response = { output: output && output.output, title: output && output.title };
@@ -80,7 +80,7 @@ export function buildPostToolUsePayload(input, output, cwd) {
80
80
  }
81
81
 
82
82
  // kih root: WK_KIH_DIR, else the checkout this file sits in (wkappbot-code/plugin -> ../..).
83
- export function resolveKihDir() {
83
+ function resolveKihDir() {
84
84
  if (process.env.WK_KIH_DIR) return process.env.WK_KIH_DIR;
85
85
  try {
86
86
  const up = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
@@ -89,11 +89,36 @@ export function resolveKihDir() {
89
89
  return "";
90
90
  }
91
91
 
92
- // Pipe JSON into "wkharness-run.sh <arm>"; resolves {code, stdout, stderr}. Never rejects.
92
+ // Pipe JSON into "<hook> <arm>" (wkharness-run.sh, or wkharness-lite.sh without PowerShell); resolves {code, stdout, stderr}. Never rejects.
93
93
  // No hook found = fail-open (code 0, skipped) so a missing kih never bricks OpenCode.
94
- export function runHook(arm, payload, opts = {}) {
94
+ // PowerShell on PATH? The full harness (wkharness-run.sh) runs its guards in PowerShell; without one it exits 127,
95
+ // which used to read as "no verdict" and let EVERY call through (QA 2026-11-06, fresh Linux HOME).
96
+ function onPath(names) {
97
+ const dirs = String(process.env.PATH || "").split(process.platform === "win32" ? ";" : ":");
98
+ for (const d of dirs) for (const n of names) { try { if (d && existsSync(join(d, n))) return true; } catch {} }
99
+ return false;
100
+ }
101
+ let _ps = null;
102
+ function hasPowerShell() {
103
+ if (process.env.WK_APPBOT_FORCE_LITE === "1") return false;
104
+ if (_ps === null) _ps = onPath(["pwsh", "pwsh.exe", "powershell.exe", "powershell"]);
105
+ return _ps;
106
+ }
107
+
108
+ // Which hook script serves an arm: the full harness when PowerShell is there, else the bash lite harness
109
+ // (tools/wkharness-lite.sh: study lock, intent lock, stop lint). The prompt and session arms exist only in lite.
110
+ function pickHook(arm, kih) {
111
+ if (!kih) return "";
112
+ const full = join(kih, ".claude", "hooks", "wkharness-run.sh"), lite = join(kih, "tools", "wkharness-lite.sh");
113
+ const liteArm = arm === "prompt" || arm === "session";
114
+ if (!liteArm && hasPowerShell() && existsSync(full)) return full;
115
+ if (existsSync(lite)) return lite;
116
+ return liteArm ? "" : full;
117
+ }
118
+
119
+ function runHook(arm, payload, opts = {}) {
95
120
  const kih = opts.kihDir ?? resolveKihDir();
96
- const hook = opts.hook || process.env.WK_APPBOT_HOOK || (kih && join(kih, ".claude", "hooks", "wkharness-run.sh"));
121
+ const hook = opts.hook || process.env.WK_APPBOT_HOOK || pickHook(arm, kih);
97
122
  return new Promise((done) => {
98
123
  let settled = false;
99
124
  const finish = (r) => { if (!settled) { settled = true; if (timer) clearTimeout(timer); done(r); } };
@@ -125,7 +150,7 @@ export function runHook(arm, payload, opts = {}) {
125
150
  }
126
151
 
127
152
  // Claude contract: exit 2 refuses; so does a stdout JSON deny/block verdict.
128
- export function isRefusal(r) {
153
+ function isRefusal(r) {
129
154
  if (r.timedOut || r.skipped) return false;
130
155
  if (r.code === 2) return true;
131
156
  try {
@@ -135,7 +160,7 @@ export function isRefusal(r) {
135
160
  }
136
161
 
137
162
  // A stdout deny JSON names its own reason; stderr then may hold only warn lines (wkharness-run.sh wk_pre_emit).
138
- export function refusalReason(r) {
163
+ function refusalReason(r) {
139
164
  try {
140
165
  const j = JSON.parse(r.stdout);
141
166
  const why = (j.hookSpecificOutput && j.hookSpecificOutput.permissionDecisionReason) || j.reason;
@@ -160,9 +185,23 @@ export const WisdomKernel = async (ctx = {}, options = {}) => {
160
185
  "tool.execute.after": async (input, output) => {
161
186
  try { await runHook("post", buildPostToolUsePayload(input || {}, output, cwd), { kihDir: k, timeoutMs: 30000 }); } catch {}
162
187
  },
188
+ // UserPromptSubmit: arms the intent lock for the next real tool call (fail-open, never blocks the message).
189
+ "chat.message": async (input, output) => {
190
+ try {
191
+ const parts = (output && output.parts) || [];
192
+ const prompt = parts.filter((p) => p && p.type === "text" && !p.synthetic).map((p) => p.text || "").join("\n");
193
+ if (!prompt) return;
194
+ await runHook("prompt", { hook_event_name: "UserPromptSubmit", session_id: input && input.sessionID, cwd, prompt, agent_family: "opencode" }, { kihDir: k, timeoutMs: 30000 });
195
+ } catch {}
196
+ },
163
197
  event: async (arg) => {
164
198
  try {
165
199
  const event = arg && arg.event;
200
+ if (event && event.type === "session.created") {
201
+ const info = (event.properties && event.properties.info) || {};
202
+ await runHook("session", { hook_event_name: "SessionStart", session_id: info.id, cwd, source: "startup", agent_family: "opencode" }, { kihDir: k, timeoutMs: 30000 });
203
+ return;
204
+ }
166
205
  if (!event || event.type !== "session.idle") return;
167
206
  const sid = event.properties && event.properties.sessionID;
168
207
  await runHook("stop-archive", { hook_event_name: "Stop", session_id: sid, cwd, agent_family: "opencode" }, { kihDir: k, timeoutMs: 30000 });
@@ -171,4 +210,8 @@ export const WisdomKernel = async (ctx = {}, options = {}) => {
171
210
  };
172
211
  };
173
212
 
213
+ // Helpers for the selftest ride on the plugin function, never as module exports (OpenCode calls every export).
214
+ WisdomKernel.internals = { TOOL_MAP, PRE_TIMEOUT_MS, toClaudeInput, claudeToolName, buildPreToolUsePayload,
215
+ buildPostToolUsePayload, resolveKihDir, runHook, isRefusal, refusalReason, hasPowerShell, pickHook };
216
+
174
217
  export default WisdomKernel;