Skip to content
10 changes: 9 additions & 1 deletion bin/axiomcode.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand All @@ -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);
Expand Down
18 changes: 9 additions & 9 deletions hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,45 +3,45 @@
"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 }
]
}
],
"BeforeTool": [
{
"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 }
]
}
],
"AfterTool": [
{
"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 } ] }
]
}
}
20 changes: 10 additions & 10 deletions plugins/axiomcode/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
Expand All @@ -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
}
]
Expand All @@ -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
}
]
Expand All @@ -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
}
]
Expand All @@ -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
}
]
Expand All @@ -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
}
]
Expand All @@ -69,15 +69,15 @@
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/orient.py\""
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run.js\" orient.py"
}
]
},
{
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/refresh.py\"",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run.js\" refresh.py",
"timeout": 5
}
]
Expand All @@ -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
}
]
Expand All @@ -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
}
]
Expand Down
36 changes: 36 additions & 0 deletions plugins/axiomcode/hooks/run.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// run.js <hook>.py — run one hook under the Python find-python.js finds (#1331).
//
// hooks.json used to say `python3 <hook>.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 <hook>.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 <hook>.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);
53 changes: 53 additions & 0 deletions plugins/axiomcode/mcp/find-python.js
Original file line number Diff line number Diff line change
@@ -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 };
10 changes: 6 additions & 4 deletions plugins/axiomcode/mcp/launch.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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];
Expand All @@ -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`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
9 changes: 6 additions & 3 deletions plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__))

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 16 additions & 0 deletions plugins/axiomcode/skills/axiomcode/scripts/axiomcode
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
5 changes: 5 additions & 0 deletions plugins/axiomcode/skills/axiomcode/scripts/pyshim/python3
Original file line number Diff line number Diff line change
@@ -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}" "$@"
Loading
Loading