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 +3 -0
- package/bin/postinstall.js +7 -4
- package/bin/prepack.js +8 -0
- package/bin/wkappbot-code.js +93 -14
- package/lib/ai-clis.js +68 -0
- package/lib/allow-compact.js +55 -0
- package/lib/config.js +4 -0
- package/lib/hook-wire.js +173 -0
- package/lib/prereq.js +57 -0
- package/lib/setup-run.js +28 -7
- package/lib/starter-md.js +46 -0
- package/lib/wk-bin-acquire.sh +40 -17
- package/lib/wk-hook.js +8 -4
- package/package.json +2 -1
- package/templates/global-CLAUDE.md +20 -0
package/README.md
CHANGED
|
@@ -41,6 +41,7 @@ Other roads to the CLI: `npm i -g https://kiexpert.github.io/wkappbot-kih/wkappb
|
|
|
41
41
|
| part | what `install` lays down | skip with |
|
|
42
42
|
|---|---|---|
|
|
43
43
|
| opencode | pinned `opencode-ai@1.19.5` (`npm -g`) | `--no-npm` |
|
|
44
|
+
| ai-clis | the well-known AI coding CLIs `claude` (`@anthropic-ai/claude-code`), `codex` (`@openai/codex`), `gemini` (`@google/gemini-cli`) and `qwen` (`@qwen-code/qwen-code`), in one `npm -g` pass; no key or login is asked (each CLI asks for its own sign-in on first run), and a CLI that fails to install is a WARN, never a failed install | `--no-ai-clis` or `--no-npm` |
|
|
44
45
|
| plugin | `plugin/wisdom-kernel.js` + `lib/wk-hook.js` plus the `opencode.json` plugin entry | -- |
|
|
45
46
|
| bin | the `wkappbot` core for this platform from the SDK latest release asset `wkappbot-<platform>`, accepted only when it matches its `SHA256SUMS` line (`lib/wk-bin-acquire.sh`); placed at `~/.local/share/wkappbot/wkappbot` and linked as `wkappbot` beside `wkappbot-code` | `--no-bin` |
|
|
46
47
|
| harness | when no harness checkout is found, the scrubbed web harness to `~/.wk-harness` through its own published `install.sh` (sha256-checked); `--web-harness` forces it | `--no-harness` |
|
|
@@ -48,6 +49,8 @@ Other roads to the CLI: `npm i -g https://kiexpert.github.io/wkappbot-kih/wkappb
|
|
|
48
49
|
|
|
49
50
|
`install` prints whether `pwsh` was found and which harness will give the verdict. The harness install ends with an advisory line, `[wk-verify] WARN n/6 advisory, not a failure -- c2 ...; c5 ...; c6 ...`, naming each check that missed and why; in a good web install c2 (wkhippo), c5 (core skill read before the core is linked) and c6 (no harness git checkout) are expected to miss. The verdict file is `~/.wkappbot-code/verify.json`.
|
|
50
51
|
|
|
52
|
+
`npm install wkappbot-code` sets your global git `core.hooksPath` to `~/.wk-harness-hooks` (the push-owner gate, which then runs the repo's own hooks); `wkappbot-code uninstall` puts the previous value back, and leaves it alone if you changed it since.
|
|
53
|
+
|
|
51
54
|
The skills are a **read-only copy**: the HQ has no git remote, and a skill you edit yourself lands in the private home `~/.wkappbot/private-skills`, a local git repo with no remote. Doctor's `skill-push = owner-only` row names the GitHub login a push would use.
|
|
52
55
|
|
|
53
56
|
The generated `opencode.json` holds the minimum: `$schema` and the plugin entry (with `kihDir`, the harness the plugin calls). No provider and no permission block is added, because no refusal depends on one (every refusal comes from the plugin and the harness). An existing `opencode.json` is backed up (`opencode.json.bak-<stamp>`) and only the plugin entry is merged in; every other key you set stays. A config that is not plain JSON is left untouched, and `wkappbot-code.json` is written beside it for you to merge by hand. `wkappbot-code config` detects the provider and model from the API-key variables that are set (values are never read) and lets you edit them. Pass `--kih-dir` (or set `WK_KIH_DIR`) when a harness checkout is not auto-found (`~/wkappbot-kih` is searched). Relocation and mock overrides: `WK_BIN_BASE`, `WK_HARNESS_BASE`, `WK_HARNESS_DIR`, `WK_BIN_DEST`, `WK_APPBOT_HQ`, `WK_LINK_DIR`, `WK_PRIVATE_SKILLS`, `WK_SETTINGS`.
|
package/bin/postinstall.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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 });
|
package/bin/wkappbot-code.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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
|
-
|
|
406
|
-
|
|
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
|
-
//
|
|
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 (" +
|
|
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 (
|
|
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 };
|
package/lib/hook-wire.js
ADDED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
+
}
|
package/lib/wk-bin-acquire.sh
CHANGED
|
@@ -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
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
145
|
-
run
|
|
146
|
-
run
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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" "
|
|
182
|
-
install_file "$core" "$dest/wkappbot-core.exe" "
|
|
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
|
-
|
|
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) =>
|
|
265
|
+
if (tg.some((x) => GIT_SEG.test(x))) return "write under .git";
|
|
262
266
|
}
|
|
263
267
|
}
|
|
264
|
-
if (w.some((x) =>
|
|
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[\\/]
|
|
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.
|
|
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.
|