diff --git a/bin/axiomcode.js b/bin/axiomcode.js index 299605fe..4a0f6c73 100755 --- a/bin/axiomcode.js +++ b/bin/axiomcode.js @@ -13,11 +13,16 @@ // // node resolves this file through the .bin symlink before setting __dirname, so bin/axiomcode is // found beside it and its own root walk (#886) starts inside the package, as before. +// +// Python is chosen here too (#1331): the query verbs call `python3`, which a python.org install on Windows does +// not provide. find-python.js hands bash a python3 that runs whichever interpreter answered. A build needs no +// Python, so none found is not an error here; a verb that needs it says so from scripts/axiomcode. // ───────────────────────────────────────────────────────────────────────────── 'use strict'; const { spawnSync } = require('child_process'); const path = require('path'); const { findBash } = require('../plugins/axiomcode/mcp/find-bash.js'); +const { findPython, withPython } = require('../plugins/axiomcode/mcp/find-python.js'); function fail(msg) { process.stderr.write(`❌ ${msg}\n`); @@ -26,7 +31,10 @@ function fail(msg) { const { bash, error } = findBash(); if (error) fail(error); -const r = spawnSync(bash, [path.join(__dirname, 'axiomcode'), ...process.argv.slice(2)], { stdio: 'inherit' }); +const py = findPython(); +// AXIOMCODE_BASH too, for the builds Python starts: a bare `bash` from Python on Windows is WSL's or nothing. +const env = { ...(py.exe ? withPython(process.env, py) : process.env), AXIOMCODE_BASH: bash }; +const r = spawnSync(bash, [path.join(__dirname, 'axiomcode'), ...process.argv.slice(2)], { stdio: 'inherit', env }); if (r.error) fail(`could not start bash: ${r.error.message}`); // Die of the same signal the CLI died of, so a caller sees what really happened. if (r.signal) process.kill(process.pid, r.signal); diff --git a/hooks/hooks.json b/hooks/hooks.json index 8b96b0ec..160dad33 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -3,9 +3,9 @@ "BeforeAgent": [ { "hooks": [ - { "type": "command", "command": "python3 \"${extensionPath}${/}plugins${/}axiomcode${/}hooks${/}changes.py\"", "timeout": 25000 }, - { "type": "command", "command": "python3 \"${extensionPath}${/}plugins${/}axiomcode${/}hooks${/}orient.py\"", "timeout": 20000 }, - { "type": "command", "command": "python3 \"${extensionPath}${/}plugins${/}axiomcode${/}hooks${/}refresh.py\"", "timeout": 5000 } + { "type": "command", "command": "node \"${extensionPath}${/}plugins${/}axiomcode${/}hooks${/}run.js\" changes.py", "timeout": 25000 }, + { "type": "command", "command": "node \"${extensionPath}${/}plugins${/}axiomcode${/}hooks${/}run.js\" orient.py", "timeout": 20000 }, + { "type": "command", "command": "node \"${extensionPath}${/}plugins${/}axiomcode${/}hooks${/}run.js\" refresh.py", "timeout": 5000 } ] } ], @@ -13,7 +13,7 @@ { "matcher": "replace|write_file", "hooks": [ - { "type": "command", "command": "python3 \"${extensionPath}${/}plugins${/}axiomcode${/}hooks${/}changes.py\"", "timeout": 25000 } + { "type": "command", "command": "node \"${extensionPath}${/}plugins${/}axiomcode${/}hooks${/}run.js\" changes.py", "timeout": 25000 } ] } ], @@ -21,27 +21,27 @@ { "matcher": "read_file|grep_search|glob|run_shell_command|replace|write_file", "hooks": [ - { "type": "command", "command": "python3 \"${extensionPath}${/}plugins${/}axiomcode${/}hooks${/}enrich.py\"", "timeout": 20000 } + { "type": "command", "command": "node \"${extensionPath}${/}plugins${/}axiomcode${/}hooks${/}run.js\" enrich.py", "timeout": 20000 } ] }, { "matcher": "run_shell_command", "hooks": [ - { "type": "command", "command": "python3 \"${extensionPath}${/}plugins${/}axiomcode${/}hooks${/}changes.py\"", "timeout": 25000 } + { "type": "command", "command": "node \"${extensionPath}${/}plugins${/}axiomcode${/}hooks${/}run.js\" changes.py", "timeout": 25000 } ] }, { "matcher": "replace|write_file|run_shell_command", "hooks": [ - { "type": "command", "command": "python3 \"${extensionPath}${/}plugins${/}axiomcode${/}hooks${/}refresh.py\"", "timeout": 5000 } + { "type": "command", "command": "node \"${extensionPath}${/}plugins${/}axiomcode${/}hooks${/}run.js\" refresh.py", "timeout": 5000 } ] } ], "AfterAgent": [ - { "hooks": [ { "type": "command", "command": "python3 \"${extensionPath}${/}plugins${/}axiomcode${/}hooks${/}refresh.py\"", "timeout": 5000 } ] } + { "hooks": [ { "type": "command", "command": "node \"${extensionPath}${/}plugins${/}axiomcode${/}hooks${/}run.js\" refresh.py", "timeout": 5000 } ] } ], "SessionStart": [ - { "hooks": [ { "type": "command", "command": "python3 \"${extensionPath}${/}plugins${/}axiomcode${/}hooks${/}refresh.py\"", "timeout": 5000 } ] } + { "hooks": [ { "type": "command", "command": "node \"${extensionPath}${/}plugins${/}axiomcode${/}hooks${/}run.js\" refresh.py", "timeout": 5000 } ] } ] } } diff --git a/plugins/axiomcode/hooks/hooks.json b/plugins/axiomcode/hooks/hooks.json index 4baf303d..e7a5ffbc 100644 --- a/plugins/axiomcode/hooks/hooks.json +++ b/plugins/axiomcode/hooks/hooks.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/enrich.py\"", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run.js\" enrich.py", "timeout": 20 } ] @@ -16,7 +16,7 @@ "hooks": [ { "type": "command", - "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/changes.py\"", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run.js\" changes.py", "timeout": 25 } ] @@ -26,7 +26,7 @@ "hooks": [ { "type": "command", - "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/refresh.py\"", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run.js\" refresh.py", "timeout": 5 } ] @@ -38,7 +38,7 @@ "hooks": [ { "type": "command", - "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/direct.py\"", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run.js\" direct.py", "timeout": 10 } ] @@ -48,7 +48,7 @@ "hooks": [ { "type": "command", - "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/changes.py\"", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run.js\" changes.py", "timeout": 20 } ] @@ -59,7 +59,7 @@ "hooks": [ { "type": "command", - "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/changes.py\"", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run.js\" changes.py", "timeout": 25 } ] @@ -69,7 +69,7 @@ "hooks": [ { "type": "command", - "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/orient.py\"" + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run.js\" orient.py" } ] }, @@ -77,7 +77,7 @@ "hooks": [ { "type": "command", - "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/refresh.py\"", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run.js\" refresh.py", "timeout": 5 } ] @@ -88,7 +88,7 @@ "hooks": [ { "type": "command", - "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/refresh.py\"", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run.js\" refresh.py", "timeout": 5 } ] @@ -99,7 +99,7 @@ "hooks": [ { "type": "command", - "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/refresh.py\"", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run.js\" refresh.py", "timeout": 5 } ] diff --git a/plugins/axiomcode/hooks/run.js b/plugins/axiomcode/hooks/run.js new file mode 100644 index 00000000..f97ca3bb --- /dev/null +++ b/plugins/axiomcode/hooks/run.js @@ -0,0 +1,36 @@ +// run.js .py — run one hook under the Python find-python.js finds (#1331). +// +// hooks.json used to say `python3 .py`, and on Windows `python3` is the Store placeholder or nothing, +// so every hook failed on every tool call. The command is now `node run.js .py`: node is the same name +// on every platform, and the MCP server already needs it (#1233). +// +// The event arrives on stdin and is passed on whole; stdout, stderr and the exit status come back unchanged, +// because exit 2 is how a hook blocks. A hook must never block a tool for want of an interpreter, so with no +// Python the runner exits 0 and says why on stderr, which the host shows only in its debug output. +// +// The environment it passes carries the python3 bash needs and the bash find-bash.js chose, for the +// background refresh a hook starts: that runs axiomcode-build, a bash script that calls python3. +'use strict'; +const { spawnSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const { findPython, withPython } = require('../mcp/find-python.js'); +const { findBash } = require('../mcp/find-bash.js'); + +const hook = process.argv[2]; +if (!hook) { process.stderr.write('usage: node run.js .py\n'); process.exit(0); } + +const py = findPython(); +if (!py.exe) { process.stderr.write(`axiomcode hook ${hook}: ${py.error}\n`); process.exit(0); } + +let input = ''; +try { input = fs.readFileSync(0); } catch { /* no stdin: the hook reads an empty event */ } + +const env = withPython(process.env, py); +const { bash } = findBash(); +if (bash) env.AXIOMCODE_BASH = bash; + +const r = spawnSync(py.exe, [path.join(__dirname, hook)], { input, env, windowsHide: true, + stdio: ['pipe', 'inherit', 'inherit'] }); +if (r.error) { process.stderr.write(`axiomcode hook ${hook}: could not start ${py.exe}: ${r.error.message}\n`); process.exit(0); } +process.exit(r.status ?? 0); diff --git a/plugins/axiomcode/mcp/find-python.js b/plugins/axiomcode/mcp/find-python.js new file mode 100644 index 00000000..70c9fdd7 --- /dev/null +++ b/plugins/axiomcode/mcp/find-python.js @@ -0,0 +1,53 @@ +// find-python.js — the Python the CLI and the hooks run under, found rather than assumed (#1331). +// +// Every verb and every hook is a Python script, and the bash half of the CLI calls it as `python3`. A +// python.org install on Windows provides python.exe and py.exe and no python3, and on a desktop Windows +// `python3` is the Microsoft Store placeholder, which is on PATH and exits 9009. So the interpreter is +// probed in the order launch.js has always used for the MCP server: AXIOMCODE_PYTHON, python3, python, +// and on Windows `py -3`. A candidate is taken only if it runs, and it reports its own sys.executable, so +// what bash is handed is a real file and not the py launcher or a placeholder. +// +// bash is then given a `python3` that runs it: skills/axiomcode/scripts/pyshim/python3, first on PATH, +// which execs AXIOMCODE_PYTHON_EXE. Python's own children use sys.executable and need nothing. +// +// Used by bin/axiomcode.js (the command npm links), mcp/launch.js (the MCP server) and hooks/run.js. +'use strict'; +const { spawnSync } = require('child_process'); +const path = require('path'); + +const SHIM = path.join(__dirname, '..', 'skills', 'axiomcode', 'scripts', 'pyshim'); + +function candidates() { + return [process.env.AXIOMCODE_PYTHON && [process.env.AXIOMCODE_PYTHON], ['python3'], ['python'], + process.platform === 'win32' && ['py', '-3']].filter(Boolean); +} + +// { cmd, exe } or { error }: cmd is how the candidate was named, exe the interpreter file it runs. +function findPython() { + for (const cmd of candidates()) { + const r = spawnSync(cmd[0], [...cmd.slice(1), '-c', 'import sys; print(sys.executable)'], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }); + const exe = r.status === 0 && String(r.stdout).trim(); + if (exe) return { cmd, exe }; + } + return { error: 'axiomcode needs Python 3, and no python3, python' + (process.platform === 'win32' ? ' or py -3' : '') + + ' on PATH runs.\n • install it (https://www.python.org/downloads/), or\n' + + ' • set AXIOMCODE_PYTHON to the full path of a python executable.' }; +} + +// A copy of env in which bash's `python3` is the interpreter findPython chose. On POSIX, when that is +// python3 itself, PATH is left alone. On Windows the shim always goes first: a python3 that answered a +// probe from here can still be a Store alias that Git Bash cannot run. +function withPython(env, py) { + const out = { ...env, AXIOMCODE_PYTHON_EXE: py.exe.replace(/\\/g, '/') }; + // Windows Python writes a pipe in the ANSI code page and opens files in it, so the first → in an answer raised + // UnicodeEncodeError, and a source file in UTF-8 read wrong. UTF-8 mode fixes both; a user's own setting stands. + if (process.platform === 'win32' && out.PYTHONUTF8 === undefined) out.PYTHONUTF8 = '1'; + if (process.platform !== 'win32' && py.cmd.length === 1 && py.cmd[0] === 'python3') return out; + // Windows keeps it as Path, and a second PATH key beside it would be one of two the child picks from. + const key = Object.keys(out).find((k) => k.toUpperCase() === 'PATH') || 'PATH'; + out[key] = out[key] ? SHIM + path.delimiter + out[key] : SHIM; + return out; +} + +module.exports = { candidates, findPython, withPython }; diff --git a/plugins/axiomcode/mcp/launch.js b/plugins/axiomcode/mcp/launch.js index bab079f1..a5af1145 100644 --- a/plugins/axiomcode/mcp/launch.js +++ b/plugins/axiomcode/mcp/launch.js @@ -23,11 +23,13 @@ // reuses it, on failure the launcher says why and moves on to the fallback. // // The server shells out to the CLI, a bash script, so the bash find-bash.js chose is handed to it as -// AXIOMCODE_BASH; a bare `bash` from server.py would hit the same Windows lookup this file avoids. +// AXIOMCODE_BASH; a bare `bash` from server.py would hit the same Windows lookup this file avoids. That CLI +// calls `python3`, so the server's environment also carries find-python.js's python3 for bash (#1331). 'use strict'; const { spawn, spawnSync } = require('child_process'); const path = require('path'); const { findBash } = require('./find-bash.js'); +const { candidates, findPython, withPython } = require('./find-python.js'); const SERVER = path.join(__dirname, 'server.py'); // Run as `node launch.js …` the rest of the command line is the server's; required from mcp.json's @@ -52,8 +54,7 @@ function uvWorks() { return false; } -const pythons = [process.env.AXIOMCODE_PYTHON && [process.env.AXIOMCODE_PYTHON], ['python3'], ['python'], - process.platform === 'win32' && ['py', '-3']].filter(Boolean); +const pythons = candidates(); function choose() { for (const py of pythons) if (runs(py, ['-c', 'import mcp'])) return [...py, SERVER]; @@ -62,7 +63,8 @@ function choose() { return null; } -const env = { ...process.env }; +const py = findPython(); +const env = py.exe ? withPython(process.env, py) : { ...process.env }; const { bash, error } = findBash(); if (bash) env.AXIOMCODE_BASH = bash; else process.stderr.write(`axiomcode mcp: ${error}\n The server starts, but every tool will say it cannot run the CLI.\n`); diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_contract.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_contract.py index 2a8f095c..0910ab36 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_contract.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_contract.py @@ -385,7 +385,7 @@ def ensure_graph(repo, db): build = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'axiomcode-build') if not os.path.exists(build): return False print(f"no graph for {repo} yet — building one (this is the only slow call; later ones read it) …", file=sys.stderr) - r = subprocess.run(['bash', build, repo]) + r = subprocess.run([os.environ.get('AXIOMCODE_BASH') or 'bash', build, repo]) return r.returncode == 0 and os.path.exists(db) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py index 37563868..175d3817 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py @@ -26,7 +26,7 @@ Environment: AXIOMCODE_NO_REFRESH=1 turns every trigger off; AXIOMCODE_REFRESH_DEBOUNCE (seconds, default 2) is the quiet window; AXIOMCODE_FRESH_WAIT (seconds, default 10) is how long a query verb waits.""" -import hashlib, json, os, subprocess, sys, time +import errno, hashlib, json, os, subprocess, sys, time H = os.path.dirname(os.path.abspath(__file__)) @@ -166,7 +166,10 @@ def _flock(fd, block): import msvcrt while True: try: msvcrt.locking(fd, msvcrt.LK_NBLCK, 1); return True - except OSError: + except OSError as e: + # Git Bash's fd 9 is not a descriptor in a native python.exe, which inherits only 0-2 (#1331). There is + # no lock to take; waiting for one looped forever, so the build runs unlocked, as a single build did. + if e.errno == errno.EBADF: return True if not block: return False time.sleep(0.5) except OSError: return False @@ -250,7 +253,7 @@ def worker(repo): t0 = time.time(); write_state(repo, state='building', started=t0, files=sum(len(x) for x in c)) if any(c): print(f"{time.strftime('%H:%M:%S')} refresh: {sum(len(x) for x in c)} file(s) changed ({', '.join((c[0] + c[1] + c[2])[:5])}) — rebuilding", flush=True) else: print(f"{time.strftime('%H:%M:%S')} refresh: HEAD moved — moving the baseline to it", flush=True) - r = subprocess.run(['bash', os.path.join(H, 'axiomcode-build'), repo], env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + r = subprocess.run([os.environ.get('AXIOMCODE_BASH') or 'bash', os.path.join(H, 'axiomcode-build'), repo], env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) took = round(time.time() - t0, 1) if r.returncode != 0: write_state(repo, state='failed', finished=time.time(), seconds=took, failed_table=fp, diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode index e6d608a9..3c9e75a3 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode @@ -47,6 +47,22 @@ # .js test scripts is taken for JavaScript); --src limits the analysed tree (e.g. src); --library names dependency roots. H="$(cd "$(dirname "$0")" && pwd)" +# PYTHON UNDER ANOTHER NAME (#1331). Every verb runs `python3`, which a python.org install on Windows does not +# provide, and which on a desktop Windows is the Store placeholder. Started from node (the `axiomcode` command, the +# MCP server, the hooks) find-python.js has already put pyshim/python3 first on PATH and set AXIOMCODE_PYTHON_EXE. +# Run straight from a shell, as the skill says to when there are no MCP tools, this makes the same choice: the +# first of AXIOMCODE_PYTHON, python and py -3 that runs. Off Windows a python3 on PATH is taken without a probe. +if [ -z "${AXIOMCODE_PYTHON_EXE:-}" ] && + case "${OSTYPE:-}" in msys*|cygwin*) ! python3 -c '' >/dev/null 2>&1;; *) ! command -v python3 >/dev/null 2>&1;; esac; then + for p in "${AXIOMCODE_PYTHON:-}" python py; do + [ -n "$p" ] || continue; a=(); [ "$p" = py ] && a=(-3) + e="$("$p" ${a[@]+"${a[@]}"} -c 'import sys; print(sys.executable)' 2>/dev/null | tr -d '\r')" || e="" + if [ -n "$e" ]; then export AXIOMCODE_PYTHON_EXE="${e//\\//}" PATH="$H/pyshim:$PATH"; break; fi + done +fi +# and in UTF-8 mode, as find-python.js sets it: otherwise a piped answer is written in cp1252 and the first → raises +case "${OSTYPE:-}" in msys*|cygwin*) : "${PYTHONUTF8:=1}"; export PYTHONUTF8;; esac + # A VERB ANSWERS; IT DOES NOT HAND BACK AN INSTRUCTION. Reached through this dispatcher — which is # what `axiomcode` on $PATH and the MCP server both go through — a query on a repository with no # graph builds one and then answers, instead of exiting with "run `axiomcode index` first". That diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph index e96593c5..06affc8d 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph @@ -187,7 +187,7 @@ def build(repo, page, library=None): cands = [os.path.join(HERE, 'axiomcode-build'), os.path.join(os.environ.get('AXIOMCODE_ENGINE', ''), 'skills', 'axiomcode', 'scripts', 'axiomcode-build')] b = next((os.path.realpath(c) for c in cands if os.path.isfile(c)), None) if not b: sys.exit("axiomcode-build not found beside this script (set AXIOMCODE_ENGINE to the engine checkout)") - r = subprocess.run([b, repo]) + r = subprocess.run([os.environ.get('AXIOMCODE_BASH') or 'bash', b, repo]) # a bash script: Windows cannot exec it itself if r.returncode: sys.exit(r.returncode) export(repo, page) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/pyshim/python3 b/plugins/axiomcode/skills/axiomcode/scripts/pyshim/python3 new file mode 100755 index 00000000..0b970a96 --- /dev/null +++ b/plugins/axiomcode/skills/axiomcode/scripts/pyshim/python3 @@ -0,0 +1,5 @@ +#!/bin/sh +# python3 for a machine whose Python has another name: python.exe or py.exe from python.org on Windows (#1331). +# find-python.js, or the fallback at the head of scripts/axiomcode, puts this directory first on PATH only after +# setting AXIOMCODE_PYTHON_EXE to an interpreter that ran. +exec "${AXIOMCODE_PYTHON_EXE:?pyshim/python3 is on PATH but AXIOMCODE_PYTHON_EXE is not set}" "$@" diff --git a/tests/manifests.py b/tests/manifests.py index d24caae4..679f9105 100644 --- a/tests/manifests.py +++ b/tests/manifests.py @@ -27,6 +27,7 @@ only in skills/ at that root, so skills/axiomcode/ holds a copy of the skill's text (packaging/copies.py). No server is started by `bash` or `python3`: on Windows a bare `bash` is WSL's or nothing and `python3` is a Store placeholder, and a manifest has no per-platform variant, so every one starts `node` (#1233). + Hooks too: each is `node hooks/run.js .py`, which finds the Python to run it under (#1331). python3 tests/manifests.py """ @@ -34,6 +35,13 @@ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) PLUGIN = os.path.join(ROOT, 'plugins', 'axiomcode') +HOOKS = os.path.join(PLUGIN, 'hooks') + + +def runs_a_hook(command, runner, hooks): + """command is `node "" .py`, and the runner and the hook both exist""" + m = re.fullmatch(r'node "' + re.escape(runner) + r'" (\w+\.py)', command) + return bool(m) and os.path.isfile(os.path.join(hooks, 'run.js')) and os.path.isfile(os.path.join(hooks, m.group(1))) def load(*parts): @@ -103,9 +111,9 @@ def gemini_path(value): for event, groups in load('plugins', 'axiomcode', 'hooks', 'hooks.json')['hooks'].items(): for group in groups: for hook in group['hooks']: - script = re.search(r'\$\{CLAUDE_PLUGIN_ROOT\}(/[^"\s]+)', hook['command']) - if not script or not os.path.isfile(PLUGIN + script.group(1)): - bad.append(f"hooks.json {event}: {hook['command']!r} names no script that exists") + if not runs_a_hook(hook['command'], '${CLAUDE_PLUGIN_ROOT}/hooks/run.js', HOOKS): + bad.append(f"hooks.json {event}: {hook['command']!r} is not `node /hooks/run.js .py` " + "naming a hook that exists; python3 is not a program on Windows (#1331)") # Cursor: its marketplace leads to the plugin, and its manifest agrees with the others and names files # that exist once ${CURSOR_PLUGIN_ROOT} is the plugin directory. @@ -146,9 +154,9 @@ def gemini_path(value): for event, groups in gemini_hooks.items(): for group in groups: for hook in group['hooks']: - script = re.search(r'"([^"]+\.py)"', hook['command']) - if not script or not os.path.isfile(gemini_path(script.group(1))): - bad.append(f"hooks/hooks.json {event}: {hook['command']!r} names no script that exists") + if not runs_a_hook(hook['command'], '${extensionPath}${/}plugins${/}axiomcode${/}hooks${/}run.js', HOOKS): + bad.append(f"hooks/hooks.json {event}: {hook['command']!r} is not `node /plugins/axiomcode/hooks/run.js " + ".py` naming a hook that exists; python3 is not a program on Windows (#1331)") # Gemini's copy of the skill and Cursor's rule are current copies of their sources. sync = subprocess.run([sys.executable, os.path.join(ROOT, 'packaging', 'copies.py'), '--check'], diff --git a/tests/python_names.py b/tests/python_names.py new file mode 100644 index 00000000..e2283796 --- /dev/null +++ b/tests/python_names.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""tests/python_names.py — the CLI and the hooks run on a machine whose Python is not called python3 (#1331). + +A python.org install on Windows has python.exe and py.exe and no python3, and on a desktop Windows `python3` is +the Store placeholder, which is on PATH and exits 9009. Both are simulated here: a `python3` that exits 9009, +and a PATH with no python3 at all. `python` is a wrapper that records each call and runs this interpreter. The +launcher asks it for sys.executable and hands bash that file, so what the record shows is that `python` was the +one asked, and the placeholder records that it was never run past the probe that passes over it. + +The real machine is Windows; this pins the resolution order and the hand-off to bash on any platform. +""" +import json, os, shutil, stat, subprocess, sys, tempfile + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +AXJS = os.path.join(ROOT, 'bin', 'axiomcode.js') +RUNJS = os.path.join(ROOT, 'plugins', 'axiomcode', 'hooks', 'run.js') +FRONT = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'axiomcode') + +fails, checked = [], [] +def check(why, cond, detail=''): + checked.append(why) + print(('ok ' if cond else 'FAIL ') + why + (f'\n {detail}' if not cond and detail else '')) + if not cond: + fails.append(why) + + +def script(path, text): + with open(path, 'w') as f: + f.write(text) + os.chmod(path, os.stat(path).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +with tempfile.TemporaryDirectory() as tmp: + log = os.path.join(tmp, 'python.log') + stubs = os.path.join(tmp, 'stubs') # placeholder python3 + recording python, ahead of the real PATH + bare = os.path.join(tmp, 'bare') # only the tools the front end needs, and no python3 at all + os.makedirs(stubs); os.makedirs(bare) + script(os.path.join(stubs, 'python3'), f'#!/bin/sh\necho "placeholder $*" >> "{log}"\necho "Python was not found" >&2\nexit 9009\n') + python = f'#!/bin/sh\necho "$*" >> "{log}"\nexec "{sys.executable}" "$@"\n' + script(os.path.join(stubs, 'python'), python) + script(os.path.join(bare, 'python'), python) + for tool in ('bash', 'sh', 'sed', 'awk', 'head', 'grep', 'tr', 'dirname', 'cat', 'env', 'node'): + found = shutil.which(tool) + if found: os.symlink(found, os.path.join(bare, tool)) + placeholder = dict(os.environ, PATH=stubs + os.pathsep + os.environ['PATH']) + placeholder.pop('AXIOMCODE_PYTHON', None); placeholder.pop('AXIOMCODE_PYTHON_EXE', None) + + open(log, 'w').close() + + def calls(): + try: + with open(log) as f: return f.read() + finally: + open(log, 'w').close() + + # the command npm links: bash's python3 is the placeholder until the launcher puts its own first + r = subprocess.run(['node', AXJS, 'help', 'context'], env=placeholder, capture_output=True, text=True, timeout=60) + check('`axiomcode help context` answers when python3 is the Store placeholder', + r.returncode == 0 and 'context' in r.stdout and 'Python was not found' not in r.stderr, + f'rc={r.returncode} err={r.stderr[-300:]}') + c = calls() + check('...and the Python bash ran is the one `python` named, not the placeholder', + 'import sys' in c and 'ast.get_docstring' not in c, c) + + # a hook: stdin reaches it, and its exit status comes back unchanged (exit 2 is how a hook blocks) + ev = json.dumps({'tool_name': 'Grep', 'tool_input': {'pattern': 'f'}, 'cwd': tmp, 'session_id': 's1'}) + r = subprocess.run(['node', RUNJS, 'direct.py'], input=ev, env=placeholder, capture_output=True, text=True, timeout=60) + check('a hook runs under `python` when python3 is the placeholder', r.returncode == 0 and 'import sys' in calls(), + f'rc={r.returncode} err={r.stderr[-300:]}') + r = subprocess.run(['node', RUNJS, 'no-such-hook.py'], input='{}', env=placeholder, capture_output=True, text=True, timeout=60) + check("a hook's exit status is passed through, not replaced", r.returncode == 2, f'rc={r.returncode}') + calls() + + # AXIOMCODE_PYTHON comes first, as it does for the MCP server + chosen = os.path.join(tmp, 'mine') + script(chosen, f'#!/bin/sh\necho "mine $*" >> "{log}"\nexec "{sys.executable}" "$@"\n') + r = subprocess.run(['node', AXJS, 'help', 'context'], env=dict(placeholder, AXIOMCODE_PYTHON=chosen), + capture_output=True, text=True, timeout=60) + check('AXIOMCODE_PYTHON is the Python bash runs', r.returncode == 0 and 'mine' in calls(), f'rc={r.returncode}') + + # no Python at all: a hook must never block a tool for want of one + only_node = os.path.join(tmp, 'only-node'); os.makedirs(only_node) + os.symlink(shutil.which('node'), os.path.join(only_node, 'node')) + nopy = dict(placeholder, PATH=only_node) + r = subprocess.run([shutil.which('node'), RUNJS, 'direct.py'], input='{}', env=nopy, capture_output=True, text=True, timeout=60) + check('with no Python, a hook exits 0 and says why on stderr', r.returncode == 0 and 'Python' in r.stderr, + f'rc={r.returncode} err={r.stderr[-300:]}') + + # the skill's own fallback, run from a shell with no node in between and no python3 anywhere on PATH + r = subprocess.run(['bash', FRONT, 'help', 'context'], env=dict(placeholder, PATH=bare), + capture_output=True, text=True, timeout=60) + check('scripts/axiomcode run directly finds `python` when there is no python3', + r.returncode == 0 and 'context' in r.stdout and 'import sys' in calls(), + f'rc={r.returncode} err={r.stderr[-300:]}') + +print() +print(f"{len(checked) - len(fails)} of {len(checked)} check(s) held" if not fails else f"{len(fails)} FAILED: " + '; '.join(fails)) +sys.exit(1 if fails else 0)