wkappbot-code 0.0.0-stage → 0.3.0
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 +74 -2
- package/bin/wkappbot-code.js +420 -0
- package/install.sh +16 -0
- package/lib/wk-bin-acquire.sh +305 -0
- package/opencode.json +31 -0
- package/package.json +31 -4
- package/plugin/wisdom-kernel.js +174 -0
package/README.md
CHANGED
|
@@ -1,3 +1,75 @@
|
|
|
1
|
-
#
|
|
1
|
+
# wkappbot-code -- Wisdom Kernel (WK)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A prototype OpenCode distribution: the upstream OpenCode release, pinned to `opencode-ai@1.19.5`, plus one kih plugin. wkappbot-code contains no coding engine of its own.
|
|
4
|
+
|
|
5
|
+
## Bridge, not harness
|
|
6
|
+
|
|
7
|
+
OpenCode is the **bridge**. It connects any model (Claude, GPT, Qwen, Kimi, GLM and local models) to files, shell, LSP and its clients. kih is the **harness**: the discipline layer that rides on the bridge, made of refusal gates, skill learning, memory and pace. In wkappbot-code the kernel loads bridges the way an OS kernel loads drivers. OpenCode, Claude Code and Codex are the drivers, and each model is a device behind them.
|
|
8
|
+
|
|
9
|
+
The plugin `plugin/wisdom-kernel.js` maps OpenCode plugin events onto the existing kih hook launcher `.claude/hooks/wkharness-run.sh`:
|
|
10
|
+
|
|
11
|
+
| OpenCode event | kih arm | effect |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| `tool.execute.before` | `pre` | Claude-shaped PreToolUse JSON (`tool_name`, `tool_input`, `session_id`, `cwd`). Exit 2 throws, which refuses the tool call with the hook's stderr as the reason. |
|
|
14
|
+
| `tool.execute.after` | `post` | fail-open |
|
|
15
|
+
| `event` `session.idle` | `stop-archive` | archive only, fail-open |
|
|
16
|
+
|
|
17
|
+
Tool names are mapped as `bash`/`edit`/`write`/`read` -> `Bash`/`Edit`/`Write`/`Read`, with camelCase args (`filePath`, `oldString`) mapped to snake_case (`file_path`, `old_string`).
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
bash wkappbot-code/install.sh # npm i -g opencode-ai@1.19.5, copy plugin + config (idempotent)
|
|
23
|
+
bash wkappbot-code/install.sh --no-npm # wire the plugin only
|
|
24
|
+
bash wkappbot-code/selftest.sh # RESULT N/N; --synthetic-only where no kih hook can run
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### One command installs everything
|
|
28
|
+
|
|
29
|
+
Get the CLI by one of four roads, then run `wkappbot-code install` once:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm i -g https://kiexpert.github.io/wkappbot-kih/wkappbot-code/wkappbot-code.tgz # web: the scrub-gated tgz the SDK workflow ships daily (SHA256SUMS + version.json beside it)
|
|
33
|
+
npm i -g wkappbot-code # registry (published only when the SDK has NPM_TOKEN)
|
|
34
|
+
npm i -g --install-links git+https://github.com/kiexpert/wkappbot-kih.git # git: the repo ROOT package exposes the same bin
|
|
35
|
+
npm pack ./wkappbot-code && npm i -g ./wkappbot-code-0.3.0.tgz # from a checkout
|
|
36
|
+
|
|
37
|
+
wkappbot-code install # every part below, idempotent
|
|
38
|
+
wkappbot-code doctor # one PASS/WARN/FAIL/SKIP row per part
|
|
39
|
+
wkappbot-code --version
|
|
40
|
+
# the old command name wk-appbot-code (0.2.x) still works: it is a second bin entry for the same file
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The git road needs `--install-links`: without it npm 10 links the bin to a temp clone it then deletes.
|
|
44
|
+
|
|
45
|
+
| part | what `install` lays down | skip with |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| opencode | pinned `opencode-ai@1.19.5` (`npm -g`) | `--no-npm` |
|
|
48
|
+
| plugin | `plugin/wisdom-kernel.js` plus the `opencode.json` plugin entry | -- |
|
|
49
|
+
| 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`, byte-identical to kih `tools/wk-bin-acquire.sh`); placed at `~/.local/share/wkappbot/wkappbot` and linked as `wkappbot` beside `wkappbot-code` | `--no-bin` |
|
|
50
|
+
| harness | when no kih checkout is found, the scrubbed web harness to `~/.wk-harness` through its own published `install.sh` (sha256-checked); `--web-harness` forces it | `--no-harness` |
|
|
51
|
+
| skills | the latest public skills from the same published tarball (sha256-checked) into the core's HQ `<core dir>/wkappbot.hq/skills`, so `wkappbot skill read on-load` prints | `--no-skills` |
|
|
52
|
+
|
|
53
|
+
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. Skill push belongs to the owner's GitHub account only; doctor's `skill-push = owner-only` row names the login a push would use (`gh api user`, else `none`) and WARNs unless it is the owner's.
|
|
54
|
+
|
|
55
|
+
`install.sh` and `wkappbot-code install` are the same code. An existing `~/.config/opencode/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. Pass `--kih-dir` (or set `WK_KIH_DIR`) when the kih checkout is not auto-found; without a checkout or `~/.wk-harness` the plugin lets every call through. 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` (`selftest.sh` part (i) mocks every network part with them).
|
|
56
|
+
|
|
57
|
+
## Model swap
|
|
58
|
+
|
|
59
|
+
- One headless turn: `opencode run -m provider/model "prompt"`
|
|
60
|
+
- Inside the TUI: `/models`
|
|
61
|
+
- In-house Qwen: `opencode.json` already declares the `qwen-inhouse` provider (`@ai-sdk/openai-compatible`). Set `QWEN_BASE_URL` and `QWEN_API_KEY` in the environment, then run `opencode run -m qwen-inhouse/qwen3-coder "hello"`. No key is stored in git.
|
|
62
|
+
|
|
63
|
+
## In the browser WebVM (no login needed)
|
|
64
|
+
|
|
65
|
+
The x64 WebVM page (`webvm/x64/index.html`) types `wk-ai wkappbot-code` once the shell is ready, so the wkappbot-code prompt is up without typing. `?noappbot` turns this off, and an embedded page only autostarts with `?appbot`. Each question goes through the page bridge:
|
|
66
|
+
|
|
67
|
+
1. A custom OpenAI-compatible provider in `localStorage` (`webvm.appbot.provider` = `{"baseURL","model","key"}`, with the key in `webvm.key.<key>`), for example the in-house Qwen.
|
|
68
|
+
2. Otherwise the first family key the browser holds (claude, openai, gemini, xai).
|
|
69
|
+
3. Otherwise the **dummy haiku**: an OpenAI-compatible mock inside the page. It never calls a model, and its first line reads `dummy haiku (로그인 전)`.
|
|
70
|
+
|
|
71
|
+
The kih lite hooks stay live in the guest. Each question is a prompt event, and `!<command>` is a Bash tool call checked by `tools/wkharness-lite.sh` first, so the intent lock refuses a command until a `!wkskill search ...` has run. Proof: `node webvm/selftest/appbot-dummy-test.js` (no VM, no network), and `webvm/selftest/x64-appbot-test.js --page ... --install ...` against the published image. The guest's `wk-ai` gets the `!cmd`/hook support only after the SDK webvm-x64 bake; until then the page's dummy and provider path already works with the old image.
|
|
72
|
+
|
|
73
|
+
## Floor
|
|
74
|
+
|
|
75
|
+
Signing, submitting and paying stay human-only. Never install kih or anything built from it onto the SK laptop.
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// wkappbot-code -- wkappbot-code (Wisdom Kernel) installer CLI: one npm line, then `wkappbot-code install`.
|
|
3
|
+
// wkappbot-code --version package version and the pinned OpenCode release
|
|
4
|
+
// wkappbot-code install [opts] ONE COMMAND lays down every part (idempotent):
|
|
5
|
+
// opencode pinned opencode-ai (npm -g) skip: --no-npm
|
|
6
|
+
// plugin plugin/wisdom-kernel.js + the opencode.json plugin entry (always)
|
|
7
|
+
// bin the wkappbot core for this platform, from the SDK latest release asset verified by
|
|
8
|
+
// SHA256SUMS -- the SAME road as kih tools/wk-bin-acquire.sh, shipped byte-identical
|
|
9
|
+
// in lib/ (the selftest pins the two copies equal) skip: --no-bin
|
|
10
|
+
// harness the scrubbed web harness to ~/.wk-harness through its own published install.sh,
|
|
11
|
+
// only when no kih checkout is found (--web-harness forces it) skip: --no-harness
|
|
12
|
+
// skills the latest public skills from the same published tarball (sha256-checked) into the
|
|
13
|
+
// core's HQ (<core dir>/wkappbot.hq/skills), so `wkappbot skill read on-load` prints;
|
|
14
|
+
// a READ-ONLY copy with no git remote. A user's own skill edits go to the private
|
|
15
|
+
// home ~/.wkappbot/private-skills (a local git repo, no remote) skip: --no-skills
|
|
16
|
+
// wkappbot-code doctor [opts] one PASS/WARN/FAIL/SKIP row per part (exit 1 if a required part fails)
|
|
17
|
+
// opts: --config-dir DIR, --kih-dir DIR, --bin-dest FILE, --hq-dir DIR
|
|
18
|
+
// env (mock and relocation): WK_BIN_BASE (SDK release base), WK_HARNESS_BASE (web harness base),
|
|
19
|
+
// WK_HARNESS_DIR (~/.wk-harness), WK_BIN_DEST, WK_APPBOT_HQ, WK_LINK_DIR, WK_PRIVATE_SKILLS,
|
|
20
|
+
// WK_KIH_DIR (a kih checkout; "none" = ignore every checkout on this machine)
|
|
21
|
+
// Installer owns all setup, surgically: an existing user opencode.json is BACKED UP and only the plugin entry is
|
|
22
|
+
// merged in; every other key is kept. Spec: wkappbot skill read agent-harness-landscape-2026-howto
|
|
23
|
+
// and wkappbot skill read harness-installer-owns-all-setup (step 9 ONE-COMMAND APPBOT CODE INSTALL)
|
|
24
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
25
|
+
import { createHash } from "node:crypto";
|
|
26
|
+
import {
|
|
27
|
+
copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync,
|
|
28
|
+
realpathSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync, utimesSync, writeFileSync,
|
|
29
|
+
} from "node:fs";
|
|
30
|
+
import { homedir, tmpdir } from "node:os";
|
|
31
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
32
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
33
|
+
|
|
34
|
+
const PKG_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
35
|
+
const PKG = JSON.parse(readFileSync(join(PKG_DIR, "package.json"), "utf8"));
|
|
36
|
+
const OPENCODE_VERSION = PKG.wkAppbotCode.opencodeVersion;
|
|
37
|
+
const OWNER_LOGIN = "kiexpert";
|
|
38
|
+
const HARNESS_BASE = () => (process.env.WK_HARNESS_BASE || "https://kiexpert.github.io/wkappbot-kih/webvm/x64/harness").replace(/\/+$/, "");
|
|
39
|
+
const HARNESS_DIR = () => process.env.WK_HARNESS_DIR || join(homedir(), ".wk-harness");
|
|
40
|
+
const IS_WIN = process.platform === "win32";
|
|
41
|
+
|
|
42
|
+
function parseArgs(argv) {
|
|
43
|
+
const o = { cmd: argv[0] || "help", npm: true, bin: true, harness: true, skills: true, webHarness: false,
|
|
44
|
+
configDir: "", kihDir: "", binDest: "", hqDir: "" };
|
|
45
|
+
for (let i = 1; i < argv.length; i++) {
|
|
46
|
+
const a = argv[i];
|
|
47
|
+
if (a === "--no-npm") o.npm = false;
|
|
48
|
+
else if (a === "--no-bin") o.bin = false;
|
|
49
|
+
else if (a === "--no-harness") o.harness = false;
|
|
50
|
+
else if (a === "--no-skills") o.skills = false;
|
|
51
|
+
else if (a === "--web-harness") o.webHarness = true;
|
|
52
|
+
else if (a === "--config-dir") o.configDir = argv[++i] || "";
|
|
53
|
+
else if (a === "--kih-dir") o.kihDir = argv[++i] || "";
|
|
54
|
+
else if (a === "--bin-dest") o.binDest = argv[++i] || "";
|
|
55
|
+
else if (a === "--hq-dir") o.hqDir = argv[++i] || "";
|
|
56
|
+
else { console.error("unknown option: " + a); process.exit(64); }
|
|
57
|
+
}
|
|
58
|
+
return o;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function configDir(o) {
|
|
62
|
+
if (o.configDir) return resolve(o.configDir);
|
|
63
|
+
if (process.env.OPENCODE_CONFIG_DIR) return process.env.OPENCODE_CONFIG_DIR;
|
|
64
|
+
return join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "opencode");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const hasHook = (d) => !!d && existsSync(join(d, ".claude", "hooks", "wkharness-run.sh"));
|
|
68
|
+
// A kih CHECKOUT (never the web harness): explicit dirs first, then the usual checkout places.
|
|
69
|
+
// WK_KIH_DIR=none: act as on a machine with no checkout (the selftest's isolation from this VM's kih).
|
|
70
|
+
function kihCheckout(o) {
|
|
71
|
+
if (process.env.WK_KIH_DIR === "none") return o.kihDir && hasHook(o.kihDir) ? resolve(o.kihDir) : "";
|
|
72
|
+
const tries = [o.kihDir, process.env.WK_KIH_DIR, resolve(PKG_DIR, ".."),
|
|
73
|
+
join(homedir(), "wkappbot-kih"), "/home/user/wkappbot-kih", "D:/GitHub/wkappbot-kih"];
|
|
74
|
+
for (const d of tries) if (hasHook(d)) return resolve(d);
|
|
75
|
+
return "";
|
|
76
|
+
}
|
|
77
|
+
// The kih root whose .claude/hooks/wkharness-run.sh the plugin calls: a checkout, else the web harness
|
|
78
|
+
// (~/.wk-harness). Empty = plugin fails open.
|
|
79
|
+
function kihDir(o) {
|
|
80
|
+
return kihCheckout(o) || (hasHook(HARNESS_DIR()) ? resolve(HARNESS_DIR()) : "");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function opencodeVersion() {
|
|
84
|
+
try { return execFileSync("opencode", ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); }
|
|
85
|
+
catch { return ""; }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const stamp = () => new Date().toISOString().replace(/[-:]/g, "").replace(/\..*/, "");
|
|
89
|
+
const log = (s) => console.log("[wkappbot-code] " + s);
|
|
90
|
+
const sha256File = (f) => createHash("sha256").update(readFileSync(f)).digest("hex");
|
|
91
|
+
const statePath = (o) => join(configDir(o), "wkappbot-code", "install-state.json");
|
|
92
|
+
function readState(o) { try { return JSON.parse(readFileSync(statePath(o), "utf8")); } catch { return {}; } }
|
|
93
|
+
|
|
94
|
+
// Same rule as wk-bin-acquire.sh platform_detect (linux-x64, osx-arm64, osx-x64, win-x64).
|
|
95
|
+
export function platformId(platform = process.platform, arch = process.arch) {
|
|
96
|
+
if (platform === "linux" && arch === "x64") return "linux-x64";
|
|
97
|
+
if (platform === "darwin") return arch === "arm64" ? "osx-arm64" : arch === "x64" ? "osx-x64" : "";
|
|
98
|
+
if (platform === "win32") return "win-x64";
|
|
99
|
+
return "";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Merge our plugin entry into a user config: drop any older wisdom-kernel entry, append the new one.
|
|
103
|
+
export function mergeConfig(user, template, url, kih) {
|
|
104
|
+
const out = { ...user };
|
|
105
|
+
const ours = [url, { kihDir: kih }];
|
|
106
|
+
const keep = (Array.isArray(user.plugin) ? user.plugin : []).filter((p) => !String(Array.isArray(p) ? p[0] : p).includes("wisdom-kernel"));
|
|
107
|
+
out.plugin = [...keep, ours];
|
|
108
|
+
if (!out.$schema && template.$schema) out.$schema = template.$schema;
|
|
109
|
+
if (!out.provider) out.provider = template.provider;
|
|
110
|
+
else if (template.provider) for (const [k, v] of Object.entries(template.provider)) if (!(k in out.provider)) out.provider[k] = v;
|
|
111
|
+
if (!out.permission) out.permission = template.permission;
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---- part: bin ------------------------------------------------------------------------------------
|
|
116
|
+
function binDest(o) {
|
|
117
|
+
if (o.binDest) return resolve(o.binDest);
|
|
118
|
+
if (process.env.WK_BIN_DEST) return resolve(process.env.WK_BIN_DEST);
|
|
119
|
+
// The same fallback place tools/wkappbot-root.sh looks for a core when none sits beside it.
|
|
120
|
+
return join(homedir(), ".local", "share", "wkappbot", IS_WIN ? "wkappbot.exe" : "wkappbot");
|
|
121
|
+
}
|
|
122
|
+
// The dir of the CLI's own bin as npm placed it (argv[1] is npm's symlink in PREFIX/bin, never realpath'd).
|
|
123
|
+
// Run from a checkout or lib/node_modules (that dir is inside PKG_DIR): ~/.local/bin instead.
|
|
124
|
+
function linkDir() {
|
|
125
|
+
if (process.env.WK_LINK_DIR) return process.env.WK_LINK_DIR;
|
|
126
|
+
const here = resolve(dirname(process.argv[1] || PKG_DIR));
|
|
127
|
+
const rel = relative(PKG_DIR, here);
|
|
128
|
+
if (rel.startsWith("..") || isAbsolute(rel)) return here;
|
|
129
|
+
return join(homedir(), ".local", "bin");
|
|
130
|
+
}
|
|
131
|
+
function installBin(o) {
|
|
132
|
+
const dest = binDest(o);
|
|
133
|
+
const plat = platformId();
|
|
134
|
+
if (!plat) return { ok: false, dest, note: "no wkappbot build for " + process.platform + "-" + process.arch };
|
|
135
|
+
if (plat === "win-x64") return { ok: false, dest, note: "win-x64 has no single SDK asset; run install.cmd from a kih checkout" };
|
|
136
|
+
// A kih checkout runs its own copy (same bytes, plus the MANIFEST-verified repo bin/ fallback); the package
|
|
137
|
+
// copy outside a checkout takes only the SDK release asset verified by SHA256SUMS.
|
|
138
|
+
const co = kihCheckout(o);
|
|
139
|
+
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");
|
|
140
|
+
log("bin: bash " + script + " acquire --dest " + dest);
|
|
141
|
+
const r = spawnSync("bash", [script, "acquire", "--dest", dest], { encoding: "utf8", env: process.env });
|
|
142
|
+
for (const line of ((r.stdout || "") + (r.stderr || "")).split("\n").filter(Boolean)) log(" " + line);
|
|
143
|
+
if (r.status !== 0) return { ok: false, dest, note: "wk-bin-acquire rc " + r.status };
|
|
144
|
+
// Put `wkappbot` on PATH beside wkappbot-code itself, unless that name is already taken by someone else.
|
|
145
|
+
let link = "";
|
|
146
|
+
const ld = linkDir();
|
|
147
|
+
const lp = join(ld, "wkappbot");
|
|
148
|
+
try {
|
|
149
|
+
const taken = existsSync(lp) || isSymlink(lp);
|
|
150
|
+
const ours = taken && isSymlink(lp) && resolve(dirname(lp), readlinkSync(lp)) === dest;
|
|
151
|
+
if (taken && !ours) log("bin: kept existing wkappbot link " + lp + " (not ours); call " + dest + " directly");
|
|
152
|
+
else {
|
|
153
|
+
mkdirSync(ld, { recursive: true });
|
|
154
|
+
if (ours) unlinkSync(lp);
|
|
155
|
+
symlinkSync(dest, lp);
|
|
156
|
+
link = lp;
|
|
157
|
+
log("bin: linked " + lp + " -> " + dest);
|
|
158
|
+
}
|
|
159
|
+
} catch (e) { log("bin: could not link " + lp + " (" + e.message + ")"); }
|
|
160
|
+
return { ok: true, dest, link };
|
|
161
|
+
}
|
|
162
|
+
function isSymlink(p) { try { return lstatSync(p).isSymbolicLink(); } catch { return false; } }
|
|
163
|
+
|
|
164
|
+
// ---- part: harness --------------------------------------------------------------------------------
|
|
165
|
+
function curl(url, out) {
|
|
166
|
+
const r = spawnSync("curl", ["-fsSL", "-m", "300", url, "-o", out], { encoding: "utf8" });
|
|
167
|
+
return r.status === 0 ? "" : ("curl " + url + " rc " + r.status + " " + (r.stderr || "").trim());
|
|
168
|
+
}
|
|
169
|
+
function installHarness(o) {
|
|
170
|
+
const found = kihCheckout(o);
|
|
171
|
+
if (found && !o.webHarness) {
|
|
172
|
+
log("harness: kih checkout " + found + " found; the web harness is not needed (--web-harness forces it)");
|
|
173
|
+
return { ok: true, mode: "checkout", dir: found };
|
|
174
|
+
}
|
|
175
|
+
const tmp = mkdtempSync(join(tmpdir(), "wk-appbot-harness-"));
|
|
176
|
+
try {
|
|
177
|
+
const err = curl(HARNESS_BASE() + "/install.sh", join(tmp, "install.sh"));
|
|
178
|
+
if (err) return { ok: false, mode: "web", dir: HARNESS_DIR(), note: err };
|
|
179
|
+
log("harness: sh " + HARNESS_BASE() + "/install.sh (to " + HARNESS_DIR() + ")");
|
|
180
|
+
const r = spawnSync("sh", [join(tmp, "install.sh")], { encoding: "utf8",
|
|
181
|
+
env: { ...process.env, WK_HARNESS_BASE: HARNESS_BASE(), WK_HARNESS_DIR: HARNESS_DIR() } });
|
|
182
|
+
for (const line of ((r.stdout || "") + (r.stderr || "")).split("\n").filter(Boolean)) log(" " + line);
|
|
183
|
+
const ok = r.status === 0 && hasHook(HARNESS_DIR());
|
|
184
|
+
return { ok, mode: "web", dir: HARNESS_DIR(), note: ok ? "" : "harness install rc " + r.status };
|
|
185
|
+
} finally { rmSync(tmp, { recursive: true, force: true }); }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---- part: skills ---------------------------------------------------------------------------------
|
|
189
|
+
function hqDir(o, dest) {
|
|
190
|
+
if (o.hqDir) return resolve(o.hqDir);
|
|
191
|
+
if (process.env.WK_APPBOT_HQ) return resolve(process.env.WK_APPBOT_HQ);
|
|
192
|
+
let real = dest;
|
|
193
|
+
try { real = realpathSync(dest); } catch {}
|
|
194
|
+
return join(dirname(real), "wkappbot.hq", "skills");
|
|
195
|
+
}
|
|
196
|
+
// Copy every file under src into dst when it is missing or older there; keeps mtimes. Returns counts.
|
|
197
|
+
export function syncTree(src, dst) {
|
|
198
|
+
const c = { added: 0, updated: 0, kept: 0 };
|
|
199
|
+
const walk = (d) => {
|
|
200
|
+
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
201
|
+
const s = join(d, e.name);
|
|
202
|
+
const t = join(dst, relative(src, s));
|
|
203
|
+
if (e.isDirectory()) { walk(s); continue; }
|
|
204
|
+
if (!e.isFile()) continue;
|
|
205
|
+
const ss = statSync(s);
|
|
206
|
+
let ts = null; try { ts = statSync(t); } catch {}
|
|
207
|
+
if (ts && ts.mtimeMs >= ss.mtimeMs) { c.kept++; continue; }
|
|
208
|
+
mkdirSync(dirname(t), { recursive: true });
|
|
209
|
+
copyFileSync(s, t + ".new");
|
|
210
|
+
renameSync(t + ".new", t);
|
|
211
|
+
utimesSync(t, ss.atime, ss.mtime);
|
|
212
|
+
ts ? c.updated++ : c.added++;
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
walk(src);
|
|
216
|
+
return c;
|
|
217
|
+
}
|
|
218
|
+
const countSkills = (d) => {
|
|
219
|
+
let n = 0;
|
|
220
|
+
const walk = (x) => { for (const e of readdirSync(x, { withFileTypes: true })) {
|
|
221
|
+
if (e.isDirectory()) walk(join(x, e.name)); else if (e.name.endsWith(".skill.json")) n++; } };
|
|
222
|
+
try { walk(d); } catch {}
|
|
223
|
+
return n;
|
|
224
|
+
};
|
|
225
|
+
function privateHome() { return process.env.WK_PRIVATE_SKILLS || join(homedir(), ".wkappbot", "private-skills"); }
|
|
226
|
+
function gitRemotes(dir) {
|
|
227
|
+
const r = spawnSync("git", ["-C", dir, "remote"], { encoding: "utf8" });
|
|
228
|
+
return r.status === 0 ? r.stdout.split("\n").filter(Boolean) : null;
|
|
229
|
+
}
|
|
230
|
+
function installSkills(o, dest) {
|
|
231
|
+
const hq = hqDir(o, dest);
|
|
232
|
+
const base = HARNESS_BASE();
|
|
233
|
+
const tmp = mkdtempSync(join(tmpdir(), "wk-appbot-skills-"));
|
|
234
|
+
try {
|
|
235
|
+
let src = "", from = "";
|
|
236
|
+
let err = curl(base + "/version.json", join(tmp, "version.json"));
|
|
237
|
+
if (err) return { ok: false, hq, note: err };
|
|
238
|
+
let vj = {}; try { vj = JSON.parse(readFileSync(join(tmp, "version.json"), "utf8")); } catch {}
|
|
239
|
+
// The web harness just installed (or already current) holds the same tarball's skills/: reuse it.
|
|
240
|
+
let iv = {}; try { iv = JSON.parse(readFileSync(join(HARNESS_DIR(), ".installed-version"), "utf8")); } catch {}
|
|
241
|
+
if (vj.sha256 && iv.sha256 === vj.sha256 && existsSync(join(HARNESS_DIR(), "skills"))) {
|
|
242
|
+
src = join(HARNESS_DIR(), "skills"); from = HARNESS_DIR() + "/skills (installed version " + iv.version + ")";
|
|
243
|
+
} else {
|
|
244
|
+
const tgz = join(tmp, "wk-harness-min.tar.gz");
|
|
245
|
+
if ((err = curl(base + "/wk-harness-min.tar.gz.sha256", join(tmp, "want.sha256")))) return { ok: false, hq, note: err };
|
|
246
|
+
const want = readFileSync(join(tmp, "want.sha256"), "utf8").trim().split(/\s+/)[0];
|
|
247
|
+
if (!/^[0-9a-f]{64}$/.test(want)) return { ok: false, hq, note: "the sha256 file holds no 64-hex hash" };
|
|
248
|
+
if ((err = curl(base + "/wk-harness-min.tar.gz", tgz))) return { ok: false, hq, note: err };
|
|
249
|
+
const got = sha256File(tgz);
|
|
250
|
+
if (got !== want) return { ok: false, hq, note: "sha256 MISMATCH for wk-harness-min.tar.gz: expected " + want + ", got " + got + "; no skill was copied" };
|
|
251
|
+
mkdirSync(join(tmp, "x"));
|
|
252
|
+
const r = spawnSync("tar", ["-xzf", tgz, "-C", join(tmp, "x")], { encoding: "utf8" });
|
|
253
|
+
if (r.status !== 0 || !existsSync(join(tmp, "x", "skills"))) return { ok: false, hq, note: "unpack failed or no skills/ in the tarball: " + (r.stderr || "") };
|
|
254
|
+
src = join(tmp, "x", "skills"); from = base + "/wk-harness-min.tar.gz (sha256 ok, version " + (vj.version || "?") + ")";
|
|
255
|
+
}
|
|
256
|
+
const c = syncTree(src, hq);
|
|
257
|
+
const n = countSkills(hq);
|
|
258
|
+
log("skills: " + n + " skills in HQ " + hq + " from " + from + " (added " + c.added + ", updated " + c.updated + ", kept " + c.kept + ")");
|
|
259
|
+
// Read-only copy: the HQ carries no git remote, and a user's own edits go to a local private home with none.
|
|
260
|
+
const ph = privateHome();
|
|
261
|
+
mkdirSync(ph, { recursive: true });
|
|
262
|
+
if (!existsSync(join(ph, ".git")) && spawnSync("git", ["init", "-q", ph]).status === 0)
|
|
263
|
+
log("skills: private home " + ph + " created as a local git repo with no remote");
|
|
264
|
+
return { ok: n > 0 && existsSync(join(hq, "wkappbot-workflow", "on-load.skill.json")), hq, count: n, privateHome: ph };
|
|
265
|
+
} finally { rmSync(tmp, { recursive: true, force: true }); }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ---- install --------------------------------------------------------------------------------------
|
|
269
|
+
function installPlugin(o, kih) {
|
|
270
|
+
const cfg = configDir(o);
|
|
271
|
+
mkdirSync(join(cfg, "wkappbot-code"), { recursive: true });
|
|
272
|
+
const dest = join(cfg, "wkappbot-code", "wisdom-kernel.js");
|
|
273
|
+
copyFileSync(join(PKG_DIR, "plugin", "wisdom-kernel.js"), dest);
|
|
274
|
+
// The copy imports nothing from the package, but mark its folder ESM so Node never re-parses it.
|
|
275
|
+
writeFileSync(join(cfg, "wkappbot-code", "package.json"), '{ "type": "module" }\n');
|
|
276
|
+
const url = pathToFileURL(dest).href;
|
|
277
|
+
const tmpl = JSON.parse(readFileSync(join(PKG_DIR, "opencode.json"), "utf8"));
|
|
278
|
+
const target = join(cfg, "opencode.json");
|
|
279
|
+
let next, note = "";
|
|
280
|
+
if (existsSync(target)) {
|
|
281
|
+
const raw = readFileSync(target, "utf8");
|
|
282
|
+
let user;
|
|
283
|
+
try { user = JSON.parse(raw); } catch { user = null; }
|
|
284
|
+
if (!user) {
|
|
285
|
+
// JSONC or broken: never rewrite what we cannot parse; leave a sidecar to merge by hand.
|
|
286
|
+
const side = join(cfg, "wkappbot-code.json");
|
|
287
|
+
writeFileSync(side, JSON.stringify(mergeConfig({}, tmpl, url, kih), null, 2) + "\n");
|
|
288
|
+
log(target + " is not plain JSON; left untouched, wrote " + side + " -- merge its plugin entry by hand");
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
next = mergeConfig(user, tmpl, url, kih);
|
|
292
|
+
if (JSON.stringify(next, null, 2) + "\n" === raw) note = " (unchanged)";
|
|
293
|
+
else {
|
|
294
|
+
const bak = target + ".bak-" + stamp();
|
|
295
|
+
copyFileSync(target, bak);
|
|
296
|
+
note = " (merged; backup " + bak + ")";
|
|
297
|
+
}
|
|
298
|
+
} else next = mergeConfig({}, tmpl, url, kih);
|
|
299
|
+
writeFileSync(target + ".tmp", JSON.stringify(next, null, 2) + "\n");
|
|
300
|
+
renameSync(target + ".tmp", target);
|
|
301
|
+
log("plugin: " + url);
|
|
302
|
+
log("config: " + target + note);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function install(o) {
|
|
306
|
+
const st = { version: PKG.version, at: new Date().toISOString() };
|
|
307
|
+
let bad = 0;
|
|
308
|
+
if (o.npm) {
|
|
309
|
+
const have = opencodeVersion();
|
|
310
|
+
if (have === OPENCODE_VERSION) log("opencode " + have + " already installed");
|
|
311
|
+
else {
|
|
312
|
+
log("npm install -g opencode-ai@" + OPENCODE_VERSION + " (was: " + (have || "none") + ")");
|
|
313
|
+
execFileSync("npm", ["install", "-g", "opencode-ai@" + OPENCODE_VERSION, "--no-audit", "--no-fund"], { stdio: "inherit" });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const dest = binDest(o);
|
|
317
|
+
st.bin = o.bin ? installBin(o) : { skipped: true, dest };
|
|
318
|
+
if (o.bin && !st.bin.ok) { bad++; log("bin: FAILED -- " + st.bin.note); }
|
|
319
|
+
st.harness = o.harness ? installHarness(o) : { skipped: true };
|
|
320
|
+
if (o.harness && !st.harness.ok) { bad++; log("harness: FAILED -- " + st.harness.note); }
|
|
321
|
+
// The plugin entry names the harness the install just chose (a forced web harness wins over a checkout).
|
|
322
|
+
const kih = (st.harness.mode === "web" && st.harness.ok) ? st.harness.dir : kihDir(o);
|
|
323
|
+
// Global pre-push dispatcher (owner 2026-11-06): the push-owner gate runs in every repo, then the repo's own hooks.
|
|
324
|
+
// A user's own absolute global core.hooksPath is kept (the script WARNs). Skipped with --no-harness.
|
|
325
|
+
st.pushGate = { skipped: true };
|
|
326
|
+
if (o.harness && kih && existsSync(join(kih, "tools", "wk-global-hooks.sh"))) {
|
|
327
|
+
const r = spawnSync("bash", [join(kih, "tools", "wk-global-hooks.sh"), "install", "--gate-dir", join(kih, ".githooks", "lib")], { encoding: "utf8" });
|
|
328
|
+
for (const line of ((r.stdout || "") + (r.stderr || "")).split("\n").filter(Boolean)) log("push-gate: " + line);
|
|
329
|
+
st.pushGate = { ok: r.status === 0, kih };
|
|
330
|
+
}
|
|
331
|
+
st.skills = o.skills ? installSkills(o, dest) : { skipped: true };
|
|
332
|
+
if (o.skills && !st.skills.ok) { bad++; log("skills: FAILED -- " + (st.skills.note || "on-load missing from the HQ")); }
|
|
333
|
+
installPlugin(o, kih);
|
|
334
|
+
st.kihDir = kih;
|
|
335
|
+
writeFileSync(statePath(o), JSON.stringify(st, null, 2) + "\n");
|
|
336
|
+
log("kih: " + (kih || "NOT FOUND -- plugin fails open until --kih-dir or WK_KIH_DIR points at a kih checkout"));
|
|
337
|
+
log((bad ? "done with " + bad + " failed part(s)" : "done") + " -- check: wkappbot-code doctor then run: opencode");
|
|
338
|
+
return bad ? 1 : 0;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ---- doctor ---------------------------------------------------------------------------------------
|
|
342
|
+
// The GitHub login a skill push would use: gh when present, else "none". Push is the owner's alone.
|
|
343
|
+
export function pushLogin() {
|
|
344
|
+
const r = spawnSync("gh", ["api", "user", "-q", ".login"], { encoding: "utf8", timeout: 15000 });
|
|
345
|
+
if (r.error) return "none";
|
|
346
|
+
return r.status === 0 && r.stdout.trim() ? r.stdout.trim() : "none";
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function doctor(o) {
|
|
350
|
+
const cfg = configDir(o);
|
|
351
|
+
const st = readState(o);
|
|
352
|
+
const rows = [];
|
|
353
|
+
const add = (name, ok, detail, required, skip) => rows.push({ name, ok, detail, required, skip });
|
|
354
|
+
const v = opencodeVersion();
|
|
355
|
+
add("opencode", v === OPENCODE_VERSION, v ? v + (v === OPENCODE_VERSION ? "" : " (pinned " + OPENCODE_VERSION + ")") : "not on PATH", false);
|
|
356
|
+
const pdest = join(cfg, "wkappbot-code", "wisdom-kernel.js");
|
|
357
|
+
add("plugin", existsSync(pdest), pdest, true);
|
|
358
|
+
let entry = null;
|
|
359
|
+
try { entry = (JSON.parse(readFileSync(join(cfg, "opencode.json"), "utf8")).plugin || []).find((p) => String(Array.isArray(p) ? p[0] : p).includes("wisdom-kernel")); } catch {}
|
|
360
|
+
add("config", !!entry, join(cfg, "opencode.json"), true);
|
|
361
|
+
// bin: the core answers --version
|
|
362
|
+
const dest = o.binDest ? resolve(o.binDest) : (st.bin && st.bin.dest) || binDest(o);
|
|
363
|
+
let core = "";
|
|
364
|
+
if (st.bin && st.bin.skipped) add("bin", false, "skipped (--no-bin)", false, true);
|
|
365
|
+
else {
|
|
366
|
+
const r = spawnSync(dest, ["--version"], { encoding: "utf8", timeout: 60000 });
|
|
367
|
+
core = r.status === 0 ? (r.stdout || "").trim().split("\n")[0] : "";
|
|
368
|
+
add("bin", !!core, core ? dest + " " + core + (st.bin && st.bin.link ? " (PATH link " + st.bin.link + ")" : "") : dest + " does not run", !!(st.bin && !st.bin.skipped));
|
|
369
|
+
}
|
|
370
|
+
// kih-hook: the harness the plugin calls
|
|
371
|
+
const k = (entry && Array.isArray(entry) && entry[1] && entry[1].kihDir) || kihDir(o);
|
|
372
|
+
const hk = hasHook(k);
|
|
373
|
+
if (st.harness && st.harness.skipped && !hk) add("kih-hook", false, "skipped (--no-harness)", false, true);
|
|
374
|
+
else add("kih-hook", hk, k ? k + (st.harness && st.harness.mode ? " (" + st.harness.mode + ")" : "") : "not found", !!(st.harness && st.harness.mode === "web"));
|
|
375
|
+
// skills: the HQ holds on-load and the core prints it
|
|
376
|
+
const hq = (st.skills && st.skills.hq) || hqDir(o, dest);
|
|
377
|
+
if (st.skills && st.skills.skipped) add("skills", false, "skipped (--no-skills)", false, true);
|
|
378
|
+
else {
|
|
379
|
+
const n = countSkills(hq);
|
|
380
|
+
let printed = false, why = "";
|
|
381
|
+
if (core) {
|
|
382
|
+
const cwd = mkdtempSync(join(tmpdir(), "wk-appbot-doctor-"));
|
|
383
|
+
const r = spawnSync(dest, ["skill", "read", "on-load"], { cwd, encoding: "utf8", timeout: 120000,
|
|
384
|
+
env: { ...process.env, WKAPPBOT_NO_REMOTE_CHECK: "1" } });
|
|
385
|
+
printed = /\bID\s*:\s*on-load\b/.test(r.stdout || "");
|
|
386
|
+
if (!printed) why = " skill read on-load rc " + r.status;
|
|
387
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
388
|
+
} else why = " (no core to read with)";
|
|
389
|
+
add("skills", n > 0 && printed, n + " skills in " + hq + (printed ? "; wkappbot skill read on-load prints" : why), !!(st.skills && !st.skills.skipped));
|
|
390
|
+
}
|
|
391
|
+
// push-gate: the global core.hooksPath runs the push-owner gate (directly, or through the global dispatcher).
|
|
392
|
+
const ghp = (() => { try { return execFileSync("git", ["config", "--global", "--get", "core.hooksPath"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); } catch { return ""; } })();
|
|
393
|
+
let gated = false;
|
|
394
|
+
try { gated = /push-owner-gate|wk-global-hooks\.sh/.test(readFileSync(join(ghp.replace(/^~(?=\/|$)/, homedir()), "pre-push"), "utf8")); } catch {}
|
|
395
|
+
add("push-gate", gated, "global core.hooksPath " + (ghp || "unset") + (gated ? " runs the push-owner gate" : " does not run the push-owner gate"), false);
|
|
396
|
+
// skill-push: owner-only. The laid-down skills are a read-only copy; a push may only ever use the owner login.
|
|
397
|
+
const login = pushLogin();
|
|
398
|
+
const ph = privateHome();
|
|
399
|
+
const rem = existsSync(join(ph, ".git")) ? gitRemotes(ph) : [];
|
|
400
|
+
const remNote = rem && rem.length ? "; private home " + ph + " HAS remote(s) " + rem.join(",") : "; private home " + ph + " has no remote";
|
|
401
|
+
add("skill-push", login === OWNER_LOGIN && !(rem && rem.length), "= owner-only; a push would use github login " + login + remNote, false);
|
|
402
|
+
let bad = 0;
|
|
403
|
+
for (const r of rows) {
|
|
404
|
+
console.log((r.skip ? "SKIP " : r.ok ? "PASS " : r.required ? "FAIL " : "WARN ") + r.name + " " + r.detail);
|
|
405
|
+
if (!r.ok && r.required && !r.skip) bad++;
|
|
406
|
+
}
|
|
407
|
+
return bad ? 1 : 0;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const o = parseArgs(process.argv.slice(2));
|
|
411
|
+
switch (o.cmd) {
|
|
412
|
+
case "--version": case "-v": case "version":
|
|
413
|
+
console.log("wkappbot-code " + PKG.version + " (opencode-ai " + OPENCODE_VERSION + ")"); process.exit(0);
|
|
414
|
+
case "install": process.exit(install(o));
|
|
415
|
+
case "doctor": process.exit(doctor(o));
|
|
416
|
+
default:
|
|
417
|
+
console.log("usage: wkappbot-code --version | install [--no-npm] [--no-bin] [--no-harness] [--no-skills] [--web-harness]\n" +
|
|
418
|
+
" [--config-dir DIR] [--kih-dir DIR] [--bin-dest FILE] [--hq-dir DIR] | doctor [--config-dir DIR]");
|
|
419
|
+
process.exit(o.cmd === "help" || o.cmd === "--help" ? 0 : 64);
|
|
420
|
+
}
|
package/install.sh
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# wkappbot-code -- Wisdom Kernel installer from a kih checkout (idempotent). Installs the PINNED OpenCode release
|
|
3
|
+
# and wires the one kih plugin into the user's OpenCode config (a non-JSON config is left untouched and a
|
|
4
|
+
# sidecar wkappbot-code.json is written to merge by hand).
|
|
5
|
+
# Spec: wkappbot skill read agent-harness-landscape-2026-howto
|
|
6
|
+
# Usage: bash wkappbot-code/install.sh [--no-npm] [--config-dir DIR]
|
|
7
|
+
command -v wk_stopwatch_lap >/dev/null 2>&1 && wk_stopwatch_lap "wkappbot-code install"
|
|
8
|
+
# ONE INSTALL PATH (2026-11-05): this wraps bin/wkappbot-code.js install, the same code that
|
|
9
|
+
# `npm install -g wkappbot-code` puts on PATH, so a checkout and the npm package never install differently.
|
|
10
|
+
# The pin lives in package.json wkAppbotCode.opencodeVersion; an existing opencode.json is backed up and
|
|
11
|
+
# only the plugin entry is merged in (every other user key is kept).
|
|
12
|
+
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
13
|
+
KIH="$(cd "$HERE/.." && pwd)"
|
|
14
|
+
NODE="$(command -v node || true)"
|
|
15
|
+
[ -z "$NODE" ] && { echo "[wkappbot-code] node not found; install Node.js 18+ first" >&2; exit 1; }
|
|
16
|
+
exec "$NODE" "$HERE/bin/wkappbot-code.js" install --kih-dir "$KIH" "$@"
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# wk-bin-acquire -- acquire, install and publish the wkappbot binary for every platform.
|
|
3
|
+
#
|
|
4
|
+
# Usage:
|
|
5
|
+
# wk-bin-acquire.sh acquire [--dest PATH] [--dry-run] [--offline]
|
|
6
|
+
# Platform from uname: linux-x64, osx-arm64, osx-x64, win-x64. Unix: PATH is the binary file.
|
|
7
|
+
# Source 1 (unless --offline): SDK latest release asset wkappbot-PLATFORM, accepted only when its
|
|
8
|
+
# sha256 matches its SHA256SUMS line. Source 2: REPO/bin/PLATFORM/wkappbot, accepted only when
|
|
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 bin/win-x64/wkappbot-core.exe.xz), from bin/ only.
|
|
11
|
+
# Copies, never links; writes via a .new temp then mv; "up to date" when the sha already matches.
|
|
12
|
+
# wk-bin-acquire.sh publish SRC [--platform P] [--dry-run]
|
|
13
|
+
# Stores SRC in REPO/bin/P/ (a Windows core .exe as wkappbot-core.exe.xz, xz -9e -T0), rewrites
|
|
14
|
+
# its MANIFEST.md row, commits the two paths with mode 100755 (non-Windows), then pushes.
|
|
15
|
+
#
|
|
16
|
+
# Env: WK_BIN_NO_PUSH=1 skips the push; WKAPPBOT_DIR locates bin/wkpush.sh;
|
|
17
|
+
# WKAPPBOT_HOME selects the Windows install directory; WK_BIN_BASE replaces the SDK release download
|
|
18
|
+
# base (a selftest points it at a file:// mock).
|
|
19
|
+
# Outside a git checkout (the copy shipped in the wkappbot-code npm package, wkappbot-code/lib/) REPO is
|
|
20
|
+
# empty: acquire then takes only the verified SDK asset, and the repo fallback refuses with a clear line.
|
|
21
|
+
set -euo pipefail
|
|
22
|
+
|
|
23
|
+
WK_DRY=0
|
|
24
|
+
WK_TMP=""
|
|
25
|
+
SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
26
|
+
REPO="$(git -C "$SELF_DIR" rev-parse --show-toplevel 2>/dev/null || true)"
|
|
27
|
+
|
|
28
|
+
die() {
|
|
29
|
+
command -v wk_stopwatch_lap >/dev/null 2>&1 && wk_stopwatch_lap "die" || true
|
|
30
|
+
echo "wk-bin-acquire: $*" >&2
|
|
31
|
+
exit 1
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
note() {
|
|
35
|
+
command -v wk_stopwatch_lap >/dev/null 2>&1 && wk_stopwatch_lap "note" || true
|
|
36
|
+
echo "wk-bin-acquire: $*" >&2
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
run() {
|
|
40
|
+
command -v wk_stopwatch_lap >/dev/null 2>&1 && wk_stopwatch_lap "run" || true
|
|
41
|
+
if [ "$WK_DRY" = 1 ]; then
|
|
42
|
+
echo "DRY: $*"
|
|
43
|
+
else
|
|
44
|
+
"$@"
|
|
45
|
+
fi
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
sha_of() {
|
|
49
|
+
command -v wk_stopwatch_lap >/dev/null 2>&1 && wk_stopwatch_lap "sha_of" || true
|
|
50
|
+
[ -f "$1" ] || return 1
|
|
51
|
+
if command -v sha256sum >/dev/null 2>&1; then
|
|
52
|
+
sha256sum "$1" | cut -d' ' -f1
|
|
53
|
+
else
|
|
54
|
+
shasum -a 256 "$1" | cut -d' ' -f1
|
|
55
|
+
fi
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
platform_detect() {
|
|
59
|
+
command -v wk_stopwatch_lap >/dev/null 2>&1 && wk_stopwatch_lap "platform_detect" || true
|
|
60
|
+
local s m
|
|
61
|
+
s="$(uname -s)"
|
|
62
|
+
m="$(uname -m)"
|
|
63
|
+
case "$s" in
|
|
64
|
+
Linux)
|
|
65
|
+
if [ "$m" = x86_64 ]; then echo linux-x64; else die "unsupported Linux arch: $m"; fi ;;
|
|
66
|
+
Darwin)
|
|
67
|
+
case "$m" in
|
|
68
|
+
arm64) echo osx-arm64 ;;
|
|
69
|
+
x86_64) echo osx-x64 ;;
|
|
70
|
+
*) die "unsupported macOS arch: $m" ;;
|
|
71
|
+
esac ;;
|
|
72
|
+
MINGW*|MSYS*|CYGWIN*) echo win-x64 ;;
|
|
73
|
+
*) die "unsupported OS: $s" ;;
|
|
74
|
+
esac
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# manifest_field REL COL -- COL 3 platform, 4 version, 6 sha256 of the bin/MANIFEST.md row for REL.
|
|
78
|
+
manifest_field() {
|
|
79
|
+
command -v wk_stopwatch_lap >/dev/null 2>&1 && wk_stopwatch_lap "manifest_field" || true
|
|
80
|
+
awk -F'|' -v p="$1" -v c="$2" '
|
|
81
|
+
{ x = $2; gsub(/^[ \t]+|[ \t]+$/, "", x)
|
|
82
|
+
if (x == p) { y = $c; gsub(/^[ \t]+|[ \t]+$/, "", y); print y; exit } }' "$REPO/bin/MANIFEST.md"
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
default_dest_unix() {
|
|
86
|
+
command -v wk_stopwatch_lap >/dev/null 2>&1 && wk_stopwatch_lap "default_dest_unix" || true
|
|
87
|
+
if [ -w /opt/wkappbot ] || { [ ! -e /opt/wkappbot ] && [ -w /opt ]; }; then
|
|
88
|
+
echo /opt/wkappbot/wkappbot
|
|
89
|
+
else
|
|
90
|
+
echo "$HOME/.local/opt/wkappbot/wkappbot"
|
|
91
|
+
fi
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
default_dest_win() {
|
|
95
|
+
command -v wk_stopwatch_lap >/dev/null 2>&1 && wk_stopwatch_lap "default_dest_win" || true
|
|
96
|
+
if [ -n "${WKAPPBOT_HOME:-}" ]; then
|
|
97
|
+
echo "$WKAPPBOT_HOME/bin"
|
|
98
|
+
else
|
|
99
|
+
echo "$HOME/wkappbot/bin"
|
|
100
|
+
fi
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# sdk_fetch PLAT DIR -- prints the verified asset path, or returns 1 and prints nothing.
|
|
104
|
+
sdk_fetch() {
|
|
105
|
+
command -v wk_stopwatch_lap >/dev/null 2>&1 && wk_stopwatch_lap "sdk_fetch" || true
|
|
106
|
+
local plat="$1" dir="$2" asset base want got
|
|
107
|
+
asset="wkappbot-$plat"
|
|
108
|
+
base="${WK_BIN_BASE:-https://github.com/kiexpert/wkappbot-sdk/releases/latest/download}"
|
|
109
|
+
curl -fsSL -m 120 "$base/$asset" -o "$dir/$asset" || return 1
|
|
110
|
+
curl -fsSL -m 120 "$base/SHA256SUMS" -o "$dir/SHA256SUMS" || return 1
|
|
111
|
+
want="$(awk -v f="$asset" '{ n = $2; sub(/^\*/, "", n); if (n == f) { print $1; exit } }' "$dir/SHA256SUMS")"
|
|
112
|
+
[ -n "$want" ] || return 1
|
|
113
|
+
got="$(sha_of "$dir/$asset" || true)"
|
|
114
|
+
[ "$got" = "$want" ] || return 1
|
|
115
|
+
echo "$dir/$asset"
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
# repo_file REL -- prints REPO/bin/REL when its sha256 matches the MANIFEST row, else dies.
|
|
119
|
+
repo_file() {
|
|
120
|
+
command -v wk_stopwatch_lap >/dev/null 2>&1 && wk_stopwatch_lap "repo_file" || true
|
|
121
|
+
local rel="$1" want got
|
|
122
|
+
[ -n "$REPO" ] || die "no verified SDK asset and no repo copy of bin/$rel (this copy is not inside a kih checkout)"
|
|
123
|
+
want="$(manifest_field "$rel" 6 || true)"
|
|
124
|
+
[ -n "$want" ] || die "no MANIFEST row for bin/$rel"
|
|
125
|
+
got="$(sha_of "$REPO/bin/$rel" || true)"
|
|
126
|
+
[ -n "$got" ] || die "missing bin/$rel"
|
|
127
|
+
[ "$got" = "$want" ] || die "sha256 mismatch for bin/$rel: MANIFEST $want, file $got"
|
|
128
|
+
echo "$REPO/bin/$rel"
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
# install_file SRC DEST LABEL -- copy via .new then mv; "up to date" when DEST already matches.
|
|
132
|
+
install_file() {
|
|
133
|
+
command -v wk_stopwatch_lap >/dev/null 2>&1 && wk_stopwatch_lap "install_file" || true
|
|
134
|
+
local src="$1" dest="$2" label="$3" sum verb="installed"
|
|
135
|
+
sum="$(sha_of "$src")"
|
|
136
|
+
if [ -f "$dest" ] && [ "$(sha_of "$dest" || true)" = "$sum" ]; then
|
|
137
|
+
echo "up to date: $dest (source: $label) sha256=$sum"
|
|
138
|
+
return 0
|
|
139
|
+
fi
|
|
140
|
+
if [ "$WK_DRY" = 1 ]; then verb="would install"; fi
|
|
141
|
+
run mkdir -p "$(dirname "$dest")"
|
|
142
|
+
run cp "$src" "$dest.new"
|
|
143
|
+
run chmod +x "$dest.new"
|
|
144
|
+
run mv -f "$dest.new" "$dest"
|
|
145
|
+
echo "$verb: $dest (source: $label) sha256=$sum"
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
acquire_unix() {
|
|
149
|
+
command -v wk_stopwatch_lap >/dev/null 2>&1 && wk_stopwatch_lap "acquire_unix" || true
|
|
150
|
+
local dest="$1" plat="$2" offline="$3" src label
|
|
151
|
+
[ -n "$dest" ] || dest="$(default_dest_unix)"
|
|
152
|
+
if [ "$offline" = 0 ] && src="$(sdk_fetch "$plat" "$WK_TMP")"; then
|
|
153
|
+
label="SDK release wkappbot-$plat"
|
|
154
|
+
else
|
|
155
|
+
if [ "$offline" = 0 ]; then note "SDK asset unavailable or unverified, using repo bin/$plat"; fi
|
|
156
|
+
src="$(repo_file "$plat/wkappbot")"
|
|
157
|
+
label="repo bin/$plat/wkappbot"
|
|
158
|
+
fi
|
|
159
|
+
install_file "$src" "$dest" "$label"
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
acquire_win() {
|
|
163
|
+
command -v wk_stopwatch_lap >/dev/null 2>&1 && wk_stopwatch_lap "acquire_win" || true
|
|
164
|
+
local dest="$1" exe xzsrc core
|
|
165
|
+
[ -n "$dest" ] || dest="$(default_dest_win)"
|
|
166
|
+
note "win-x64 installs from bin/ only: the two-file set has no single SDK asset"
|
|
167
|
+
exe="$(repo_file "win-x64/wkappbot.exe")"
|
|
168
|
+
xzsrc="$(repo_file "win-x64/wkappbot-core.exe.xz")"
|
|
169
|
+
core="$WK_TMP/wkappbot-core.exe"
|
|
170
|
+
xz -dc "$xzsrc" > "$core"
|
|
171
|
+
install_file "$exe" "$dest/wkappbot.exe" "repo bin/win-x64/wkappbot.exe"
|
|
172
|
+
install_file "$core" "$dest/wkappbot-core.exe" "repo bin/win-x64/wkappbot-core.exe.xz"
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
acquire() {
|
|
176
|
+
command -v wk_stopwatch_lap >/dev/null 2>&1 && wk_stopwatch_lap "acquire" || true
|
|
177
|
+
local dest="" offline=0 plat
|
|
178
|
+
while [ $# -gt 0 ]; do
|
|
179
|
+
case "$1" in
|
|
180
|
+
--dest) dest="${2:-}"; [ -n "$dest" ] || die "--dest needs a path"; shift 2 ;;
|
|
181
|
+
--dry-run) WK_DRY=1; shift ;;
|
|
182
|
+
--offline) offline=1; shift ;;
|
|
183
|
+
*) die "acquire: unknown argument: $1" ;;
|
|
184
|
+
esac
|
|
185
|
+
done
|
|
186
|
+
plat="$(platform_detect)"
|
|
187
|
+
if [ "$plat" = win-x64 ]; then
|
|
188
|
+
acquire_win "$dest"
|
|
189
|
+
else
|
|
190
|
+
acquire_unix "$dest" "$plat" "$offline"
|
|
191
|
+
fi
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
# rewrite_manifest REL ROW -- replaces REL's row, or appends ROW after the last table row.
|
|
195
|
+
rewrite_manifest() {
|
|
196
|
+
command -v wk_stopwatch_lap >/dev/null 2>&1 && wk_stopwatch_lap "rewrite_manifest" || true
|
|
197
|
+
local rel="$1" row="$2" m="$REPO/bin/MANIFEST.md"
|
|
198
|
+
if [ "$WK_DRY" = 1 ]; then
|
|
199
|
+
echo "DRY: set bin/MANIFEST.md row: $row"
|
|
200
|
+
return 0
|
|
201
|
+
fi
|
|
202
|
+
[ -f "$m" ] || die "no $m"
|
|
203
|
+
grep -q '^|' "$m" || die "no table in $m"
|
|
204
|
+
awk -v p="$rel" -v nr="$row" '
|
|
205
|
+
function rowpath(s, a, x) { split(s, a, "|"); x = a[2]; gsub(/^[ \t]+|[ \t]+$/, "", x); return x }
|
|
206
|
+
{ L[NR] = $0
|
|
207
|
+
if ($0 ~ /^\|/) { last = NR; if (rowpath($0) == p) hit = NR } }
|
|
208
|
+
END {
|
|
209
|
+
for (i = 1; i <= NR; i++) {
|
|
210
|
+
if (i == hit) print nr; else print L[i]
|
|
211
|
+
if (hit == "" && i == last) print nr
|
|
212
|
+
}
|
|
213
|
+
}' "$m" > "$m.new"
|
|
214
|
+
mv -f "$m.new" "$m"
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
# push_bin GPATH -- pushes the two committed paths; skipped with WK_BIN_NO_PUSH=1.
|
|
218
|
+
push_bin() {
|
|
219
|
+
command -v wk_stopwatch_lap >/dev/null 2>&1 && wk_stopwatch_lap "push_bin" || true
|
|
220
|
+
local gpath="$1" wkp="${WKAPPBOT_DIR:-$(dirname "$REPO")/WKAppBot}/bin/wkpush.sh"
|
|
221
|
+
if [ "${WK_BIN_NO_PUSH:-0}" = 1 ]; then
|
|
222
|
+
echo "push skipped: WK_BIN_NO_PUSH=1"
|
|
223
|
+
return 0
|
|
224
|
+
fi
|
|
225
|
+
if [ -f "$wkp" ]; then
|
|
226
|
+
run bash "$wkp" -C "$REPO" -m "$gpath" bin/MANIFEST.md
|
|
227
|
+
else
|
|
228
|
+
run git -C "$REPO" push origin HEAD:main
|
|
229
|
+
fi
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
publish() {
|
|
233
|
+
command -v wk_stopwatch_lap >/dev/null 2>&1 && wk_stopwatch_lap "publish" || true
|
|
234
|
+
local src plat="" base rel gpath mode="copy" stored sha bytes v="" ver desc newrow dest msg
|
|
235
|
+
src="${1:-}"
|
|
236
|
+
[ -n "$src" ] || die "publish: SRC required"
|
|
237
|
+
shift
|
|
238
|
+
while [ $# -gt 0 ]; do
|
|
239
|
+
case "$1" in
|
|
240
|
+
--platform) plat="${2:-}"; [ -n "$plat" ] || die "--platform needs a value"; shift 2 ;;
|
|
241
|
+
--dry-run) WK_DRY=1; shift ;;
|
|
242
|
+
*) die "publish: unknown argument: $1" ;;
|
|
243
|
+
esac
|
|
244
|
+
done
|
|
245
|
+
[ -n "$plat" ] || plat="$(platform_detect)"
|
|
246
|
+
[ -f "$src" ] || die "publish: no such file: $src"
|
|
247
|
+
base="$(basename "$src")"
|
|
248
|
+
rel="$plat/$base"
|
|
249
|
+
if [ "$plat" = win-x64 ]; then
|
|
250
|
+
case "$base" in
|
|
251
|
+
*core*.exe) rel="$plat/wkappbot-core.exe.xz"; mode="xz" ;;
|
|
252
|
+
esac
|
|
253
|
+
fi
|
|
254
|
+
gpath="bin/$rel"
|
|
255
|
+
# Scratch copy only: the repo is touched by run() lines alone, so --dry-run changes nothing.
|
|
256
|
+
stored="$WK_TMP/stored"
|
|
257
|
+
if [ "$mode" = xz ]; then xz -9e -T0 -c "$src" > "$stored"; else cp "$src" "$stored"; fi
|
|
258
|
+
sha="$(sha_of "$stored")"
|
|
259
|
+
bytes="$(wc -c < "$stored" | tr -d ' ')"
|
|
260
|
+
# A Windows core is never executed here; every other SRC reports its own version line.
|
|
261
|
+
if [ "$mode" = copy ]; then
|
|
262
|
+
v="$("$src" --version 2>/dev/null | head -n 1 || true)"
|
|
263
|
+
fi
|
|
264
|
+
if [ -n "$v" ]; then v="${v#wkappbot }"; v="${v//|/}"; fi
|
|
265
|
+
ver="$(manifest_field "$rel" 4 || true)"
|
|
266
|
+
if [ -n "$v" ]; then ver="$v"; fi
|
|
267
|
+
[ -n "$ver" ] || ver="unknown"
|
|
268
|
+
desc="$(manifest_field "$rel" 3 || true)"
|
|
269
|
+
[ -n "$desc" ] || desc="$plat"
|
|
270
|
+
newrow="| $rel | $desc | $ver | $bytes | $sha |"
|
|
271
|
+
dest="$REPO/$gpath"
|
|
272
|
+
run mkdir -p "$(dirname "$dest")"
|
|
273
|
+
run cp "$stored" "$dest"
|
|
274
|
+
if [ "$plat" != win-x64 ]; then run chmod 755 "$dest"; fi
|
|
275
|
+
rewrite_manifest "$rel" "$newrow"
|
|
276
|
+
msg="bin: publish $plat binary ${sha:0:12}"
|
|
277
|
+
run git -C "$REPO" add -- "$gpath" bin/MANIFEST.md
|
|
278
|
+
if [ "$plat" != win-x64 ]; then run git -C "$REPO" update-index --chmod=+x -- "$gpath"; fi
|
|
279
|
+
run git -C "$REPO" commit -m "$msg" -- "$gpath" bin/MANIFEST.md
|
|
280
|
+
push_bin "$gpath"
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
usage() {
|
|
284
|
+
command -v wk_stopwatch_lap >/dev/null 2>&1 && wk_stopwatch_lap "usage" || true
|
|
285
|
+
cat <<'EOF'
|
|
286
|
+
usage: wk-bin-acquire.sh acquire [--dest PATH] [--dry-run] [--offline]
|
|
287
|
+
wk-bin-acquire.sh publish SRC [--platform P] [--dry-run]
|
|
288
|
+
EOF
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
main() {
|
|
292
|
+
command -v wk_stopwatch_lap >/dev/null 2>&1 && wk_stopwatch_lap "main" || true
|
|
293
|
+
local verb="${1:-}"
|
|
294
|
+
WK_TMP="$(mktemp -d)"
|
|
295
|
+
trap 'rm -rf "$WK_TMP"' EXIT
|
|
296
|
+
if [ $# -gt 0 ]; then shift; fi
|
|
297
|
+
case "$verb" in
|
|
298
|
+
acquire) acquire "$@" ;;
|
|
299
|
+
publish) publish "$@" ;;
|
|
300
|
+
-h|--help) usage ;;
|
|
301
|
+
*) usage; die "unknown verb: '$verb' (acquire|publish)" ;;
|
|
302
|
+
esac
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
main "$@"
|
package/opencode.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://opencode.ai/config.json",
|
|
3
|
+
"plugin": [
|
|
4
|
+
["__WK_PLUGIN_URL__", { "kihDir": "__WK_KIH_DIR__" }]
|
|
5
|
+
],
|
|
6
|
+
"permission": {
|
|
7
|
+
"edit": "ask",
|
|
8
|
+
"webfetch": "ask",
|
|
9
|
+
"bash": {
|
|
10
|
+
"*": "ask",
|
|
11
|
+
"ls*": "allow",
|
|
12
|
+
"pwd": "allow",
|
|
13
|
+
"git status*": "allow",
|
|
14
|
+
"git diff*": "allow",
|
|
15
|
+
"git log*": "allow"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"provider": {
|
|
19
|
+
"qwen-inhouse": {
|
|
20
|
+
"npm": "@ai-sdk/openai-compatible",
|
|
21
|
+
"name": "In-house Qwen (OpenAI-compatible)",
|
|
22
|
+
"options": {
|
|
23
|
+
"baseURL": "{env:QWEN_BASE_URL}",
|
|
24
|
+
"apiKey": "{env:QWEN_API_KEY}"
|
|
25
|
+
},
|
|
26
|
+
"models": {
|
|
27
|
+
"qwen3-coder": { "name": "Qwen3 Coder (in-house)" }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wkappbot-code",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "wkappbot-code -- Wisdom Kernel: the pinned OpenCode bridge plus the kih harness plugin, the wkappbot core, the web harness and the latest public skills in one install",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"wkappbot-code": "bin/wkappbot-code.js",
|
|
8
|
+
"wk-appbot-code": "bin/wkappbot-code.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin/",
|
|
12
|
+
"lib/",
|
|
13
|
+
"plugin/",
|
|
14
|
+
"opencode.json",
|
|
15
|
+
"install.sh",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"wkAppbotCode": {
|
|
19
|
+
"opencodeVersion": "1.19.5"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=18"
|
|
23
|
+
},
|
|
24
|
+
"license": "UNLICENSED",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/kiexpert/wkappbot-sdk.git"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/kiexpert/wkappbot-sdk",
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// wkappbot-code -- Wisdom Kernel: the one kih plugin for OpenCode (pinned: opencode-ai 1.19.5).
|
|
2
|
+
// OpenCode is the BRIDGE (model <-> tools); kih is the HARNESS riding on it. This file only maps
|
|
3
|
+
// OpenCode plugin events onto the existing kih hook launcher, .claude/hooks/wkharness-run.sh:
|
|
4
|
+
// tool.execute.before -> "pre" (exit 2 = refuse: throw with the hook stderr as the reason)
|
|
5
|
+
// tool.execute.after -> "post" (fail-open, never changes the tool result)
|
|
6
|
+
// event session.idle -> "stop-archive" (archive only, fail-open, no verdict)
|
|
7
|
+
// Fail direction matches Claude Code: only a hook VERDICT refuses; a plugin error, a missing hook or a
|
|
8
|
+
// hung hook (killed at PRE_TIMEOUT_MS) lets the call through.
|
|
9
|
+
// API verified 2026-11-05 from @opencode-ai/plugin 1.19.5 dist/index.d.ts (interface Hooks) and
|
|
10
|
+
// @opencode-ai/sdk 1.19.5 dist/gen/types.gen.d.ts:413 (EventSessionIdle). Spec:
|
|
11
|
+
// wkappbot skill read agent-harness-landscape-2026-howto
|
|
12
|
+
// KNOWN GAP: no transcript_path is sent (OpenCode keeps sessions in its own store), so kih gates that
|
|
13
|
+
// resolve the model or read the transcript see an unknown model and fall back to their defaults.
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { existsSync } from "node:fs";
|
|
16
|
+
import { dirname, join, resolve } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
|
|
19
|
+
// OpenCode tool id -> Claude tool name the kih guards already know.
|
|
20
|
+
export const TOOL_MAP = {
|
|
21
|
+
bash: "Bash", edit: "Edit", write: "Write", read: "Read",
|
|
22
|
+
glob: "Glob", grep: "Grep", list: "LS", patch: "Edit", multiedit: "MultiEdit",
|
|
23
|
+
webfetch: "WebFetch", websearch: "WebSearch", task: "Agent", todowrite: "TodoWrite",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// pre arm budget: a hung hook is killed and the call fails OPEN, like a Claude hook timeout.
|
|
27
|
+
export const PRE_TIMEOUT_MS = Number(process.env.WK_APPBOT_PRE_TIMEOUT_MS || 60000);
|
|
28
|
+
|
|
29
|
+
// OpenCode args (camelCase) -> Claude tool_input (snake_case) for the tools the guards inspect.
|
|
30
|
+
export function toClaudeInput(tool, args) {
|
|
31
|
+
const a = args || {};
|
|
32
|
+
switch (tool) {
|
|
33
|
+
case "bash": {
|
|
34
|
+
const o = { command: a.command ?? "" };
|
|
35
|
+
if (a.description !== undefined) o.description = a.description;
|
|
36
|
+
if (a.timeout !== undefined) o.timeout = a.timeout;
|
|
37
|
+
return o;
|
|
38
|
+
}
|
|
39
|
+
case "edit":
|
|
40
|
+
return { file_path: a.filePath ?? a.file_path, old_string: a.oldString ?? a.old_string,
|
|
41
|
+
new_string: a.newString ?? a.new_string, replace_all: a.replaceAll ?? a.replace_all ?? false };
|
|
42
|
+
case "write":
|
|
43
|
+
return { file_path: a.filePath ?? a.file_path, content: a.content ?? "" };
|
|
44
|
+
case "read": {
|
|
45
|
+
const o = { file_path: a.filePath ?? a.file_path };
|
|
46
|
+
if (a.offset !== undefined) o.offset = a.offset;
|
|
47
|
+
if (a.limit !== undefined) o.limit = a.limit;
|
|
48
|
+
return o;
|
|
49
|
+
}
|
|
50
|
+
default: {
|
|
51
|
+
const o = { ...a };
|
|
52
|
+
if (o.filePath !== undefined && o.file_path === undefined) o.file_path = o.filePath;
|
|
53
|
+
return o;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function claudeToolName(tool) {
|
|
59
|
+
return TOOL_MAP[tool] || tool;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The Claude-shaped hook payload for one OpenCode tool call.
|
|
63
|
+
export function buildPreToolUsePayload(input, args, cwd) {
|
|
64
|
+
return {
|
|
65
|
+
hook_event_name: "PreToolUse",
|
|
66
|
+
session_id: input.sessionID,
|
|
67
|
+
cwd: (args && args.workdir) || cwd,
|
|
68
|
+
tool_name: claudeToolName(input.tool),
|
|
69
|
+
tool_input: toClaudeInput(input.tool, args),
|
|
70
|
+
tool_use_id: input.callID,
|
|
71
|
+
agent_family: "opencode",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function buildPostToolUsePayload(input, output, cwd) {
|
|
76
|
+
const p = buildPreToolUsePayload(input, input.args, cwd);
|
|
77
|
+
p.hook_event_name = "PostToolUse";
|
|
78
|
+
p.tool_response = { output: output && output.output, title: output && output.title };
|
|
79
|
+
return p;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// kih root: WK_KIH_DIR, else the checkout this file sits in (wkappbot-code/plugin -> ../..).
|
|
83
|
+
export function resolveKihDir() {
|
|
84
|
+
if (process.env.WK_KIH_DIR) return process.env.WK_KIH_DIR;
|
|
85
|
+
try {
|
|
86
|
+
const up = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
87
|
+
if (existsSync(join(up, ".claude", "hooks", "wkharness-run.sh"))) return up;
|
|
88
|
+
} catch {}
|
|
89
|
+
return "";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Pipe JSON into "wkharness-run.sh <arm>"; resolves {code, stdout, stderr}. Never rejects.
|
|
93
|
+
// No hook found = fail-open (code 0, skipped) so a missing kih never bricks OpenCode.
|
|
94
|
+
export function runHook(arm, payload, opts = {}) {
|
|
95
|
+
const kih = opts.kihDir ?? resolveKihDir();
|
|
96
|
+
const hook = opts.hook || process.env.WK_APPBOT_HOOK || (kih && join(kih, ".claude", "hooks", "wkharness-run.sh"));
|
|
97
|
+
return new Promise((done) => {
|
|
98
|
+
let settled = false;
|
|
99
|
+
const finish = (r) => { if (!settled) { settled = true; if (timer) clearTimeout(timer); done(r); } };
|
|
100
|
+
let timer = null, out = "", err = "", child;
|
|
101
|
+
if (!hook || !existsSync(hook)) return finish({ code: 0, stdout: "", stderr: "", skipped: true });
|
|
102
|
+
let body;
|
|
103
|
+
try { body = JSON.stringify(payload); } catch (e) { return finish({ code: 0, stdout: "", stderr: String(e), skipped: true }); }
|
|
104
|
+
try {
|
|
105
|
+
child = spawn("bash", [hook, arm], {
|
|
106
|
+
env: { ...process.env, ...(kih ? { CLAUDE_PROJECT_DIR: kih } : {}) },
|
|
107
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
108
|
+
});
|
|
109
|
+
} catch (e) {
|
|
110
|
+
return finish({ code: 0, stdout: "", stderr: String(e), skipped: true });
|
|
111
|
+
}
|
|
112
|
+
if (opts.timeoutMs) {
|
|
113
|
+
timer = setTimeout(() => {
|
|
114
|
+
try { child.kill("SIGKILL"); } catch {}
|
|
115
|
+
finish({ code: 0, stdout: out, stderr: err, timedOut: true });
|
|
116
|
+
}, opts.timeoutMs);
|
|
117
|
+
}
|
|
118
|
+
child.stdout.on("data", (d) => (out += d));
|
|
119
|
+
child.stderr.on("data", (d) => (err += d));
|
|
120
|
+
child.on("error", (e) => finish({ code: 0, stdout: out, stderr: err + String(e), skipped: true }));
|
|
121
|
+
child.on("close", (code) => finish({ code: code ?? 0, stdout: out, stderr: err }));
|
|
122
|
+
child.stdin.on("error", () => {});
|
|
123
|
+
child.stdin.end(body);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Claude contract: exit 2 refuses; so does a stdout JSON deny/block verdict.
|
|
128
|
+
export function isRefusal(r) {
|
|
129
|
+
if (r.timedOut || r.skipped) return false;
|
|
130
|
+
if (r.code === 2) return true;
|
|
131
|
+
try {
|
|
132
|
+
const j = JSON.parse(r.stdout);
|
|
133
|
+
return (j.hookSpecificOutput && j.hookSpecificOutput.permissionDecision === "deny") || j.decision === "block";
|
|
134
|
+
} catch { return false; }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// A stdout deny JSON names its own reason; stderr then may hold only warn lines (wkharness-run.sh wk_pre_emit).
|
|
138
|
+
export function refusalReason(r) {
|
|
139
|
+
try {
|
|
140
|
+
const j = JSON.parse(r.stdout);
|
|
141
|
+
const why = (j.hookSpecificOutput && j.hookSpecificOutput.permissionDecisionReason) || j.reason;
|
|
142
|
+
if (why) return why;
|
|
143
|
+
} catch {}
|
|
144
|
+
return (r.stderr || "").trim() || (r.stdout || "").trim() || "refused by the kih harness";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// options = the second slot of the opencode.json plugin entry: ["file://.../wisdom-kernel.js", { "kihDir": "..." }].
|
|
148
|
+
// install.sh fills kihDir, because the installed copy no longer sits inside the kih checkout.
|
|
149
|
+
export const WisdomKernel = async (ctx = {}, options = {}) => {
|
|
150
|
+
const cwd = ctx.directory || ctx.worktree || process.cwd();
|
|
151
|
+
const k = (options && options.kihDir) || undefined;
|
|
152
|
+
return {
|
|
153
|
+
"tool.execute.before": async (input, output) => {
|
|
154
|
+
let r;
|
|
155
|
+
try {
|
|
156
|
+
r = await runHook("pre", buildPreToolUsePayload(input || {}, output && output.args, cwd), { kihDir: k, timeoutMs: PRE_TIMEOUT_MS });
|
|
157
|
+
} catch { return; } // a plugin bug never refuses a call
|
|
158
|
+
if (isRefusal(r)) throw new Error("[wisdom-kernel] " + refusalReason(r));
|
|
159
|
+
},
|
|
160
|
+
"tool.execute.after": async (input, output) => {
|
|
161
|
+
try { await runHook("post", buildPostToolUsePayload(input || {}, output, cwd), { kihDir: k, timeoutMs: 30000 }); } catch {}
|
|
162
|
+
},
|
|
163
|
+
event: async (arg) => {
|
|
164
|
+
try {
|
|
165
|
+
const event = arg && arg.event;
|
|
166
|
+
if (!event || event.type !== "session.idle") return;
|
|
167
|
+
const sid = event.properties && event.properties.sessionID;
|
|
168
|
+
await runHook("stop-archive", { hook_event_name: "Stop", session_id: sid, cwd, agent_family: "opencode" }, { kihDir: k, timeoutMs: 30000 });
|
|
169
|
+
} catch {}
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
export default WisdomKernel;
|