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 +1 -1
- package/bin/wkappbot-code.js +28 -9
- package/lib/fileurl.js +1 -0
- package/lib/providers.js +126 -0
- package/lib/rmtree.js +23 -1
- package/package.json +1 -1
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:
|
|
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
|
|
package/bin/wkappbot-code.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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";
|
package/lib/providers.js
ADDED
|
@@ -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.
|
|
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": {
|