wkappbot-code 0.3.19 → 0.3.22

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.
@@ -47,6 +47,7 @@ import { hooksRow, wireClaudeHooks } from "../lib/hook-wire.js";
47
47
  import { installStarterMd, removeStarterMd } from "../lib/starter-md.js";
48
48
  import { addAllows, removeAllows } from "../lib/allow-compact.js";
49
49
  import { ensurePrereqs } from "../lib/prereq.js";
50
+ import { ensurePython } from "../lib/python-acquire.js";
50
51
  import { files as setupFiles, launchOpenCode, printBanner } from "../lib/setup-run.js";
51
52
 
52
53
  const PKG_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
@@ -204,6 +205,13 @@ function installBin(o) {
204
205
  // need no core and still refuse the risky classes, but the install reports the missing core as a FAILED part (0.3.13).
205
206
  if (r.status === 3) return { ok: false, degraded: true, dest, note: "core not fetched (release host unreachable); hooks + lite harness run without it -- rerun wkappbot-code install once github.com is reachable" };
206
207
  if (r.status !== 0) return { ok: false, dest, note: "wk-bin-acquire rc " + r.status };
208
+ // Core recognizes this marker beside the actual executable; no Core rebuild needed.
209
+ if (IS_WIN) {
210
+ const core = join(target, "wkappbot-core.exe");
211
+ if (!existsSync(core)) return { ok: false, dest, note: "Windows core missing after acquire: " + core };
212
+ try { writeFileSync(join(target, ".wkappbot-code-install"), "wkappbot-code npm install\n"); }
213
+ catch (e) { return { ok: false, dest, note: "npm license marker: " + e.message }; }
214
+ }
207
215
  // Put `wkappbot` on PATH beside wkappbot-code itself, unless that name is already taken by someone else.
208
216
  let link = "";
209
217
  const ld = linkDir();
@@ -425,6 +433,8 @@ function install(o) {
425
433
  // is not on PATH is used from its folder, and a missing one is installed with winget (lib/prereq.js)
426
434
  st.prereq = ensurePrereqs({ log });
427
435
  if (!st.prereq.ok) bad++;
436
+ // 0.3.20 (owner 2026-11-09): a real Python before any step runs python -- the Store alias stub made install exit 49
437
+ st.python = ensurePython({ log });
428
438
  if (o.update) log("install --update: refreshing the core, the harness and the public skills");
429
439
  const ps = pwshPath();
430
440
  log("pwsh: " + (ps || "not found") + " -- the FULL harness (PowerShell 7) runs only on a dev seat (a harness checkout, or --dev-seat);" +
@@ -568,6 +578,12 @@ function uninstall(o) {
568
578
  if (body.includes(core)) { rmSync(cmdShim, { force: true }); linkRemoved = cmdShim; log("shim: removed " + cmdShim + " (it called the core this install placed)"); }
569
579
  else log("shim: kept " + cmdShim + " (it no longer calls " + core + ")");
570
580
  }
581
+ // Undo only our npm license marker; do not alter a marker owned by another installer.
582
+ if (IS_WIN && st.bin?.ok) {
583
+ const marker = join(dirname(core), ".wkappbot-code-install");
584
+ try { if (existsSync(marker) && readFileSync(marker, "utf8") === "wkappbot-code npm install\n") { rmSync(marker); log("bin: removed npm license marker " + marker); } }
585
+ catch (e) { rc = 1; log("bin: could not remove npm license marker (" + e.message + ")"); }
586
+ }
571
587
  // 3d. the AI CLIs this install added (lib/ai-clis.js), never one the user had before
572
588
  removeAiClis((st.aiClis && st.aiClis.installed) || [], log, process.env, undefined, (st.aiClis && st.aiClis.prefix) || "");
573
589
  // 4. the harness dirs we created, the plugin entry and our config folder
@@ -575,6 +591,7 @@ function uninstall(o) {
575
591
  const rmTree = (p, what) => {
576
592
  const r = rmTreeHard(p); if (r.ok) { if (what) log(what + ": removed " + p); return; }
577
593
  rc = 1; log((what || "remove") + ": could not remove " + p + " -- blocked by " + r.blockers.slice(0, 5).join("; ") + " -- delete it by hand");
594
+ if (IS_WIN) { const d = spawnSync("tasklist.exe", ["/v"], { encoding: "utf8", timeout: 15000 }); log("remove diagnostics (process list, not file owners): " + String(d.stdout || d.stderr || "").slice(0, 4000)); }
578
595
  };
579
596
  if (st.harness && st.harness.mode === "web" && pr.harnessDirExisted !== true && existsSync(HARNESS_DIR())) rmTree(HARNESS_DIR(), "harness");
580
597
  if (pr.hooksDirExisted !== true && existsSync(HOOKS_DIR())) rmTree(HOOKS_DIR(), "push-gate");
package/lib/hook-wire.js CHANGED
@@ -13,6 +13,7 @@ import { spawnSync } from "node:child_process";
13
13
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
14
  import { dirname, join, resolve } from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
+ import { pickPython, stubWarning } from "./python-pick.js";
16
17
 
17
18
  // The same harness-hook pattern as tools/wkharness-lite.sh --wire HARNESS_HOOK. ANCHORED (2026-11-09 review, proven on
18
19
  // fixtures): the loose form also took a user's own pwsh lint.ps1, bash ~/bin/session-start.sh and my-wkharness-notes.sh
@@ -147,8 +148,9 @@ export function ensureRules(kih, log = () => {}) {
147
148
  if (existsSync(want)) return "present";
148
149
  const shipped = join(kih, "tools", "wkharness-lite-rules.tsv");
149
150
  if (existsSync(shipped)) { mkdirSync(dirname(want), { recursive: true }); writeFileSync(want, readFileSync(shipped)); return "copied"; }
150
- const py = spawnSync("sh", ["-c", "command -v python3"], { encoding: "utf8" });
151
- if (py.status === 0) {
151
+ const py = pickPython();
152
+ if (py.stubs.length) log("hooks: " + stubWarning(py.stubs));
153
+ if (py.cmd) {
152
154
  const r = spawnSync("sh", [join(kih, "tools", "wkharness-lite.sh").replace(/\\/g, "/"), "--wire"], { encoding: "utf8" });
153
155
  if (r.status === 0 && existsSync(want)) return "built";
154
156
  }
@@ -0,0 +1,114 @@
1
+ // wkappbot-code lib/python-acquire.js -- a REAL Python 3 on Windows before any step calls python (owner 2026-11-09:
2
+ // "the harness install fails with code 49 when the Store alias is there -- detect the fake python, then force-install").
3
+ // 49 is 9009 % 256: the Microsoft Store alias stub (WindowsApps\python.exe) seen through Git Bash or node.
4
+ // ensurePython: probe (lib/python-pick.js, which RUNS each candidate); if no real Python and win32:
5
+ // 1. winget install --id Python.Python.3.12 --scope user --silent (accept agreements)
6
+ // 2. else the python.org embeddable zip, pinned 3.12.7 and sha256-checked, unpacked into ~/.wk-harness/python
7
+ // 3. the real python dir goes AHEAD of %LOCALAPPDATA%\Microsoft\WindowsApps in the user PATH and in this process
8
+ // 4. re-probe, then ONE line: "OK python <version> <path>" or "FAIL python: <reason>". It never throws.
9
+ // Off Windows it only probes and reports (the package manager owns python there; lib/prereq.js names it).
10
+ // Spec: wkappbot skill read harness-installer-owns-all-setup
11
+ import { spawnSync } from "node:child_process";
12
+ import { createHash } from "node:crypto";
13
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { join } from "node:path";
16
+ import { pickPython, stubWarning } from "./python-pick.js";
17
+
18
+ export const EMBED = {
19
+ version: "3.12.7",
20
+ url: "https://www.python.org/ftp/python/3.12.7/python-3.12.7-embed-amd64.zip",
21
+ // measured 2026-11-09 from the python.org download over TLS (the release page lists no sha256 for this file)
22
+ sha256: "0d57bb6cb078b74d23dbfe91f77d6780d45bed328911609f1f7ee2ba1606bf44",
23
+ };
24
+
25
+ const realRun = (cmd, args, env, extra = {}) => spawnSync(cmd, args, { encoding: "utf8", env, timeout: 600000, windowsHide: true, ...extra });
26
+ const pathKey = (env) => Object.keys(env).find((k) => k.toUpperCase() === "PATH") || "PATH";
27
+
28
+ // dir first on a Windows PATH string (";" separated on every host, so the selftest runs anywhere), but always ahead
29
+ // of any WindowsApps entry
30
+ export function pathAheadOfWindowsApps(pathStr, dir) {
31
+ const parts = String(pathStr || "").split(";").filter((p) => p && p.toLowerCase() !== dir.toLowerCase());
32
+ const i = parts.findIndex((p) => /\\Microsoft\\WindowsApps\\?$/i.test(p));
33
+ if (i < 0) return [dir, ...parts].join(";");
34
+ parts.splice(i, 0, dir);
35
+ return parts.join(";");
36
+ }
37
+
38
+ // The folder winget's per-user Python lands in (LOCALAPPDATA\Programs\Python\Python3xx), newest first.
39
+ function wingetPythonDir(env, exists, list) {
40
+ const base = join(env.LOCALAPPDATA || join(homedir(), "AppData", "Local"), "Programs", "Python");
41
+ let names = [];
42
+ try { names = list(base).filter((n) => /^Python3\d+$/i.test(n)).sort().reverse(); } catch { return ""; }
43
+ for (const n of names) if (exists(join(base, n, "python.exe"))) return join(base, n);
44
+ return "";
45
+ }
46
+
47
+ function setUserPath(dir, run, env) {
48
+ // the user PATH (registry), not the merged one; same ordering rule as tools/wk-fresh-windows-tools.ps1
49
+ const ps = (cmd, e) => run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", cmd], e);
50
+ const cur = ps("[Environment]::GetEnvironmentVariable('Path','User')", env);
51
+ if (!cur || cur.status !== 0) return false;
52
+ const next = pathAheadOfWindowsApps(String(cur.stdout || "").trim(), dir);
53
+ // the value travels in an env var, so no quoting of the PATH text is ever needed
54
+ const w = ps("[Environment]::SetEnvironmentVariable('Path',$env:WK_NEW_USER_PATH,'User')", { ...env, WK_NEW_USER_PATH: next });
55
+ return w && w.status === 0;
56
+ }
57
+
58
+ export function ensurePython(o = {}) {
59
+ const env = o.env || process.env, log = o.log || (() => {}), run = o.run || realRun;
60
+ const platform = o.platform || process.platform, exists = o.exists || existsSync, list = o.list || readdirSync;
61
+ const home = o.home || homedir(), read = o.read || readFileSync, EMB = o.embed || EMBED;
62
+ const report = (ok, msg, extra = {}) => { log((ok ? "OK python " : "FAIL python: ") + msg); return { ok, note: msg, ...extra }; };
63
+ try {
64
+ let p = pickPython({ env, run: o.probeRun });
65
+ const warn = stubWarning(p.stubs);
66
+ if (p.cmd) {
67
+ const v = run(p.cmd, [...p.args, "-c", "import sys;print(sys.version.split()[0]);print(sys.executable)"], env); // no shell: see python-pick.js
68
+ const [ver, exe] = String((v && v.stdout) || "").trim().split(/\r?\n/);
69
+ return report(true, (ver || "3") + " " + (exe || p.cmd), { cmd: p.cmd, args: p.args, via: "present" });
70
+ }
71
+ if (warn) log(warn);
72
+ if (platform !== "win32") return report(false, "no python3 -- install it with the package manager (apt/dnf/brew install python3)", { via: "none" });
73
+ if (o.noInstall) return report(false, (warn ? "only the Microsoft Store alias stub" : "no Python 3") + " -- run: wkappbot-code install", { via: "none" });
74
+ let dir = "", via = "";
75
+ // 1. winget, per user
76
+ const wg = run("winget", ["install", "--id", "Python.Python.3.12", "-e", "--scope", "user", "--silent", "--accept-package-agreements", "--accept-source-agreements"], env, { shell: true });
77
+ log("python: winget install Python.Python.3.12 rc " + (wg ? wg.status : "?"));
78
+ dir = wingetPythonDir(env, exists, list);
79
+ if (dir) via = "winget";
80
+ // 2. the pinned embeddable zip
81
+ if (!dir) {
82
+ const target = join(home, ".wk-harness", "python"), zip = join(home, ".wk-harness", "python-embed.zip");
83
+ mkdirSync(target, { recursive: true });
84
+ const dl = run("curl", ["-fsSL", "--retry", "6", "--retry-delay", "5", "-m", "300", "-o", zip, EMB.url], env);
85
+ if (!dl || dl.status !== 0) return report(false, "winget gave no Python and the download of " + EMB.url + " failed (curl rc " + (dl ? dl.status : "?") + ")", { via: "none" });
86
+ const sum = createHash("sha256").update(read(zip)).digest("hex");
87
+ if (sum !== EMB.sha256) return report(false, "the embeddable zip sha256 " + sum + " is not the pinned " + EMB.sha256, { via: "none" });
88
+ // Windows: the bsdtar in System32 reads a zip; the GNU tar of Git Bash, first on PATH there, cannot (rc 128, 0.3.20)
89
+ const tarExe = platform === "win32" ? join(env.SystemRoot || env.windir || "C:\\Windows", "System32", "tar.exe") : "tar";
90
+ let ux = run(tarExe, ["-xf", zip, "-C", target], env);
91
+ if ((!ux || ux.status !== 0) && platform === "win32")
92
+ ux = run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", "Expand-Archive -Force -LiteralPath $env:WK_ZIP -DestinationPath $env:WK_DEST"], { ...env, WK_ZIP: zip, WK_DEST: target });
93
+ if (!ux || ux.status !== 0) return report(false, "could not unpack " + zip + " (" + tarExe + " rc " + (ux ? ux.status : "?") + ")", { via: "none" });
94
+ // the zip's pythonXY._pth keeps the script folder off sys.path, so harness scripts could not import their siblings
95
+ try { for (const n of list(target)) if (/^python\d*\._pth$/i.test(n)) (o.unlink || unlinkSync)(join(target, n)); } catch {}
96
+ dir = target; via = "embed";
97
+ }
98
+ // 3. a python3.exe beside python.exe (winget and the zip ship only python.exe, so `python3` would still hit the
99
+ // WindowsApps stub), PYTHON_CMD for every later step, and the dir ahead of WindowsApps in this process and the user PATH
100
+ try { if (!exists(join(dir, "python3.exe"))) (o.copy || copyFileSync)(join(dir, "python.exe"), join(dir, "python3.exe")); } catch (e) { log("python: WARN no python3.exe beside python.exe (" + (e && e.message || e) + ")"); }
101
+ env.PYTHON_CMD = join(dir, "python.exe");
102
+ const k = pathKey(env);
103
+ env[k] = pathAheadOfWindowsApps(env[k], dir);
104
+ if (env !== process.env && o.env === undefined) { const pk = pathKey(process.env); process.env[pk] = pathAheadOfWindowsApps(process.env[pk], dir); }
105
+ if (!setUserPath(dir, run, env)) log("python: WARN could not write the user PATH; add " + dir + " ahead of WindowsApps");
106
+ // 4. re-probe the real exe by path
107
+ const exe = join(dir, "python.exe");
108
+ const v = run(exe, ["-c", "import sys;print(sys.version.split()[0])"], env);
109
+ if (v && v.status === 0 && /^3\./.test(String(v.stdout).trim())) return report(true, String(v.stdout).trim() + " " + exe, { cmd: exe, args: [], via });
110
+ return report(false, exe + " did not run (rc " + (v ? v.status : "?") + ")", { via });
111
+ } catch (e) {
112
+ return report(false, "unexpected: " + (e && e.message || e), { via: "none" });
113
+ }
114
+ }
@@ -0,0 +1,45 @@
1
+ // wkappbot-code lib/python-pick.js -- the ONE way the package chooses a Python 3 (owner order 2026-11-09).
2
+ // A name on PATH is not a Python: on Windows %LOCALAPPDATA%\Microsoft\WindowsApps\python.exe and python3.exe are
3
+ // Store app-execution-alias stubs. `command -v python3` finds them, yet running one prints "Python was not found
4
+ // ... Microsoft Store" and exits 9009. So every candidate is RUN: python3, then python, then py -3, each with
5
+ // -c "import sys;print(sys.version_info[0])", and accepted only on rc 0 with the output 3. A WindowsApps path, rc 9009
6
+ // or the Store text marks a stub; pickPython reports it so the caller prints ONE warning (stubWarning).
7
+ // Twin for sh: tools/wk-python-pick.sh (wk_python). Spec: wkappbot skill read harness-installer-owns-all-setup
8
+ import { spawnSync } from "node:child_process";
9
+
10
+ export const CANDIDATES = [["python3"], ["python"], ["py", "-3"]];
11
+ // Windows asks python and py -3 first: python.org and tool-cache installs ship python.exe with no python3.exe, so
12
+ // python3 there is usually the WindowsApps stub (spokesman order 2026-11-10).
13
+ export const candidatesFor = (platform) => (platform === "win32" ? [["python"], ["py", "-3"], ["python3"]] : CANDIDATES);
14
+ const PROBE = ["-c", "import sys;print(sys.version_info[0])"];
15
+ const STORE_RE = /Microsoft Store|WindowsApps|app execution alias/i;
16
+
17
+ // NEVER a shell: on win32 shell:true handed the probe to cmd.exe unquoted, so the ";" and "(" of -c broke it and every
18
+ // REAL Python failed (0.3.20, SDK os-qa 42730232937 win-x64). Node finds python.exe and py.exe on PATH by itself, and
19
+ // a WindowsApps stub still runs and exits 9009.
20
+ export const spawnOpts = (env, timeout = 15000) => ({ encoding: "utf8", env, timeout, shell: false, windowsHide: true });
21
+ const realRun = (cmd, args, env) => spawnSync(cmd, args, spawnOpts(env));
22
+
23
+ // -> { cmd, args, stubs: [names] } ; cmd "" when no working Python 3 exists.
24
+ export function pickPython(o = {}) {
25
+ const env = o.env || process.env, run = o.run || realRun, stubs = [];
26
+ if (env.PYTHON_CMD) {
27
+ const r = run(env.PYTHON_CMD, PROBE, env);
28
+ if (r && r.status === 0 && String(r.stdout || "").trim() === "3") return { cmd: env.PYTHON_CMD, args: [], stubs };
29
+ }
30
+ for (const [cmd, ...pre] of candidatesFor(o.platform || process.platform)) {
31
+ const r = run(cmd, [...pre, ...PROBE], env);
32
+ if (!r) continue;
33
+ const out = String(r.stdout || "") + String(r.stderr || "");
34
+ if (r.status === 0 && String(r.stdout || "").trim() === "3") return { cmd, args: pre, stubs };
35
+ if (r.status === 9009 || r.status === 49 || STORE_RE.test(out)) stubs.push(cmd); // 49 = 9009 % 256 through a shell
36
+ }
37
+ return { cmd: "", args: [], stubs };
38
+ }
39
+
40
+ // The one WARN line for a Store stub, or "" when none was seen.
41
+ export function stubWarning(stubs) {
42
+ if (!stubs || !stubs.length) return "";
43
+ return "WARN python: " + stubs.join(", ") + " is the Microsoft Store alias stub, not Python -- install Python 3, " +
44
+ "or turn the alias off under Settings > Apps > Advanced app settings > App execution aliases";
45
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wkappbot-code",
3
- "version": "0.3.19",
3
+ "version": "0.3.22",
4
4
  "description": "wkappbot-code -- Wisdom Kernel: the pinned OpenCode bridge plus the reflex harness plugin, the wkappbot core, the web harness and the latest public skills in one install",
5
5
  "type": "module",
6
6
  "scripts": {