UNPKG

@jovan158/vantage

Version:

See and control what your coding agent does

175 lines (174 loc) • 9.08 kB
// `vantage doctor`: is everything Vantage relies on in place on this machine? // // Each check runs the real thing where it can — starts each agent for its // version, runs the registered hook the way Claude Code would — so a pass // means it works, not just that a file exists. Every problem comes with what // to do about it. import fs from "node:fs"; import path from "node:path"; import { spawnSync } from "node:child_process"; import { resolveCommand } from "./resolve.js"; import { hookInvocation } from "./hook.js"; import { loadRules, policyFilePath } from "./rules.js"; import { activePrices, STALE_AFTER_DAYS } from "./pricing.js"; import { vantageHome, knownSessions, sessionRunning } from "./home.js"; import { AGENTS } from "./agents/index.js"; function firstLine(s) { return (s ?? "").trim().split(/\r?\n/)[0] ?? ""; } export function checkNode(version = process.versions.node) { const [major = 0, minor = 0] = version.split(".").map(Number); const ok = major > 22 || (major === 22 && minor >= 6); return ok ? { level: "ok", text: `Node.js ${version}` } : { level: "fail", text: `Node.js ${version} is too old`, hint: "install Node.js 22.6 or newer" }; } // Is the agent installed and does it start? `required`: a missing agent is // a problem (it was asked about, or it is the only one) rather than a note. export function checkAgent(adapter, opts, required = true) { const env = opts.env ?? process.env; const override = env.VANTAGE_AGENT_PATH; if (override && !fs.existsSync(override)) { return { level: "fail", text: `VANTAGE_AGENT_PATH points at ${override}, which does not exist`, hint: "fix or unset VANTAGE_AGENT_PATH" }; } const target = resolveCommand(override || adapter.command, { env, platform: opts.platform }); if (!target.ok) { return required ? { level: "fail", text: `${adapter.name} not found`, hint: `${target.reason} — install it (${adapter.install}) or set VANTAGE_AGENT_PATH` } : { level: "info", text: `${adapter.name} not installed` }; } const r = spawnSync(target.resolved.command, [...target.resolved.prefix, "--version"], { env, encoding: "utf8", timeout: 20_000, windowsHide: true }); if (r.error || r.status !== 0) { const missing = r.error?.code === "ENOENT"; if (missing && !required) return { level: "info", text: `${adapter.name} not installed` }; const why = r.error ? (missing ? "not found on PATH" : r.error.message) : firstLine(r.stderr) || `exit ${r.status}`; return { level: "fail", text: `${adapter.name} could not be started (${why})`, hint: `install it (${adapter.install}) or set VANTAGE_AGENT_PATH to its executable` }; } const where = override ? ` via VANTAGE_AGENT_PATH` : target.resolved.command === adapter.command ? "" : ` (${target.resolved.command})`; // Agents print their version in their own words; the number is enough. const line = firstLine(r.stdout); const version = /\d+\.\d+(?:\.\d+)?(?:[-+][\w.]+)?/.exec(line)?.[0] ?? line; return { level: "ok", text: `${adapter.name} ${version}${where}` }; } export function checkClaude(opts) { return checkAgent(AGENTS[0], opts); } // Every agent Vantage knows: the ones installed, and a note for the rest. // Without any installed agent there is nothing to run. VANTAGE_AGENT_PATH // names one executable; without an agent named, it is Claude Code's. export function checkAgents(opts) { const env = opts.env ?? process.env; const checks = AGENTS.map((a, i) => checkAgent(a, i === 0 ? opts : { ...opts, env: { ...env, VANTAGE_AGENT_PATH: "" } }, false)); if (checks.every((c) => c.level === "info")) { return [{ level: "fail", text: "no coding agent found", hint: `install one, e.g. ${AGENTS[0].install}` }, ...checks]; } return checks; } // Runs the hook exactly as registered for Claude Code (exec form, no shell) // with a call a deny rule must block. export function checkHook(opts) { const inv = hookInvocation(process.execPath, opts.entry); const payload = JSON.stringify({ hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: { command: "echo vantage-doctor" }, cwd: opts.cwd }); const r = spawnSync(inv.command, inv.args, { input: payload, encoding: "utf8", timeout: 20_000, env: { ...(opts.env ?? process.env), VANTAGE_POLICY: "shell:deny", VANTAGE_POLICY_FILE: "", VANTAGE_EVENTS_FILE: "" }, windowsHide: true, }); try { const decision = JSON.parse(r.stdout).hookSpecificOutput.permissionDecision; if (decision === "deny") return { level: "ok", text: "approval hook runs and blocks (as Claude Code starts it, without a shell)" }; } catch { /* fall through */ } return { level: "fail", text: "approval hook did not answer as expected", hint: firstLine(r.stderr) || "run `vantage hook` by hand to see the error" }; } export function checkGit(opts) { const v = spawnSync("git", ["--version"], { encoding: "utf8", timeout: 10_000, windowsHide: true }); if (v.error || v.status !== 0) { return { level: "warn", text: "git not found", hint: "install git for change summaries and --isolate" }; } const inRepo = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd: opts.cwd, encoding: "utf8", timeout: 10_000, windowsHide: true }); const repo = inRepo.status === 0 ? "this folder is a git repository: sessions end with a change summary" : "this folder is not a git repository: no change summary here"; return { level: "ok", text: `${firstLine(v.stdout)} · ${repo}` }; } export function checkHome() { const home = vantageHome(); try { fs.mkdirSync(home, { recursive: true }); const probe = path.join(home, `.doctor-${process.pid}`); fs.writeFileSync(probe, "ok"); fs.rmSync(probe); return { level: "ok", text: `settings folder is writable (${home})` }; } catch (err) { return { level: "fail", text: `cannot write to ${home}`, hint: `${err.message} — or point VANTAGE_HOME elsewhere` }; } } export function checkPolicy(opts) { const file = policyFilePath(opts.cwd); if (!fs.existsSync(file)) return { level: "info", text: "no rules for this folder", note: "`vantage policy init` creates a starter set" }; try { JSON.parse(fs.readFileSync(file, "utf8")); } catch (err) { return { level: "fail", text: `${path.relative(opts.cwd, file)} is not valid JSON — its rules are not applied`, hint: err.message }; } const rules = loadRules(file); return { level: "ok", text: `${rules.length} file/command rule(s) in ${path.relative(opts.cwd, file)}` }; } export function checkPrices(nowMs = Date.now()) { const p = activePrices(); if (p.cacheError) return { level: "warn", text: "downloaded price list ignored", hint: p.cacheError }; const days = Math.floor((nowMs - Date.parse(p.asOf)) / 86_400_000); if (days >= STALE_AFTER_DAYS) return { level: "warn", text: `price list is ${days} days old`, hint: "`vantage pricing update`" }; return { level: "ok", text: `price lists from ${p.asOf.slice(0, 10)} (${Object.keys(p.models).length} models from Anthropic, OpenAI and Google)` }; } export function checkSessions(opts) { const all = knownSessions(opts.cwd); const running = all.filter((r) => sessionRunning(r)).length; return { level: "info", text: `${all.length} session(s) recorded, ${running} running` }; } // `agent`: only that one, required; otherwise all of them. export function runDoctor(opts, agent) { return [ checkNode(), ...(agent ? [checkAgent(agent, opts)] : checkAgents(opts)), checkHook(opts), checkGit(opts), checkHome(), checkPolicy(opts), checkPrices(opts.nowMs), checkSessions(opts), ]; } export function renderDoctor(checks, color = true) { const paint = color ? { ok: "\x1b[32m", warn: "\x1b[33m", fail: "\x1b[31m", info: "\x1b[2m" } : { ok: "", warn: "", fail: "", info: "" }; const reset = color ? "\x1b[0m" : ""; const dim = color ? "\x1b[2m" : ""; const lines = [`${color ? "\x1b[1m" : ""}vantage doctor${reset}`, ""]; for (const c of checks) { lines.push(` ${paint[c.level]}${c.level.padEnd(4)}${reset} ${c.text}`); const extra = c.level === "warn" || c.level === "fail" ? c.hint : undefined; for (const line of [extra, c.note].filter(Boolean)) lines.push(` ${dim}${line}${reset}`); } const fails = checks.filter((c) => c.level === "fail").length; const warns = checks.filter((c) => c.level === "warn").length; lines.push(""); lines.push(fails ? `${paint.fail}${fails} problem(s) to fix${reset}${warns ? `, ${warns} warning(s)` : ""}` : warns ? `${paint.warn}Ready, with ${warns} warning(s)${reset}` : `${paint.ok}Everything is in place.${reset}`); return lines.join("\n"); }