wkappbot-code 0.3.26 → 0.3.29

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
@@ -63,7 +63,7 @@ The generated `opencode.json` holds the minimum: `$schema` and the plugin entry
63
63
 
64
64
  - One headless turn: `opencode run -m provider/model "prompt"`
65
65
  - Inside the TUI: `/models`
66
- - Any OpenAI-compatible endpoint: declare a provider with `"npm": "@ai-sdk/openai-compatible"` and `"options": { "baseURL": "{env:MY_BASE_URL}", "apiKey": "{env:MY_API_KEY}" }` in your own `opencode.json`. No key is stored in a file.
66
+ - Any OpenAI-compatible endpoint: `wkappbot-code provider add acme --base-url-env MY_BASE_URL --api-key-env MY_API_KEY --model my-model` writes an `@ai-sdk/openai-compatible` provider into your `opencode.json` (`"baseURL": "{env:MY_BASE_URL}"`, `"apiKey": "{env:MY_API_KEY}"`), then `opencode run -m acme/my-model`. The arguments are env var NAMES: a key or a URL typed in their place is refused, and no key is stored in a file. The file is backed up first, a config that is not plain JSON is left untouched, and an existing provider of the same id needs `--force`. `provider list` prints ids and env var names only, `provider remove ID` deletes just that provider, `uninstall` keeps your providers, and `doctor` WARNs (names only) when a referenced variable is unset.
67
67
 
68
68
  ## In the browser WebVM (no login needed)
69
69
 
@@ -18,6 +18,7 @@
18
18
  // wkappbot-code doctor [opts] one PASS/WARN/FAIL/SKIP row per part (exit 1 if a required part fails)
19
19
  // wkappbot-code update [opts] newer version.json -> tgz sha256-checked -> npm i -g -> install --update (lib/update.js)
20
20
  // wkappbot-code config [opts] detect provider/model/plugin/harness, edit at a TTY, merge into opencode.json (lib/config.js)
21
+ // wkappbot-code provider add|list|remove an OpenAI-compatible endpoint (env var NAMES only) in opencode.json (lib/providers.js)
21
22
  // wkappbot-code uninstall [opts] undo install: the recorded pre-install global core.hooksPath comes back, the
22
23
  // harness hook arms leave ~/.claude/settings.json (your own entries stay), the recorded shims
23
24
  // leave the bin dirs, ~/.wk-harness and ~/.wk-harness-hooks go, the plugin entry leaves opencode.json
@@ -40,9 +41,10 @@ import {
40
41
  import { homedir, tmpdir } from "node:os";
41
42
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
42
43
  import { fileURLToPath, pathToFileURL } from "node:url";
43
- import { quietCheck, runUpdate, updateRow } from "../lib/update.js"; import { rmTreeHard } from "../lib/rmtree.js"; import { plainFileUrl } from "../lib/fileurl.js"; import { removeStaleWkskill } from "../lib/stale-wkskill.js"; import { checkPath, installBothLinks, removeCreatedLinks } from "../lib/doctor-path.js";
44
+ import { quietCheck, runUpdate, updateRow } from "../lib/update.js"; import { rmTreeHard, waitForArchive } from "../lib/rmtree.js"; import { plainFileUrl } from "../lib/fileurl.js"; import { removeStaleWkskill } from "../lib/stale-wkskill.js"; import { checkPath, installBothLinks, removeCreatedLinks } from "../lib/doctor-path.js";
44
45
  import { applySets, askEach, buildConfig, configRow, detect, lineDiff, printDetected } from "../lib/config.js";
45
46
  import { aiCliRows, installAiClis, removeAiClis } from "../lib/ai-clis.js";
47
+ import { providerCli, providerRow } from "../lib/providers.js";
46
48
  import { hooksRow, wireClaudeHooks } from "../lib/hook-wire.js";
47
49
  import { installStarterMd, removeStarterMd } from "../lib/starter-md.js";
48
50
  import { addAllows, removeAllows } from "../lib/allow-compact.js";
@@ -378,15 +380,16 @@ function installPlugin(o, kih, kihSource) {
378
380
  const side = join(dirname(target), "wkappbot-code.json");
379
381
  writeFileSync(side, JSON.stringify(buildConfig({}, it, tmpl), null, 2) + "\n");
380
382
  log(target + " is not plain JSON; left untouched, wrote " + side + " -- merge its plugin entry by hand");
381
- return;
383
+ return null;
382
384
  }
383
385
  const text = JSON.stringify(buildConfig(user, it, tmpl), null, 2) + "\n";
384
- let note = "";
386
+ let note = "", backup = null; // backup: the opencode.json copy THIS install takes; install records it, uninstall removes exactly that (0.3.29)
385
387
  if (raw && text === raw) note = " (unchanged)";
386
388
  else {
387
389
  if (raw) {
388
390
  const bak = target + ".bak-" + stamp();
389
391
  copyFileSync(target, bak);
392
+ backup = bak;
390
393
  note = " (merged; backup " + bak + ")";
391
394
  }
392
395
  for (const l of lineDiff(raw, text)) log("diff " + l);
@@ -395,14 +398,16 @@ function installPlugin(o, kih, kihSource) {
395
398
  }
396
399
  log("plugin: " + url);
397
400
  log("config: " + target + note);
401
+ return backup;
398
402
  }
399
403
 
400
404
  // The machine state an uninstall must give back, recorded ONCE (a re-install never overwrites the first record, or
401
405
  // it would record our own dispatcher as the "prior" hooksPath). Shim paths are those install may create.
402
406
  function shimCandidates() {
403
- return [join(linkDir(), "wkappbot"), join(HARNESS_LINKS(), "wk-verify"), join(HARNESS_LINKS(), "wk-verify.cmd")]; // Windows (Git Bash) .cmd twins from harness-install.sh: recorded like the rest, so uninstall removes them, never a user file
407
+ return [join(linkDir(), "wkappbot"), join(linkDir(), "wkappbot-core"), join(linkDir(), "wkappbot.cmd"), join(linkDir(), "wkappbot-core.cmd"), join(HARNESS_LINKS(), "wk-verify"), join(HARNESS_LINKS(), "wk-verify.cmd")]; // wkappbot-core: the harness install.sh links it beside wkappbot (SDK e2e nopwsh u1: local-bin= vs local-bin=wkappbot-core) // Windows (Git Bash) .cmd twins from harness-install.sh: recorded like the rest, so uninstall removes them, never a user file
404
408
  }
405
409
  const PRE_SETTINGS = () => join(STATE_HOME(), "settings.pre-install.json");
410
+ function settingsBackup() { const bak = SETTINGS() + ".bak-" + stamp(); copyFileSync(SETTINGS(), bak); log("settings: backup " + bak); return bak; }
406
411
  function recordPrior(prev) {
407
412
  if (prev && prev.prior) return prev.prior;
408
413
  // The user's settings.json as it was, byte for byte: uninstall gives these bytes back when nothing else changed.
@@ -431,6 +436,8 @@ function install(o) {
431
436
  let bad = 0;
432
437
  // 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
433
438
  // is not on PATH is used from its folder, and a missing one is installed with winget (lib/prereq.js)
439
+ // 0.3.29 (2026-11-12, spokesman order after SDK gate run 43227049219): uninstall waits (bounded 15 s) for a RECORDED archive pid before it removes the harness copy (lib/rmtree.js waitForArchive; the detached archive child starts from cwd / with the script fd closed), so no EPERM on .claude/hooks/wkharness-run.sh; install records st.opencodeBackups and uninstall leaves no opencode.json.bak-<stamp> of its own.
440
+ // 0.3.28 (owner 2026-11-12 via the spokesman): uninstall removes only the settings backup the install recorded (st.settingsBackup); the .cmd twins of the core links are shim candidates; the clean-baseline guard no longer reads --model of non-delegating commands; doctor sets process.exitCode.
434
441
  st.prereq = ensurePrereqs({ log });
435
442
  if (!st.prereq.ok) bad++;
436
443
  // 0.3.20 (owner 2026-11-09): a real Python before any step runs python -- the Store alias stub made install exit 49
@@ -464,6 +471,8 @@ function install(o) {
464
471
  const kih = (st.harness.mode === "web" && st.harness.ok) ? st.harness.dir : kihDir(o);
465
472
  // 0.3.13 (owner 2026-11-09): read the Claude settings first, then point every harness hook at the SAME harness the
466
473
  // plugin uses, on both roads (web harness or checkout); the user's own hooks stay; bad JSON is reported, not touched.
474
+ // the one settings.json copy install takes before it edits the file: uninstall removes this recorded copy and no other
475
+ st.settingsBackup = prev.settingsBackup || (o.harness && existsSync(SETTINGS()) ? settingsBackup() : null);
467
476
  st.hooks = o.harness ? wireClaudeHooks({ settings: SETTINGS(), kih, pwsh: !!ps, log }) : { skipped: true };
468
477
  // the compressed allow list (lib/allow-compact.js): five harness-own prefixes, never the wide rows learned on the owner's seats
469
478
  st.allows = (o.harness && o.allows) ? addAllows(SETTINGS(), prev.allows || {}, log) : (prev.allows || { skipped: true });
@@ -478,7 +487,8 @@ function install(o) {
478
487
  }
479
488
  st.skills = o.skills ? installSkills(o, dest) : { skipped: true };
480
489
  if (o.skills && !st.skills.ok) { bad++; log("skills: FAILED -- " + (st.skills.note || "on-load missing from the HQ")); }
481
- installPlugin(o, kih, (st.harness.mode === "web" && st.harness.ok) ? "web harness" : "harness checkout");
490
+ // 0.3.29: the opencode.json copy a merge takes is recorded (like st.settingsBackup); uninstall removes exactly the recorded copies, never a user's own .bak-*
491
+ { const ob = installPlugin(o, kih, (st.harness.mode === "web" && st.harness.ok) ? "web harness" : "harness checkout"); st.opencodeBackups = [...(prev.opencodeBackups || []), ...(ob ? [ob] : [])]; }
482
492
  st.kihDir = kih;
483
493
  // The shims this install created (absent before, present now): uninstall removes exactly these.
484
494
  st.shims = [...new Set([...(prev.shims || []), ...shimCandidates().filter((p) => (existsSync(p) || isSymlink(p)) && !st.prior.shimsExisting.includes(p))])];
@@ -544,7 +554,6 @@ function uninstall(o) {
544
554
  }
545
555
  }
546
556
  if (removed || restored) {
547
- copyFileSync(sp, sp + ".bak-" + stamp());
548
557
  if (pr.settingsExisted === false && !Object.keys(d).length) { rmSync(sp); log("settings: removed " + sp + " (it did not exist before install)"); }
549
558
  else {
550
559
  const same = pre && JSON.stringify(sortKeys(pre)) === JSON.stringify(sortKeys(d));
@@ -555,6 +564,8 @@ function uninstall(o) {
555
564
  } else log("settings: no harness hook in " + sp);
556
565
  } catch (e) { rc = 1; log("settings: " + sp + " is not plain JSON, left untouched (" + e.message + ")"); }
557
566
  }
567
+ // the settings copy install recorded (only that one) goes with the uninstall; a user's own settings.json.bak-* stays
568
+ if (st.settingsBackup && existsSync(st.settingsBackup)) { rmSync(st.settingsBackup, { force: true }); log("settings: removed the backup install recorded, " + st.settingsBackup); }
558
569
  // 3. the shims install created
559
570
  for (const p of st.shims || []) {
560
571
  if (isSymlink(p) || existsSync(p)) { rmSync(p, { force: true }); log("shim: removed " + p); }
@@ -593,6 +604,8 @@ function uninstall(o) {
593
604
  rc = 1; log((what || "remove") + ": could not remove " + p + " -- blocked by " + r.blockers.slice(0, 5).join("; ") + " -- delete it by hand");
594
605
  if (IS_WIN) { const d = spawnSync("tasklist.exe", ["/v"], { encoding: "utf8", timeout: 15000 }); log("remove diagnostics (process list, not file owners): " + String(d.stdout || d.stderr || "").slice(0, 4000)); }
595
606
  };
607
+ // (B) the detached per-turn archive child may still hold .claude/hooks/wkharness-run.sh (Windows EPERM): wait for its RECORDED pid first.
608
+ if (existsSync(HARNESS_DIR())) { const w = waitForArchive(HARNESS_DIR()); if (w.waited) log("harness: waited " + w.waited + " ms for the archive child" + (w.alive.length ? " (pid " + w.alive.join(",") + " still running)" : "")); }
596
609
  if (st.harness && st.harness.mode === "web" && pr.harnessDirExisted !== true && existsSync(HARNESS_DIR())) rmTree(HARNESS_DIR(), "harness");
597
610
  if (pr.hooksDirExisted !== true && existsSync(HOOKS_DIR())) rmTree(HOOKS_DIR(), "push-gate");
598
611
  const cfgFile = join(configDir(o), "opencode.json");
@@ -600,12 +613,14 @@ function uninstall(o) {
600
613
  const c = JSON.parse(readFileSync(cfgFile, "utf8"));
601
614
  const keep = (c.plugin || []).filter((p) => !String(Array.isArray(p) ? p[0] : p).includes("wisdom-kernel"));
602
615
  if (keep.length !== (c.plugin || []).length) {
603
- copyFileSync(cfgFile, cfgFile + ".bak-" + stamp());
616
+ // 0.3.29 (spokesman ruling 2026-11-12): no backup of OUR OWN edit -- it left an opencode.json.bak-<stamp> behind. Only our entry goes.
604
617
  if (keep.length) c.plugin = keep; else delete c.plugin;
605
618
  writeFileSync(cfgFile, JSON.stringify(c, null, 2) + "\n");
606
619
  log("config: plugin entry removed from " + cfgFile);
607
620
  }
608
621
  } catch {}
622
+ // the opencode.json copies install recorded (and only those) go with the uninstall; a user's own opencode.json.bak-* stays
623
+ for (const b of (st.opencodeBackups || [])) { try { if (existsSync(b)) { rmSync(b, { force: true }); log("config: removed the backup install recorded, " + b); } } catch (e) { log("config: could not remove " + b + " (" + e.message + ")"); } }
609
624
  rmTree(STATE_HOME());
610
625
  rmTree(join(configDir(o), "wkappbot-code"));
611
626
  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)");
@@ -646,6 +661,7 @@ function doctor(o) {
646
661
  add("config", !!entry, join(cfg, "opencode.json"), true);
647
662
  const oc = configRow(join(cfg, "opencode.json"));
648
663
  add("opencode-config", oc.ok, oc.detail, false);
664
+ try { const pv = providerRow(JSON.parse(readFileSync(join(cfg, "opencode.json"), "utf8"))); if (!pv.none) add("provider", pv.ok, pv.detail, false); } catch {} // WARN names an unset env var (never its value)
649
665
  aiCliRows(add, !!(st.aiClis && st.aiClis.skipped));
650
666
  // bin: the core answers --version
651
667
  const dest = o.binDest ? resolve(o.binDest) : (st.bin && st.bin.dest) || binDest(o);
@@ -708,6 +724,8 @@ function doctor(o) {
708
724
  return bad ? 1 : 0;
709
725
  }
710
726
 
727
+ // provider add|list|remove (lib/providers.js): env var NAMES into the user's opencode.json, no key stored
728
+ if (process.argv[2] === "provider") process.exit(providerCli(process.argv.slice(3), { target: join(configDir({}), "opencode.json"), stamp }));
711
729
  const o = parseArgs(process.argv.slice(2));
712
730
  // install --update (from update) and these flags are forwarded to the re-run install of a fresh version.
713
731
  const fwd = process.argv.slice(3).filter((a) => a !== "--auto-update");
@@ -725,12 +743,13 @@ switch (o.cmd) {
725
743
  console.log("wkappbot-code " + PKG.version + " (opencode-ai " + OPENCODE_VERSION + ")"); process.exit(0);
726
744
  case "install": process.exit(install(o));
727
745
  case "uninstall": process.exit(uninstall(o));
728
- case "doctor": process.exit(doctor(o));
746
+ case "doctor": process.exitCode = doctor(o); break;
729
747
  case "update": process.exit(upd());
730
748
  case "config": installPlugin(o, kihDir(o), kihCheckout(o) ? "harness checkout" : "web harness"); process.exit(0);
731
749
  default:
732
750
  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" +
733
751
  " [--config-dir DIR] [--kih-dir DIR] [--bin-dest FILE] [--hq-dir DIR] [--update] [--auto-update] | doctor [--config-dir DIR]\n" +
734
- " | uninstall [--config-dir DIR] | update [install opts] | config [--yes] [--project] [--set key=value] [--config-dir DIR]");
752
+ " | uninstall [--config-dir DIR] | update [install opts] | config [--yes] [--project] [--set key=value] [--config-dir DIR]\n" +
753
+ " | provider add ID --base-url-env VAR --api-key-env VAR --model NAME | provider list | provider remove ID");
735
754
  process.exit(o.cmd === "help" || o.cmd === "--help" ? 0 : 64);
736
755
  }
package/lib/fileurl.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // fileurl.js -- a file:// base that curl can open on every platform.
2
+ // VERIFIED 2026-11-12 (kih ROOT 3): selftest-rmtree.mjs case r4 (plainFileUrl decodes %7E to ~, an https base is untouched) PASS in RESULT 8/8.
2
3
  // pathToFileURL percent-encodes "~" ("RUNNER~1" -> "RUNNER%7E1") and Windows curl does not decode a file:// path, so a
3
4
  // Windows profile with a short name, a space or a non-ASCII letter failed to read the harness. plainFileUrl hands curl the plain path.
4
5
  import { fileURLToPath } from "node:url";
@@ -0,0 +1,126 @@
1
+ // providers.js -- `wkappbot-code provider add|list|remove`: an OpenAI-compatible endpoint written into the user's
2
+ // opencode.json as an @ai-sdk/openai-compatible provider (owner 2026-11-12). The file holds env var NAMES only
3
+ // ("{env:VAR}"); a key or a URL typed where a name belongs is refused and never echoed, and no env var value is read
4
+ // except by the doctor row, which only checks that a variable is set and prints its name.
5
+ // Pure build/merge/remove functions first, then the CLI body. Spec: wkappbot skill read agent-harness-landscape-2026
6
+ import { copyFileSync, existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
7
+
8
+ export const NPM = "@ai-sdk/openai-compatible";
9
+ export const ENV_NAME = /^[A-Z_][A-Z0-9_]*$/;
10
+ const ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
11
+
12
+ // "" when v is a usable env var NAME; else a reason that never contains v.
13
+ export function envNameProblem(v, label) {
14
+ const s = String(v == null ? "" : v);
15
+ if (!s) return label + " needs the NAME of an environment variable";
16
+ if (ENV_NAME.test(s)) return "";
17
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(s) || s.includes("/") || s.includes(".")) return label + " looks like a URL; pass the NAME of an env var that holds it (letters, digits, underscore, e.g. MY_BASE_URL)";
18
+ if (/^(sk|pk|gsk|ghp|xox|AIza|Bearer)[-_ ]?/i.test(s) || s.length > 40 || /[-\s]/.test(s)) return label + " looks like a secret; pass the NAME of an env var that holds it, never the value";
19
+ return label + " is not an env var name (it must match ^[A-Z_][A-Z0-9_]*$)";
20
+ }
21
+
22
+ // spec = { id, baseUrlEnv, apiKeyEnv, models: [names], label } -> { ok, error, id, block }
23
+ export function buildProviderBlock(spec) {
24
+ const id = String(spec.id || "");
25
+ if (!ID_RE.test(id)) return { ok: false, error: "provider id must be lowercase letters, digits, - or _ (got a " + id.length + "-character id)" };
26
+ const e = envNameProblem(spec.baseUrlEnv, "--base-url-env") || envNameProblem(spec.apiKeyEnv, "--api-key-env");
27
+ if (e) return { ok: false, error: e };
28
+ const models = (spec.models || []).map(String).filter(Boolean);
29
+ if (!models.length) return { ok: false, error: "at least one --model NAME is required" };
30
+ const block = { npm: NPM, name: spec.label || id, options: { baseURL: "{env:" + spec.baseUrlEnv + "}", apiKey: "{env:" + spec.apiKeyEnv + "}" }, models: {} };
31
+ for (const m of models) block.models[m] = { name: m, tool_call: true };
32
+ return { ok: true, id, block };
33
+ }
34
+
35
+ // A new config: every user key kept; an existing same-id provider stays unless force.
36
+ export function mergeProvider(cfg, id, block, force = false) {
37
+ const out = { ...cfg, provider: { ...(cfg.provider || {}) } };
38
+ if (out.provider[id] && !force) return { ok: false, error: "provider " + id + " already exists; pass --force to replace it" };
39
+ out.provider[id] = block;
40
+ return { ok: true, cfg: out };
41
+ }
42
+
43
+ // A new config without provider `id` (the provider key goes when it was the last one).
44
+ export function removeProvider(cfg, id) {
45
+ if (!cfg.provider || !(id in cfg.provider)) return { ok: false, error: "no provider " + id };
46
+ const out = { ...cfg, provider: { ...cfg.provider } };
47
+ delete out.provider[id];
48
+ if (!Object.keys(out.provider).length) delete out.provider;
49
+ return { ok: true, cfg: out };
50
+ }
51
+
52
+ // The env var NAMES a provider block references ("{env:NAME}" in options); values are never read here.
53
+ export function envRefs(block) {
54
+ const names = [];
55
+ for (const v of Object.values((block && block.options) || {})) for (const m of String(v).matchAll(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g)) if (!names.includes(m[1])) names.push(m[1]);
56
+ return names;
57
+ }
58
+
59
+ export const listProviders = (cfg) => Object.entries(cfg.provider || {}).map(([id, b]) => ({ id, npm: (b && b.npm) || "", refs: envRefs(b), models: Object.keys((b && b.models) || {}) }));
60
+
61
+ // The doctor row: { ok, detail, none } -- none when the config declares no provider that references an env var.
62
+ export function providerRow(cfg, env = process.env) {
63
+ const rows = listProviders(cfg).filter((r) => r.refs.length);
64
+ if (!rows.length) return { ok: true, none: true, detail: "no provider with env var references" };
65
+ const unset = [];
66
+ for (const r of rows) for (const n of r.refs) if (!env[n] && !unset.includes(n)) unset.push(n);
67
+ const ids = rows.map((r) => r.id).join(",");
68
+ return unset.length ? { ok: false, detail: ids + ": env var not set: " + unset.join(", ") } : { ok: true, detail: ids + ": every referenced env var is set" };
69
+ }
70
+
71
+ function parse(argv) {
72
+ const o = { sub: argv[0] || "", id: "", models: [], force: false, baseUrlEnv: "", apiKeyEnv: "", label: "", bad: "" };
73
+ for (let i = 1; i < argv.length; i++) {
74
+ const a = argv[i];
75
+ if (a === "--base-url-env") o.baseUrlEnv = argv[++i] || "";
76
+ else if (a === "--api-key-env") o.apiKeyEnv = argv[++i] || "";
77
+ else if (a === "--model") o.models.push(argv[++i] || "");
78
+ else if (a === "--name") o.label = argv[++i] || "";
79
+ else if (a === "--force") o.force = true;
80
+ else if (a.startsWith("--")) o.bad = a;
81
+ else if (!o.id) o.id = a;
82
+ else o.bad = "extra argument";
83
+ }
84
+ return o;
85
+ }
86
+
87
+ export const USAGE = "usage: wkappbot-code provider add ID --base-url-env VAR --api-key-env VAR --model NAME [--model NAME ...] [--name LABEL] [--force]\n" +
88
+ " | provider list | provider remove ID (VAR is an environment variable NAME; no key or URL is ever stored)";
89
+
90
+ // The CLI body. target = the opencode.json path. Returns the exit code.
91
+ export function providerCli(argv, { target, log = console.log, err = console.error, stamp = () => Date.now().toString() }) {
92
+ const o = parse(argv);
93
+ if (o.bad) { err("provider: unknown option " + o.bad); return 64; }
94
+ if (!["add", "list", "remove"].includes(o.sub)) { log(USAGE); return o.sub === "" || o.sub === "--help" ? 0 : 64; }
95
+ let raw = "", cfg = {};
96
+ if (existsSync(target)) {
97
+ raw = readFileSync(target, "utf8");
98
+ try { cfg = JSON.parse(raw); } catch { err("provider: " + target + " is not plain JSON; left untouched -- edit it by hand"); return 1; }
99
+ if (!cfg || typeof cfg !== "object" || Array.isArray(cfg)) { err("provider: " + target + " is not a JSON object; left untouched"); return 1; }
100
+ }
101
+ if (o.sub === "list") {
102
+ const rows = listProviders(cfg);
103
+ if (!rows.length) log("no providers in " + target);
104
+ for (const r of rows) log(r.id + " " + (r.npm || "-") + " env: " + (r.refs.join(", ") || "-") + " models: " + (r.models.join(", ") || "-"));
105
+ return 0;
106
+ }
107
+ let next;
108
+ if (o.sub === "add") {
109
+ const b = buildProviderBlock({ id: o.id, baseUrlEnv: o.baseUrlEnv, apiKeyEnv: o.apiKeyEnv, models: o.models, label: o.label });
110
+ if (!b.ok) { err("provider: " + b.error); return 2; }
111
+ const m = mergeProvider(cfg, b.id, b.block, o.force);
112
+ if (!m.ok) { err("provider: " + m.error); return 3; }
113
+ next = m.cfg;
114
+ } else {
115
+ if (!o.id) { err("provider remove needs an ID"); return 64; }
116
+ const m = removeProvider(cfg, o.id);
117
+ if (!m.ok) { err("provider: " + m.error); return 3; }
118
+ next = m.cfg;
119
+ }
120
+ const text = JSON.stringify(next, null, 2) + "\n";
121
+ if (raw) { const bak = target + ".bak-" + stamp(); copyFileSync(target, bak); log("backup " + bak); }
122
+ writeFileSync(target + ".tmp", text);
123
+ renameSync(target + ".tmp", target);
124
+ log("provider " + o.id + (o.sub === "add" ? " added to " : " removed from ") + target);
125
+ return 0;
126
+ }
package/lib/rmtree.js CHANGED
@@ -1,10 +1,32 @@
1
1
  // rmtree.js -- delete a tree on a platform that releases a just-closed handle late (Windows: EBUSY/EPERM/ENOTEMPTY).
2
2
  // rmTreeHard(p, rm) -> { ok, blockers[] }: a long bounded retry first, then a per-entry pass that names what stays.
3
- import { existsSync, lstatSync, readdirSync, rmSync } from "node:fs";
3
+ import { existsSync, lstatSync, readdirSync, readFileSync, rmSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
 
6
6
  const sleepMs = (ms) => { try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* no wait available */ } };
7
7
 
8
+ // waitForArchive(harnessDir, opts) -> { waited, alive[] }: the detached per-turn archive child (tools/wk-hippo-archive.sh) records its
9
+ // pid in <harnessDir>/.tmp/hippo-archive/archive-SID.pid and removes it on exit. A Windows uninstall that runs inside that child's
10
+ // lifetime hits EPERM on .claude/hooks/wkharness-run.sh, so uninstall waits (bounded) for every RECORDED pid to leave first.
11
+ // isAlive and sleep are injectable so the selftest fakes a held handle. A pid file whose pid is dead is stale and ignored.
12
+ const defaultAlive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return !!e && e.code === "EPERM"; } };
13
+ export function waitForArchive(harnessDir, opts = {}) {
14
+ const isAlive = opts.isAlive || defaultAlive, maxMs = opts.maxMs ?? 15000, gap = opts.gap || 250, nap = opts.sleep || sleepMs;
15
+ const dir = join(harnessDir, ".tmp", "hippo-archive");
16
+ const pids = () => {
17
+ let names = []; try { names = readdirSync(dir).filter((n) => /\.pid$/.test(n)); } catch { return []; }
18
+ const out = [];
19
+ for (const n of names) {
20
+ let pid = 0; try { pid = parseInt(String(readFileSync(join(dir, n), "utf8")).trim(), 10); } catch { /* unreadable */ }
21
+ if (pid > 0 && isAlive(pid)) out.push(pid);
22
+ }
23
+ return out;
24
+ };
25
+ let alive = pids(), waited = 0;
26
+ while (alive.length && waited < maxMs) { nap(gap); waited += gap; alive = pids(); }
27
+ return { waited, alive };
28
+ }
29
+
8
30
  // rm is injectable so the selftest can fake a held handle (default: fs.rmSync with Node's own retry)
9
31
  export function rmTreeHard(p, rm = (q, o) => rmSync(q, o), opts = {}) {
10
32
  const tries = opts.tries || 6, gap = opts.gap || 1000;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wkappbot-code",
3
- "version": "0.3.26",
3
+ "version": "0.3.29",
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": {