wkappbot-code 0.3.11 → 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`.
@@ -5,7 +5,7 @@
5
5
  // Owner order 2026-11-08: "npm install wkappbot-code should do the initial setup and the tests automatically" -- on
6
6
  // Windows, macOS and Linux alike, not only on a cloud VM.
7
7
  // CONTRACT: (a) ALWAYS exits 0 and returns within a moment -- the real work runs DETACHED and logs to
8
- // ~/.wkappbot-code/postinstall.log; (b) the worker runs `wkappbot-code install --no-npm` (lays the web harness, wires
8
+ // ~/.wkappbot-code/postinstall.log; (b) the worker runs `wkappbot-code install` (lays the web harness, wires
9
9
  // ~/.claude/settings.json through the lite harness -- PreToolUse, Stop, UserPromptSubmit, SessionStart -- the global git
10
10
  // hooks, the wkskill/wk-verify shims and the public skills, keeping every user setting), then `wkappbot-code doctor`,
11
11
  // then a short self-test (lib/setup-run.js: core --version, skill read on-load, one hook allow, one hook refuse), and
@@ -23,7 +23,7 @@ import { homedir } from "node:os";
23
23
  import { appendFileSync } from "node:fs";
24
24
  import { dirname, join } from "node:path";
25
25
  import { fileURLToPath } from "node:url";
26
- import { buildDone, files, markRunning, parseDoctor, platformOf, selfTest, writeDone } from "../lib/setup-run.js";
26
+ import { buildDone, files, globalBinDir, markRunning, parseDoctor, platformOf, selfTest, withPathFirst, writeDone } from "../lib/setup-run.js";
27
27
 
28
28
  const HERE = dirname(fileURLToPath(import.meta.url));
29
29
  const CLI = join(HERE, "wkappbot-code.js");
@@ -41,12 +41,15 @@ function stamp(msg) { try { mkdirSync(STATE, { recursive: true }); appendFileSyn
41
41
 
42
42
  // --run: the detached worker. install, then doctor, then the self-test, then the done file.
43
43
  async function runWorker() {
44
- const args = [CLI, "install", "--no-npm"];
44
+ // 0.3.12: no --no-npm -- the worker installs the pinned OpenCode (npm -g, so `opencode` lands on PATH beside wkappbot-code);
45
+ // the selftests keep their machine untouched by adding --no-npm through WKAPPBOT_CODE_INSTALL_ARGS.
46
+ const args = [CLI, "install"];
45
47
  if (process.env.WKAPPBOT_CODE_DEV_SEAT === "1") args.push("--dev-seat");
46
48
  // extra install flags, e.g. "--no-bin --no-skills" (the selftest; a seat without the SDK release assets)
47
49
  args.push(...String(process.env.WKAPPBOT_CODE_INSTALL_ARGS || "").split(/\s+/).filter((a) => /^--[a-z-]+$/.test(a)));
48
50
  // the children are the worker's own: they must not print the first-run banner (it is for the user's next command)
49
- const env = { ...process.env, WKAPPBOT_CODE_NO_UPDATE_CHECK: "1", WKAPPBOT_CODE_SETUP_WORKER: "1" };
51
+ // the global npm bin dir holds `opencode` right after the install, whether or not the user PATH names it yet
52
+ const env = withPathFirst({ ...process.env, WKAPPBOT_CODE_NO_UPDATE_CHECK: "1", WKAPPBOT_CODE_SETUP_WORKER: "1" }, globalBinDir(process.env));
50
53
  stamp("worker start: node " + args.join(" "));
51
54
  const out = openSync(LOG, "a");
52
55
  const installRc = await new Promise((res) => {
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,7 +42,12 @@ 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";
43
- import { launchOpenCode, printBanner } from "../lib/setup-run.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";
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)), "..");
46
53
  const PKG = JSON.parse(readFileSync(join(PKG_DIR, "package.json"), "utf8"));
@@ -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);
@@ -383,10 +403,28 @@ function recordPrior(prev) {
383
403
  hooksDirExisted: existsSync(HOOKS_DIR()), shimsExisting: shimCandidates().filter((p) => existsSync(p) || isSymlink(p)) };
384
404
  }
385
405
 
406
+ // A user's own `wkappbot-code install` right after `npm i -g` must not race the background setup worker (both wrote the
407
+ // same core file; SDK run 42504778443, install rc 1): wait for a live worker's postinstall.running to go, at most 10 min.
408
+ function waitForSetupWorker() {
409
+ if (process.env.WKAPPBOT_CODE_SETUP_WORKER === "1") return; // the worker's own install
410
+ const f = setupFiles(homedir());
411
+ const alive = () => { try { const r = JSON.parse(readFileSync(f.running, "utf8")); const age = Date.now() - Date.parse(r.at || 0); return age >= 0 && age < 30 * 60 * 1000; } catch { return false; } };
412
+ if (!alive()) return;
413
+ log("waiting for the background setup to finish (log: " + f.log + ")");
414
+ const nap = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
415
+ for (let i = 0; i < 600 && alive(); i++) nap(1000);
416
+ log(alive() ? "background setup still running after 10 min; installing anyway" : "background setup finished; continuing");
417
+ }
418
+
386
419
  function install(o) {
420
+ waitForSetupWorker();
387
421
  const prev = readState(o);
388
422
  const st = { version: PKG.version, at: new Date().toISOString(), prior: recordPrior(prev) };
389
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++;
390
428
  if (o.update) log("install --update: refreshing the core, the harness and the public skills");
391
429
  const ps = pwshPath();
392
430
  log("pwsh: " + (ps || "not found") + " -- the FULL harness (PowerShell 7) runs only on a dev seat (a harness checkout, or --dev-seat);" +
@@ -397,18 +435,31 @@ function install(o) {
397
435
  if (have === OPENCODE_VERSION) log("opencode " + have + " already installed");
398
436
  else {
399
437
  log("npm install -g opencode-ai@" + OPENCODE_VERSION + " (was: " + (have || "none") + ")");
400
- execFileSync("npm", ["install", "-g", "opencode-ai@" + OPENCODE_VERSION, "--no-audit", "--no-fund"], { shell: process.platform === "win32", stdio: "inherit" });
438
+ // 0.3.12: a failed OpenCode install (offline, no npm rights) must not abort the harness, plugin and config parts below.
439
+ try { execFileSync("npm", ["install", "-g", "opencode-ai@" + OPENCODE_VERSION, "--no-audit", "--no-fund"], { shell: process.platform === "win32", stdio: "inherit" }); }
440
+ catch (e) { bad++; log("opencode: npm install FAILED (" + String(e.message).split("\n")[0] + ") -- run: npm install -g opencode-ai@" + OPENCODE_VERSION); }
401
441
  }
402
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
403
446
  const dest = binDest(o);
404
447
  st.bin = o.bin ? installBin(o) : { skipped: true, dest };
405
- if (o.bin && st.bin.degraded) log("bin: WARN -- " + st.bin.note);
406
- 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); }
407
452
  st.harness = o.harness ? installHarness(o) : { skipped: true };
408
453
  if (o.harness && !st.harness.ok) { bad++; log("harness: FAILED -- " + st.harness.note); }
409
454
  // The plugin entry names the harness the install just chose (a forced web harness wins over a checkout).
410
455
  const kih = (st.harness.mode === "web" && st.harness.ok) ? st.harness.dir : kihDir(o);
411
- // 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.
412
463
  // A user's own absolute global core.hooksPath is kept (the script WARNs). Skipped with --no-harness.
413
464
  st.pushGate = { skipped: true };
414
465
  if (o.harness && kih && existsSync(join(kih, "tools", "wk-global-hooks.sh"))) {
@@ -462,8 +513,9 @@ function uninstall(o) {
462
513
  const r = pr.hooksPath ? gitGlobal("core.hooksPath", pr.hooksPath) : gitGlobal("--unset", "core.hooksPath");
463
514
  log("hooksPath: " + (cur || "unset") + " -> " + (pr.hooksPath || "unset") + (r.status === 0 ? "" : " (git rc " + r.status + ")"));
464
515
  } else log("hooksPath: " + (cur || "unset") + (cur === (pr.hooksPath ?? null) ? " (as before install)" : " (set by you, kept)"));
465
- // 2. settings.json: our arms out, yours kept
516
+ // 2. settings.json: our allow rules and arms out, yours kept
466
517
  const sp = SETTINGS();
518
+ removeAllows(sp, st.allows, log);
467
519
  if (existsSync(sp)) {
468
520
  try {
469
521
  const raw = readFileSync(sp, "utf8");
@@ -495,6 +547,26 @@ function uninstall(o) {
495
547
  for (const p of st.shims || []) {
496
548
  if (isSymlink(p) || existsSync(p)) { rmSync(p, { force: true }); log("shim: removed " + p); }
497
549
  }
550
+ // 3b. the ~/.local/bin/wkappbot link: removed ONLY when it points at the core this install placed (the core file stays)
551
+ const core = (st.bin && st.bin.dest) || binDest(o);
552
+ removeStarterMd(st.starterMd, log);
553
+ let linkRemoved = "";
554
+ for (const p of new Set(shimCandidates().concat([join(homedir(), ".local", "bin", "wkappbot")]))) {
555
+ if (!isSymlink(p) || (st.shims || []).includes(p)) continue;
556
+ let tgt = ""; try { tgt = resolve(dirname(p), readlinkSync(p)); } catch {}
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 + ")"); }
558
+ else log("shim: kept " + p + " (not a link to " + core + ")");
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);
498
570
  // 4. the harness dirs we created, the plugin entry and our config folder
499
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.
500
572
  const rmTree = (p, what) => {
@@ -516,7 +588,7 @@ function uninstall(o) {
516
588
  } catch {}
517
589
  rmTree(STATE_HOME());
518
590
  rmTree(join(configDir(o), "wkappbot-code"));
519
- log("uninstall done. Kept: opencode-ai, the wkappbot core (" + ((st.bin && st.bin.dest) || binDest(o)) + ") and its skills, and this CLI (npm rm -g wkappbot-code)");
591
+ log("uninstall done." + (linkRemoved ? " Removed the link " + linkRemoved + "." : "") + " Kept: opencode-ai, the wkappbot core (" + core + ") and its skills, and this CLI (npm rm -g wkappbot-code)");
520
592
  return rc;
521
593
  }
522
594
 
@@ -542,6 +614,9 @@ function doctor(o) {
542
614
  const st = readState(o);
543
615
  const rows = [];
544
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);
545
620
  const v = opencodeVersion();
546
621
  add("opencode", v === OPENCODE_VERSION, v ? v + (v === OPENCODE_VERSION ? "" : " (pinned " + OPENCODE_VERSION + ")") : "not on PATH", false);
547
622
  const pdest = join(cfg, "wkappbot-code", "wisdom-kernel.js");
@@ -551,11 +626,12 @@ function doctor(o) {
551
626
  add("config", !!entry, join(cfg, "opencode.json"), true);
552
627
  const oc = configRow(join(cfg, "opencode.json"));
553
628
  add("opencode-config", oc.ok, oc.detail, false);
629
+ aiCliRows(add, !!(st.aiClis && st.aiClis.skipped));
554
630
  // bin: the core answers --version
555
631
  const dest = o.binDest ? resolve(o.binDest) : (st.bin && st.bin.dest) || binDest(o);
556
632
  let core = "";
557
633
  if (st.bin && st.bin.skipped) add("bin", false, "skipped (--no-bin)", false, true);
558
- 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);
559
635
  else {
560
636
  const r = spawnSync(dest, ["--version"], { encoding: "utf8", timeout: 60000 });
561
637
  core = r.status === 0 ? (r.stdout || "").trim().split("\n")[0] : "";
@@ -570,6 +646,9 @@ function doctor(o) {
570
646
  if (st.harness && st.harness.skipped && !hk) add("kih-hook", false, "skipped (--no-harness)", false, true);
571
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);
572
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); }
573
652
  // skills: the HQ holds on-load and the core prints it
574
653
  const hq = (st.skills && st.skills.hq) || hqDir(o, dest);
575
654
  if (st.skills && st.skills.skipped) add("skills", false, "skipped (--no-skills)", false, true);
@@ -630,7 +709,7 @@ switch (o.cmd) {
630
709
  case "update": process.exit(upd());
631
710
  case "config": installPlugin(o, kihDir(o), kihCheckout(o) ? "harness checkout" : "web harness"); process.exit(0);
632
711
  default:
633
- 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" +
634
713
  " [--config-dir DIR] [--kih-dir DIR] [--bin-dest FILE] [--hq-dir DIR] [--update] [--auto-update] | doctor [--config-dir DIR]\n" +
635
714
  " | uninstall [--config-dir DIR] | update [install opts] | config [--yes] [--project] [--set key=value] [--config-dir DIR]");
636
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
+ }
package/lib/config.js CHANGED
@@ -16,6 +16,8 @@ export const PROVIDERS = [
16
16
  ["GROQ_API_KEY", "groq", "groq/llama-3.3-70b-versatile"],
17
17
  ["OPENROUTER_API_KEY", "openrouter", "openrouter/anthropic/claude-sonnet-4.5"],
18
18
  ];
19
+ // The free OpenCode tier: no API key, no login (opencode.ai Zen free models). Used only when the user set nothing.
20
+ export const FREE_MODEL = "opencode/big-pickle";
19
21
  const defaultModel = (prov) => { const d = PROVIDERS.find((p) => p[1] === prov); return d ? d[2] : prov + "/default"; };
20
22
 
21
23
  // Provider ids of an existing `opencode auth login` (key names only).
@@ -34,6 +36,8 @@ export function detect({ env = process.env, user = {}, pluginUrl = "", kih = "",
34
36
  else {
35
37
  const a = authProviders(env)[0];
36
38
  if (a) it.model = { value: defaultModel(a), source: "opencode auth login (" + a + ")" };
39
+ // 0.3.12: nothing set anywhere -> the free OpenCode tier (no key, no login), so a fresh install opens a working start screen
40
+ else it.model = { value: FREE_MODEL, source: "OpenCode free default, no key needed" };
37
41
  }
38
42
  }
39
43
  if (it.model) it.provider = { value: it.model.value.split("/")[0], source: it.model.source };
@@ -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
@@ -1,7 +1,7 @@
1
1
  // wkappbot-code lib/setup-run.js -- what `npm install wkappbot-code` does after the files land, and what the first
2
2
  // `wkappbot-code` run says about it (owner order 2026-11-08: "npm install wkappbot-code should do the initial setup and
3
3
  // the tests automatically", "typing wkappbot-code should show the OpenCode start screen").
4
- // worker bin/postinstall.js --run: install --no-npm, then doctor, then selfTest() below, then ONE postinstall.done
4
+ // worker bin/postinstall.js --run: install, then doctor, then selfTest() below, then ONE postinstall.done
5
5
  // JSON {version, installRc, rc, doctor:{pass,warn,fail,skip}, selftest:{n,m}, platform, at, items}.
6
6
  // banner bannerFor() / printBanner(): the one line the first run prints ("setup ok, doctor 9/9, selftest 4/4", the
7
7
  // failing item with the log path, or "setup still running"), shown ONCE per done file (postinstall.seen).
@@ -11,7 +11,7 @@
11
11
  import { spawnSync } from "node:child_process";
12
12
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
13
13
  import { homedir, tmpdir } from "node:os";
14
- import { join } from "node:path";
14
+ import { delimiter, dirname, join } from "node:path";
15
15
 
16
16
  const RUNNING_STALE_MS = 30 * 60 * 1000; // a worker that left no done file for 30 minutes is dead, not "still running"
17
17
  const IS_WIN = process.platform === "win32";
@@ -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);
@@ -128,8 +131,26 @@ export function printBanner({ home = homedir(), out = console.log, now = Date.no
128
131
  // ---- bare launch ----------------------------------------------------------------------------------
129
132
  // `wkappbot-code` with no arguments opens OpenCode (its start screen). Returns the exit code, or null when opencode
130
133
  // cannot be started (not on PATH): the caller then prints the usage as before.
131
- export function launchOpenCode(env = process.env) {
132
- const r = spawnSync("opencode", [], { stdio: "inherit", shell: IS_WIN, env });
134
+ // 0.3.12: `opencode` is looked up on PATH first, then beside this CLI (argv1 = the npm bin dir where `npm i -g opencode-ai`
135
+ // linked it), so a global prefix that is not on the user PATH still opens it.
136
+ export function launchOpenCode(env = process.env, argv1 = process.argv[1] || "") {
137
+ const run = (cmd, e) => spawnSync(cmd, [], { stdio: "inherit", shell: IS_WIN, env: e });
138
+ let r = run("opencode", env);
139
+ if (r.error && argv1) r = run("opencode", withPathFirst({ ...env }, dirname(argv1)));
133
140
  if (r.error) return null;
134
141
  return r.status ?? 1;
135
142
  }
143
+
144
+ // The npm global bin dir (where `npm i -g` links binaries): prefix/bin, or the prefix itself on Windows. "" when unknown.
145
+ export function globalBinDir(env = process.env) {
146
+ const r = spawnSync("npm", ["prefix", "-g"], { encoding: "utf8", env, shell: IS_WIN, timeout: 30000 });
147
+ const p = r.status === 0 ? String(r.stdout || "").trim().split(/\r?\n/).pop() : "";
148
+ return p ? (IS_WIN ? p : join(p, "bin")) : "";
149
+ }
150
+ // env with dir first on PATH (the key is spelled Path on Windows).
151
+ export function withPathFirst(env, dir) {
152
+ if (!dir) return env;
153
+ const k = Object.keys(env).find((x) => x.toLowerCase() === "path") || "PATH";
154
+ env[k] = dir + delimiter + (env[k] || "");
155
+ return env;
156
+ }
@@ -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
@@ -130,7 +133,9 @@ repo_file() {
130
133
  echo "$REPO/bin/$rel"
131
134
  }
132
135
 
133
- # install_file SRC DEST LABEL -- copy via .new then mv; "up to date" when DEST already matches.
136
+ # install_file SRC DEST LABEL -- copy via a per-process .new.PID then mv; "up to date" when DEST already matches.
137
+ # The temp name is unique per process: two installs at once (npm's background setup and a user's own
138
+ # `wkappbot-code install`, SDK run 42504778443) both wrote DEST.new and one failed with cp: File exists.
134
139
  install_file() {
135
140
  command -v wk_stopwatch_lap >/dev/null 2>&1 && wk_stopwatch_lap "install_file" || true
136
141
  local src="$1" dest="$2" label="$3" sum verb="installed"
@@ -141,9 +146,11 @@ install_file() {
141
146
  fi
142
147
  if [ "$WK_DRY" = 1 ]; then verb="would install"; fi
143
148
  run mkdir -p "$(dirname "$dest")"
144
- run cp "$src" "$dest.new"
145
- run chmod +x "$dest.new"
146
- run mv -f "$dest.new" "$dest"
149
+ local tmp="$dest.new.$$"
150
+ run rm -f "$tmp"
151
+ run cp "$src" "$tmp"
152
+ run chmod +x "$tmp"
153
+ run mv -f "$tmp" "$dest"
147
154
  echo "$verb: $dest (source: $label) sha256=$sum"
148
155
  }
149
156
 
@@ -171,15 +178,31 @@ acquire_unix() {
171
178
 
172
179
  acquire_win() {
173
180
  command -v wk_stopwatch_lap >/dev/null 2>&1 && wk_stopwatch_lap "acquire_win" || true
174
- local dest="$1" exe xzsrc core
181
+ local dest="$1" offline="${2:-0}" exe="" xzsrc="" core frc=3 xrc=3 label
175
182
  [ -n "$dest" ] || dest="$(default_dest_win)"
176
- note "win-x64 installs from bin/ only: the two-file set has no single SDK asset"
177
- exe="$(repo_file "win-x64/wkappbot.exe")"
178
- 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
179
202
  core="$WK_TMP/wkappbot-core.exe"
180
- xz -dc "$xzsrc" > "$core"
181
- install_file "$exe" "$dest/wkappbot.exe" "repo bin/win-x64/wkappbot.exe"
182
- 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"
183
206
  }
184
207
 
185
208
  acquire() {
@@ -195,7 +218,7 @@ acquire() {
195
218
  done
196
219
  plat="$(platform_detect)"
197
220
  if [ "$plat" = win-x64 ]; then
198
- acquire_win "$dest"
221
+ acquire_win "$dest" "$offline"
199
222
  else
200
223
  acquire_unix "$dest" "$plat" "$offline"
201
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.11",
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.