From 6be13575e816411716388b97790b280c66011952 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:25:45 -0700 Subject: [PATCH 1/6] windows: find Python the way the MCP launcher does, and give bash a python3 that runs it (#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. Every CLI verb and every hook called python3, so all of them failed although Python was installed. - mcp/find-python.js: AXIOMCODE_PYTHON, python3, python, py -3; the first that runs, by its sys.executable. launch.js now takes its list from here. - bin/axiomcode.js and launch.js put scripts/pyshim/python3 first on bash's PATH, pointing at that interpreter (always on Windows, and elsewhere when python3 is not the one chosen). - hooks: every command is node hooks/run.js .py, which passes stdin, stdout, stderr and the exit status through unchanged, and exits 0 when there is no Python so a hook never blocks a tool. - scripts/axiomcode makes the same choice when run straight from a shell, as the skill says to without MCP tools. - Python-started builds use AXIOMCODE_BASH when set, as server.py already does. - tests/python_names.py: 7 checks, all red on dev; manifests.py requires the runner. - version 0.1.1. --- bin/axiomcode.js | 10 +- gemini-extension.json | 2 +- hooks/hooks.json | 18 ++-- package.json | 12 +-- parser/package.json | 2 +- plugins/axiomcode/.claude-plugin/plugin.json | 2 +- plugins/axiomcode/.codex-plugin/plugin.json | 2 +- plugins/axiomcode/.cursor-plugin/plugin.json | 2 +- plugins/axiomcode/hooks/hooks.json | 20 ++-- plugins/axiomcode/hooks/run.js | 36 +++++++ plugins/axiomcode/mcp/find-python.js | 50 ++++++++++ plugins/axiomcode/mcp/launch.js | 10 +- .../skills/axiomcode/scripts/ax_contract.py | 2 +- .../skills/axiomcode/scripts/ax_fresh.py | 2 +- .../skills/axiomcode/scripts/axiomcode | 14 +++ .../skills/axiomcode/scripts/pyshim/python3 | 5 + tests/README.md | 3 + tests/manifests.py | 20 ++-- tests/python_names.py | 98 +++++++++++++++++++ 19 files changed, 267 insertions(+), 43 deletions(-) create mode 100644 plugins/axiomcode/hooks/run.js create mode 100644 plugins/axiomcode/mcp/find-python.js create mode 100755 plugins/axiomcode/skills/axiomcode/scripts/pyshim/python3 create mode 100644 tests/python_names.py 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/gemini-extension.json b/gemini-extension.json index aeb7b99b..bdc7980f 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "axiomcode", - "version": "0.1.0", + "version": "0.1.1", "description": "Ask your repository how its code connects: who calls this, what breaks if I change it, which tests an edit reaches, how A reaches B. Answers come from a resolved call graph and are verified against it; nothing is guessed.", "contextFileName": "plugins/axiomcode/AGENTS.md", "mcpServers": { 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/package.json b/package.json index aee53b53..0248c3ab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@axiomcode/code-graph", - "version": "0.1.0", + "version": "0.1.1", "description": "AxiomCode Graph: a resolved call graph of your codebase grounded in formal methods, so you and your coding agents can see who calls what, what a change breaks, and which tests it reaches.", "repository": { "type": "git", @@ -30,11 +30,11 @@ ], "license": "FSL-1.1-Apache-2.0", "optionalDependencies": { - "@axiomcode/engine-darwin-arm64": "0.1.0", - "@axiomcode/engine-darwin-x64": "0.1.0", - "@axiomcode/engine-linux-x64": "0.1.0", - "@axiomcode/engine-linux-arm64": "0.1.0", - "@axiomcode/engine-win32-x64": "0.1.0" + "@axiomcode/engine-darwin-arm64": "0.1.1", + "@axiomcode/engine-darwin-x64": "0.1.1", + "@axiomcode/engine-linux-x64": "0.1.1", + "@axiomcode/engine-linux-arm64": "0.1.1", + "@axiomcode/engine-win32-x64": "0.1.1" }, "bin": { "axiomcode": "bin/axiomcode.js" diff --git a/parser/package.json b/parser/package.json index fe7527c5..6c9395ca 100644 --- a/parser/package.json +++ b/parser/package.json @@ -1,6 +1,6 @@ { "name": "@axiomcode/parser", - "version": "0.1.0", + "version": "0.1.1", "description": "AxiomCode Parser — compiles source code and build configuration into a relational intermediate representation.", "main": "dist/extract.js", "types": "dist/extract.d.ts", diff --git a/plugins/axiomcode/.claude-plugin/plugin.json b/plugins/axiomcode/.claude-plugin/plugin.json index 16f99e47..514bde66 100644 --- a/plugins/axiomcode/.claude-plugin/plugin.json +++ b/plugins/axiomcode/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "axiomcode", "description": "Ask your repository how its code connects: who calls this, what breaks if I change it, which tests an edit reaches, how A reaches B. Answers come from a resolved call graph and are verified against it; nothing is guessed.", - "version": "0.1.0", + "version": "0.1.1", "author": { "name": "AxiomCode" } diff --git a/plugins/axiomcode/.codex-plugin/plugin.json b/plugins/axiomcode/.codex-plugin/plugin.json index 57d2e99f..811f1fb2 100644 --- a/plugins/axiomcode/.codex-plugin/plugin.json +++ b/plugins/axiomcode/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "axiomcode", - "version": "0.1.0", + "version": "0.1.1", "description": "Ask your repository how its code connects: who calls this, what breaks if I change it, which tests an edit reaches, how A reaches B. Answers come from a resolved call graph and are verified against it; nothing is guessed.", "author": { "name": "AxiomCode", "url": "https://github.com/AxiomCodeAI/axiomcodegraph" }, "homepage": "https://github.com/AxiomCodeAI/axiomcodegraph", diff --git a/plugins/axiomcode/.cursor-plugin/plugin.json b/plugins/axiomcode/.cursor-plugin/plugin.json index d7a2d38e..229f4d27 100644 --- a/plugins/axiomcode/.cursor-plugin/plugin.json +++ b/plugins/axiomcode/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "axiomcode", - "version": "0.1.0", + "version": "0.1.1", "description": "Ask your repository how its code connects: who calls this, what breaks if I change it, which tests an edit reaches, how A reaches B. Answers come from a resolved call graph and are verified against it; nothing is guessed. Java, TypeScript, Python, JavaScript.", "author": { "name": "AxiomCode" }, "homepage": "https://github.com/AxiomCodeAI/axiomcodegraph", 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..280f485a --- /dev/null +++ b/plugins/axiomcode/mcp/find-python.js @@ -0,0 +1,50 @@ +// 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, '/') }; + 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..a856c843 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py @@ -250,7 +250,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..66753404 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode @@ -47,6 +47,20 @@ # .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 + # 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/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/README.md b/tests/README.md index 1aef034a..93b56acf 100644 --- a/tests/README.md +++ b/tests/README.md @@ -24,6 +24,9 @@ One check needs no graph and is its own script: directly, through an npm-style symlink to bin/axiomcode.js, on the SDK-free fallback, and from .mcp.json, .codex-plugin/mcp.json and .cursor-plugin as each host starts it + python3 tests/python_names.py the CLI, the hooks and the skill's own entry point run on a machine whose + Python is `python` or `py -3` and whose python3 is the Store placeholder, + as on Windows with python.org's installer (#1331) python3 tests/manifests.py every agent's manifest (Claude, Codex, Cursor, Gemini) names the same plugin and points at files that exist, the way that agent resolves them, and Gemini's skill and Cursor's rule are current copies 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) From 4d0d1577700e04dde575802db799119f3590acd9 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:27:08 -0700 Subject: [PATCH 2/6] ax_fresh: Git Bash's fd 9 is not a descriptor in native python.exe, so the build lock looped forever (#1331) tests/README.md is left as it was. --- plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py | 7 +++++-- tests/README.md | 3 --- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py index a856c843..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 diff --git a/tests/README.md b/tests/README.md index 93b56acf..1aef034a 100644 --- a/tests/README.md +++ b/tests/README.md @@ -24,9 +24,6 @@ One check needs no graph and is its own script: directly, through an npm-style symlink to bin/axiomcode.js, on the SDK-free fallback, and from .mcp.json, .codex-plugin/mcp.json and .cursor-plugin as each host starts it - python3 tests/python_names.py the CLI, the hooks and the skill's own entry point run on a machine whose - Python is `python` or `py -3` and whose python3 is the Store placeholder, - as on Windows with python.org's installer (#1331) python3 tests/manifests.py every agent's manifest (Claude, Codex, Cursor, Gemini) names the same plugin and points at files that exist, the way that agent resolves them, and Gemini's skill and Cursor's rule are current copies From 01c009486480becae0c252fa2561a56abe4e53b4 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:30:25 -0700 Subject: [PATCH 3/6] =?UTF-8?q?windows:=20run=20Python=20in=20UTF-8=20mode?= =?UTF-8?q?,=20so=20a=20piped=20answer=20with=20=E2=86=92=20does=20not=20r?= =?UTF-8?q?aise=20UnicodeEncodeError=20(#1331)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/axiomcode/mcp/find-python.js | 3 +++ plugins/axiomcode/skills/axiomcode/scripts/axiomcode | 2 ++ 2 files changed, 5 insertions(+) diff --git a/plugins/axiomcode/mcp/find-python.js b/plugins/axiomcode/mcp/find-python.js index 280f485a..70c9fdd7 100644 --- a/plugins/axiomcode/mcp/find-python.js +++ b/plugins/axiomcode/mcp/find-python.js @@ -40,6 +40,9 @@ function findPython() { // 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'; diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode index 66753404..3c9e75a3 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode @@ -60,6 +60,8 @@ if [ -z "${AXIOMCODE_PYTHON_EXE:-}" ] && 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 From c30f0c5c91c5f4c9e4a2ee1dce73715a51f5c106 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:31:07 -0700 Subject: [PATCH 4/6] graph: run axiomcode-build through bash; Windows cannot exec a script (#1331) --- plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) From e19a5f1c11a03d6bfe55c40cdbb5d5fdeaea22d7 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:38:38 -0700 Subject: [PATCH 5/6] parser stays at 0.1.0 --- parser/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parser/package.json b/parser/package.json index 6c9395ca..fe7527c5 100644 --- a/parser/package.json +++ b/parser/package.json @@ -1,6 +1,6 @@ { "name": "@axiomcode/parser", - "version": "0.1.1", + "version": "0.1.0", "description": "AxiomCode Parser — compiles source code and build configuration into a relational intermediate representation.", "main": "dist/extract.js", "types": "dist/extract.d.ts", From 7a11c1509caeb0e4c45462797f6dd0105e0c0697 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:39:16 -0700 Subject: [PATCH 6/6] version.mjs: the vendored parser keeps its own version --- .github/scripts/version.mjs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/scripts/version.mjs b/.github/scripts/version.mjs index 7d5cbc2a..e1af7b4e 100755 --- a/.github/scripts/version.mjs +++ b/.github/scripts/version.mjs @@ -4,7 +4,7 @@ // // The release version is package.json's `version`. It is repeated in the engine // pins (optionalDependencies — each engine package is published under the same -// version as this one), the vendored parser, and every agent plugin manifest a +// version as this one) and every agent plugin manifest a // marketplace reads. A release where they disagree ships a plugin that reports a // version nobody can install, or an install that pins engines that were never // published. @@ -24,7 +24,6 @@ const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); // reading the file, so a fifth platform is covered without editing this list. const MANIFESTS = [ 'package.json', - 'parser/package.json', 'gemini-extension.json', 'plugins/axiomcode/.claude-plugin/plugin.json', 'plugins/axiomcode/.codex-plugin/plugin.json',