wkappbot-code 0.0.0-stage → 0.3.1
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 +73 -2
- package/bin/wkappbot-code.js +453 -0
- package/install.sh +16 -0
- package/lib/config.js +122 -0
- package/lib/update.js +127 -0
- package/lib/wk-bin-acquire.sh +305 -0
- package/opencode.json +31 -0
- package/package.json +31 -4
- package/plugin/wisdom-kernel.js +217 -0
package/README.md
CHANGED
|
@@ -1,3 +1,74 @@
|
|
|
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 three 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 pack ./wkappbot-code && npm i -g ./wkappbot-code-0.3.1.tgz # from a checkout
|
|
35
|
+
|
|
36
|
+
wkappbot-code install # every part below, idempotent
|
|
37
|
+
wkappbot-code doctor # one PASS/WARN/FAIL/SKIP row per part
|
|
38
|
+
wkappbot-code --version
|
|
39
|
+
# the old command name wk-appbot-code (0.2.x) still works: it is a second bin entry for the same file
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
| part | what `install` lays down | skip with |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| opencode | pinned `opencode-ai@1.19.5` (`npm -g`) | `--no-npm` |
|
|
46
|
+
| plugin | `plugin/wisdom-kernel.js` plus the `opencode.json` plugin entry | -- |
|
|
47
|
+
| 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` |
|
|
48
|
+
| 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` |
|
|
49
|
+
| 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` |
|
|
50
|
+
|
|
51
|
+
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.
|
|
52
|
+
|
|
53
|
+
`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).
|
|
54
|
+
|
|
55
|
+
## Model swap
|
|
56
|
+
|
|
57
|
+
- One headless turn: `opencode run -m provider/model "prompt"`
|
|
58
|
+
- Inside the TUI: `/models`
|
|
59
|
+
- 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.
|
|
60
|
+
|
|
61
|
+
## In the browser WebVM (no login needed)
|
|
62
|
+
|
|
63
|
+
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:
|
|
64
|
+
|
|
65
|
+
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.
|
|
66
|
+
2. Otherwise the first family key the browser holds (claude, openai, gemini, xai).
|
|
67
|
+
3. Otherwise the **dummy haiku**: an OpenAI-compatible mock inside the page. It never calls a model, and its first line reads `dummy haiku (로그인 전)`.
|
|
68
|
+
|
|
69
|
+
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.
|
|
70
|
+
|
|
71
|
+
## Floor
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
Signing, submitting and paying stay human-only.
|
|
@@ -0,0 +1,453 @@
|
|
|
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
|
+
// wkappbot-code update [opts] newer version.json -> tgz sha256-checked -> npm i -g -> install --update (lib/update.js)
|
|
18
|
+
// wkappbot-code config [opts] detect provider/model/plugin/harness, edit at a TTY, merge into opencode.json (lib/config.js)
|
|
19
|
+
// install/doctor/config check for a newer version at most once a day (WKAPPBOT_CODE_NO_UPDATE_CHECK=1 turns it off;
|
|
20
|
+
// --auto-update or WKAPPBOT_CODE_AUTO_UPDATE=1 applies it). config opts: --yes, --project, --set key=value
|
|
21
|
+
// opts: --config-dir DIR, --kih-dir DIR, --bin-dest FILE, --hq-dir DIR
|
|
22
|
+
// env (mock and relocation): WK_BIN_BASE (SDK release base), WK_HARNESS_BASE (web harness base),
|
|
23
|
+
// WK_HARNESS_DIR (~/.wk-harness), WK_BIN_DEST, WK_APPBOT_HQ, WK_LINK_DIR, WK_PRIVATE_SKILLS,
|
|
24
|
+
// WK_KIH_DIR (a kih checkout; "none" = ignore every checkout on this machine)
|
|
25
|
+
// Installer owns all setup, surgically: an existing user opencode.json is BACKED UP and only the plugin entry is
|
|
26
|
+
// merged in; every other key is kept. Spec: wkappbot skill read agent-harness-landscape-2026-howto
|
|
27
|
+
// and wkappbot skill read harness-installer-owns-all-setup (step 9 ONE-COMMAND APPBOT CODE INSTALL)
|
|
28
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
29
|
+
import { createHash } from "node:crypto";
|
|
30
|
+
import {
|
|
31
|
+
copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync,
|
|
32
|
+
realpathSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync, utimesSync, writeFileSync,
|
|
33
|
+
} from "node:fs";
|
|
34
|
+
import { homedir, tmpdir } from "node:os";
|
|
35
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
36
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
37
|
+
import { quietCheck, runUpdate, updateRow } from "../lib/update.js";
|
|
38
|
+
import { applySets, askEach, buildConfig, configRow, detect, lineDiff, printDetected } from "../lib/config.js";
|
|
39
|
+
|
|
40
|
+
const PKG_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
41
|
+
const PKG = JSON.parse(readFileSync(join(PKG_DIR, "package.json"), "utf8"));
|
|
42
|
+
const OPENCODE_VERSION = PKG.wkAppbotCode.opencodeVersion;
|
|
43
|
+
const OWNER_LOGIN = "kiexpert";
|
|
44
|
+
const HARNESS_BASE = () => (process.env.WK_HARNESS_BASE || "https://kiexpert.github.io/wkappbot-kih/webvm/x64/harness").replace(/\/+$/, "");
|
|
45
|
+
const HARNESS_DIR = () => process.env.WK_HARNESS_DIR || join(homedir(), ".wk-harness");
|
|
46
|
+
const IS_WIN = process.platform === "win32";
|
|
47
|
+
|
|
48
|
+
function parseArgs(argv) {
|
|
49
|
+
const o = { cmd: argv[0] || "help", npm: true, bin: true, harness: true, skills: true, webHarness: false,
|
|
50
|
+
configDir: "", kihDir: "", binDest: "", hqDir: "", update: false, autoUpdate: false, yes: false, project: false, sets: [] };
|
|
51
|
+
for (let i = 1; i < argv.length; i++) {
|
|
52
|
+
const a = argv[i];
|
|
53
|
+
if (a === "--no-npm") o.npm = false;
|
|
54
|
+
else if (a === "--no-bin") o.bin = false;
|
|
55
|
+
else if (a === "--no-harness") o.harness = false;
|
|
56
|
+
else if (a === "--no-skills") o.skills = false;
|
|
57
|
+
else if (a === "--web-harness") o.webHarness = true;
|
|
58
|
+
else if (a === "--config-dir") o.configDir = argv[++i] || "";
|
|
59
|
+
else if (a === "--kih-dir") o.kihDir = argv[++i] || "";
|
|
60
|
+
else if (a === "--bin-dest") o.binDest = argv[++i] || "";
|
|
61
|
+
else if (a === "--hq-dir") o.hqDir = argv[++i] || "";
|
|
62
|
+
else if (a === "--update") o.update = true;
|
|
63
|
+
else if (a === "--auto-update") o.autoUpdate = true;
|
|
64
|
+
else if (a === "--yes" || a === "-y") o.yes = true;
|
|
65
|
+
else if (a === "--project") o.project = true;
|
|
66
|
+
else if (a === "--set") o.sets.push(argv[++i] || "");
|
|
67
|
+
else { console.error("unknown option: " + a); process.exit(64); }
|
|
68
|
+
}
|
|
69
|
+
return o;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function configDir(o) {
|
|
73
|
+
if (o.configDir) return resolve(o.configDir);
|
|
74
|
+
if (process.env.OPENCODE_CONFIG_DIR) return process.env.OPENCODE_CONFIG_DIR;
|
|
75
|
+
return join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "opencode");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const hasHook = (d) => !!d && existsSync(join(d, ".claude", "hooks", "wkharness-run.sh"));
|
|
79
|
+
// A kih CHECKOUT (never the web harness): explicit dirs first, then the usual checkout places.
|
|
80
|
+
// WK_KIH_DIR=none: act as on a machine with no checkout (the selftest's isolation from this VM's kih).
|
|
81
|
+
function kihCheckout(o) {
|
|
82
|
+
if (process.env.WK_KIH_DIR === "none") return o.kihDir && hasHook(o.kihDir) ? resolve(o.kihDir) : "";
|
|
83
|
+
const tries = [o.kihDir, process.env.WK_KIH_DIR, resolve(PKG_DIR, ".."),
|
|
84
|
+
join(homedir(), "wkappbot-kih"), "/home/user/wkappbot-kih", "D:/GitHub/wkappbot-kih"];
|
|
85
|
+
for (const d of tries) if (hasHook(d)) return resolve(d);
|
|
86
|
+
return "";
|
|
87
|
+
}
|
|
88
|
+
// The kih root whose .claude/hooks/wkharness-run.sh the plugin calls: a checkout, else the web harness
|
|
89
|
+
// (~/.wk-harness). Empty = plugin fails open.
|
|
90
|
+
function kihDir(o) {
|
|
91
|
+
return kihCheckout(o) || (hasHook(HARNESS_DIR()) ? resolve(HARNESS_DIR()) : "");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function opencodeVersion() {
|
|
95
|
+
try { return execFileSync("opencode", ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); }
|
|
96
|
+
catch { return ""; }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const stamp = () => new Date().toISOString().replace(/[-:]/g, "").replace(/\..*/, "");
|
|
100
|
+
const log = (s) => console.log("[wkappbot-code] " + s);
|
|
101
|
+
const sha256File = (f) => createHash("sha256").update(readFileSync(f)).digest("hex");
|
|
102
|
+
const statePath = (o) => join(configDir(o), "wkappbot-code", "install-state.json");
|
|
103
|
+
function readState(o) { try { return JSON.parse(readFileSync(statePath(o), "utf8")); } catch { return {}; } }
|
|
104
|
+
|
|
105
|
+
// Same rule as wk-bin-acquire.sh platform_detect (linux-x64, osx-arm64, osx-x64, win-x64).
|
|
106
|
+
export function platformId(platform = process.platform, arch = process.arch) {
|
|
107
|
+
if (platform === "linux" && arch === "x64") return "linux-x64";
|
|
108
|
+
if (platform === "darwin") return arch === "arm64" ? "osx-arm64" : arch === "x64" ? "osx-x64" : "";
|
|
109
|
+
if (platform === "win32") return "win-x64";
|
|
110
|
+
return "";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Merge our plugin entry into a user config: drop any older wisdom-kernel entry, append the new one.
|
|
114
|
+
export function mergeConfig(user, template, url, kih) {
|
|
115
|
+
return buildConfig(user, { plugin: { value: url }, harness: { value: kih } }, template);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---- part: bin ------------------------------------------------------------------------------------
|
|
119
|
+
function binDest(o) {
|
|
120
|
+
if (o.binDest) return resolve(o.binDest);
|
|
121
|
+
if (process.env.WK_BIN_DEST) return resolve(process.env.WK_BIN_DEST);
|
|
122
|
+
// The same fallback place tools/wkappbot-root.sh looks for a core when none sits beside it.
|
|
123
|
+
return join(homedir(), ".local", "share", "wkappbot", IS_WIN ? "wkappbot.exe" : "wkappbot");
|
|
124
|
+
}
|
|
125
|
+
// The dir of the CLI's own bin as npm placed it (argv[1] is npm's symlink in PREFIX/bin, never realpath'd).
|
|
126
|
+
// Run from a checkout or lib/node_modules (that dir is inside PKG_DIR): ~/.local/bin instead.
|
|
127
|
+
function linkDir() {
|
|
128
|
+
if (process.env.WK_LINK_DIR) return process.env.WK_LINK_DIR;
|
|
129
|
+
const here = resolve(dirname(process.argv[1] || PKG_DIR));
|
|
130
|
+
const rel = relative(PKG_DIR, here);
|
|
131
|
+
if (rel.startsWith("..") || isAbsolute(rel)) return here;
|
|
132
|
+
return join(homedir(), ".local", "bin");
|
|
133
|
+
}
|
|
134
|
+
function installBin(o) {
|
|
135
|
+
const dest = binDest(o);
|
|
136
|
+
const plat = platformId();
|
|
137
|
+
if (!plat) return { ok: false, dest, note: "no wkappbot build for " + process.platform + "-" + process.arch };
|
|
138
|
+
if (plat === "win-x64") return { ok: false, dest, note: "win-x64 has no single SDK asset; run install.cmd from a kih checkout" };
|
|
139
|
+
// A kih checkout runs its own copy (same bytes, plus the MANIFEST-verified repo bin/ fallback); the package
|
|
140
|
+
// copy outside a checkout takes only the SDK release asset verified by SHA256SUMS.
|
|
141
|
+
const co = kihCheckout(o);
|
|
142
|
+
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");
|
|
143
|
+
log("bin: bash " + script + " acquire --dest " + dest);
|
|
144
|
+
const r = spawnSync("bash", [script, "acquire", "--dest", dest], { encoding: "utf8", env: process.env });
|
|
145
|
+
for (const line of ((r.stdout || "") + (r.stderr || "")).split("\n").filter(Boolean)) log(" " + line);
|
|
146
|
+
if (r.status !== 0) return { ok: false, dest, note: "wk-bin-acquire rc " + r.status };
|
|
147
|
+
// Put `wkappbot` on PATH beside wkappbot-code itself, unless that name is already taken by someone else.
|
|
148
|
+
let link = "";
|
|
149
|
+
const ld = linkDir();
|
|
150
|
+
const lp = join(ld, "wkappbot");
|
|
151
|
+
try {
|
|
152
|
+
const taken = existsSync(lp) || isSymlink(lp);
|
|
153
|
+
const ours = taken && isSymlink(lp) && resolve(dirname(lp), readlinkSync(lp)) === dest;
|
|
154
|
+
if (taken && !ours) log("bin: kept existing wkappbot link " + lp + " (not ours); call " + dest + " directly");
|
|
155
|
+
else {
|
|
156
|
+
mkdirSync(ld, { recursive: true });
|
|
157
|
+
if (ours) unlinkSync(lp);
|
|
158
|
+
symlinkSync(dest, lp);
|
|
159
|
+
link = lp;
|
|
160
|
+
log("bin: linked " + lp + " -> " + dest);
|
|
161
|
+
}
|
|
162
|
+
} catch (e) { log("bin: could not link " + lp + " (" + e.message + ")"); }
|
|
163
|
+
return { ok: true, dest, link };
|
|
164
|
+
}
|
|
165
|
+
function isSymlink(p) { try { return lstatSync(p).isSymbolicLink(); } catch { return false; } }
|
|
166
|
+
|
|
167
|
+
// ---- part: harness --------------------------------------------------------------------------------
|
|
168
|
+
function curl(url, out) {
|
|
169
|
+
const r = spawnSync("curl", ["-fsSL", "-m", "300", url, "-o", out], { encoding: "utf8" });
|
|
170
|
+
return r.status === 0 ? "" : ("curl " + url + " rc " + r.status + " " + (r.stderr || "").trim());
|
|
171
|
+
}
|
|
172
|
+
function installHarness(o) {
|
|
173
|
+
const found = kihCheckout(o);
|
|
174
|
+
if (found && !o.webHarness) {
|
|
175
|
+
log("harness: kih checkout " + found + " found; the web harness is not needed (--web-harness forces it)");
|
|
176
|
+
return { ok: true, mode: "checkout", dir: found };
|
|
177
|
+
}
|
|
178
|
+
const tmp = mkdtempSync(join(tmpdir(), "wk-appbot-harness-"));
|
|
179
|
+
try {
|
|
180
|
+
const err = curl(HARNESS_BASE() + "/install.sh", join(tmp, "install.sh"));
|
|
181
|
+
if (err) return { ok: false, mode: "web", dir: HARNESS_DIR(), note: err };
|
|
182
|
+
log("harness: sh " + HARNESS_BASE() + "/install.sh (to " + HARNESS_DIR() + ")");
|
|
183
|
+
const r = spawnSync("sh", [join(tmp, "install.sh")], { encoding: "utf8",
|
|
184
|
+
env: { ...process.env, WK_HARNESS_BASE: HARNESS_BASE(), WK_HARNESS_DIR: HARNESS_DIR() } });
|
|
185
|
+
for (const line of ((r.stdout || "") + (r.stderr || "")).split("\n").filter(Boolean)) log(" " + line);
|
|
186
|
+
const ok = r.status === 0 && hasHook(HARNESS_DIR());
|
|
187
|
+
return { ok, mode: "web", dir: HARNESS_DIR(), note: ok ? "" : "harness install rc " + r.status };
|
|
188
|
+
} finally { rmSync(tmp, { recursive: true, force: true }); }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---- part: skills ---------------------------------------------------------------------------------
|
|
192
|
+
function hqDir(o, dest) {
|
|
193
|
+
if (o.hqDir) return resolve(o.hqDir);
|
|
194
|
+
if (process.env.WK_APPBOT_HQ) return resolve(process.env.WK_APPBOT_HQ);
|
|
195
|
+
let real = dest;
|
|
196
|
+
try { real = realpathSync(dest); } catch {}
|
|
197
|
+
return join(dirname(real), "wkappbot.hq", "skills");
|
|
198
|
+
}
|
|
199
|
+
// Copy every file under src into dst when it is missing or older there; keeps mtimes. Returns counts.
|
|
200
|
+
export function syncTree(src, dst) {
|
|
201
|
+
const c = { added: 0, updated: 0, kept: 0 };
|
|
202
|
+
const walk = (d) => {
|
|
203
|
+
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
204
|
+
const s = join(d, e.name);
|
|
205
|
+
const t = join(dst, relative(src, s));
|
|
206
|
+
if (e.isDirectory()) { walk(s); continue; }
|
|
207
|
+
if (!e.isFile()) continue;
|
|
208
|
+
const ss = statSync(s);
|
|
209
|
+
let ts = null; try { ts = statSync(t); } catch {}
|
|
210
|
+
if (ts && ts.mtimeMs >= ss.mtimeMs) { c.kept++; continue; }
|
|
211
|
+
mkdirSync(dirname(t), { recursive: true });
|
|
212
|
+
copyFileSync(s, t + ".new");
|
|
213
|
+
renameSync(t + ".new", t);
|
|
214
|
+
utimesSync(t, ss.atime, ss.mtime);
|
|
215
|
+
ts ? c.updated++ : c.added++;
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
walk(src);
|
|
219
|
+
return c;
|
|
220
|
+
}
|
|
221
|
+
const countSkills = (d) => {
|
|
222
|
+
let n = 0;
|
|
223
|
+
const walk = (x) => { for (const e of readdirSync(x, { withFileTypes: true })) {
|
|
224
|
+
if (e.isDirectory()) walk(join(x, e.name)); else if (e.name.endsWith(".skill.json")) n++; } };
|
|
225
|
+
try { walk(d); } catch {}
|
|
226
|
+
return n;
|
|
227
|
+
};
|
|
228
|
+
function privateHome() { return process.env.WK_PRIVATE_SKILLS || join(homedir(), ".wkappbot", "private-skills"); }
|
|
229
|
+
function gitRemotes(dir) {
|
|
230
|
+
const r = spawnSync("git", ["-C", dir, "remote"], { encoding: "utf8" });
|
|
231
|
+
return r.status === 0 ? r.stdout.split("\n").filter(Boolean) : null;
|
|
232
|
+
}
|
|
233
|
+
function installSkills(o, dest) {
|
|
234
|
+
const hq = hqDir(o, dest);
|
|
235
|
+
const base = HARNESS_BASE();
|
|
236
|
+
const tmp = mkdtempSync(join(tmpdir(), "wk-appbot-skills-"));
|
|
237
|
+
try {
|
|
238
|
+
let src = "", from = "";
|
|
239
|
+
let err = curl(base + "/version.json", join(tmp, "version.json"));
|
|
240
|
+
if (err) return { ok: false, hq, note: err };
|
|
241
|
+
let vj = {}; try { vj = JSON.parse(readFileSync(join(tmp, "version.json"), "utf8")); } catch {}
|
|
242
|
+
// The web harness just installed (or already current) holds the same tarball's skills/: reuse it.
|
|
243
|
+
let iv = {}; try { iv = JSON.parse(readFileSync(join(HARNESS_DIR(), ".installed-version"), "utf8")); } catch {}
|
|
244
|
+
if (vj.sha256 && iv.sha256 === vj.sha256 && existsSync(join(HARNESS_DIR(), "skills"))) {
|
|
245
|
+
src = join(HARNESS_DIR(), "skills"); from = HARNESS_DIR() + "/skills (installed version " + iv.version + ")";
|
|
246
|
+
} else {
|
|
247
|
+
const tgz = join(tmp, "wk-harness-min.tar.gz");
|
|
248
|
+
if ((err = curl(base + "/wk-harness-min.tar.gz.sha256", join(tmp, "want.sha256")))) return { ok: false, hq, note: err };
|
|
249
|
+
const want = readFileSync(join(tmp, "want.sha256"), "utf8").trim().split(/\s+/)[0];
|
|
250
|
+
if (!/^[0-9a-f]{64}$/.test(want)) return { ok: false, hq, note: "the sha256 file holds no 64-hex hash" };
|
|
251
|
+
if ((err = curl(base + "/wk-harness-min.tar.gz", tgz))) return { ok: false, hq, note: err };
|
|
252
|
+
const got = sha256File(tgz);
|
|
253
|
+
if (got !== want) return { ok: false, hq, note: "sha256 MISMATCH for wk-harness-min.tar.gz: expected " + want + ", got " + got + "; no skill was copied" };
|
|
254
|
+
mkdirSync(join(tmp, "x"));
|
|
255
|
+
const r = spawnSync("tar", ["-xzf", tgz, "-C", join(tmp, "x")], { encoding: "utf8" });
|
|
256
|
+
if (r.status !== 0 || !existsSync(join(tmp, "x", "skills"))) return { ok: false, hq, note: "unpack failed or no skills/ in the tarball: " + (r.stderr || "") };
|
|
257
|
+
src = join(tmp, "x", "skills"); from = base + "/wk-harness-min.tar.gz (sha256 ok, version " + (vj.version || "?") + ")";
|
|
258
|
+
}
|
|
259
|
+
const c = syncTree(src, hq);
|
|
260
|
+
const n = countSkills(hq);
|
|
261
|
+
log("skills: " + n + " skills in HQ " + hq + " from " + from + " (added " + c.added + ", updated " + c.updated + ", kept " + c.kept + ")");
|
|
262
|
+
// Read-only copy: the HQ carries no git remote, and a user's own edits go to a local private home with none.
|
|
263
|
+
const ph = privateHome();
|
|
264
|
+
mkdirSync(ph, { recursive: true });
|
|
265
|
+
if (!existsSync(join(ph, ".git")) && spawnSync("git", ["init", "-q", ph]).status === 0)
|
|
266
|
+
log("skills: private home " + ph + " created as a local git repo with no remote");
|
|
267
|
+
return { ok: n > 0 && existsSync(join(hq, "wkappbot-workflow", "on-load.skill.json")), hq, count: n, privateHome: ph };
|
|
268
|
+
} finally { rmSync(tmp, { recursive: true, force: true }); }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ---- install --------------------------------------------------------------------------------------
|
|
272
|
+
function installPlugin(o, kih, kihSource) {
|
|
273
|
+
const cfg = configDir(o);
|
|
274
|
+
mkdirSync(join(cfg, "wkappbot-code"), { recursive: true });
|
|
275
|
+
const dest = join(cfg, "wkappbot-code", "wisdom-kernel.js");
|
|
276
|
+
copyFileSync(join(PKG_DIR, "plugin", "wisdom-kernel.js"), dest);
|
|
277
|
+
// The copy imports nothing from the package, but mark its folder ESM so Node never re-parses it.
|
|
278
|
+
writeFileSync(join(cfg, "wkappbot-code", "package.json"), '{ "type": "module" }\n');
|
|
279
|
+
const url = pathToFileURL(dest).href;
|
|
280
|
+
const tmpl = JSON.parse(readFileSync(join(PKG_DIR, "opencode.json"), "utf8"));
|
|
281
|
+
const target = o.project ? join(process.cwd(), "opencode.json") : join(cfg, "opencode.json");
|
|
282
|
+
let user = {}, raw = "";
|
|
283
|
+
if (existsSync(target)) {
|
|
284
|
+
raw = readFileSync(target, "utf8");
|
|
285
|
+
try { user = JSON.parse(raw); } catch { user = null; }
|
|
286
|
+
}
|
|
287
|
+
const it = detect({ user: user || {}, pluginUrl: url, kih, kihSource: kihSource || "kih checkout" });
|
|
288
|
+
for (const k of applySets(it, o.sets)) log("config: unknown --set key " + k + " (provider|model|plugin|harness)");
|
|
289
|
+
printDetected(it, log);
|
|
290
|
+
if (process.stdin.isTTY && process.stdout.isTTY && !o.yes) askEach(it);
|
|
291
|
+
if (!user) {
|
|
292
|
+
// JSONC or broken: never rewrite what we cannot parse; leave a sidecar to merge by hand.
|
|
293
|
+
const side = join(dirname(target), "wkappbot-code.json");
|
|
294
|
+
writeFileSync(side, JSON.stringify(buildConfig({}, it, tmpl), null, 2) + "\n");
|
|
295
|
+
log(target + " is not plain JSON; left untouched, wrote " + side + " -- merge its plugin entry by hand");
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const text = JSON.stringify(buildConfig(user, it, tmpl), null, 2) + "\n";
|
|
299
|
+
let note = "";
|
|
300
|
+
if (raw && text === raw) note = " (unchanged)";
|
|
301
|
+
else {
|
|
302
|
+
if (raw) {
|
|
303
|
+
const bak = target + ".bak-" + stamp();
|
|
304
|
+
copyFileSync(target, bak);
|
|
305
|
+
note = " (merged; backup " + bak + ")";
|
|
306
|
+
}
|
|
307
|
+
for (const l of lineDiff(raw, text)) log("diff " + l);
|
|
308
|
+
writeFileSync(target + ".tmp", text);
|
|
309
|
+
renameSync(target + ".tmp", target);
|
|
310
|
+
}
|
|
311
|
+
log("plugin: " + url);
|
|
312
|
+
log("config: " + target + note);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function install(o) {
|
|
316
|
+
const st = { version: PKG.version, at: new Date().toISOString() };
|
|
317
|
+
let bad = 0;
|
|
318
|
+
if (o.update) log("install --update: refreshing the core, the harness and the public skills");
|
|
319
|
+
if (o.npm) {
|
|
320
|
+
const have = opencodeVersion();
|
|
321
|
+
if (have === OPENCODE_VERSION) log("opencode " + have + " already installed");
|
|
322
|
+
else {
|
|
323
|
+
log("npm install -g opencode-ai@" + OPENCODE_VERSION + " (was: " + (have || "none") + ")");
|
|
324
|
+
execFileSync("npm", ["install", "-g", "opencode-ai@" + OPENCODE_VERSION, "--no-audit", "--no-fund"], { stdio: "inherit" });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const dest = binDest(o);
|
|
328
|
+
st.bin = o.bin ? installBin(o) : { skipped: true, dest };
|
|
329
|
+
if (o.bin && !st.bin.ok) { bad++; log("bin: FAILED -- " + st.bin.note); }
|
|
330
|
+
st.harness = o.harness ? installHarness(o) : { skipped: true };
|
|
331
|
+
if (o.harness && !st.harness.ok) { bad++; log("harness: FAILED -- " + st.harness.note); }
|
|
332
|
+
// The plugin entry names the harness the install just chose (a forced web harness wins over a checkout).
|
|
333
|
+
const kih = (st.harness.mode === "web" && st.harness.ok) ? st.harness.dir : kihDir(o);
|
|
334
|
+
// Global pre-push dispatcher (owner 2026-11-06): the push-owner gate runs in every repo, then the repo's own hooks.
|
|
335
|
+
// A user's own absolute global core.hooksPath is kept (the script WARNs). Skipped with --no-harness.
|
|
336
|
+
st.pushGate = { skipped: true };
|
|
337
|
+
if (o.harness && kih && existsSync(join(kih, "tools", "wk-global-hooks.sh"))) {
|
|
338
|
+
const r = spawnSync("bash", [join(kih, "tools", "wk-global-hooks.sh"), "install", "--gate-dir", join(kih, ".githooks", "lib")], { encoding: "utf8" });
|
|
339
|
+
for (const line of ((r.stdout || "") + (r.stderr || "")).split("\n").filter(Boolean)) log("push-gate: " + line);
|
|
340
|
+
st.pushGate = { ok: r.status === 0, kih };
|
|
341
|
+
}
|
|
342
|
+
st.skills = o.skills ? installSkills(o, dest) : { skipped: true };
|
|
343
|
+
if (o.skills && !st.skills.ok) { bad++; log("skills: FAILED -- " + (st.skills.note || "on-load missing from the HQ")); }
|
|
344
|
+
installPlugin(o, kih, (st.harness.mode === "web" && st.harness.ok) ? "web harness" : "kih checkout");
|
|
345
|
+
st.kihDir = kih;
|
|
346
|
+
writeFileSync(statePath(o), JSON.stringify(st, null, 2) + "\n");
|
|
347
|
+
log("kih: " + (kih || "NOT FOUND -- plugin fails open until --kih-dir or WK_KIH_DIR points at a kih checkout"));
|
|
348
|
+
log((bad ? "done with " + bad + " failed part(s)" : "done") + " -- check: wkappbot-code doctor then run: opencode");
|
|
349
|
+
return bad ? 1 : 0;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ---- doctor ---------------------------------------------------------------------------------------
|
|
353
|
+
// The GitHub login a skill push would use: gh when present, else "none". Push is the owner's alone.
|
|
354
|
+
export function pushLogin() {
|
|
355
|
+
const r = spawnSync("gh", ["api", "user", "-q", ".login"], { encoding: "utf8", timeout: 15000 });
|
|
356
|
+
if (r.error) return "none";
|
|
357
|
+
return r.status === 0 && r.stdout.trim() ? r.stdout.trim() : "none";
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function doctor(o) {
|
|
361
|
+
const cfg = configDir(o);
|
|
362
|
+
const st = readState(o);
|
|
363
|
+
const rows = [];
|
|
364
|
+
const add = (name, ok, detail, required, skip) => rows.push({ name, ok, detail, required, skip });
|
|
365
|
+
const v = opencodeVersion();
|
|
366
|
+
add("opencode", v === OPENCODE_VERSION, v ? v + (v === OPENCODE_VERSION ? "" : " (pinned " + OPENCODE_VERSION + ")") : "not on PATH", false);
|
|
367
|
+
const pdest = join(cfg, "wkappbot-code", "wisdom-kernel.js");
|
|
368
|
+
add("plugin", existsSync(pdest), pdest, true);
|
|
369
|
+
let entry = null;
|
|
370
|
+
try { entry = (JSON.parse(readFileSync(join(cfg, "opencode.json"), "utf8")).plugin || []).find((p) => String(Array.isArray(p) ? p[0] : p).includes("wisdom-kernel")); } catch {}
|
|
371
|
+
add("config", !!entry, join(cfg, "opencode.json"), true);
|
|
372
|
+
const oc = configRow(join(cfg, "opencode.json"));
|
|
373
|
+
add("opencode-config", oc.ok, oc.detail, false);
|
|
374
|
+
// bin: the core answers --version
|
|
375
|
+
const dest = o.binDest ? resolve(o.binDest) : (st.bin && st.bin.dest) || binDest(o);
|
|
376
|
+
let core = "";
|
|
377
|
+
if (st.bin && st.bin.skipped) add("bin", false, "skipped (--no-bin)", false, true);
|
|
378
|
+
else {
|
|
379
|
+
const r = spawnSync(dest, ["--version"], { encoding: "utf8", timeout: 60000 });
|
|
380
|
+
core = r.status === 0 ? (r.stdout || "").trim().split("\n")[0] : "";
|
|
381
|
+
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));
|
|
382
|
+
}
|
|
383
|
+
// kih-hook: the harness the plugin calls
|
|
384
|
+
const k = (entry && Array.isArray(entry) && entry[1] && entry[1].kihDir) || kihDir(o);
|
|
385
|
+
const hk = hasHook(k);
|
|
386
|
+
if (st.harness && st.harness.skipped && !hk) add("kih-hook", false, "skipped (--no-harness)", false, true);
|
|
387
|
+
else add("kih-hook", hk, k ? k + (st.harness && st.harness.mode ? " (" + st.harness.mode + ")" : "") : "not found", !!(st.harness && st.harness.mode === "web"));
|
|
388
|
+
// guards: which guard set the plugin can run. The full set needs PowerShell; without it the plugin falls back to
|
|
389
|
+
// the bash lite harness (intent lock, study lock), so this row WARNs instead of passing silently (QA 2026-11-06).
|
|
390
|
+
if (hk) {
|
|
391
|
+
const psDirs = String(process.env.PATH || "").split(IS_WIN ? ";" : ":");
|
|
392
|
+
const ps = ["pwsh", "pwsh.exe", "powershell.exe", "powershell"].find((n) => psDirs.some((d) => d && existsSync(join(d, n))));
|
|
393
|
+
const lite = existsSync(join(k, "tools", "wkharness-lite.sh"));
|
|
394
|
+
add("guards", !!ps, ps ? "full guard set (" + ps + ")" : lite ? "no PowerShell on PATH: lite guards only (intent lock, study lock); install pwsh 7 for the full set" : "no PowerShell and no lite harness: every tool call is let through", !ps && !lite);
|
|
395
|
+
}
|
|
396
|
+
// skills: the HQ holds on-load and the core prints it
|
|
397
|
+
const hq = (st.skills && st.skills.hq) || hqDir(o, dest);
|
|
398
|
+
if (st.skills && st.skills.skipped) add("skills", false, "skipped (--no-skills)", false, true);
|
|
399
|
+
else {
|
|
400
|
+
const n = countSkills(hq);
|
|
401
|
+
let printed = false, why = "";
|
|
402
|
+
if (core) {
|
|
403
|
+
const cwd = mkdtempSync(join(tmpdir(), "wk-appbot-doctor-"));
|
|
404
|
+
const r = spawnSync(dest, ["skill", "read", "on-load"], { cwd, encoding: "utf8", timeout: 120000,
|
|
405
|
+
env: { ...process.env, WKAPPBOT_NO_REMOTE_CHECK: "1" } });
|
|
406
|
+
printed = /\bID\s*:\s*on-load\b/.test(r.stdout || "");
|
|
407
|
+
if (!printed) why = " skill read on-load rc " + r.status;
|
|
408
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
409
|
+
} else why = " (no core to read with)";
|
|
410
|
+
add("skills", n > 0 && printed, n + " skills in " + hq + (printed ? "; wkappbot skill read on-load prints" : why), !!(st.skills && !st.skills.skipped));
|
|
411
|
+
}
|
|
412
|
+
// push-gate: the global core.hooksPath runs the push-owner gate (directly, or through the global dispatcher).
|
|
413
|
+
const ghp = (() => { try { return execFileSync("git", ["config", "--global", "--get", "core.hooksPath"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); } catch { return ""; } })();
|
|
414
|
+
let gated = false;
|
|
415
|
+
try { gated = /push-owner-gate|wk-global-hooks\.sh/.test(readFileSync(join(ghp.replace(/^~(?=\/|$)/, homedir()), "pre-push"), "utf8")); } catch {}
|
|
416
|
+
add("push-gate", gated, "global core.hooksPath " + (ghp || "unset") + (gated ? " runs the push-owner gate" : " does not run the push-owner gate"), false);
|
|
417
|
+
// skill-push: owner-only. The laid-down skills are a read-only copy; a push may only ever use the owner login.
|
|
418
|
+
const login = pushLogin();
|
|
419
|
+
const ph = privateHome();
|
|
420
|
+
const rem = existsSync(join(ph, ".git")) ? gitRemotes(ph) : [];
|
|
421
|
+
const remNote = rem && rem.length ? "; private home " + ph + " HAS remote(s) " + rem.join(",") : "; private home " + ph + " has no remote";
|
|
422
|
+
add("skill-push", login === OWNER_LOGIN && !(rem && rem.length), "= owner-only; a push would use github login " + login + remNote, false);
|
|
423
|
+
const ur = updateRow(PKG.version);
|
|
424
|
+
add("update", !!ur.ok, ur.detail, false, !!ur.skip);
|
|
425
|
+
let bad = 0;
|
|
426
|
+
for (const r of rows) {
|
|
427
|
+
console.log((r.skip ? "SKIP " : r.ok ? "PASS " : r.required ? "FAIL " : "WARN ") + r.name + " " + r.detail);
|
|
428
|
+
if (!r.ok && r.required && !r.skip) bad++;
|
|
429
|
+
}
|
|
430
|
+
return bad ? 1 : 0;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const o = parseArgs(process.argv.slice(2));
|
|
434
|
+
// install --update (from update) and these flags are forwarded to the re-run install of a fresh version.
|
|
435
|
+
const fwd = process.argv.slice(3).filter((a) => a !== "--auto-update");
|
|
436
|
+
const upd = () => runUpdate({ current: PKG.version, cliPath: process.argv[1], installArgs: fwd, log: console.log });
|
|
437
|
+
if (["install", "doctor", "config"].includes(o.cmd) && !o.update) {
|
|
438
|
+
const nv = quietCheck(PKG.version, console.log);
|
|
439
|
+
if (nv && (o.autoUpdate || process.env.WKAPPBOT_CODE_AUTO_UPDATE === "1")) upd();
|
|
440
|
+
}
|
|
441
|
+
switch (o.cmd) {
|
|
442
|
+
case "--version": case "-v": case "version":
|
|
443
|
+
console.log("wkappbot-code " + PKG.version + " (opencode-ai " + OPENCODE_VERSION + ")"); process.exit(0);
|
|
444
|
+
case "install": process.exit(install(o));
|
|
445
|
+
case "doctor": process.exit(doctor(o));
|
|
446
|
+
case "update": process.exit(upd());
|
|
447
|
+
case "config": installPlugin(o, kihDir(o), kihCheckout(o) ? "kih checkout" : "web harness"); process.exit(0);
|
|
448
|
+
default:
|
|
449
|
+
console.log("usage: wkappbot-code --version | install [--no-npm] [--no-bin] [--no-harness] [--no-skills] [--web-harness]\n" +
|
|
450
|
+
" [--config-dir DIR] [--kih-dir DIR] [--bin-dest FILE] [--hq-dir DIR] [--update] [--auto-update] | doctor [--config-dir DIR]\n" +
|
|
451
|
+
" | update [install opts] | config [--yes] [--project] [--set key=value] [--config-dir DIR]");
|
|
452
|
+
process.exit(o.cmd === "help" || o.cmd === "--help" ? 0 : 64);
|
|
453
|
+
}
|
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" "$@"
|