'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const https = require('https');
const http = require('http');
const { spawn } = require('child_process');
const { pipeline } = require('stream');
const PLUGIN_NAME = 'claudix';
const GITHUB_REPO = process.env.CLAUDIX_GITHUB_REPO || 'uwuclxdy/claudix';
const PREBUILT = new Set(['linux-x86_64', 'darwin-aarch64', 'windows-x86_64']);
const GC_KEEP = 2;
const MAX_REDIRECTS = 5;
const argv = process.argv.slice(2);
const isHook = argv[0] === 'hook';
const isWindows = process.platform === 'win32';
function resolvePluginRoot() {
const env = process.env.CLAUDE_PLUGIN_ROOT;
if (env && fs.existsSync(path.join(env, 'bin', 'claudix-bootstrap.js'))) return env;
return path.resolve(__dirname, '..');
}
function resolveCacheDir() {
return (
process.env.CLAUDE_PLUGIN_DATA ||
process.env.CLAUDIX_HOME ||
path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'), PLUGIN_NAME)
);
}
const CACHE_DIR = resolveCacheDir();
const BIN_DIR = path.join(CACHE_DIR, 'bin');
const LOCK_DIR = path.join(CACHE_DIR, 'install.lock');
const PID_FILE = LOCK_DIR + '.pid';
const LOCAL_BIN = process.env.CLAUDIX_LOCAL_BIN || path.join(os.homedir(), '.local', 'bin');
const INSTALL_LOG = path.join(CACHE_DIR, 'install.log');
function cargoBinPath() {
const cargoHome = process.env.CARGO_HOME || path.join(os.homedir(), '.cargo');
return path.join(cargoHome, 'bin', PLUGIN_NAME + (isWindows ? '.exe' : ''));
}
function log(msg) {
process.stderr.write('claudix: ' + msg + '\n');
}
async function logAppend(line) {
try {
await fs.promises.appendFile(INSTALL_LOG, line.endsWith('\n') ? line : line + '\n');
} catch {
}
}
async function readWantedVersion(pluginRoot) {
const manifestPath = path.join(pluginRoot, '.claude-plugin', 'plugin.json');
let manifest;
try {
manifest = JSON.parse(await fs.promises.readFile(manifestPath, 'utf8'));
} catch {
throw new Error('plugin manifest missing or unreadable: ' + manifestPath);
}
if (!manifest.version) throw new Error('could not read version from ' + manifestPath);
return manifest.version;
}
function detectPlatform() {
if (process.env.CLAUDIX_PLATFORM_OVERRIDE) return process.env.CLAUDIX_PLATFORM_OVERRIDE;
let osName;
switch (process.platform) {
case 'linux': osName = 'linux'; break;
case 'darwin': osName = 'darwin'; break;
case 'win32': osName = 'windows'; break;
default: throw new Error('unsupported os: ' + process.platform);
}
let arch;
switch (process.arch) {
case 'x64': arch = 'x86_64'; break;
case 'arm64': arch = 'aarch64'; break;
default: throw new Error('unsupported arch: ' + process.arch);
}
return osName + '-' + arch;
}
function assetName(platform) {
return PLUGIN_NAME + '-' + platform + (platform.startsWith('windows') ? '.exe' : '');
}
function binaryPathFor(version, platform) {
const cached = assetName(platform).replace(/^claudix-/, 'claudix-v' + version + '-');
return path.join(BIN_DIR, cached);
}
async function canExec(p) {
try {
const s = await fs.promises.stat(p);
if (!s.isFile()) return false;
if (isWindows) return true;
return (s.mode & 0o111) !== 0; } catch {
return false;
}
}
function cargoVersion(cb) {
return new Promise((resolve) => {
const c = spawn(cb, ['-V'], { stdio: ['ignore', 'pipe', 'ignore'] });
let out = '';
c.stdout.on('data', (d) => (out += d));
c.on('error', () => resolve(null));
c.on('exit', (code) => {
if (code !== 0) return resolve(null);
const parts = out.trim().split(/\s+/);
resolve(parts.length >= 2 ? parts[1] : null);
});
});
}
async function installedPath(version, platform) {
const binary = binaryPathFor(version, platform);
if (await canExec(binary)) return binary;
const cb = cargoBinPath();
if (await canExec(cb)) {
const v = await cargoVersion(cb);
if (v === version) return cb;
}
return null;
}
function configDevValue(file) {
let content;
try {
content = fs.readFileSync(file, 'utf8');
} catch {
return null;
}
const lines = content.split('\n').filter((l) => /^\s*development_mode\s*=\s*(true|false)/.test(l));
if (lines.length === 0) return null;
const m = lines[lines.length - 1].match(/=\s*(true|false)/);
return m ? m[1] : null;
}
function developmentModeOn() {
const project = process.env.CLAUDE_PROJECT_DIR || process.env.PWD || process.cwd();
let val = configDevValue(path.join(project, '.claude', 'claudix.toml'));
if (val === null) val = configDevValue(path.join(os.homedir(), '.claude', 'claudix.toml'));
return val === 'true';
}
async function resolveDevBinary() {
const cb = cargoBinPath();
if (await canExec(cb)) return cb;
log(
"development_mode is on but no cargo binary at " +
cb +
"; run 'cargo install --path .' from the claudix repo"
);
return null;
}
async function sha256File(p) {
const h = crypto.createHash('sha256');
await pipeline(fs.createReadStream(p), h);
return h.digest('hex');
}
function fetchFile(url, dest) {
return new Promise((resolve, reject) => {
if (url.startsWith('file://')) {
fs.promises.copyFile(url.slice('file://'.length), dest).then(resolve, (e) => {
fs.promises.unlink(dest).catch(() => {});
reject(e);
});
return;
}
const proto = url.startsWith('https') ? https : http;
let hops = 0;
const doGet = (u) => {
const req = proto.get(u, { timeout: 270000 }, (res) => {
if (
res.statusCode >= 300 &&
res.statusCode < 400 &&
res.headers.location &&
hops < MAX_REDIRECTS
) {
hops += 1;
res.destroy();
let next = res.headers.location;
if (next.startsWith('/')) next = new URL(u).origin + next;
doGet(next);
return;
}
if (res.statusCode !== 200) {
res.destroy();
reject(new Error('HTTP ' + res.statusCode + ' for ' + u));
return;
}
const stream = fs.createWriteStream(dest);
res.pipe(stream);
stream.on('finish', () => stream.close(resolve));
stream.on('error', (e) => {
fs.promises.unlink(dest).catch(() => {});
reject(e);
});
});
req.on('error', (e) => {
fs.promises.unlink(dest).catch(() => {});
reject(e);
});
req.on('timeout', () => req.destroy(new Error('download timed out for ' + u)));
};
doGet(url);
});
}
async function gcOldVersions(platform) {
let entries;
try {
entries = await fs.promises.readdir(BIN_DIR);
} catch {
return;
}
const suffix = '-' + platform + (platform.startsWith('windows') ? '.exe' : '');
const ours = entries.filter((f) => f.startsWith(PLUGIN_NAME + '-v') && f.endsWith(suffix));
if (ours.length <= GC_KEEP) return;
const statted = await Promise.all(
ours.map(async (f) => {
try {
const s = await fs.promises.stat(path.join(BIN_DIR, f));
return { f, mtime: s.mtimeMs };
} catch {
return null;
}
})
);
statted
.filter(Boolean)
.sort((a, b) => b.mtime - a.mtime)
.slice(GC_KEEP)
.forEach(({ f }) => fs.promises.unlink(path.join(BIN_DIR, f)).catch(() => {}));
}
async function warnCargoDuplicate(releaseBinary) {
const cb = cargoBinPath();
if (!(await canExec(cb))) return;
let cbReal;
let relReal;
try {
cbReal = await fs.promises.realpath(cb);
relReal = await fs.promises.realpath(releaseBinary);
} catch {
return;
}
if (cbReal === relReal) return;
const ver = await cargoVersion(cb);
log(
'found cargo-installed ' +
PLUGIN_NAME +
' at ' +
cb +
' (v' +
(ver || 'unknown') +
"); left in place, remove with 'cargo uninstall " +
PLUGIN_NAME +
"' if undesired"
);
}
async function exposeCommand(binary, platform) {
if (platform.startsWith('windows')) return; await fs.promises.mkdir(LOCAL_BIN, { recursive: true }).catch(() => {});
const link = path.join(LOCAL_BIN, PLUGIN_NAME);
try {
await fs.promises.symlink(binary, link);
} catch (e) {
if (e.code !== 'EEXIST') return;
await fs.promises.unlink(link).catch(() => {}); await fs.promises.symlink(binary, link).catch(() => {});
}
log('linked ' + link + ' -> ' + path.basename(binary));
await warnCargoDuplicate(binary);
if ((':' + (process.env.PATH || '') + ':').indexOf(':' + LOCAL_BIN + ':') === -1) {
log("add " + LOCAL_BIN + " to PATH to run '" + PLUGIN_NAME + "' from your shell");
}
}
async function installFromRelease(version, platform) {
const asset = assetName(platform);
const binaryDest = binaryPathFor(version, platform);
const baseUrl =
process.env.CLAUDIX_RELEASE_BASE_URL ||
'https://github.com/' + GITHUB_REPO + '/releases/download/v' + version;
await fs.promises.mkdir(BIN_DIR, { recursive: true });
const tmpDir = await fs.promises.mkdtemp(path.join(CACHE_DIR, 'download.'));
const assetPath = path.join(tmpDir, asset);
const sumsPath = path.join(tmpDir, 'SHA256SUMS');
try {
log('downloading ' + asset + ' v' + version);
await logAppend('downloading ' + asset + ' v' + version);
await fetchFile(baseUrl + '/' + asset, assetPath);
await fetchFile(baseUrl + '/SHA256SUMS', sumsPath);
const sums = await fs.promises.readFile(sumsPath, 'utf8');
let expected = null;
for (const line of sums.split('\n')) {
const parts = line.trim().split(/\s+/);
if (parts.length === 2 && parts[1] === asset) {
expected = parts[0];
break;
}
}
if (!expected) throw new Error('SHA256SUMS does not contain ' + asset);
const actual = await sha256File(assetPath);
if (actual !== expected) throw new Error('checksum mismatch for ' + asset);
if (!isWindows) await fs.promises.chmod(assetPath, 0o755);
await fs.promises.rename(assetPath, binaryDest); log('installed claudix v' + version + ' for ' + platform);
await gcOldVersions(platform);
await exposeCommand(binaryDest, platform);
return binaryDest;
} finally {
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
}
function runOk(cmd, args, shell) {
return new Promise((resolve) => {
const c = spawn(cmd, args, { stdio: 'ignore', shell: !!shell });
c.on('error', () => resolve(false));
c.on('exit', (code) => resolve(code === 0));
});
}
async function tryCargo(version) {
if (!(await runOk('cargo', ['--version'], isWindows))) return null;
log('no prebuilt for this target; falling back to cargo install');
await logAppend('falling back to cargo install claudix@' + version);
if (!(await runOk('cargo', ['install', PLUGIN_NAME + '@' + version, '--quiet'], isWindows))) {
return null;
}
const cb = cargoBinPath();
if (await canExec(cb)) return cb;
return null;
}
function pidAlive(pid) {
try {
process.kill(pid, 0);
return true;
} catch (e) {
return e.code !== 'ESRCH'; }
}
async function acquireLock() {
await fs.promises.mkdir(CACHE_DIR, { recursive: true });
let attempts = 0;
for (;;) {
try {
await fs.promises.mkdir(LOCK_DIR);
break;
} catch (e) {
if (e.code !== 'EEXIST') throw e;
let pid = null;
try {
pid = parseInt(await fs.promises.readFile(PID_FILE, 'utf8'), 10);
} catch {
}
if (pid && !pidAlive(pid)) {
await fs.promises.rm(LOCK_DIR, { recursive: true, force: true }).catch(() => {});
await fs.promises.rm(PID_FILE, { force: true }).catch(() => {});
continue;
}
if (++attempts > 120) throw new Error('install lock stuck; remove ' + LOCK_DIR + ' and retry');
await new Promise((r) => setTimeout(r, 1000));
}
}
await fs.promises.writeFile(PID_FILE, String(process.pid));
}
function releaseLock() {
return Promise.all([
fs.promises.rm(PID_FILE, { force: true }).catch(() => {}),
fs.promises.rmdir(LOCK_DIR).catch(() => {}),
]);
}
async function doInstall(version, platform) {
await acquireLock();
try {
const hit = await installedPath(version, platform);
if (hit) {
await exposeCommand(hit, platform); return hit;
}
if (PREBUILT.has(platform)) return await installFromRelease(version, platform);
const cargoBin = await tryCargo(version);
if (cargoBin) return cargoBin;
throw new Error(
'no prebuilt for ' + platform + ' and cargo install failed; install rust from https://rustup.rs and retry'
);
} catch (e) {
await logAppend('error: ' + ((e && e.message) || String(e)));
throw e;
} finally {
await releaseLock();
}
}
async function cleanupStale() {
let entries;
try {
entries = await fs.promises.readdir(CACHE_DIR);
} catch {
return;
}
for (const e of entries) {
if (!e.startsWith('download.')) continue;
const p = path.join(CACHE_DIR, e);
try {
const s = await fs.promises.stat(p);
if (s.isDirectory()) await fs.promises.rm(p, { recursive: true, force: true });
} catch {
}
}
if (fs.existsSync(LOCK_DIR) && fs.existsSync(PID_FILE)) {
let pid = null;
try {
pid = parseInt(await fs.promises.readFile(PID_FILE, 'utf8'), 10);
} catch {
}
if (pid && !pidAlive(pid)) {
await fs.promises.rm(LOCK_DIR, { recursive: true, force: true }).catch(() => {});
await fs.promises.rm(PID_FILE, { force: true }).catch(() => {});
}
}
}
function spawnNative(binary, forwardArgs, hook) {
const child = spawn(binary, forwardArgs, {
stdio: 'inherit', env: process.env,
});
['SIGINT', 'SIGTERM', 'SIGHUP'].forEach((sig) => {
process.on(sig, () => {
try {
child.kill(sig);
} catch {
}
});
});
child.on('error', () => process.exit(hook ? 0 : 1));
child.on('exit', (code) => {
process.exit(hook ? 0 : code == null ? 1 : code);
});
}
function spawnInstallBackground() {
try {
const out = fs.openSync(INSTALL_LOG, 'a');
const child = spawn(
process.execPath,
[path.join(__dirname, 'claudix-bootstrap.js'), '--install'],
{
detached: true,
stdio: ['ignore', out, out],
env: { ...process.env, CLAUDE_PLUGIN_ROOT: resolvePluginRoot() },
}
);
child.unref();
} catch (e) {
logAppend('error: failed to spawn background install: ' + e.message).catch(() => {});
}
}
async function main() {
await cleanupStale();
const pluginRoot = resolvePluginRoot();
if (developmentModeOn()) {
const cb = await resolveDevBinary();
if (cb) {
if (argv[0] === '--check-only' || argv[0] === '--install') {
process.stdout.write(cb + '\n');
process.exit(0);
}
spawnNative(cb, argv, isHook);
return;
}
if (isHook) process.exit(0);
if (argv[0] === 'mcp') process.exit(1);
process.exit(3);
}
const version = await readWantedVersion(pluginRoot);
const platform = detectPlatform();
switch (argv[0]) {
case '--check-only': {
const hit = await installedPath(version, platform);
if (hit) {
process.stdout.write(hit + '\n');
process.exit(0);
}
process.exit(1);
}
case '--install': {
const hit = await installedPath(version, platform);
if (hit) {
await exposeCommand(hit, platform);
process.stdout.write(hit + '\n');
process.exit(0);
}
const installed = await doInstall(version, platform);
process.stdout.write(installed + '\n');
process.exit(0);
}
case 'mcp':
case 'hook':
default: {
const hit = await installedPath(version, platform);
if (hit) {
spawnNative(hit, argv, isHook);
return;
}
spawnInstallBackground();
if (argv[0] === 'mcp') {
process.stderr.write(
'claudix: native binary not yet cached — downloading in background, restart Claude Code once it completes\n'
);
process.exit(1); }
process.stderr.write(
'claudix: binary installing in background; hooks activate on next session start\n'
);
process.exit(0); }
}
}
function failTop(err) {
try {
process.stderr.write('claudix: ' + ((err && err.stack) || err) + '\n');
} catch {
}
process.exit(isHook ? 0 : 1);
}
process.on('uncaughtException', failTop);
process.on('unhandledRejection', failTop);
main().catch(failTop);