wkappbot-code 0.3.12 → 0.3.13

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
@@ -41,6 +41,7 @@ Other roads to the CLI: `npm i -g https://kiexpert.github.io/wkappbot-kih/wkappb
41
41
  | part | what `install` lays down | skip with |
42
42
  |---|---|---|
43
43
  | opencode | pinned `opencode-ai@1.19.5` (`npm -g`) | `--no-npm` |
44
+ | ai-clis | the well-known AI coding CLIs `claude` (`@anthropic-ai/claude-code`), `codex` (`@openai/codex`), `gemini` (`@google/gemini-cli`) and `qwen` (`@qwen-code/qwen-code`), in one `npm -g` pass; no key or login is asked (each CLI asks for its own sign-in on first run), and a CLI that fails to install is a WARN, never a failed install | `--no-ai-clis` or `--no-npm` |
44
45
  | plugin | `plugin/wisdom-kernel.js` + `lib/wk-hook.js` plus the `opencode.json` plugin entry | -- |
45
46
  | bin | the `wkappbot` core for this platform from the SDK latest release asset `wkappbot-<platform>`, accepted only when it matches its `SHA256SUMS` line (`lib/wk-bin-acquire.sh`); placed at `~/.local/share/wkappbot/wkappbot` and linked as `wkappbot` beside `wkappbot-code` | `--no-bin` |
46
47
  | harness | when no harness checkout is found, the scrubbed web harness to `~/.wk-harness` through its own published `install.sh` (sha256-checked); `--web-harness` forces it | `--no-harness` |
@@ -48,6 +49,8 @@ Other roads to the CLI: `npm i -g https://kiexpert.github.io/wkappbot-kih/wkappb
48
49
 
49
50
  `install` prints whether `pwsh` was found and which harness will give the verdict. The harness install ends with an advisory line, `[wk-verify] WARN n/6 advisory, not a failure -- c2 ...; c5 ...; c6 ...`, naming each check that missed and why; in a good web install c2 (wkhippo), c5 (core skill read before the core is linked) and c6 (no harness git checkout) are expected to miss. The verdict file is `~/.wkappbot-code/verify.json`.
50
51
 
52
+ `npm install wkappbot-code` sets your global git `core.hooksPath` to `~/.wk-harness-hooks` (the push-owner gate, which then runs the repo's own hooks); `wkappbot-code uninstall` puts the previous value back, and leaves it alone if you changed it since.
53
+
51
54
  The skills are a **read-only copy**: the HQ has no git remote, and a skill you edit yourself lands in the private home `~/.wkappbot/private-skills`, a local git repo with no remote. Doctor's `skill-push = owner-only` row names the GitHub login a push would use.
52
55
 
53
56
  The generated `opencode.json` holds the minimum: `$schema` and the plugin entry (with `kihDir`, the harness the plugin calls). No provider and no permission block is added, because no refusal depends on one (every refusal comes from the plugin and the harness). An existing `opencode.json` is backed up (`opencode.json.bak-<stamp>`) and only the plugin entry is merged in; every other key you set stays. A config that is not plain JSON is left untouched, and `wkappbot-code.json` is written beside it for you to merge by hand. `wkappbot-code config` detects the provider and model from the API-key variables that are set (values are never read) and lets you edit them. Pass `--kih-dir` (or set `WK_KIH_DIR`) when a harness checkout is not auto-found (`~/wkappbot-kih` is searched). Relocation and mock overrides: `WK_BIN_BASE`, `WK_HARNESS_BASE`, `WK_HARNESS_DIR`, `WK_BIN_DEST`, `WK_APPBOT_HQ`, `WK_LINK_DIR`, `WK_PRIVATE_SKILLS`, `WK_SETTINGS`.
package/bin/prepack.js CHANGED
@@ -39,6 +39,14 @@ if (!stage) {
39
39
  if (r.status !== 0) die("harness bake rc " + r.status + "\n" + String(r.stderr).slice(-800));
40
40
  }
41
41
  if (!existsSync(join(stage, "tools", "wkharness-lite.sh"))) die("the baked tree " + stage + " has no tools/wkharness-lite.sh");
42
+ // The study-lock rule table, built here once (python3 on the build machine) and shipped as tools/wkharness-lite-rules.tsv,
43
+ // so a user machine with no python3 still gets the study lock (owner 2026-11-09: the web install failed without python).
44
+ {
45
+ const w = spawnSync("sh", [sh(join(stage, "tools", "wkharness-lite.sh")), "--wire"], { encoding: "utf8" });
46
+ const built = join(stage, ".tmp", "hook-lite", "rules.tsv");
47
+ if (w.status === 0 && existsSync(built)) { copyFileSync(built, join(stage, "tools", "wkharness-lite-rules.tsv")); rmSync(join(stage, ".tmp"), { recursive: true, force: true }); }
48
+ else die("study-lock rule table not built (needs python3 on the BUILD machine): rc " + w.status + " " + String(w.stderr || "").slice(-300));
49
+ }
42
50
 
43
51
  rmSync(OUT, { recursive: true, force: true });
44
52
  mkdirSync(OUT, { recursive: true });
@@ -3,6 +3,8 @@
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
+ // ai-clis the famous AI CLIs claude, codex, gemini, qwen (npm -g, login-free; lib/ai-clis.js); a failure
7
+ // is a WARN, never a failed part skip: --no-ai-clis or --no-npm
6
8
  // plugin plugin/wisdom-kernel.js + lib/wk-hook.js + the opencode.json plugin entry (always)
7
9
  // bin the wkappbot core for this platform, from the SDK latest release asset verified by
8
10
  // SHA256SUMS -- the SAME road as tools/wk-bin-acquire.sh of the harness repo, shipped
@@ -40,6 +42,11 @@ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
40
42
  import { fileURLToPath, pathToFileURL } from "node:url";
41
43
  import { quietCheck, runUpdate, updateRow } from "../lib/update.js"; import { rmTreeHard } from "../lib/rmtree.js"; import { plainFileUrl } from "../lib/fileurl.js";
42
44
  import { applySets, askEach, buildConfig, configRow, detect, lineDiff, printDetected } from "../lib/config.js";
45
+ import { aiCliRows, installAiClis, removeAiClis } from "../lib/ai-clis.js";
46
+ import { hooksRow, wireClaudeHooks } from "../lib/hook-wire.js";
47
+ import { installStarterMd, removeStarterMd } from "../lib/starter-md.js";
48
+ import { addAllows, removeAllows } from "../lib/allow-compact.js";
49
+ import { ensurePrereqs } from "../lib/prereq.js";
43
50
  import { files as setupFiles, launchOpenCode, printBanner } from "../lib/setup-run.js";
44
51
 
45
52
  const PKG_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
@@ -74,11 +81,13 @@ const tarArgs = (tgz, dir) => {
74
81
  };
75
82
 
76
83
  function parseArgs(argv) {
77
- const o = { cmd: argv[0] || "help", npm: true, bin: true, harness: true, skills: true, webHarness: false, devSeat: false,
84
+ const o = { cmd: argv[0] || "help", npm: true, aiClis: true, allows: true, bin: true, harness: true, skills: true, webHarness: false, devSeat: false,
78
85
  configDir: "", kihDir: "", binDest: "", hqDir: "", update: false, autoUpdate: false, yes: false, project: false, sets: [] };
79
86
  for (let i = 1; i < argv.length; i++) {
80
87
  const a = argv[i];
81
88
  if (a === "--no-npm") o.npm = false;
89
+ else if (a === "--no-ai-clis") o.aiClis = false;
90
+ else if (a === "--no-allows") o.allows = false;
82
91
  else if (a === "--no-bin") o.bin = false;
83
92
  else if (a === "--no-harness") o.harness = false;
84
93
  else if (a === "--no-skills") o.skills = false;
@@ -182,13 +191,14 @@ function installBin(o) {
182
191
  const dest = binDest(o);
183
192
  const plat = platformId();
184
193
  if (!plat) return { ok: false, dest, note: "no wkappbot build for " + process.platform + "-" + process.arch };
185
- if (plat === "win-x64") return { ok: false, dest, note: "win-x64 has no single SDK asset; run install.cmd from a harness checkout" };
186
194
  // A checkout runs its own copy (same bytes, plus the MANIFEST-verified repo bin/ fallback); the package
187
- // copy outside a checkout takes only the SDK release asset verified by SHA256SUMS.
195
+ // copy outside a checkout takes only the SDK release asset verified by SHA256SUMS. win-x64 (2026-11-09: no checkout
196
+ // meant NO core at all) takes the release pair wkappbot.exe + wkappbot-core.exe.xz into the DIRECTORY of dest.
188
197
  const co = kihCheckout(o);
189
198
  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");
190
- log("bin: bash " + script + " acquire --dest " + dest);
191
- const r = spawnSync("bash", [shPath(script), "acquire", "--dest", shPath(dest)], { encoding: "utf8", env: shEnv() });
199
+ const target = plat === "win-x64" ? dirname(dest) : dest;
200
+ log("bin: bash " + script + " acquire --dest " + target);
201
+ const r = spawnSync("bash", [shPath(script), "acquire", "--dest", shPath(target)], { encoding: "utf8", env: shEnv() });
192
202
  for (const line of ((r.stdout || "") + (r.stderr || "")).split("\n").filter(Boolean)) log(" " + line);
193
203
  // rc 3 = the release host could not be reached (a package-registry-only VM): no core, but NOT a failed install --
194
204
  // the hooks and the lite harness need no core and still refuse the risky classes. A sha256 mismatch stays rc 1.
@@ -197,6 +207,16 @@ function installBin(o) {
197
207
  // Put `wkappbot` on PATH beside wkappbot-code itself, unless that name is already taken by someone else.
198
208
  let link = "";
199
209
  const ld = linkDir();
210
+ if (IS_WIN) {
211
+ // Windows: a symlink needs developer mode, so the PATH entry is a .cmd shim beside wkappbot-code.cmd.
212
+ const cp = join(ld, "wkappbot.cmd");
213
+ try {
214
+ const mine = existsSync(cp) && readFileSync(cp, "utf8").includes(dest);
215
+ if (existsSync(cp) && !mine) log("bin: kept existing " + cp + " (not ours); call " + dest + " directly");
216
+ else { writeFileSync(cp, "@echo off\r\n\"" + dest + "\" %*\r\n"); link = cp; log("bin: wrote " + cp + " -> " + dest); }
217
+ } catch (e) { log("bin: could not write " + cp + " (" + e.message + ")"); }
218
+ return { ok: true, dest, link };
219
+ }
200
220
  const lp = join(ld, "wkappbot");
201
221
  try {
202
222
  const taken = existsSync(lp) || isSymlink(lp);
@@ -401,6 +421,10 @@ function install(o) {
401
421
  const prev = readState(o);
402
422
  const st = { version: PKG.version, at: new Date().toISOString(), prior: recordPrior(prev) };
403
423
  let bad = 0;
424
+ // 0.3.13 (owner 2026-11-09: Git Bash is a required tool too): bash, sh, curl, tar and git first; on Windows a Git that
425
+ // is not on PATH is used from its folder, and a missing one is installed with winget (lib/prereq.js)
426
+ st.prereq = ensurePrereqs({ log });
427
+ if (!st.prereq.ok) bad++;
404
428
  if (o.update) log("install --update: refreshing the core, the harness and the public skills");
405
429
  const ps = pwshPath();
406
430
  log("pwsh: " + (ps || "not found") + " -- the FULL harness (PowerShell 7) runs only on a dev seat (a harness checkout, or --dev-seat);" +
@@ -416,15 +440,26 @@ function install(o) {
416
440
  catch (e) { bad++; log("opencode: npm install FAILED (" + String(e.message).split("\n")[0] + ") -- run: npm install -g opencode-ai@" + OPENCODE_VERSION); }
417
441
  }
418
442
  }
443
+ // 0.3.13 (owner 2026-11-08): the famous AI CLIs come along, so a user who knows nothing about LLMs still has them.
444
+ st.aiClis = (o.npm && o.aiClis) ? installAiClis(log, process.env, undefined, (prev.aiClis && prev.aiClis.installed) || [])
445
+ : ((prev.aiClis && (prev.aiClis.installed || []).length) ? prev.aiClis : { skipped: true }); // a skip still owns what an earlier install added
419
446
  const dest = binDest(o);
420
447
  st.bin = o.bin ? installBin(o) : { skipped: true, dest };
421
- if (o.bin && st.bin.degraded) log("bin: WARN -- " + st.bin.note);
422
- else if (o.bin && !st.bin.ok) { bad++; log("bin: FAILED -- " + st.bin.note); }
448
+ // 0.3.13 (owner 2026-11-09: "check that a missing core is handled as a failure"): NO core is a failed part on every
449
+ // road, an unreachable release host included. The hooks and the lite harness still install, so the risky classes are
450
+ // refused, but the install exits 1 and the banner says so until a rerun fetches the core.
451
+ if (o.bin && !st.bin.ok) { bad++; log("bin: FAILED -- no wkappbot core installed: " + st.bin.note); }
423
452
  st.harness = o.harness ? installHarness(o) : { skipped: true };
424
453
  if (o.harness && !st.harness.ok) { bad++; log("harness: FAILED -- " + st.harness.note); }
425
454
  // The plugin entry names the harness the install just chose (a forced web harness wins over a checkout).
426
455
  const kih = (st.harness.mode === "web" && st.harness.ok) ? st.harness.dir : kihDir(o);
427
- // Global pre-push dispatcher (owner 2026-11-06): the push-owner gate runs in every repo, then the repo's own hooks.
456
+ // 0.3.13 (owner 2026-11-09): read the Claude settings first, then point every harness hook at the SAME harness the
457
+ // plugin uses, on both roads (web harness or checkout); the user's own hooks stay; bad JSON is reported, not touched.
458
+ st.hooks = o.harness ? wireClaudeHooks({ settings: SETTINGS(), kih, pwsh: !!ps, log }) : { skipped: true };
459
+ // the compressed allow list (lib/allow-compact.js): five harness-own prefixes, never the wide rows learned on the owner's seats
460
+ st.allows = (o.harness && o.allows) ? addAllows(SETTINGS(), prev.allows || {}, log) : (prev.allows || { skipped: true });
461
+ // the standard global rules for a fresh machine (lib/starter-md.js): only where the user has no ~/.claude/CLAUDE.md
462
+ st.starterMd = o.harness ? installStarterMd({ claudeDir: dirname(SETTINGS()), pkgDir: PKG_DIR, prev: prev.starterMd, log }) : { skipped: true }; // Global pre-push dispatcher (owner 2026-11-06): the push-owner gate runs in every repo, then the repo's own hooks.
428
463
  // A user's own absolute global core.hooksPath is kept (the script WARNs). Skipped with --no-harness.
429
464
  st.pushGate = { skipped: true };
430
465
  if (o.harness && kih && existsSync(join(kih, "tools", "wk-global-hooks.sh"))) {
@@ -478,8 +513,9 @@ function uninstall(o) {
478
513
  const r = pr.hooksPath ? gitGlobal("core.hooksPath", pr.hooksPath) : gitGlobal("--unset", "core.hooksPath");
479
514
  log("hooksPath: " + (cur || "unset") + " -> " + (pr.hooksPath || "unset") + (r.status === 0 ? "" : " (git rc " + r.status + ")"));
480
515
  } else log("hooksPath: " + (cur || "unset") + (cur === (pr.hooksPath ?? null) ? " (as before install)" : " (set by you, kept)"));
481
- // 2. settings.json: our arms out, yours kept
516
+ // 2. settings.json: our allow rules and arms out, yours kept
482
517
  const sp = SETTINGS();
518
+ removeAllows(sp, st.allows, log);
483
519
  if (existsSync(sp)) {
484
520
  try {
485
521
  const raw = readFileSync(sp, "utf8");
@@ -513,6 +549,7 @@ function uninstall(o) {
513
549
  }
514
550
  // 3b. the ~/.local/bin/wkappbot link: removed ONLY when it points at the core this install placed (the core file stays)
515
551
  const core = (st.bin && st.bin.dest) || binDest(o);
552
+ removeStarterMd(st.starterMd, log);
516
553
  let linkRemoved = "";
517
554
  for (const p of new Set(shimCandidates().concat([join(homedir(), ".local", "bin", "wkappbot")]))) {
518
555
  if (!isSymlink(p) || (st.shims || []).includes(p)) continue;
@@ -520,6 +557,16 @@ function uninstall(o) {
520
557
  if (tgt && real(tgt) === real(core)) { rmSync(p, { force: true }); linkRemoved = p; log("shim: removed " + p + " (a link to the core this install placed, " + core + ")"); }
521
558
  else log("shim: kept " + p + " (not a link to " + core + ")");
522
559
  }
560
+ // 3c. the Windows wkappbot.cmd the bin step wrote is a FILE, not a link, so the loop above never saw it (SDK os-qa
561
+ // 2026-11-09 win-x64: local-bin kept wkappbot.cmd after uninstall). Removed only while it still calls this core.
562
+ const cmdShim = st.bin && st.bin.link;
563
+ if (cmdShim && /\.cmd$/i.test(cmdShim) && existsSync(cmdShim)) {
564
+ let body = ""; try { body = readFileSync(cmdShim, "utf8"); } catch {}
565
+ if (body.includes(core)) { rmSync(cmdShim, { force: true }); linkRemoved = cmdShim; log("shim: removed " + cmdShim + " (it called the core this install placed)"); }
566
+ else log("shim: kept " + cmdShim + " (it no longer calls " + core + ")");
567
+ }
568
+ // 3d. the AI CLIs this install added (lib/ai-clis.js), never one the user had before
569
+ removeAiClis((st.aiClis && st.aiClis.installed) || [], log);
523
570
  // 4. the harness dirs we created, the plugin entry and our config folder
524
571
  // Windows frees a closed handle late (ENOTEMPTY): lib/rmtree.js retries ~6 s, then a per-entry pass names the blocker; a failure goes on.
525
572
  const rmTree = (p, what) => {
@@ -567,6 +614,9 @@ function doctor(o) {
567
614
  const st = readState(o);
568
615
  const rows = [];
569
616
  const add = (name, ok, detail, required, skip) => rows.push({ name, ok, detail, required, skip });
617
+ // prereq: the tools every part runs on (check only: doctor never installs; a Git off PATH is still found)
618
+ const pq = ensurePrereqs({ noInstall: true });
619
+ add("prereq", pq.ok, pq.ok ? Object.entries(pq.found).map(([t, p]) => t + "=" + p).join(" ") : pq.note, true);
570
620
  const v = opencodeVersion();
571
621
  add("opencode", v === OPENCODE_VERSION, v ? v + (v === OPENCODE_VERSION ? "" : " (pinned " + OPENCODE_VERSION + ")") : "not on PATH", false);
572
622
  const pdest = join(cfg, "wkappbot-code", "wisdom-kernel.js");
@@ -576,11 +626,12 @@ function doctor(o) {
576
626
  add("config", !!entry, join(cfg, "opencode.json"), true);
577
627
  const oc = configRow(join(cfg, "opencode.json"));
578
628
  add("opencode-config", oc.ok, oc.detail, false);
629
+ aiCliRows(add, !!(st.aiClis && st.aiClis.skipped));
579
630
  // bin: the core answers --version
580
631
  const dest = o.binDest ? resolve(o.binDest) : (st.bin && st.bin.dest) || binDest(o);
581
632
  let core = "";
582
633
  if (st.bin && st.bin.skipped) add("bin", false, "skipped (--no-bin)", false, true);
583
- else if (st.bin && st.bin.degraded && !existsSync(dest)) add("bin", false, dest + " absent -- " + st.bin.note, false);
634
+ else if (!existsSync(dest)) add("bin", false, dest + " absent -- no wkappbot core" + (st.bin && st.bin.note ? " (" + st.bin.note + ")" : "") + "; rerun: wkappbot-code install", true);
584
635
  else {
585
636
  const r = spawnSync(dest, ["--version"], { encoding: "utf8", timeout: 60000 });
586
637
  core = r.status === 0 ? (r.stdout || "").trim().split("\n")[0] : "";
@@ -595,6 +646,9 @@ function doctor(o) {
595
646
  if (st.harness && st.harness.skipped && !hk) add("kih-hook", false, "skipped (--no-harness)", false, true);
596
647
  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);
597
648
  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"));
649
+ // claude-hooks: the Claude Code hooks run the same harness as the plugin (a WARN, never a FAIL: Claude Code is optional).
650
+ if (st.hooks && st.hooks.skipped) add("claude-hooks", false, "skipped (--no-harness)", false, true);
651
+ else if (k) { const hr = hooksRow(SETTINGS(), k); add("claude-hooks", hr.ok, hr.detail, false); }
598
652
  // skills: the HQ holds on-load and the core prints it
599
653
  const hq = (st.skills && st.skills.hq) || hqDir(o, dest);
600
654
  if (st.skills && st.skills.skipped) add("skills", false, "skipped (--no-skills)", false, true);
@@ -655,7 +709,7 @@ switch (o.cmd) {
655
709
  case "update": process.exit(upd());
656
710
  case "config": installPlugin(o, kihDir(o), kihCheckout(o) ? "harness checkout" : "web harness"); process.exit(0);
657
711
  default:
658
- console.log("usage: wkappbot-code --version | install [--no-npm] [--no-bin] [--no-harness] [--no-skills] [--web-harness] [--dev-seat]\n" +
712
+ console.log("usage: wkappbot-code --version | install [--no-npm] [--no-ai-clis] [--no-allows] [--no-bin] [--no-harness] [--no-skills] [--web-harness] [--dev-seat]\n" +
659
713
  " [--config-dir DIR] [--kih-dir DIR] [--bin-dest FILE] [--hq-dir DIR] [--update] [--auto-update] | doctor [--config-dir DIR]\n" +
660
714
  " | uninstall [--config-dir DIR] | update [install opts] | config [--yes] [--project] [--set key=value] [--config-dir DIR]");
661
715
  process.exit(o.cmd === "help" || o.cmd === "--help" ? 0 : 64);
package/lib/ai-clis.js ADDED
@@ -0,0 +1,68 @@
1
+ // ai-clis.js -- the well-known AI coding CLIs that `wkappbot-code install` lays down beside OpenCode (owner 2026-11-08:
2
+ // "install the famous CLIs in one go, so a user who knows nothing about LLMs still gets them"). No key or login is asked:
3
+ // each CLI installs login-free and asks for its own sign-in the first time the user runs it. One `npm install -g` pass for
4
+ // all the missing ones; when that pass fails, each is retried alone so one bad package never blocks the rest. A failure is
5
+ // a WARN (never a failed install part), and doctor prints one WARN-level row per CLI. Skip with --no-ai-clis (or --no-npm).
6
+ import { spawnSync } from "node:child_process";
7
+
8
+ export const AI_CLIS = [
9
+ { name: "claude", pkg: "@anthropic-ai/claude-code" },
10
+ { name: "codex", pkg: "@openai/codex" },
11
+ { name: "gemini", pkg: "@google/gemini-cli" },
12
+ { name: "qwen", pkg: "@qwen-code/qwen-code" },
13
+ ];
14
+
15
+ const IS_WIN = process.platform === "win32";
16
+
17
+ // The first line a CLI prints for --version, or "" when it is not on PATH or does not answer.
18
+ export function cliVersion(name, env = process.env) {
19
+ const r = spawnSync(name, ["--version"], { encoding: "utf8", timeout: 30000, shell: IS_WIN, env });
20
+ if (r.status !== 0) return "";
21
+ const line = String(r.stdout || "").trim().split("\n")[0] || "";
22
+ const m = line.match(/\d+\.\d+\.\d+[\w.-]*/);
23
+ return m ? m[0] : line.slice(0, 40);
24
+ }
25
+
26
+ function npmGlobal(pkgs, env) {
27
+ const r = spawnSync("npm", ["install", "-g", ...pkgs, "--no-audit", "--no-fund"], { stdio: "inherit", shell: IS_WIN, env });
28
+ return r.status === 0;
29
+ }
30
+
31
+ // Installs every CLI of AI_CLIS that is not on PATH yet. Returns { installed: [...], present: [...], failed: [...] }.
32
+ // prevInstalled: the names an earlier install added; a rerun keeps owning them, so uninstall still takes them back.
33
+ export function installAiClis(log, env = process.env, list = AI_CLIS, prevInstalled = []) {
34
+ const present = [], missing = [];
35
+ for (const c of list) (cliVersion(c.name, env) ? present : missing).push(c);
36
+ if (present.length) log("ai-clis: already installed: " + present.map((c) => c.name).join(", "));
37
+ const res = { installed: present.map((c) => c.name).filter((n) => prevInstalled.includes(n)), present: present.map((c) => c.name), failed: [] };
38
+ if (!missing.length) return res;
39
+ log("ai-clis: npm install -g " + missing.map((c) => c.pkg).join(" "));
40
+ if (npmGlobal(missing.map((c) => c.pkg), env)) res.installed.push(...missing.map((c) => c.name));
41
+ else {
42
+ log("ai-clis: the one-pass install failed, retrying each CLI alone");
43
+ for (const c of missing) (npmGlobal([c.pkg], env) ? res.installed : res.failed).push(c.name);
44
+ }
45
+ for (const n of res.failed) log("ai-clis: WARN " + n + " not installed -- run: npm install -g " + list.find((c) => c.name === n).pkg);
46
+ return res;
47
+ }
48
+
49
+ // uninstall takes back what install added (SDK os-qa 2026-11-09, run 42552244938: the npm prefix bin kept claude, codex,
50
+ // gemini and qwen after uninstall on every leg). Only the names install recorded go; a CLI the user had stays. A failed
51
+ // npm uninstall is a WARN with the command to finish it by hand.
52
+ export function removeAiClis(names, log, env = process.env, list = AI_CLIS) {
53
+ const pkgs = list.filter((c) => (names || []).includes(c.name)).map((c) => c.pkg);
54
+ if (!pkgs.length) return true;
55
+ log("ai-clis: npm uninstall -g " + pkgs.join(" "));
56
+ const r = spawnSync("npm", ["uninstall", "-g", ...pkgs, "--no-audit", "--no-fund"], { stdio: "inherit", shell: IS_WIN, env });
57
+ if (r.status !== 0) log("ai-clis: WARN npm uninstall rc " + r.status + " -- run: npm uninstall -g " + pkgs.join(" "));
58
+ return r.status === 0;
59
+ }
60
+
61
+ // doctor rows: add(name, ok, detail, required, skip). Never required: a missing CLI is a WARN.
62
+ export function aiCliRows(add, skipped, env = process.env, list = AI_CLIS) {
63
+ for (const c of list) {
64
+ if (skipped) { add("ai-cli " + c.name, false, "skipped (--no-ai-clis)", false, true); continue; }
65
+ const v = cliVersion(c.name, env);
66
+ add("ai-cli " + c.name, !!v, v ? v + " (" + c.pkg + ")" : "not on PATH -- npm install -g " + c.pkg, false);
67
+ }
68
+ }
@@ -0,0 +1,55 @@
1
+ // lib/allow-compact.js -- the compressed allow list (owner 2026-11-09: "simplify the patterns, keep 3 to 5 keywords,
2
+ // make a compressed list"). The auto-mode classifier refused harmless harness-own commands on the owner's seats; the
3
+ // rows learned from those refusals (tools/wkharness-claude-allows-learned.txt: git -C *, python3 -I *, send_message ...)
4
+ // are far too wide for a stranger's machine. A fresh install gets ONLY these five read-or-own-store prefixes. Every call
5
+ // still meets the harness PreToolUse hook first, and a hook refusal beats an allow rule, so an entry here skips the
6
+ // permission prompt, never the harness. Uninstall removes exactly the entries this install added.
7
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
8
+ import { dirname } from "node:path";
9
+ import { readSettings } from "./hook-wire.js";
10
+
11
+ export const COMPACT_ALLOWS = [
12
+ "Bash(wkskill *)", // skill read / search / like (the user's own store)
13
+ "Bash(wkappbot skill *)", // the same through the core
14
+ "Bash(wkappbot file read *)", // read-only file view
15
+ "Bash(wk-verify)", // live hook check, no arguments
16
+ "Bash(wkappbot-code doctor)", // read-only health table
17
+ ];
18
+
19
+ // prev: the state recorded by the last install ({ added, madeAllow, madePerms }); a reinstall keeps owning what it added.
20
+ export function addAllows(path, prev = {}, log = () => {}) {
21
+ const r = readSettings(path);
22
+ if (!r.ok) { log("allows: WARN " + path + " is not plain JSON (" + r.err + "); no allow rule added"); return { ok: false, added: prev.added || [] }; }
23
+ const d = r.json || {};
24
+ const madePerms = !!prev.madePerms || !(d.permissions && typeof d.permissions === "object" && !Array.isArray(d.permissions));
25
+ if (madePerms && !(d.permissions && typeof d.permissions === "object" && !Array.isArray(d.permissions))) d.permissions = {};
26
+ const madeAllow = !!prev.madeAllow || !Array.isArray(d.permissions.allow);
27
+ if (!Array.isArray(d.permissions.allow)) d.permissions.allow = [];
28
+ const allow = d.permissions.allow;
29
+ const kept = (prev.added || []).filter((e) => allow.includes(e));
30
+ const fresh = COMPACT_ALLOWS.filter((e) => !allow.includes(e));
31
+ allow.push(...fresh);
32
+ if (fresh.length || !existsSync(path)) { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, JSON.stringify(d, null, 2) + "\n"); }
33
+ const added = [...new Set([...kept, ...fresh])];
34
+ log("allows: " + (fresh.length ? "added " + fresh.length : "kept") + " of the " + COMPACT_ALLOWS.length + " harness allow rules in " + path + " (the harness hook still checks every call)");
35
+ return { ok: true, added, madeAllow, madePerms };
36
+ }
37
+
38
+ // Removes only the entries addAllows recorded; drops an allow list or permissions object only when the install made it
39
+ // and it is empty now. Returns the count removed; bad JSON is left untouched.
40
+ export function removeAllows(path, st, log = () => {}) {
41
+ if (!st || !(st.added || []).length || !existsSync(path)) return 0;
42
+ const r = readSettings(path);
43
+ if (!r.ok) { log("allows: " + path + " is not plain JSON, left untouched (" + r.err + ")"); return 0; }
44
+ const d = r.json || {}, p = d.permissions;
45
+ if (!p || !Array.isArray(p.allow)) return 0;
46
+ const before = p.allow.length;
47
+ p.allow = p.allow.filter((e) => !st.added.includes(e));
48
+ const n = before - p.allow.length;
49
+ if (!n) return 0;
50
+ if (st.madeAllow && !p.allow.length) delete p.allow;
51
+ if (st.madePerms && !Object.keys(p).length) delete d.permissions;
52
+ writeFileSync(path, JSON.stringify(d, null, 2) + "\n");
53
+ log("allows: removed " + n + " harness allow rule" + (n === 1 ? "" : "s") + " from " + path);
54
+ return n;
55
+ }
@@ -0,0 +1,173 @@
1
+ // hook-wire.js -- read the user's Claude settings first, then bring the harness hooks to the harness this install chose
2
+ // (owner 2026-11-09: "analyze the customer environment, overwrite the needed settings and hooks with the latest, and ask
3
+ // for a change when needed"; "it must work without the harness repo, pick the road by whether the repo is there, with
4
+ // the most consistency"; "the web install failed where python is missing"). Install-mode QA before this module, 23/26:
5
+ // a checkout install wired NO Claude hooks, a switch from the web harness to a checkout left the Claude hooks on the old
6
+ // harness while the OpenCode plugin moved, a settings.json that is not plain JSON was skipped without a word, and with no
7
+ // python3 the web harness install stopped at "needs python3 on PATH".
8
+ // wireSettings is the node port of tools/wkharness-lite.sh --wire (python3, kept for the WebVM image build): every
9
+ // harness hook is repointed at <dir>/tools/wkharness-lite.sh, an unroutable harness hook is dropped, a user's own hook
10
+ // stays verbatim, and every lite arm is present once. The web installer calls it through the CLI form below when it has
11
+ // node but no python3: node hook-wire.js wire SETTINGS LITE
12
+ import { spawnSync } from "node:child_process";
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
+ import { dirname, join, resolve } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ // The same harness-hook pattern as tools/wkharness-lite.sh --wire HARNESS_HOOK. ANCHORED (2026-11-09 review, proven on
18
+ // fixtures): the loose form also took a user's own pwsh lint.ps1, bash ~/bin/session-start.sh and my-wkharness-notes.sh
19
+ // for harness hooks and deleted or rewrote them. Now a harness hook is a wkharness*.sh/.ps1/.cmd file name standing at a
20
+ // path or word start, a file under a .wk-harness folder, the kih .claude/hooks scripts, or the two named sync hooks.
21
+ const HARNESS_HOOK = /(^|[\s"'\\/])wkharness(-[\w-]+)?\.(sh|ps1|cmd)\b|\.wk-harness[\\/]|[\\/]\.claude[\\/]hooks[\\/](user-prompt-submit|session-start)\.sh\b|(^|[\s"'\\/])(wk-global-md-sync|wk-session-pull|wkappbot-shim)(\.[a-z0-9]+)?\b/;
22
+ const ARMS = [["PreToolUse", "pre"], ["Stop", "stop"], ["UserPromptSubmit", "prompt"], ["SessionStart", "session"]];
23
+ const fwd = (p) => String(p || "").replace(/\\/g, "/").replace(/\/+$/, "");
24
+
25
+ export function readSettings(path) {
26
+ if (!existsSync(path)) return { ok: true, json: null };
27
+ // a UTF-8 BOM (Windows editors write one) is valid JSON text; strip it (2026-11-09 review: it read as "not JSON")
28
+ const text = readFileSync(path, "utf8").replace(/^/, "");
29
+ if (!text.trim()) return { ok: true, json: null };
30
+ try { return { ok: true, json: JSON.parse(text) }; }
31
+ catch (e) { return { ok: false, err: String(e.message).split("\n")[0] }; }
32
+ }
33
+
34
+ // An event's groups as an array: a single group object (hand-written settings) counts as one group, anything else as none
35
+ // (2026-11-09 review: a non-array value threw "(groups || []) is not iterable" and crashed the whole install).
36
+ const asGroups = (v) => (Array.isArray(v) ? v : v && typeof v === "object" ? [v] : []);
37
+ const asHooks = (g) => (g && Array.isArray(g.hooks) ? g.hooks : []);
38
+
39
+ // Every harness hook command, by event.
40
+ export function harnessHooks(json) {
41
+ const out = [];
42
+ const hk = json && typeof json.hooks === "object" && json.hooks ? json.hooks : {};
43
+ for (const [ev, groups] of Object.entries(hk))
44
+ for (const g of asGroups(groups)) for (const h of asHooks(g))
45
+ if (h && typeof h.command === "string" && HARNESS_HOOK.test(h.command)) out.push({ ev, command: h.command });
46
+ return out;
47
+ }
48
+
49
+ // --wire route(): a harness hook command -> the lite command for the same arm, or null (not routable).
50
+ function route(cmd, lite) {
51
+ let m = /wkharness-lite\.sh"?\s+([a-z]+)/.exec(cmd);
52
+ if (m) return '"' + lite + '" ' + m[1];
53
+ m = /wkharness-run\.sh"?\s+([a-z-]+)/.exec(cmd);
54
+ if (m) return '"' + lite + '" ' + (m[1].startsWith("stop") ? "stop" : m[1]);
55
+ if (cmd.includes("user-prompt-submit.sh")) return '"' + lite + '" prompt';
56
+ if (cmd.includes("session-start.sh")) return '"' + lite + '" session';
57
+ return null;
58
+ }
59
+
60
+ // The node port of --wire, plus the create step of the web installer. Throws on a settings file that is not JSON.
61
+ export function wireSettings(path, lite) {
62
+ const r = readSettings(path);
63
+ if (!r.ok) throw new Error(path + " is not plain JSON (" + r.err + ")");
64
+ const d = r.json || {};
65
+ const out = {};
66
+ for (const [ev, groups] of Object.entries(d.hooks && typeof d.hooks === "object" ? d.hooks : {})) {
67
+ const keep = [];
68
+ for (const g of asGroups(groups)) {
69
+ // dedupe INSIDE a group only (2026-11-09 review): a per-event set emptied the second matcher group (Edit|Write)
70
+ // when it held the same harness command as the first (Bash), so PreToolUse stopped firing for edits
71
+ const seen = new Set(), hs = [];
72
+ for (const h of asHooks(g)) {
73
+ const cmd = String((h && h.command) || "");
74
+ const c = HARNESS_HOOK.test(cmd) ? route(cmd, lite) : null; // only a harness hook is ever rewritten
75
+ if (c && !seen.has(c)) { seen.add(c); hs.push({ type: "command", command: c, timeout: 10 }); }
76
+ else if (!c && !HARNESS_HOOK.test(cmd)) hs.push(h); // a user's own hook stays verbatim
77
+ }
78
+ if (hs.length) keep.push({ ...g, hooks: hs });
79
+ }
80
+ if (keep.length) out[ev] = keep;
81
+ }
82
+ for (const [ev, arm] of ARMS) {
83
+ const c = '"' + lite + '" ' + arm;
84
+ if (!(out[ev] || []).some((g) => (g.hooks || []).some((h) => h.command === c)))
85
+ (out[ev] = out[ev] || []).push({ hooks: [{ type: "command", command: c, timeout: 10 }] });
86
+ }
87
+ d.hooks = out;
88
+ mkdirSync(dirname(path), { recursive: true });
89
+ writeFileSync(path, JSON.stringify(d, null, 2) + "\n");
90
+ return Object.keys(out).sort();
91
+ }
92
+
93
+ // state: unreadable | none | ours (every harness hook points into kih and all four arms are there) | partial | other
94
+ export function analyzeHooks(path, kih) {
95
+ const r = readSettings(path);
96
+ if (!r.ok) return { state: "unreadable", err: r.err, hooks: [] };
97
+ const hooks = harnessHooks(r.json);
98
+ if (!hooks.length) return { state: "none", hooks };
99
+ const k = fwd(kih);
100
+ const mine = hooks.filter((h) => k && fwd(h.command).includes(k + "/"));
101
+ if (mine.length === hooks.length) {
102
+ const evs = new Set(hooks.map((h) => h.ev));
103
+ return { state: ARMS.every(([e]) => evs.has(e)) ? "ours" : "partial", hooks };
104
+ }
105
+ return { state: "other", hooks, elsewhere: [...new Set(hooks.filter((h) => !mine.includes(h)).map((h) => h.command))] };
106
+ }
107
+
108
+ // o: { settings, kih, pwsh, log }. Never throws: a surprise in a hand-written settings file becomes a WARN line and the
109
+ // rest of the install goes on (2026-11-09 review: a non-array hooks value crashed the whole install).
110
+ export function wireClaudeHooks(o) {
111
+ try { return wireClaudeHooksInner(o); }
112
+ catch (e) { (o.log || (() => {}))("hooks: WARN could not read the hooks in " + o.settings + " (" + e.message + "); left untouched -- please check it, then run: wkappbot-code install"); return { before: "error", after: "error", changed: false }; }
113
+ }
114
+ function wireClaudeHooksInner(o) {
115
+ const log = o.log || (() => {});
116
+ const before = analyzeHooks(o.settings, o.kih);
117
+ const res = { before: before.state, after: before.state, changed: false };
118
+ if (!o.kih) { log("hooks: no harness was installed, so " + o.settings + " is left as it is"); return res; }
119
+ if (before.state === "unreadable") {
120
+ log("hooks: WARN " + o.settings + " is not plain JSON (" + before.err + "); left untouched -- please fix it, then run: wkappbot-code install");
121
+ return res;
122
+ }
123
+ if (before.state === "ours") { log("hooks: " + o.settings + " already runs every harness hook from " + o.kih); return res; }
124
+ // A FULL harness hook (wkharness-run.sh, the pwsh hub of a dev seat) is never rewritten into the lite one where pwsh
125
+ // runs: the rewrite would downgrade the owner's own seats. Leave it and say what to do.
126
+ const full = before.hooks.find((h) => /wkharness-run\.sh/.test(h.command));
127
+ if (o.pwsh && full) {
128
+ log("hooks: WARN your Claude hooks run the full harness (" + full.command + ", state '" + before.state + "'); left as they are -- if they should run " + o.kih + ", rewire them from that checkout's own installer");
129
+ return res;
130
+ }
131
+ const lite = join(o.kih, "tools", "wkharness-lite.sh");
132
+ if (!existsSync(lite)) { log("hooks: WARN " + lite + " is missing, so the Claude hooks were not wired"); return res; }
133
+ try { wireSettings(o.settings, lite); }
134
+ catch (e) { log("hooks: WARN wiring " + o.settings + " failed (" + e.message + ")"); return res; }
135
+ ensureRules(o.kih, log);
136
+ const after = analyzeHooks(o.settings, o.kih);
137
+ res.after = after.state; res.changed = true;
138
+ log("hooks: " + o.settings + " was '" + before.state + "'" + (before.elsewhere ? " (pointing at " + before.elsewhere[0] + ")" : "") +
139
+ ", now '" + after.state + "'; your own hooks were kept");
140
+ return res;
141
+ }
142
+
143
+ // The lite study lock reads <kih>/.tmp/hook-lite/rules.tsv. A web package ships it as tools/wkharness-lite-rules.tsv
144
+ // (built by prepack); a checkout builds it with python3 through the lite --wire; with neither, say what is missing.
145
+ export function ensureRules(kih, log = () => {}) {
146
+ const want = join(kih, ".tmp", "hook-lite", "rules.tsv");
147
+ if (existsSync(want)) return "present";
148
+ const shipped = join(kih, "tools", "wkharness-lite-rules.tsv");
149
+ if (existsSync(shipped)) { mkdirSync(dirname(want), { recursive: true }); writeFileSync(want, readFileSync(shipped)); return "copied"; }
150
+ const py = spawnSync("sh", ["-c", "command -v python3"], { encoding: "utf8" });
151
+ if (py.status === 0) {
152
+ const r = spawnSync("sh", [join(kih, "tools", "wkharness-lite.sh").replace(/\\/g, "/"), "--wire"], { encoding: "utf8" });
153
+ if (r.status === 0 && existsSync(want)) return "built";
154
+ }
155
+ log("hooks: WARN the study lock has no rule table (" + want + "): install python3, then run: wkappbot-code install");
156
+ return "missing";
157
+ }
158
+
159
+ // doctor row: the Claude hooks and the OpenCode plugin must run the same harness.
160
+ export function hooksRow(settings, kih) {
161
+ let a;
162
+ try { a = analyzeHooks(settings, kih); } catch (e) { return { ok: false, detail: settings + " could not be read (" + e.message + ")" }; }
163
+ if (a.state === "ours") return { ok: true, detail: settings + " runs every harness hook from " + kih };
164
+ if (a.state === "unreadable") return { ok: false, detail: settings + " is not plain JSON (" + a.err + ")" };
165
+ if (a.state === "none") return { ok: false, detail: "no harness hook in " + settings + " -- run: wkappbot-code install" };
166
+ return { ok: false, detail: settings + " is '" + a.state + "'" + (a.elsewhere ? ", pointing at " + a.elsewhere[0] : "") + " while the plugin uses " + kih + " -- run: wkappbot-code install" };
167
+ }
168
+
169
+ // CLI form for the web installer on a machine with node and no python3: node hook-wire.js wire SETTINGS LITE
170
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url) && process.argv[2] === "wire") {
171
+ try { console.log("[wk-harness] wired " + process.argv[3] + " with node: " + wireSettings(process.argv[3], process.argv[4]).join(", ")); }
172
+ catch (e) { console.error("[wk-harness] " + e.message); process.exit(1); }
173
+ }
package/lib/prereq.js ADDED
@@ -0,0 +1,57 @@
1
+ // prereq.js -- the tools every install part runs on: bash and sh (the harness, the core acquire), curl and tar (the
2
+ // downloads), git (hooks, skills). Owner 2026-11-09: "Git Bash is a required tool too; check that it is installed along".
3
+ // On Windows they all come from Git for Windows (Git Bash): (1) a Git install that is not on PATH is found at its usual
4
+ // folders and put on this process's PATH; (2) no Git at all and winget present: `winget install --id Git.Git` (silent),
5
+ // then found as in (1); (3) neither: a FAILED part with the one line that says what to install. Every probe is
6
+ // injectable so the selftest drives all three roads on any OS.
7
+ import { spawnSync } from "node:child_process";
8
+ import { existsSync } from "node:fs";
9
+ import { delimiter, join } from "node:path";
10
+
11
+ export const TOOLS = ["bash", "sh", "curl", "tar", "git"];
12
+ const GIT_DIRS = (env) => [env.ProgramFiles && join(env.ProgramFiles, "Git"), env["ProgramFiles(x86)"] && join(env["ProgramFiles(x86)"], "Git"),
13
+ env.LOCALAPPDATA && join(env.LOCALAPPDATA, "Programs", "Git"), "C:\\Program Files\\Git"].filter(Boolean);
14
+
15
+ function realWhich(cmd, env) {
16
+ const win = process.platform === "win32";
17
+ const r = spawnSync(win ? "where" : "sh", win ? [cmd] : ["-c", "command -v " + cmd], { encoding: "utf8", env });
18
+ return r.status === 0 ? String(r.stdout || "").trim().split(/\r?\n/)[0] : "";
19
+ }
20
+ const realRun = (cmd, args, env) => spawnSync(cmd, args, { stdio: "inherit", env, shell: process.platform === "win32" }).status;
21
+
22
+ // o: { platform, env (mutated: PATH gains the Git dirs), which, exists, run, log, noInstall (doctor: check only) }
23
+ // -> { ok, missing: [...], found: {tool: path}, via: "path"|"git-dir"|"winget"|"", note }
24
+ export function ensurePrereqs(o = {}) {
25
+ const platform = o.platform || process.platform, env = o.env || process.env, log = o.log || (() => {});
26
+ const which = o.which || realWhich, exists = o.exists || existsSync, run = o.run || realRun;
27
+ const probe = () => { const found = {}, missing = []; for (const t of TOOLS) { const p = which(t, env); if (p) found[t] = p; else missing.push(t); } return { found, missing }; };
28
+ let p = probe(), via = "path";
29
+ if (!p.missing.length) return { ok: true, missing: [], found: p.found, via, note: "" };
30
+ if (platform !== "win32") {
31
+ const note = "missing " + p.missing.join(", ") + " -- install them with your package manager (e.g. apt install " + p.missing.join(" ") + "), then run: wkappbot-code install";
32
+ log("prereq: FAILED -- " + note);
33
+ return { ok: false, missing: p.missing, found: p.found, via: "", note };
34
+ }
35
+ const addGit = () => {
36
+ for (const d of GIT_DIRS(env)) {
37
+ if (!exists(join(d, "bin", "bash.exe"))) continue;
38
+ const key = Object.keys(env).find((k) => k.toUpperCase() === "PATH") || "PATH";
39
+ env[key] = [join(d, "bin"), join(d, "usr", "bin"), join(d, "cmd")].join(delimiter) + delimiter + (env[key] || "");
40
+ return d;
41
+ }
42
+ return "";
43
+ };
44
+ let dir = addGit();
45
+ if (dir) { p = probe(); via = "git-dir"; log("prereq: Git Bash found at " + dir + " (not on PATH) -- used for this install; add " + join(dir, "bin") + " to your PATH"); }
46
+ if (p.missing.length && !o.noInstall && which("winget", env)) {
47
+ log("prereq: Git for Windows (Git Bash) is missing -- installing it with winget");
48
+ const rc = run("winget", ["install", "--id", "Git.Git", "-e", "--source", "winget", "--silent", "--accept-package-agreements", "--accept-source-agreements"], env);
49
+ dir = addGit();
50
+ if (dir) { p = probe(); via = "winget"; log("prereq: winget installed Git for Windows at " + dir + " (rc " + rc + ")"); }
51
+ else log("prereq: winget rc " + rc + " but no Git Bash was found afterwards");
52
+ }
53
+ if (!p.missing.length) return { ok: true, missing: [], found: p.found, via, note: "" };
54
+ const note = "missing " + p.missing.join(", ") + " -- install Git for Windows (it brings Git Bash with bash, sh, curl and tar): https://git-scm.com/download/win , then run: wkappbot-code install";
55
+ log("prereq: FAILED -- " + note);
56
+ return { ok: false, missing: p.missing, found: p.found, via: "", note };
57
+ }
package/lib/setup-run.js CHANGED
@@ -102,18 +102,21 @@ export function bannerFor({ done, running, seen, logPath, now = Date.now() }) {
102
102
  if (age >= 0 && age < RUNNING_STALE_MS) return { line: "[wkappbot-code] setup still running (log: " + logPath + ") -- opening OpenCode anyway", mark: false };
103
103
  return { line: "[wkappbot-code] setup did not finish (worker started " + (running.at || "?") + ", no result); log: " + logPath + " -- rerun: wkappbot-code install", mark: false };
104
104
  }
105
- if (!done || (seen && seen === done.at)) return { line: "", mark: false };
105
+ if (!done) return { line: "", mark: false };
106
106
  const d = done.doctor || {}, s = done.selftest || {};
107
107
  const total = (d.pass || 0) + (d.warn || 0) + (d.fail || 0);
108
108
  const tail = "doctor " + (d.pass || 0) + "/" + total + (d.warn ? " (" + d.warn + " warn)" : "") + ", selftest " + (s.n || 0) + "/" + (s.m || 0);
109
109
  const installRc = done.installRc ?? done.rc;
110
- if (installRc === 0 && !d.fail && s.n === s.m && s.m > 0) return { line: "[wkappbot-code] setup ok, " + tail, mark: true };
110
+ // only a GOOD result goes quiet once seen; a failed one is checked below and always printed
111
+ if (installRc === 0 && !d.fail && s.n === s.m && s.m > 0) return (seen && seen === done.at) ? { line: "", mark: false } : { line: "[wkappbot-code] setup ok, " + tail, mark: true };
111
112
  const bad = [];
112
113
  if (installRc !== 0) bad.push("install rc " + installRc);
113
114
  for (const f of done.doctorFailed || []) bad.push("doctor " + f + " FAIL");
114
115
  for (const i of done.items || []) bad.push("selftest " + i);
115
116
  if (!bad.length) bad.push(d.fail ? "doctor " + d.fail + " FAIL" : "selftest " + (s.n || 0) + "/" + (s.m || 0));
116
- return { line: "[wkappbot-code] setup needs attention: " + bad.slice(0, 3).join("; ") + " (" + tail + "); log: " + logPath, mark: true };
117
+ // A failure is NEVER marked seen (owner 2026-11-09: a setup that failed for want of python3 was not noticed): it is
118
+ // printed on every run until a rerun of install gives a good result.
119
+ return { line: "[wkappbot-code] SETUP FAILED: " + bad.slice(0, 3).join("; ") + " (" + tail + "); log: " + logPath + " -- fix it, then run: wkappbot-code install", mark: false };
117
120
  }
118
121
  export function printBanner({ home = homedir(), out = console.log, now = Date.now() } = {}) {
119
122
  const f = files(home);
@@ -0,0 +1,46 @@
1
+ // starter-md.js -- a standard global ~/.claude/CLAUDE.md for a new machine (owner 2026-11-09: "install CLAUDE.md as an
2
+ // initial standard copy too, so a raw untrained model is guarded on a new machine"). The starter
3
+ // (templates/global-CLAUDE.md) holds only user-safe working rules: reply language, skills first, run-not-guess, the
4
+ // risk classes the harness refuses, no secrets, no outward act without the user's go. Written only where the user has
5
+ // NO global CLAUDE.md; refreshed only while it still equals what this install wrote (sha256 kept in install-state.json);
6
+ // a file the user changed or wrote is never touched. Uninstall removes it only when it is still unchanged.
7
+ import { createHash } from "node:crypto";
8
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+
11
+ const sha = (s) => createHash("sha256").update(s).digest("hex");
12
+
13
+ // o: { claudeDir, pkgDir, prev (install-state starterMd), log } -> { path, sha, action }
14
+ export function installStarterMd(o) {
15
+ const log = o.log || (() => {});
16
+ const path = join(o.claudeDir, "CLAUDE.md");
17
+ const tplPath = join(o.pkgDir, "templates", "global-CLAUDE.md");
18
+ if (!existsSync(tplPath)) { log("claude-md: WARN the starter " + tplPath + " is missing from this package"); return { path, action: "missing-template" }; }
19
+ const tpl = readFileSync(tplPath, "utf8");
20
+ if (!existsSync(path)) {
21
+ mkdirSync(dirname(path), { recursive: true });
22
+ writeFileSync(path, tpl);
23
+ log("claude-md: wrote the starter global rules to " + path + " (you had none; edit it freely)");
24
+ return { path, sha: sha(tpl), action: "written" };
25
+ }
26
+ const cur = readFileSync(path, "utf8");
27
+ // identical text is ours only when this install wrote it before (2026-11-09 review: a user's own copy that happened to
28
+ // equal the starter was recorded and then deleted by uninstall)
29
+ if (cur === tpl) return (o.prev && o.prev.sha === sha(cur)) ? { path, sha: sha(tpl), action: "current" } : { path, action: "kept" };
30
+ if (o.prev && o.prev.sha && o.prev.sha === sha(cur)) {
31
+ writeFileSync(path, tpl);
32
+ log("claude-md: refreshed the starter global rules in " + path + " (unchanged since the last install)");
33
+ return { path, sha: sha(tpl), action: "refreshed" };
34
+ }
35
+ log("claude-md: kept your " + path + " (yours); the starter is at " + tplPath + " if you want to compare");
36
+ return { path, action: "kept" };
37
+ }
38
+
39
+ // uninstall: remove the starter only when it is still exactly what this install wrote.
40
+ export function removeStarterMd(rec, log = () => {}) {
41
+ if (!rec || !rec.path || !rec.sha || !existsSync(rec.path)) return false;
42
+ if (sha(readFileSync(rec.path, "utf8")) !== rec.sha) { log("claude-md: kept " + rec.path + " (you changed it)"); return false; }
43
+ rmSync(rec.path, { force: true });
44
+ log("claude-md: removed the starter " + rec.path + " (unchanged since install)");
45
+ return true;
46
+ }
@@ -7,7 +7,8 @@
7
7
  # Source 1 (unless --offline): SDK latest release asset wkappbot-PLATFORM, accepted only when its
8
8
  # sha256 matches its SHA256SUMS line. Source 2: REPO/bin/PLATFORM/wkappbot, accepted only when
9
9
  # its sha256 matches the MANIFEST.md row. Windows: PATH is a directory that receives
10
- # wkappbot.exe and wkappbot-core.exe (xz -dc of bin/win-x64/wkappbot-core.exe.xz), from bin/ only.
10
+ # wkappbot.exe and wkappbot-core.exe (xz -dc of wkappbot-core.exe.xz): first the SDK release pair, each file
11
+ # verified by its SHA256SUMS line, else repo bin/win-x64 (2026-11-09; bin/ only before, so no checkout = no core).
11
12
  # Copies, never links; writes via a .new temp then mv; "up to date" when the sha already matches.
12
13
  # wk-bin-acquire.sh publish SRC [--platform P] [--dry-run]
13
14
  # Stores SRC in REPO/bin/P/ (a Windows core .exe as wkappbot-core.exe.xz, xz -9e -T0), rewrites
@@ -103,10 +104,12 @@ default_dest_win() {
103
104
  # sdk_fetch PLAT DIR -- prints the verified asset path; else prints nothing and returns
104
105
  # 3 when a download failed (host unreachable, no such asset) or 4 when the asset is UNVERIFIED
105
106
  # (no SHA256SUMS line for it, or a sha256 mismatch). Only 3 may degrade an install to hooks-only.
106
- sdk_fetch() {
107
- command -v wk_stopwatch_lap >/dev/null 2>&1 && wk_stopwatch_lap "sdk_fetch" || true
108
- local plat="$1" dir="$2" asset base want got
109
- asset="wkappbot-$plat"
107
+ sdk_fetch() { sdk_fetch_asset "wkappbot-$1" "$2"; }
108
+ # sdk_fetch_asset ASSET DIR -- the same verified download for any release asset name (win-x64 takes two:
109
+ # wkappbot.exe and wkappbot-core.exe.xz, 2026-11-09: a Windows user with no kih checkout got NO core at all).
110
+ sdk_fetch_asset() {
111
+ command -v wk_stopwatch_lap >/dev/null 2>&1 && wk_stopwatch_lap "sdk_fetch_asset" || true
112
+ local asset="$1" dir="$2" base want got
110
113
  base="${WK_BIN_BASE:-https://github.com/kiexpert/wkappbot-sdk/releases/latest/download}"
111
114
  curl -fsSL -m "${WK_BIN_TIMEOUT:-120}" "$base/$asset" -o "$dir/$asset" || return 3
112
115
  curl -fsSL -m "${WK_BIN_TIMEOUT:-120}" "$base/SHA256SUMS" -o "$dir/SHA256SUMS" || return 3
@@ -175,15 +178,31 @@ acquire_unix() {
175
178
 
176
179
  acquire_win() {
177
180
  command -v wk_stopwatch_lap >/dev/null 2>&1 && wk_stopwatch_lap "acquire_win" || true
178
- local dest="$1" exe xzsrc core
181
+ local dest="$1" offline="${2:-0}" exe="" xzsrc="" core frc=3 xrc=3 label
179
182
  [ -n "$dest" ] || dest="$(default_dest_win)"
180
- note "win-x64 installs from bin/ only: the two-file set has no single SDK asset"
181
- exe="$(repo_file "win-x64/wkappbot.exe")"
182
- xzsrc="$(repo_file "win-x64/wkappbot-core.exe.xz")"
183
+ command -v xz >/dev/null 2>&1 || die "win-x64 needs xz on PATH to unpack wkappbot-core.exe.xz (Git for Windows ships it in usr/bin)"
184
+ # Source 1: the SDK release pair, each file verified by its own SHA256SUMS line. Source 2: repo bin/win-x64.
185
+ if [ "$offline" = 0 ]; then
186
+ exe="$(sdk_fetch_asset wkappbot.exe "$WK_TMP")" && frc=0 || frc=$?
187
+ [ "$frc" = 0 ] && { xzsrc="$(sdk_fetch_asset wkappbot-core.exe.xz "$WK_TMP")" && xrc=0 || xrc=$?; }
188
+ fi
189
+ if [ "$frc" = 0 ] && [ "$xrc" = 0 ]; then
190
+ label="SDK release"
191
+ else
192
+ [ "$frc" = 4 ] || [ "$xrc" = 4 ] && [ -z "$REPO" ] && die "the SDK release asset for win-x64 is UNVERIFIED (no SHA256SUMS line or a sha256 mismatch); nothing installed"
193
+ if [ "$offline" = 0 ]; then note "SDK assets unavailable or unverified, using repo bin/win-x64"; fi
194
+ if [ -z "$REPO" ]; then
195
+ note "UNREACHABLE: wkappbot.exe and wkappbot-core.exe.xz could not be downloaded from ${WK_BIN_BASE:-github.com/kiexpert/wkappbot-sdk releases} and no kih checkout holds bin/win-x64; nothing installed"
196
+ exit 3
197
+ fi
198
+ exe="$(repo_file "win-x64/wkappbot.exe")"
199
+ xzsrc="$(repo_file "win-x64/wkappbot-core.exe.xz")"
200
+ label="repo bin/win-x64"
201
+ fi
183
202
  core="$WK_TMP/wkappbot-core.exe"
184
- xz -dc "$xzsrc" > "$core"
185
- install_file "$exe" "$dest/wkappbot.exe" "repo bin/win-x64/wkappbot.exe"
186
- install_file "$core" "$dest/wkappbot-core.exe" "repo bin/win-x64/wkappbot-core.exe.xz"
203
+ xz -dc "$xzsrc" > "$core" || die "xz could not unpack $xzsrc"
204
+ install_file "$exe" "$dest/wkappbot.exe" "$label wkappbot.exe"
205
+ install_file "$core" "$dest/wkappbot-core.exe" "$label wkappbot-core.exe.xz"
187
206
  }
188
207
 
189
208
  acquire() {
@@ -199,7 +218,7 @@ acquire() {
199
218
  done
200
219
  plat="$(platform_detect)"
201
220
  if [ "$plat" = win-x64 ]; then
202
- acquire_win "$dest"
221
+ acquire_win "$dest" "$offline"
203
222
  else
204
223
  acquire_unix "$dest" "$plat" "$offline"
205
224
  fi
package/lib/wk-hook.js CHANGED
@@ -163,7 +163,11 @@ export function refusalReason(r) {
163
163
  // The SAME classes tools/wkharness-lite.sh refuses by itself (wk_lite_risk there). With NO verdict from the harness,
164
164
  // a call in one of these classes is REFUSED instead of allowed: a public package must never let rm -rf HOME through
165
165
  // because pwsh happened to be missing. Returns the class label, or "".
166
- const HOME_T = /^(~|\$HOME|\$\{HOME\}|\/root|\/home\/[^/]+|\/Users\/[^/]+)\/?(\*|\.\*)?$/;
166
+ // 0.3.13 strict path QA (owner 2026-11-09): a Windows home also arrives as C:\Users\x, C:/Users/x or MSYS /c/Users/x,
167
+ // and a drive root as C: -- the same forms tools/wkharness-lite.sh rk_home refuses (selftest-paths.mjs pins both).
168
+ const HOME_T = /^(~|\$HOME|\$\{HOME\}|\/root|\/home\/[^/\\]+|\/Users\/[^/\\]+|[A-Za-z]:[\\/]+Users[\\/]+[^/\\]+|\/[A-Za-z]\/Users\/[^/\\]+|[A-Za-z]:)[\\/]*(\*|\.\*)?$/i;
169
+ // A .git path segment with either separator, in any letter case (C:\r\.GIT\config on a case-blind disk).
170
+ const GIT_SEG = /(^|[\\/])\.git([\\/]|$)/i;
167
171
  // The shell line as the risk floor reads it. A heredoc body (after <<'TAG', <<TAG or <<-TAG, up to the line TAG) is
168
172
  // dropped, and a quoted string is ONE word with its quotes removed, so its text is never read as a git flag or a path.
169
173
  // Words handed to a shell (sh/bash -c, eval) are also returned in seg.inner and are checked again as a command line.
@@ -258,16 +262,16 @@ export function riskClass(tool, input, cwd) {
258
262
  const proj = cwd ? String(cwd).replace(/\/+$/, "") : "";
259
263
  if (tg.some((x) => HOME_T.test(x) || x === "/" || x === "/*")) return "rm -r of HOME or /";
260
264
  if (tg.some((x) => [".", "./", "./*", "*", "..", "../"].includes(x) || (proj && x.replace(/\/+$/, "") === proj))) return "rm -r of the project source";
261
- if (tg.some((x) => /(^|\/)\.git(\/|$)/.test(x))) return "write under .git";
265
+ if (tg.some((x) => GIT_SEG.test(x))) return "write under .git";
262
266
  }
263
267
  }
264
- if (w.some((x) => /(^|\/)\.git(\/|$)/.test(x)) && (seg.redirect || /^(rm|mv|cp|tee|truncate|dd|ln|chmod|sed)$/.test(w[0] || ""))) return "write under .git";
268
+ if (w.some((x) => GIT_SEG.test(x)) && (seg.redirect || /^(rm|mv|cp|tee|truncate|dd|ln|chmod|sed)$/.test(w[0] || ""))) return "write under .git";
265
269
  }
266
270
  return "";
267
271
  }
268
272
  if (["Edit", "Write", "MultiEdit", "NotebookEdit"].includes(t)) {
269
273
  const f = String(i.file_path || i.notebook_path || "");
270
- if (/(^|[\\/])\.git[\\/]/.test(f)) return "write under .git";
274
+ if (/(^|[\\/])\.git[\\/]/i.test(f)) return "write under .git";
271
275
  }
272
276
  return "";
273
277
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wkappbot-code",
3
- "version": "0.3.12",
3
+ "version": "0.3.13",
4
4
  "description": "wkappbot-code -- Wisdom Kernel: the pinned OpenCode bridge plus the reflex harness plugin, the wkappbot core, the web harness and the latest public skills in one install",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -16,6 +16,7 @@
16
16
  "lib/*.js",
17
17
  "lib/*.sh",
18
18
  "plugin/*.js",
19
+ "templates/*",
19
20
  "opencode.json",
20
21
  "install.sh",
21
22
  "README.md",
@@ -0,0 +1,20 @@
1
+ # Global rules for every session (installed by wkappbot-code)
2
+
3
+ This file was written by `wkappbot-code install` because you had no `~/.claude/CLAUDE.md`. Edit it freely: once you
4
+ change it, install never touches it again, and `wkappbot-code uninstall` leaves your version in place.
5
+
6
+ ## How to work
7
+ - Reply in the language the user writes in. Keep replies short and concrete; lead with the result.
8
+ - Before real work, find the skill that governs it: `wkskill search <three or more words>`, then `wkskill read <id>`,
9
+ and follow the road it gives. Search again for any term you are not sure of, instead of guessing.
10
+ - Run, do not guess: prove a change by running its test or command, and report the real output, failures included.
11
+ - One command per tool call. Change only what the task needs, and back up a config file before you edit it.
12
+
13
+ ## Never
14
+ - Skip or weaken a safety hook: no `git --no-verify`, no `git push --force`, no change of `core.hooksPath`, no
15
+ `rm -rf` of a home folder or a project, no writes inside `.git`. The harness refuses these; when it refuses, read
16
+ the skill the refusal names and take the road it prints, never a workaround.
17
+ - Put a secret (an API key, a token, a password) in a file, a commit, a log or a reply. Read keys from environment
18
+ variables.
19
+ - Do an irreversible outward act without the user's explicit go: sending a message or mail, paying, publishing,
20
+ deploying, or deleting someone else's data.