wkappbot-code 0.3.19 → 0.3.20

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)), "..");
@@ -425,6 +426,8 @@ function install(o) {
425
426
  // is not on PATH is used from its folder, and a missing one is installed with winget (lib/prereq.js)
426
427
  st.prereq = ensurePrereqs({ log });
427
428
  if (!st.prereq.ok) bad++;
429
+ // 0.3.20 (owner 2026-11-09): a real Python before any step runs python -- the Store alias stub made install exit 49
430
+ st.python = ensurePython({ log });
428
431
  if (o.update) log("install --update: refreshing the core, the harness and the public skills");
429
432
  const ps = pwshPath();
430
433
  log("pwsh: " + (ps || "not found") + " -- the FULL harness (PowerShell 7) runs only on a dev seat (a harness checkout, or --dev-seat);" +
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,108 @@
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 } 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;
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, { shell: platform === "win32" });
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, EMBED.url], env);
85
+ if (!dl || dl.status !== 0) return report(false, "winget gave no Python and the download of " + EMBED.url + " failed (curl rc " + (dl ? dl.status : "?") + ")", { via: "none" });
86
+ const sum = createHash("sha256").update(read(zip)).digest("hex");
87
+ if (sum !== EMBED.sha256) return report(false, "the embeddable zip sha256 " + sum + " is not the pinned " + EMBED.sha256, { via: "none" });
88
+ const ux = run("tar", ["-xf", zip, "-C", target], env);
89
+ if (!ux || ux.status !== 0) return report(false, "could not unpack " + zip + " (tar rc " + (ux ? ux.status : "?") + ")", { via: "none" });
90
+ dir = target; via = "embed";
91
+ }
92
+ // 3. a python3.exe beside python.exe (winget and the zip ship only python.exe, so `python3` would still hit the
93
+ // WindowsApps stub), PYTHON_CMD for every later step, and the dir ahead of WindowsApps in this process and the user PATH
94
+ 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) + ")"); }
95
+ env.PYTHON_CMD = join(dir, "python.exe");
96
+ const k = pathKey(env);
97
+ env[k] = pathAheadOfWindowsApps(env[k], dir);
98
+ if (env !== process.env && o.env === undefined) { const pk = pathKey(process.env); process.env[pk] = pathAheadOfWindowsApps(process.env[pk], dir); }
99
+ if (!setUserPath(dir, run, env)) log("python: WARN could not write the user PATH; add " + dir + " ahead of WindowsApps");
100
+ // 4. re-probe the real exe by path
101
+ const exe = join(dir, "python.exe");
102
+ const v = run(exe, ["-c", "import sys;print(sys.version.split()[0])"], env);
103
+ if (v && v.status === 0 && /^3\./.test(String(v.stdout).trim())) return report(true, String(v.stdout).trim() + " " + exe, { cmd: exe, args: [], via });
104
+ return report(false, exe + " did not run (rc " + (v ? v.status : "?") + ")", { via });
105
+ } catch (e) {
106
+ return report(false, "unexpected: " + (e && e.message || e), { via: "none" });
107
+ }
108
+ }
@@ -0,0 +1,38 @@
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
+ const PROBE = ["-c", "import sys;print(sys.version_info[0])"];
12
+ const STORE_RE = /Microsoft Store|WindowsApps|app execution alias/i;
13
+
14
+ const realRun = (cmd, args, env) => spawnSync(cmd, args, { encoding: "utf8", env, timeout: 15000, shell: process.platform === "win32", windowsHide: true });
15
+
16
+ // -> { cmd, args, stubs: [names] } ; cmd "" when no working Python 3 exists.
17
+ export function pickPython(o = {}) {
18
+ const env = o.env || process.env, run = o.run || realRun, stubs = [];
19
+ if (env.PYTHON_CMD) {
20
+ const r = run(env.PYTHON_CMD, PROBE, env);
21
+ if (r && r.status === 0 && String(r.stdout || "").trim() === "3") return { cmd: env.PYTHON_CMD, args: [], stubs };
22
+ }
23
+ for (const [cmd, ...pre] of CANDIDATES) {
24
+ const r = run(cmd, [...pre, ...PROBE], env);
25
+ if (!r) continue;
26
+ const out = String(r.stdout || "") + String(r.stderr || "");
27
+ if (r.status === 0 && String(r.stdout || "").trim() === "3") return { cmd, args: pre, stubs };
28
+ if (r.status === 9009 || r.status === 49 || STORE_RE.test(out)) stubs.push(cmd); // 49 = 9009 % 256 through a shell
29
+ }
30
+ return { cmd: "", args: [], stubs };
31
+ }
32
+
33
+ // The one WARN line for a Store stub, or "" when none was seen.
34
+ export function stubWarning(stubs) {
35
+ if (!stubs || !stubs.length) return "";
36
+ return "WARN python: " + stubs.join(", ") + " is the Microsoft Store alias stub, not Python -- install Python 3, " +
37
+ "or turn the alias off under Settings > Apps > Advanced app settings > App execution aliases";
38
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wkappbot-code",
3
- "version": "0.3.19",
3
+ "version": "0.3.20",
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": {