wkappbot-code 0.3.25 → 0.3.27

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
@@ -43,6 +44,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
43
44
  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
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";
@@ -400,7 +402,7 @@ function installPlugin(o, kih, kihSource) {
400
402
  // The machine state an uninstall must give back, recorded ONCE (a re-install never overwrites the first record, or
401
403
  // it would record our own dispatcher as the "prior" hooksPath). Shim paths are those install may create.
402
404
  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
405
+ return [join(linkDir(), "wkappbot"), join(linkDir(), "wkappbot-core"), 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
406
  }
405
407
  const PRE_SETTINGS = () => join(STATE_HOME(), "settings.pre-install.json");
406
408
  function recordPrior(prev) {
@@ -646,6 +648,7 @@ function doctor(o) {
646
648
  add("config", !!entry, join(cfg, "opencode.json"), true);
647
649
  const oc = configRow(join(cfg, "opencode.json"));
648
650
  add("opencode-config", oc.ok, oc.detail, false);
651
+ 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
652
  aiCliRows(add, !!(st.aiClis && st.aiClis.skipped));
650
653
  // bin: the core answers --version
651
654
  const dest = o.binDest ? resolve(o.binDest) : (st.bin && st.bin.dest) || binDest(o);
@@ -708,6 +711,8 @@ function doctor(o) {
708
711
  return bad ? 1 : 0;
709
712
  }
710
713
 
714
+ // provider add|list|remove (lib/providers.js): env var NAMES into the user's opencode.json, no key stored
715
+ if (process.argv[2] === "provider") process.exit(providerCli(process.argv.slice(3), { target: join(configDir({}), "opencode.json"), stamp }));
711
716
  const o = parseArgs(process.argv.slice(2));
712
717
  // install --update (from update) and these flags are forwarded to the re-run install of a fresh version.
713
718
  const fwd = process.argv.slice(3).filter((a) => a !== "--auto-update");
@@ -731,6 +736,7 @@ switch (o.cmd) {
731
736
  default:
732
737
  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
738
  " [--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]");
739
+ " | uninstall [--config-dir DIR] | update [install opts] | config [--yes] [--project] [--set key=value] [--config-dir DIR]\n" +
740
+ " | provider add ID --base-url-env VAR --api-key-env VAR --model NAME | provider list | provider remove ID");
735
741
  process.exit(o.cmd === "help" || o.cmd === "--help" ? 0 : 64);
736
742
  }
@@ -224,13 +224,19 @@ export function ensureLinks({ home, platform = process.platform, binDir, env = p
224
224
  // Windows runs the PATH logic even when both shims already existed: the doctor row reads the persistent PATH,
225
225
  // so a missing entry is a FAIL whether or not this run wrote a shim. POSIX keeps its old rule (a link was written).
226
226
  const pathWanted = needsPath && (created.length > 0 || platform === "win32");
227
+ // POSIX: note whether ~/.profile existed BEFORE addToPath, so uninstall deletes only a profile install created.
228
+ const profilePath = platform === "win32" ? "" : join(home, ".profile");
229
+ const profileExisted = !!profilePath && existsSync(profilePath);
227
230
  const pathAdded = pathWanted ? !!addToPath({ dir, home, platform, readRegPath }) : false;
228
- return { created, dir, pathAdded };
231
+ const profileCreated = pathAdded && !profileExisted && !!profilePath && existsSync(profilePath) ? profilePath : "";
232
+ return { created, dir, pathAdded, profileCreated };
229
233
  }
230
234
 
231
235
  // removeCreatedLinks(created, opts) -> the entries actually removed. Deletes only the listed entries, nothing else.
232
236
  // A "PATH:<folder>" entry is the PATH entry install added: it is removed by removePath (exactly that folder),
233
- // never by unlink. opts: platform, home, and an injectable removePath({dir, home, platform}) -> boolean.
237
+ // never by unlink. A "PROFILE:<path>" entry is the ~/.profile install created: it is deleted only when its text is
238
+ // blank again (trim() empty), so a profile the user wrote into is never removed. opts: platform, home, and an
239
+ // injectable removePath({dir, home, platform}) -> boolean.
234
240
  export function removeCreatedLinks(created = [], { platform = process.platform, home = process.env.HOME || process.env.USERPROFILE || "", removePath = defaultRemovePath } = {}) {
235
241
  const removed = [];
236
242
  for (const p of created) {
@@ -238,6 +244,13 @@ export function removeCreatedLinks(created = [], { platform = process.platform,
238
244
  try { if (removePath({ dir: p.slice(5), home, platform })) removed.push(p); } catch { /* not removed */ }
239
245
  continue;
240
246
  }
247
+ if (p.startsWith("PROFILE:")) {
248
+ try {
249
+ const f = p.slice(8);
250
+ if (existsSync(f) && readFileSync(f, "utf8").trim() === "") { unlinkSync(f); removed.push(p); }
251
+ } catch { /* not removed */ }
252
+ continue;
253
+ }
241
254
  try { unlinkSync(p); removed.push(p); } catch { /* already gone */ }
242
255
  }
243
256
  return removed;
@@ -259,6 +272,7 @@ export function installBothLinks({ dest, home, platform = process.platform, binD
259
272
  const r = ensureLinks({ home, platform, binDir: dir, env, targets, addToPath, readRegPath });
260
273
  const created = BINS.map(pathOf).filter((p) => r.created.includes(p) || ours.some((bin) => pathOf(bin) === p));
261
274
  if (r.pathAdded) created.push("PATH:" + dir); // recorded only when this install wrote the PATH entry
275
+ if (r.profileCreated) created.push("PROFILE:" + r.profileCreated); // right after PATH: the profile install made
262
276
  return { created, pathAdded: r.pathAdded, kept, dir };
263
277
  }
264
278
 
package/lib/hook-wire.js CHANGED
@@ -61,11 +61,19 @@ function route(cmd, lite) {
61
61
  return null;
62
62
  }
63
63
 
64
+ // A dev seat: a harness checkout beside this lite (its .claude/hooks/wkharness-run.sh exists) that is not a web user install
65
+ // (no .wk-user-install marker in the harness folder). Python --wire uses the same two tests (tools/wkharness-lite.sh).
66
+ export function isDevSeat(lite) {
67
+ const kih = dirname(dirname(lite));
68
+ return existsSync(join(kih, ".claude", "hooks", "wkharness-run.sh")) && !existsSync(join(kih, ".wk-user-install"));
69
+ }
70
+
64
71
  // The node port of --wire, plus the create step of the web installer. Throws on a settings file that is not JSON.
65
72
  export function wireSettings(path, lite) {
66
73
  const r = readSettings(path);
67
74
  if (!r.ok) throw new Error(path + " is not plain JSON (" + r.err + ")");
68
75
  const d = r.json || {};
76
+ const dev = isDevSeat(lite) && harnessHooks(d).some((h) => /wkharness-run\.sh/.test(h.command));
69
77
  const out = {};
70
78
  for (const [ev, groups] of Object.entries(d.hooks && typeof d.hooks === "object" ? d.hooks : {})) {
71
79
  const keep = [];
@@ -75,6 +83,8 @@ export function wireSettings(path, lite) {
75
83
  const seen = new Set(), hs = [];
76
84
  for (const h of asHooks(g)) {
77
85
  const cmd = String((h && h.command) || "");
86
+ // a dev seat runs its full harness and never also the lite one (owner 2026-11-12): lite entries go, the rest stay verbatim
87
+ if (dev) { if (!/wkharness-lite\.sh/.test(cmd)) hs.push(h); continue; }
78
88
  const c = HARNESS_HOOK.test(cmd) ? route(cmd, lite) : null; // only a harness hook is ever rewritten
79
89
  if (c && !seen.has(c)) { seen.add(c); hs.push({ type: "command", command: c, timeout: 10 }); }
80
90
  else if (!c && !HARNESS_HOOK.test(cmd)) hs.push(h); // a user's own hook stays verbatim
@@ -83,7 +93,8 @@ export function wireSettings(path, lite) {
83
93
  }
84
94
  if (keep.length) out[ev] = keep;
85
95
  }
86
- for (const [ev, arm, matcher] of ARMS) {
96
+ // a dev seat gets no lite arm added: its full harness is the only chain
97
+ if (!dev) for (const [ev, arm, matcher] of ARMS) {
87
98
  const c = '"' + lite + '" ' + arm;
88
99
  if (!(out[ev] || []).some((g) => (g.hooks || []).some((h) => h.command === c)))
89
100
  (out[ev] = out[ev] || []).push({ ...(matcher ? { matcher } : {}), hooks: [{ type: "command", command: c, timeout: 10 }] });
@@ -124,6 +135,16 @@ function wireClaudeHooksInner(o) {
124
135
  log("hooks: WARN " + o.settings + " is not plain JSON (" + before.err + "); left untouched -- please fix it, then run: wkappbot-code install");
125
136
  return res;
126
137
  }
138
+ // A dev seat with both its full hook and a lite entry (owner 2026-11-12: two Stop chains fired): drop the lite entries
139
+ // and keep the full hook, whatever state the settings reports as.
140
+ const liteOnDisk = join(o.kih, "tools", "wkharness-lite.sh");
141
+ if (before.hooks.some((h) => /wkharness-run\.sh/.test(h.command)) && before.hooks.some((h) => /wkharness-lite\.sh/.test(h.command)) && isDevSeat(liteOnDisk)) {
142
+ wireSettings(o.settings, liteOnDisk);
143
+ const dev = analyzeHooks(o.settings, o.kih);
144
+ res.after = dev.state; res.changed = true;
145
+ log("hooks: " + o.settings + " runs the full harness on this dev seat; its lite hook entries were removed");
146
+ return res;
147
+ }
127
148
  if (before.state === "ours") { log("hooks: " + o.settings + " already runs every harness hook from " + o.kih); return res; }
128
149
  // A FULL harness hook (wkharness-run.sh, the pwsh hub of a dev seat) is never rewritten into the lite one where pwsh
129
150
  // runs: the rewrite would downgrade the owner's own seats. Leave it and say what to do.
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wkappbot-code",
3
- "version": "0.3.25",
3
+ "version": "0.3.27",
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": {