wkappbot-code 0.3.9 → 0.3.11
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/bin/postinstall.js +43 -28
- package/bin/prepack.js +3 -1
- package/bin/wkappbot-code.js +15 -9
- package/lib/fileurl.js +12 -0
- package/lib/rmtree.js +35 -0
- package/lib/setup-run.js +135 -0
- package/lib/winpath.sh +12 -0
- package/lib/wk-hook.js +93 -5
- package/package.json +1 -1
- package/plugin/wisdom-kernel.js +2 -0
package/bin/postinstall.js
CHANGED
|
@@ -2,71 +2,86 @@
|
|
|
2
2
|
// wkappbot-code postinstall -- npm runs this after `npm install wkappbot-code` (local or -g).
|
|
3
3
|
// Owner order 2026-11-06: the Default cloud environment's setup script is `npm install wkappbot-code`, and new cloud
|
|
4
4
|
// ROOTs still started with no hooks, because the package had no install script and npm only copied files.
|
|
5
|
+
// Owner order 2026-11-08: "npm install wkappbot-code should do the initial setup and the tests automatically" -- on
|
|
6
|
+
// Windows, macOS and Linux alike, not only on a cloud VM.
|
|
5
7
|
// CONTRACT: (a) ALWAYS exits 0 and returns within a moment -- the real work runs DETACHED and logs to
|
|
6
|
-
// ~/.wkappbot-code/postinstall.log
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
8
|
+
// ~/.wkappbot-code/postinstall.log; (b) the worker runs `wkappbot-code install --no-npm` (lays the web harness, wires
|
|
9
|
+
// ~/.claude/settings.json through the lite harness -- PreToolUse, Stop, UserPromptSubmit, SessionStart -- the global git
|
|
10
|
+
// hooks, the wkskill/wk-verify shims and the public skills, keeping every user setting), then `wkappbot-code doctor`,
|
|
11
|
+
// then a short self-test (lib/setup-run.js: core --version, skill read on-load, one hook allow, one hook refuse), and
|
|
12
|
+
// writes ~/.wkappbot-code/postinstall.done {version, installRc, doctor:{pass,warn,fail,skip}, selftest:{n,m}, ...};
|
|
13
|
+
// postinstall.running exists while the worker lives; (c) the first `wkappbot-code` run afterwards prints ONE summary
|
|
14
|
+
// line (lib/setup-run.js printBanner) and opens OpenCode; (d) idempotent: a done file of the same version with
|
|
15
|
+
// installRc 0 skips the rerun. WKAPPBOT_CODE_NO_POSTINSTALL=1 skips everything (the selftest sets it so npm-installs
|
|
16
|
+
// in its cases never touch the machine running it). WKAPPBOT_CODE_DEV_SEAT=1 adds --dev-seat (the full pwsh harness
|
|
17
|
+
// instead of lite); WKAPPBOT_CODE_INSTALL_ARGS adds install flags. A user install takes the lite harness: on a
|
|
18
|
+
// stranger's machine the owner's full fleet harness (busy-fleet, letter gates, emergency-edit) would be the wrong default.
|
|
15
19
|
// Spec: wkappbot skill read agent-harness-landscape-2026-howto
|
|
16
|
-
import { spawn } from "node:child_process";
|
|
17
|
-
import { existsSync, mkdirSync, openSync, readFileSync
|
|
20
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
21
|
+
import { existsSync, mkdirSync, openSync, readFileSync } from "node:fs";
|
|
18
22
|
import { homedir } from "node:os";
|
|
23
|
+
import { appendFileSync } from "node:fs";
|
|
19
24
|
import { dirname, join } from "node:path";
|
|
20
25
|
import { fileURLToPath } from "node:url";
|
|
26
|
+
import { buildDone, files, markRunning, parseDoctor, platformOf, selfTest, writeDone } from "../lib/setup-run.js";
|
|
21
27
|
|
|
22
28
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
23
29
|
const CLI = join(HERE, "wkappbot-code.js");
|
|
24
|
-
const
|
|
25
|
-
const LOG =
|
|
26
|
-
const DONE = join(STATE, "postinstall.done");
|
|
30
|
+
const F = files(homedir());
|
|
31
|
+
const STATE = F.dir, LOG = F.log, DONE = F.done;
|
|
27
32
|
|
|
28
33
|
function version() {
|
|
29
34
|
try { return JSON.parse(readFileSync(join(HERE, "..", "package.json"), "utf8")).version || "?"; } catch { return "?"; }
|
|
30
35
|
}
|
|
31
|
-
export function isCloudVm(env = process.env, home = homedir(), platform =
|
|
36
|
+
export function isCloudVm(env = process.env, home = homedir(), platform = platformOf(env)) {
|
|
32
37
|
if (env.CLAUDE_CODE_REMOTE) return true;
|
|
33
38
|
return platform === "linux" && home === "/root" && existsSync(join(home, ".claude"));
|
|
34
39
|
}
|
|
35
|
-
function stamp(msg) { try { mkdirSync(STATE, { recursive: true });
|
|
40
|
+
function stamp(msg) { try { mkdirSync(STATE, { recursive: true }); appendFileSync(LOG, new Date().toISOString() + " " + msg + "\n"); } catch {} }
|
|
36
41
|
|
|
37
|
-
// --run: the detached worker.
|
|
42
|
+
// --run: the detached worker. install, then doctor, then the self-test, then the done file.
|
|
38
43
|
async function runWorker() {
|
|
39
44
|
const args = [CLI, "install", "--no-npm"];
|
|
40
45
|
if (process.env.WKAPPBOT_CODE_DEV_SEAT === "1") args.push("--dev-seat");
|
|
41
46
|
// extra install flags, e.g. "--no-bin --no-skills" (the selftest; a seat without the SDK release assets)
|
|
42
47
|
args.push(...String(process.env.WKAPPBOT_CODE_INSTALL_ARGS || "").split(/\s+/).filter((a) => /^--[a-z-]+$/.test(a)));
|
|
48
|
+
// the children are the worker's own: they must not print the first-run banner (it is for the user's next command)
|
|
49
|
+
const env = { ...process.env, WKAPPBOT_CODE_NO_UPDATE_CHECK: "1", WKAPPBOT_CODE_SETUP_WORKER: "1" };
|
|
43
50
|
stamp("worker start: node " + args.join(" "));
|
|
44
51
|
const out = openSync(LOG, "a");
|
|
45
|
-
const
|
|
46
|
-
const c = spawn(process.execPath, args, { stdio: ["ignore", out, out], env
|
|
52
|
+
const installRc = await new Promise((res) => {
|
|
53
|
+
const c = spawn(process.execPath, args, { stdio: ["ignore", out, out], env });
|
|
47
54
|
c.on("error", () => res(-1));
|
|
48
55
|
c.on("close", (code) => res(code ?? -1));
|
|
49
56
|
});
|
|
50
|
-
|
|
51
|
-
|
|
57
|
+
stamp("install rc " + installRc + "; doctor");
|
|
58
|
+
const dr = spawnSync(process.execPath, [CLI, "doctor"], { encoding: "utf8", env, timeout: 300000 });
|
|
59
|
+
const dtext = (dr.stdout || "") + (dr.stderr || "");
|
|
60
|
+
try { appendFileSync(LOG, dtext); } catch {}
|
|
61
|
+
const doctor = parseDoctor(dtext);
|
|
62
|
+
stamp("doctor pass " + doctor.pass + " warn " + doctor.warn + " fail " + doctor.fail + " skip " + doctor.skip + "; self-test");
|
|
63
|
+
let st = { n: 0, m: 4, items: [{ ok: false, name: "self-test", detail: "did not run" }] };
|
|
64
|
+
try { st = selfTest({ env, home: homedir() }); } catch (e) { st.items[0].detail = String(e); }
|
|
65
|
+
for (const i of st.items) stamp("selftest " + (i.ok ? "PASS " : "FAIL ") + i.name + (i.ok ? "" : " -- " + i.detail));
|
|
66
|
+
try { writeDone(homedir(), buildDone({ version: version(), installRc, doctor, selftest: st, env: process.env })); } catch (e) { stamp("done file error: " + e); }
|
|
67
|
+
stamp("worker done: install rc " + installRc + ", doctor " + doctor.pass + "/" + (doctor.pass + doctor.warn + doctor.fail) + ", selftest " + st.n + "/" + st.m);
|
|
52
68
|
}
|
|
53
69
|
|
|
54
70
|
function main() {
|
|
55
71
|
if (process.argv[2] === "--run") return runWorker().catch(() => {}).finally(() => process.exit(0));
|
|
56
72
|
if (process.env.WKAPPBOT_CODE_NO_POSTINSTALL === "1") return;
|
|
57
|
-
|
|
58
|
-
console.log("[wkappbot-code] installed. To wire the harness and OpenCode on this machine run: npx wkappbot-code install (then: wkappbot-code doctor)");
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
73
|
+
const cloud = isCloudVm();
|
|
61
74
|
try {
|
|
62
75
|
const d = JSON.parse(readFileSync(DONE, "utf8"));
|
|
63
|
-
if (d.version === version() && d.rc === 0) { console.log("[wkappbot-code]
|
|
76
|
+
if (d.version === version() && (d.installRc ?? d.rc) === 0) { console.log("[wkappbot-code] already set up by " + d.version + " (" + DONE + ")"); return; }
|
|
64
77
|
} catch {}
|
|
65
78
|
mkdirSync(STATE, { recursive: true });
|
|
79
|
+
markRunning(homedir(), version());
|
|
66
80
|
const out = openSync(LOG, "a");
|
|
67
|
-
const c = spawn(process.execPath, [fileURLToPath(import.meta.url), "--run"], { detached: true, stdio: ["ignore", out, out], env: process.env });
|
|
81
|
+
const c = spawn(process.execPath, [fileURLToPath(import.meta.url), "--run"], { detached: true, stdio: ["ignore", out, out], env: process.env, windowsHide: true });
|
|
68
82
|
c.unref();
|
|
69
|
-
console.log("[wkappbot-code] cloud VM detected: wiring the Claude Code harness in the background
|
|
83
|
+
console.log("[wkappbot-code] " + (cloud ? "cloud VM detected: wiring the Claude Code harness in the background" : "setting up in the background (install, doctor, self-test)") +
|
|
84
|
+
" (log: " + LOG + ", result: " + DONE + "); the first `wkappbot-code` run prints the summary");
|
|
70
85
|
}
|
|
71
86
|
|
|
72
87
|
try { main(); } catch (e) { try { stamp("postinstall error: " + e); } catch {} }
|
package/bin/prepack.js
CHANGED
|
@@ -43,7 +43,9 @@ if (!existsSync(join(stage, "tools", "wkharness-lite.sh"))) die("the baked tree
|
|
|
43
43
|
rmSync(OUT, { recursive: true, force: true });
|
|
44
44
|
mkdirSync(OUT, { recursive: true });
|
|
45
45
|
const tgz = join(OUT, "wk-harness-min.tar.gz");
|
|
46
|
-
|
|
46
|
+
// A RELATIVE archive name, run in OUT: GNU tar on Git for Windows reads "D:/a/..." as host "D" (rsh) and fails with
|
|
47
|
+
// "Cannot connect to D: resolve failed" (SDK windows-latest 2026-11-06); bsdtar and GNU tar both take a relative -f.
|
|
48
|
+
const t = spawnSync("tar", ["--exclude=./.git", "--exclude=./bin/linux-x64", "-czf", "wk-harness-min.tar.gz", "-C", sh(stage), "."], { encoding: "utf8", cwd: OUT });
|
|
47
49
|
if (t.status !== 0) die("tar rc " + t.status + " " + t.stderr);
|
|
48
50
|
const sha = createHash("sha256").update(readFileSync(tgz)).digest("hex");
|
|
49
51
|
writeFileSync(tgz + ".sha256", sha + " wk-harness-min.tar.gz\n");
|
package/bin/wkappbot-code.js
CHANGED
|
@@ -38,8 +38,9 @@ import {
|
|
|
38
38
|
import { homedir, tmpdir } from "node:os";
|
|
39
39
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
40
40
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
41
|
-
import { quietCheck, runUpdate, updateRow } from "../lib/update.js";
|
|
41
|
+
import { quietCheck, runUpdate, updateRow } from "../lib/update.js"; import { rmTreeHard } from "../lib/rmtree.js"; import { plainFileUrl } from "../lib/fileurl.js";
|
|
42
42
|
import { applySets, askEach, buildConfig, configRow, detect, lineDiff, printDetected } from "../lib/config.js";
|
|
43
|
+
import { launchOpenCode, printBanner } from "../lib/setup-run.js";
|
|
43
44
|
|
|
44
45
|
const PKG_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
45
46
|
const PKG = JSON.parse(readFileSync(join(PKG_DIR, "package.json"), "utf8"));
|
|
@@ -48,8 +49,8 @@ const OWNER_LOGIN = "kiexpert";
|
|
|
48
49
|
// The harness this package carries (harness/, baked by bin/prepack.js at pack time) comes first: it needs no checkout
|
|
49
50
|
// and no network (spokesman 2026-11-06, a Default-environment VM). WK_HARNESS_BASE overrides; the web page serves an unbaked tree.
|
|
50
51
|
const BUNDLED_HARNESS = () => join(PKG_DIR, "harness");
|
|
51
|
-
const HARNESS_BASE = () => (process.env.WK_HARNESS_BASE || (existsSync(join(BUNDLED_HARNESS(), "install.sh")) ? pathToFileURL(BUNDLED_HARNESS()).href
|
|
52
|
-
: "https://kiexpert.github.io/wkappbot-kih/webvm/x64/harness")).replace(/\/+$/, "");
|
|
52
|
+
const HARNESS_BASE = () => plainFileUrl(process.env.WK_HARNESS_BASE || (existsSync(join(BUNDLED_HARNESS(), "install.sh")) ? pathToFileURL(BUNDLED_HARNESS()).href
|
|
53
|
+
: "https://kiexpert.github.io/wkappbot-kih/webvm/x64/harness")).replace(/\/+$/, ""); // plainFileUrl: curl on Windows reads RUNNER%7E1 literally
|
|
53
54
|
const HARNESS_DIR = () => process.env.WK_HARNESS_DIR || join(homedir(), ".wk-harness");
|
|
54
55
|
const HOOKS_DIR = () => join(homedir(), ".wk-harness-hooks"); // tools/wk-global-hooks.sh install default
|
|
55
56
|
const SETTINGS = () => process.env.WK_SETTINGS || join(homedir(), ".claude", "settings.json");
|
|
@@ -371,7 +372,7 @@ function installPlugin(o, kih, kihSource) {
|
|
|
371
372
|
// The machine state an uninstall must give back, recorded ONCE (a re-install never overwrites the first record, or
|
|
372
373
|
// it would record our own dispatcher as the "prior" hooksPath). Shim paths are those install may create.
|
|
373
374
|
function shimCandidates() {
|
|
374
|
-
return [join(linkDir(), "wkappbot"), join(HARNESS_LINKS(), "wkskill"), join(HARNESS_LINKS(), "wk-verify")];
|
|
375
|
+
return [join(linkDir(), "wkappbot"), join(HARNESS_LINKS(), "wkskill"), join(HARNESS_LINKS(), "wk-verify"), join(HARNESS_LINKS(), "wkskill.cmd"), 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
|
|
375
376
|
}
|
|
376
377
|
const PRE_SETTINGS = () => join(STATE_HOME(), "settings.pre-install.json");
|
|
377
378
|
function recordPrior(prev) {
|
|
@@ -453,8 +454,10 @@ function uninstall(o) {
|
|
|
453
454
|
if (!st.version) log("no install record at " + statePath(o) + "; removing only what is ours by name");
|
|
454
455
|
// 1. global core.hooksPath back to its recorded value (only when it still points at a dir we wrote)
|
|
455
456
|
const cur = globalHooksPath();
|
|
457
|
+
// REAL paths: macOS /var is a symlink to /private/var, so git and HOME may spell one dir two ways (SDK run 42252316617).
|
|
458
|
+
const real = (x) => { const r = resolve(x.replace(/^~(?=\/|$)/, homedir())); try { return realpathSync.native(r); } catch { return r; } }; // .native expands a Windows 8.3 name (RUNNER~1) that git config spells in the long form
|
|
456
459
|
const oursHp = (p) => !!p && [HOOKS_DIR(), join(HARNESS_DIR(), ".githooks"), st.kihDir && join(st.kihDir, ".githooks")]
|
|
457
|
-
.filter(Boolean).some((x) =>
|
|
460
|
+
.filter(Boolean).some((x) => real(p) === real(x));
|
|
458
461
|
if (cur !== (pr.hooksPath ?? null) && (cur === null || oursHp(cur))) {
|
|
459
462
|
const r = pr.hooksPath ? gitGlobal("core.hooksPath", pr.hooksPath) : gitGlobal("--unset", "core.hooksPath");
|
|
460
463
|
log("hooksPath: " + (cur || "unset") + " -> " + (pr.hooksPath || "unset") + (r.status === 0 ? "" : " (git rc " + r.status + ")"));
|
|
@@ -493,11 +496,10 @@ function uninstall(o) {
|
|
|
493
496
|
if (isSymlink(p) || existsSync(p)) { rmSync(p, { force: true }); log("shim: removed " + p); }
|
|
494
497
|
}
|
|
495
498
|
// 4. the harness dirs we created, the plugin entry and our config folder
|
|
496
|
-
// Windows
|
|
497
|
-
// still fails, names the path and goes on with the rest instead of aborting the uninstall half way.
|
|
499
|
+
// 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.
|
|
498
500
|
const rmTree = (p, what) => {
|
|
499
|
-
|
|
500
|
-
|
|
501
|
+
const r = rmTreeHard(p); if (r.ok) { if (what) log(what + ": removed " + p); return; }
|
|
502
|
+
rc = 1; log((what || "remove") + ": could not remove " + p + " -- blocked by " + r.blockers.slice(0, 5).join("; ") + " -- delete it by hand");
|
|
501
503
|
};
|
|
502
504
|
if (st.harness && st.harness.mode === "web" && pr.harnessDirExisted !== true && existsSync(HARNESS_DIR())) rmTree(HARNESS_DIR(), "harness");
|
|
503
505
|
if (pr.hooksDirExisted !== true && existsSync(HOOKS_DIR())) rmTree(HOOKS_DIR(), "push-gate");
|
|
@@ -615,6 +617,10 @@ if (["install", "doctor", "config"].includes(o.cmd) && !o.update) {
|
|
|
615
617
|
const nv = quietCheck(PKG.version, console.log);
|
|
616
618
|
if (nv && (o.autoUpdate || process.env.WKAPPBOT_CODE_AUTO_UPDATE === "1")) upd();
|
|
617
619
|
}
|
|
620
|
+
// The one-line result of the automatic post-install setup (lib/setup-run.js), once; not for the worker's own children.
|
|
621
|
+
if (!["--version", "-v", "version", "uninstall"].includes(o.cmd) && process.env.WKAPPBOT_CODE_SETUP_WORKER !== "1") { try { printBanner(); } catch {} }
|
|
622
|
+
// Bare `wkappbot-code`: open OpenCode (its start screen); no opencode on PATH falls through to the usage below.
|
|
623
|
+
if (process.argv.length <= 2 && o.cmd === "help") { const rc = launchOpenCode(); if (rc !== null) process.exit(rc); }
|
|
618
624
|
switch (o.cmd) {
|
|
619
625
|
case "--version": case "-v": case "version":
|
|
620
626
|
console.log("wkappbot-code " + PKG.version + " (opencode-ai " + OPENCODE_VERSION + ")"); process.exit(0);
|
package/lib/fileurl.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// fileurl.js -- a file:// base that curl can open on every platform.
|
|
2
|
+
// pathToFileURL percent-encodes "~" ("RUNNER~1" -> "RUNNER%7E1") and Windows curl does not decode a file:// path, so a
|
|
3
|
+
// Windows profile with a short name, a space or a non-ASCII letter failed to read the harness. plainFileUrl hands curl the plain path.
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
export function plainFileUrl(u) {
|
|
7
|
+
if (!/^file:\/\//i.test(u)) return u;
|
|
8
|
+
let p;
|
|
9
|
+
try { p = fileURLToPath(u); } catch { return u; }
|
|
10
|
+
p = p.replace(/\\/g, "/");
|
|
11
|
+
return "file://" + (p.startsWith("/") ? "" : "/") + p;
|
|
12
|
+
}
|
package/lib/rmtree.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// rmtree.js -- delete a tree on a platform that releases a just-closed handle late (Windows: EBUSY/EPERM/ENOTEMPTY).
|
|
2
|
+
// rmTreeHard(p, rm) -> { ok, blockers[] }: a long bounded retry first, then a per-entry pass that names what stays.
|
|
3
|
+
import { existsSync, lstatSync, readdirSync, rmSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const sleepMs = (ms) => { try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* no wait available */ } };
|
|
7
|
+
|
|
8
|
+
// rm is injectable so the selftest can fake a held handle (default: fs.rmSync with Node's own retry)
|
|
9
|
+
export function rmTreeHard(p, rm = (q, o) => rmSync(q, o), opts = {}) {
|
|
10
|
+
const tries = opts.tries || 6, gap = opts.gap || 1000;
|
|
11
|
+
const once = (q) => rm(q, { recursive: true, force: true, maxRetries: 10, retryDelay: 300 });
|
|
12
|
+
let last = null;
|
|
13
|
+
for (let i = 0; i < tries; i++) {
|
|
14
|
+
try { once(p); if (!existsSync(p)) return { ok: true, blockers: [] }; last = new Error("ENOTEMPTY"); }
|
|
15
|
+
catch (e) { last = e; }
|
|
16
|
+
sleepMs(gap);
|
|
17
|
+
}
|
|
18
|
+
// per-entry pass: remove what can go and name every entry (and nested file) that cannot
|
|
19
|
+
const blockers = [];
|
|
20
|
+
const walk = (d) => {
|
|
21
|
+
let names = [];
|
|
22
|
+
try { names = readdirSync(d); } catch (e) { blockers.push(d + " (" + (e.code || e.message) + ")"); return; }
|
|
23
|
+
for (const n of names) {
|
|
24
|
+
const q = join(d, n);
|
|
25
|
+
let dir = false; try { dir = lstatSync(q).isDirectory(); } catch { /* vanished */ }
|
|
26
|
+
if (dir) walk(q);
|
|
27
|
+
try { rm(q, { recursive: true, force: true }); } catch (e) { blockers.push(q + " (" + (e.code || e.message) + ")"); }
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
if (existsSync(p)) walk(p);
|
|
31
|
+
try { rm(p, { recursive: true, force: true }); } catch (e) { if (existsSync(p)) blockers.push(p + " (" + (e.code || e.message) + ")"); }
|
|
32
|
+
if (!existsSync(p)) return { ok: true, blockers: [] };
|
|
33
|
+
if (!blockers.length) blockers.push(p + " (" + ((last && (last.code || last.message)) || "not removed") + ")");
|
|
34
|
+
return { ok: false, blockers };
|
|
35
|
+
}
|
package/lib/setup-run.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// wkappbot-code lib/setup-run.js -- what `npm install wkappbot-code` does after the files land, and what the first
|
|
2
|
+
// `wkappbot-code` run says about it (owner order 2026-11-08: "npm install wkappbot-code should do the initial setup and
|
|
3
|
+
// the tests automatically", "typing wkappbot-code should show the OpenCode start screen").
|
|
4
|
+
// worker bin/postinstall.js --run: install --no-npm, then doctor, then selfTest() below, then ONE postinstall.done
|
|
5
|
+
// JSON {version, installRc, rc, doctor:{pass,warn,fail,skip}, selftest:{n,m}, platform, at, items}.
|
|
6
|
+
// banner bannerFor() / printBanner(): the one line the first run prints ("setup ok, doctor 9/9, selftest 4/4", the
|
|
7
|
+
// failing item with the log path, or "setup still running"), shown ONCE per done file (postinstall.seen).
|
|
8
|
+
// Files in ~/.wkappbot-code: postinstall.log, postinstall.running (worker alive), postinstall.done, postinstall.seen.
|
|
9
|
+
// A user's own settings.json entries are the installer's job to keep (wkappbot-code.js install); this module only reads.
|
|
10
|
+
// Spec: wkappbot skill read agent-harness-landscape-2026-howto
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { homedir, tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
|
|
16
|
+
const RUNNING_STALE_MS = 30 * 60 * 1000; // a worker that left no done file for 30 minutes is dead, not "still running"
|
|
17
|
+
const IS_WIN = process.platform === "win32";
|
|
18
|
+
|
|
19
|
+
// WKAPPBOT_CODE_FAKE_PLATFORM is the selftest's way to run the non-cloud road "as win32 / darwin" on one machine.
|
|
20
|
+
export const platformOf = (env = process.env) => env.WKAPPBOT_CODE_FAKE_PLATFORM || process.platform;
|
|
21
|
+
export const stateDir = (home = homedir()) => join(home, ".wkappbot-code");
|
|
22
|
+
export const files = (home = homedir()) => {
|
|
23
|
+
const d = stateDir(home);
|
|
24
|
+
return { dir: d, log: join(d, "postinstall.log"), running: join(d, "postinstall.running"), done: join(d, "postinstall.done"), seen: join(d, "postinstall.seen") };
|
|
25
|
+
};
|
|
26
|
+
const readJson = (f) => { try { return JSON.parse(readFileSync(f, "utf8")); } catch { return null; } };
|
|
27
|
+
// Same MSYS rule as wkappbot-code.js shPath: the sh Git for Windows starts reads "D:\a\x" as host D.
|
|
28
|
+
const shPath = (p) => (IS_WIN && /^[A-Za-z]:[\\/]/.test(p) ? "/" + p[0].toLowerCase() + p.slice(2).replace(/\\/g, "/") : p);
|
|
29
|
+
|
|
30
|
+
// ---- doctor ---------------------------------------------------------------------------------------
|
|
31
|
+
// doctor prints one `PASS|WARN|FAIL|SKIP name detail` row per part; SKIP rows are not counted in the n/m.
|
|
32
|
+
export function parseDoctor(text) {
|
|
33
|
+
const c = { pass: 0, warn: 0, fail: 0, skip: 0, failed: [] };
|
|
34
|
+
for (const line of String(text || "").split(/\r?\n/)) {
|
|
35
|
+
const m = /^(PASS|WARN|FAIL|SKIP) (\S+)/.exec(line);
|
|
36
|
+
if (!m) continue;
|
|
37
|
+
c[m[1].toLowerCase()]++;
|
|
38
|
+
if (m[1] === "FAIL") c.failed.push(m[2]);
|
|
39
|
+
}
|
|
40
|
+
return c;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---- self-test ------------------------------------------------------------------------------------
|
|
44
|
+
function installState(env, home) {
|
|
45
|
+
const cfg = env.OPENCODE_CONFIG_DIR || join(env.XDG_CONFIG_HOME || join(home, ".config"), "opencode");
|
|
46
|
+
return readJson(join(cfg, "wkappbot-code", "install-state.json")) || {};
|
|
47
|
+
}
|
|
48
|
+
const hookPayload = (cmd) => JSON.stringify({ hook_event_name: "PreToolUse", session_id: "wkappbot-code-selftest", cwd: tmpdir(), tool_name: "Bash", tool_input: { command: cmd } });
|
|
49
|
+
|
|
50
|
+
// Four items: the core answers --version, the core prints skill on-load, the lite hook ALLOWS a plain command (rc 0) and
|
|
51
|
+
// REFUSES git --no-verify (rc 2). Returns {n, m, items:[{name, ok, detail}]}.
|
|
52
|
+
export function selfTest({ env = process.env, home = homedir() } = {}) {
|
|
53
|
+
const st = installState(env, home);
|
|
54
|
+
const dest = (st.bin && st.bin.dest) || env.WK_BIN_DEST || join(home, ".local", "share", "wkappbot", IS_WIN ? "wkappbot.exe" : "wkappbot");
|
|
55
|
+
const kih = st.kihDir || env.WK_HARNESS_DIR || join(home, ".wk-harness");
|
|
56
|
+
const lite = join(kih, "tools", "wkharness-lite.sh");
|
|
57
|
+
const items = [];
|
|
58
|
+
const add = (name, ok, detail) => items.push({ name, ok: !!ok, detail: String(detail || "").replace(/\s+/g, " ").slice(0, 200) });
|
|
59
|
+
const scratch = mkdtempSync(join(tmpdir(), "wk-appbot-selftest-"));
|
|
60
|
+
const shell = IS_WIN && /\.(cmd|bat)$/i.test(dest); // node refuses to start a .cmd without a shell
|
|
61
|
+
try {
|
|
62
|
+
const core = (args) => (existsSync(dest) ? spawnSync(dest, args, { encoding: "utf8", timeout: 120000, cwd: scratch, shell, env: { ...env, WKAPPBOT_NO_REMOTE_CHECK: "1" } }) : null);
|
|
63
|
+
let r = core(["--version"]);
|
|
64
|
+
add("core --version", r && r.status === 0 && (r.stdout || "").trim(), r ? "rc " + r.status + " " + (r.stdout || r.stderr) : dest + " absent");
|
|
65
|
+
r = core(["skill", "read", "on-load"]);
|
|
66
|
+
add("skill read on-load", r && /\bID\s*:\s*on-load\b/.test(r.stdout || ""), r ? "rc " + r.status : dest + " absent");
|
|
67
|
+
const hook = (cmd) => (existsSync(lite)
|
|
68
|
+
? spawnSync("sh", [shPath(lite), "pre"], { encoding: "utf8", timeout: 60000, input: hookPayload(cmd), env: { ...env, WKHARNESS_LITE_DIR: shPath(join(scratch, "lite")) } }) : null);
|
|
69
|
+
r = hook("ls /");
|
|
70
|
+
add("hook allow", r && r.status === 0, r ? "rc " + r.status + " " + (r.stderr || "") : lite + " absent");
|
|
71
|
+
r = hook("git commit --no-verify -m selftest");
|
|
72
|
+
add("hook refuse", r && r.status === 2, r ? "rc " + r.status + " " + (r.stderr || "") : lite + " absent");
|
|
73
|
+
} finally { rmSync(scratch, { recursive: true, force: true }); }
|
|
74
|
+
return { n: items.filter((i) => i.ok).length, m: items.length, items };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---- done / running -------------------------------------------------------------------------------
|
|
78
|
+
export function markRunning(home, version) {
|
|
79
|
+
const f = files(home);
|
|
80
|
+
mkdirSync(f.dir, { recursive: true });
|
|
81
|
+
rmSync(f.done, { force: true }); // the old verdict belongs to the old install
|
|
82
|
+
writeFileSync(f.running, JSON.stringify({ version, at: new Date().toISOString(), pid: process.pid }) + "\n");
|
|
83
|
+
}
|
|
84
|
+
export function writeDone(home, rec) {
|
|
85
|
+
const f = files(home);
|
|
86
|
+
const tmp = f.done + ".tmp";
|
|
87
|
+
writeFileSync(tmp, JSON.stringify(rec) + "\n");
|
|
88
|
+
renameSync(tmp, f.done); // the done file appears whole or not at all (the selftest polls for it)
|
|
89
|
+
rmSync(f.running, { force: true });
|
|
90
|
+
}
|
|
91
|
+
export function buildDone({ version, installRc, doctor, selftest, env = process.env }) {
|
|
92
|
+
return { version, installRc, rc: installRc, doctor: { pass: doctor.pass, warn: doctor.warn, fail: doctor.fail, skip: doctor.skip },
|
|
93
|
+
selftest: { n: selftest.n, m: selftest.m }, platform: platformOf(env), at: new Date().toISOString(),
|
|
94
|
+
items: selftest.items.filter((i) => !i.ok).map((i) => i.name + ": " + i.detail), doctorFailed: doctor.failed };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---- banner ---------------------------------------------------------------------------------------
|
|
98
|
+
// Pure: the line to print for a state ("" = nothing to say) and whether it counts as seen. `now` is a ms clock.
|
|
99
|
+
export function bannerFor({ done, running, seen, logPath, now = Date.now() }) {
|
|
100
|
+
if (running && !done) {
|
|
101
|
+
const age = now - Date.parse(running.at || 0);
|
|
102
|
+
if (age >= 0 && age < RUNNING_STALE_MS) return { line: "[wkappbot-code] setup still running (log: " + logPath + ") -- opening OpenCode anyway", mark: false };
|
|
103
|
+
return { line: "[wkappbot-code] setup did not finish (worker started " + (running.at || "?") + ", no result); log: " + logPath + " -- rerun: wkappbot-code install", mark: false };
|
|
104
|
+
}
|
|
105
|
+
if (!done || (seen && seen === done.at)) return { line: "", mark: false };
|
|
106
|
+
const d = done.doctor || {}, s = done.selftest || {};
|
|
107
|
+
const total = (d.pass || 0) + (d.warn || 0) + (d.fail || 0);
|
|
108
|
+
const tail = "doctor " + (d.pass || 0) + "/" + total + (d.warn ? " (" + d.warn + " warn)" : "") + ", selftest " + (s.n || 0) + "/" + (s.m || 0);
|
|
109
|
+
const installRc = done.installRc ?? done.rc;
|
|
110
|
+
if (installRc === 0 && !d.fail && s.n === s.m && s.m > 0) return { line: "[wkappbot-code] setup ok, " + tail, mark: true };
|
|
111
|
+
const bad = [];
|
|
112
|
+
if (installRc !== 0) bad.push("install rc " + installRc);
|
|
113
|
+
for (const f of done.doctorFailed || []) bad.push("doctor " + f + " FAIL");
|
|
114
|
+
for (const i of done.items || []) bad.push("selftest " + i);
|
|
115
|
+
if (!bad.length) bad.push(d.fail ? "doctor " + d.fail + " FAIL" : "selftest " + (s.n || 0) + "/" + (s.m || 0));
|
|
116
|
+
return { line: "[wkappbot-code] setup needs attention: " + bad.slice(0, 3).join("; ") + " (" + tail + "); log: " + logPath, mark: true };
|
|
117
|
+
}
|
|
118
|
+
export function printBanner({ home = homedir(), out = console.log, now = Date.now() } = {}) {
|
|
119
|
+
const f = files(home);
|
|
120
|
+
const done = readJson(f.done), running = readJson(f.running);
|
|
121
|
+
let seen = ""; try { seen = readFileSync(f.seen, "utf8").trim(); } catch {}
|
|
122
|
+
const b = bannerFor({ done, running, seen, logPath: f.log, now });
|
|
123
|
+
if (b.line) out(b.line);
|
|
124
|
+
if (b.mark && done) { try { writeFileSync(f.seen, done.at + "\n"); } catch {} }
|
|
125
|
+
return b.line;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---- bare launch ----------------------------------------------------------------------------------
|
|
129
|
+
// `wkappbot-code` with no arguments opens OpenCode (its start screen). Returns the exit code, or null when opencode
|
|
130
|
+
// cannot be started (not on PATH): the caller then prints the usage as before.
|
|
131
|
+
export function launchOpenCode(env = process.env) {
|
|
132
|
+
const r = spawnSync("opencode", [], { stdio: "inherit", shell: IS_WIN, env });
|
|
133
|
+
if (r.error) return null;
|
|
134
|
+
return r.status ?? 1;
|
|
135
|
+
}
|
package/lib/winpath.sh
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# winpath.sh -- sourced by selftest-opencode-e2e.sh and selftest-win-paths.sh. wk_winpath PATH prints the form a NATIVE
|
|
3
|
+
# Windows program takes: `cygpath -m` (C:/x/y) when cygpath is on PATH, else PATH unchanged. A failed conversion also
|
|
4
|
+
# leaves the path as it was. bash 3.2 safe (macOS /bin/bash).
|
|
5
|
+
wk_winpath() {
|
|
6
|
+
local w
|
|
7
|
+
if command -v cygpath >/dev/null 2>&1; then
|
|
8
|
+
w="$(cygpath -m "$1" 2>/dev/null)"
|
|
9
|
+
if [ -n "$w" ]; then printf '%s\n' "$w"; return 0; fi
|
|
10
|
+
fi
|
|
11
|
+
printf '%s\n' "$1"
|
|
12
|
+
}
|
package/lib/wk-hook.js
CHANGED
|
@@ -164,13 +164,84 @@ export function refusalReason(r) {
|
|
|
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
|
const HOME_T = /^(~|\$HOME|\$\{HOME\}|\/root|\/home\/[^/]+|\/Users\/[^/]+)\/?(\*|\.\*)?$/;
|
|
167
|
+
// The shell line as the risk floor reads it. A heredoc body (after <<'TAG', <<TAG or <<-TAG, up to the line TAG) is
|
|
168
|
+
// 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
|
+
// Words handed to a shell (sh/bash -c, eval) are also returned in seg.inner and are checked again as a command line.
|
|
170
|
+
const SHELL_NAME_RE = /^(sh|bash|zsh|dash|ksh)$/;
|
|
171
|
+
export function shellSegments(cmd) {
|
|
172
|
+
const s = String(cmd || "");
|
|
173
|
+
const segs = [];
|
|
174
|
+
let seg = { words: [], inner: [], redirect: false };
|
|
175
|
+
let word = null, quote = "", pending = [], i = 0;
|
|
176
|
+
const add = (ch) => { word = (word === null ? "" : word) + ch; };
|
|
177
|
+
const endWord = () => {
|
|
178
|
+
if (word === null) return;
|
|
179
|
+
const head = seg.words[0] || "";
|
|
180
|
+
const prev = seg.words[seg.words.length - 1];
|
|
181
|
+
if ((prev === "-c" && SHELL_NAME_RE.test(head)) || head === "eval") seg.inner.push(word);
|
|
182
|
+
seg.words.push(word);
|
|
183
|
+
word = null;
|
|
184
|
+
};
|
|
185
|
+
const endSeg = () => {
|
|
186
|
+
endWord();
|
|
187
|
+
if (seg.words.length || seg.redirect) segs.push(seg);
|
|
188
|
+
seg = { words: [], inner: [], redirect: false };
|
|
189
|
+
};
|
|
190
|
+
while (i < s.length) {
|
|
191
|
+
const ch = s[i];
|
|
192
|
+
if (quote === "'") { if (ch === "'") quote = ""; else add(ch); i++; continue; }
|
|
193
|
+
if (quote === '"') {
|
|
194
|
+
if (ch === '"') quote = "";
|
|
195
|
+
else if (ch === "\\" && i + 1 < s.length && /["\\$`]/.test(s[i + 1])) { add(s[i + 1]); i++; }
|
|
196
|
+
else add(ch);
|
|
197
|
+
i++; continue;
|
|
198
|
+
}
|
|
199
|
+
if (ch === "'" || ch === '"') { quote = ch; if (word === null) word = ""; i++; continue; }
|
|
200
|
+
if (ch === "\\" && i + 1 < s.length) { add(s[i + 1]); i += 2; continue; }
|
|
201
|
+
if (ch === "<" && s[i + 1] === "<" && s[i + 2] !== "<") {
|
|
202
|
+
endWord();
|
|
203
|
+
let j = i + 2;
|
|
204
|
+
const dash = s[j] === "-";
|
|
205
|
+
if (dash) j++;
|
|
206
|
+
while (s[j] === " " || s[j] === "\t") j++;
|
|
207
|
+
let tag = "";
|
|
208
|
+
if (s[j] === "'" || s[j] === '"') { const q = s[j]; j++; while (j < s.length && s[j] !== q && s[j] !== "\n") tag += s[j++]; j++; }
|
|
209
|
+
else while (j < s.length && !/[\s;&|<>()'"]/.test(s[j])) tag += s[j++];
|
|
210
|
+
if (tag) pending.push({ tag, dash });
|
|
211
|
+
i = j; continue;
|
|
212
|
+
}
|
|
213
|
+
if (ch === "\n") {
|
|
214
|
+
endSeg(); i++;
|
|
215
|
+
for (const h of pending) {
|
|
216
|
+
for (;;) {
|
|
217
|
+
if (i >= s.length) break;
|
|
218
|
+
let e = s.indexOf("\n", i);
|
|
219
|
+
if (e < 0) e = s.length;
|
|
220
|
+
const line = s.slice(i, e).replace(/\r$/, "");
|
|
221
|
+
i = e + 1;
|
|
222
|
+
if ((h.dash ? line.replace(/^\t+/, "") : line) === h.tag) break;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
pending = [];
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (/\s/.test(ch)) { endWord(); i++; continue; }
|
|
229
|
+
if (ch === ";" || ch === "|" || ch === "&" || ch === "(" || ch === ")") { endSeg(); i++; continue; }
|
|
230
|
+
if (ch === ">") { endWord(); seg.redirect = true; i++; continue; }
|
|
231
|
+
if (ch === "<") { endWord(); i++; continue; }
|
|
232
|
+
add(ch); i++;
|
|
233
|
+
}
|
|
234
|
+
endSeg();
|
|
235
|
+
return segs;
|
|
236
|
+
}
|
|
237
|
+
|
|
167
238
|
export function riskClass(tool, input, cwd) {
|
|
168
239
|
const t = claudeToolName(tool);
|
|
169
240
|
const i = input || {};
|
|
170
241
|
if (t === "Bash") {
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
const
|
|
242
|
+
for (const seg of shellSegments(i.command)) {
|
|
243
|
+
const w = seg.words;
|
|
244
|
+
for (const inner of seg.inner) { const r = riskClass(tool, { command: inner }, cwd); if (r) return r; }
|
|
174
245
|
const gi = w.indexOf("git");
|
|
175
246
|
if (gi >= 0) {
|
|
176
247
|
const g = w.slice(gi + 1);
|
|
@@ -190,7 +261,7 @@ export function riskClass(tool, input, cwd) {
|
|
|
190
261
|
if (tg.some((x) => /(^|\/)\.git(\/|$)/.test(x))) return "write under .git";
|
|
191
262
|
}
|
|
192
263
|
}
|
|
193
|
-
if (/(
|
|
264
|
+
if (w.some((x) => /(^|\/)\.git(\/|$)/.test(x)) && (seg.redirect || /^(rm|mv|cp|tee|truncate|dd|ln|chmod|sed)$/.test(w[0] || ""))) return "write under .git";
|
|
194
265
|
}
|
|
195
266
|
return "";
|
|
196
267
|
}
|
|
@@ -219,6 +290,21 @@ export function isFastRead(tool, input) {
|
|
|
219
290
|
return true;
|
|
220
291
|
}
|
|
221
292
|
|
|
293
|
+
// SHIM PATH (SDK ROOT C 2026-11-06, run 42289814533): OpenCode's bash tool runs /bin/zsh on macOS, whose default PATH
|
|
294
|
+
// has no ~/.local/bin, so "zsh:1: command not found: wkskill" and a Mac user could never pay the intent lock. A bash call
|
|
295
|
+
// that names wkskill or wk-verify gets the shim dir (WK_LINK_DIR, else ~/.local/bin, where harness install.sh links
|
|
296
|
+
// them) prepended to PATH for that one command; nothing else changes. Returns the command to run (unchanged when the
|
|
297
|
+
// shim is absent or the command names neither shim).
|
|
298
|
+
export function withShimPath(tool, args, linkDir) {
|
|
299
|
+
if (claudeToolName(tool) !== "Bash" || !args || typeof args.command !== "string") return args && args.command;
|
|
300
|
+
const c = args.command;
|
|
301
|
+
if (!/(^|[\s;&|(`])(wkskill|wk-verify)(\s|$)/.test(c)) return c;
|
|
302
|
+
const dir = linkDir || process.env.WK_LINK_DIR || join(homedir(), ".local", "bin");
|
|
303
|
+
if (!existsSync(join(dir, "wkskill")) && !existsSync(join(dir, "wk-verify"))) return c;
|
|
304
|
+
if (c.startsWith("PATH=")) return c;
|
|
305
|
+
return "PATH=" + JSON.stringify(dir) + ':"$PATH"; export PATH; ' + c;
|
|
306
|
+
}
|
|
307
|
+
|
|
222
308
|
// PER-CALL INJECTION (owner 2026-11-06: "so we must force our settings onto every model call OpenCode makes").
|
|
223
309
|
// experimental.chat.system.transform appends RULES to the system array of EVERY model request, so a model that never
|
|
224
310
|
// saw kih (Qwen, a fresh provider) still gets the reflex body. Short, fixed, no secrets. RULES_MARK makes the append
|
|
@@ -251,7 +337,9 @@ export function onLoadLines(kihDir) {
|
|
|
251
337
|
res(lines.slice(0, 3).map((l) => l.slice(0, 240)));
|
|
252
338
|
};
|
|
253
339
|
try {
|
|
254
|
-
const
|
|
340
|
+
const target = cands.find((p) => p === "wkskill" || existsSync(p)), opt = { stdio: ["ignore", "pipe", "ignore"] };
|
|
341
|
+
// Windows: a shell-less spawn cannot run a .cmd shim, the bare wkskill name (PATHEXT) or an extensionless sh script
|
|
342
|
+
const c = process.platform !== "win32" ? spawn(target, ["read", "on-load"], opt) : /\.exe$/i.test(target) ? spawn(target, ["read", "on-load"], opt) : /\.(cmd|bat)$/i.test(target) || target === "wkskill" ? spawn(target, ["read", "on-load"], { ...opt, shell: true }) : spawn("sh", [target.replace(/\\/g, "/"), "read", "on-load"], opt);
|
|
255
343
|
c.stdout.on("data", (d) => { out += d; if (out.length > 65536) c.kill("SIGKILL"); });
|
|
256
344
|
c.on("error", finish); c.on("close", finish);
|
|
257
345
|
setTimeout(() => { try { c.kill("SIGKILL"); } catch {} finish(); }, 8000).unref();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wkappbot-code",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.11",
|
|
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": {
|
package/plugin/wisdom-kernel.js
CHANGED
|
@@ -51,6 +51,8 @@ export const WisdomKernel = async (ctx = {}, options = {}) => {
|
|
|
51
51
|
(tail ? ": " + tail : "") + ". Fix the harness (wkappbot-code doctor) and retry.");
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
|
+
// macOS /bin/zsh has no ~/.local/bin on PATH: a wkskill call gets the shim dir for that one command (lib/wk-hook.js).
|
|
55
|
+
try { if (args && typeof args.command === "string") args.command = H.withShimPath(inp.tool, args); } catch {}
|
|
54
56
|
},
|
|
55
57
|
"tool.execute.after": async (input, output) => {
|
|
56
58
|
try {
|