diff --git a/extension/package.json b/extension/package.json index 376b149..bbb1445 100644 --- a/extension/package.json +++ b/extension/package.json @@ -2,7 +2,7 @@ "name": "axme-code", "displayName": "AXME Code", "description": "Persistent memory, decisions, and safety guardrails for Cursor, GitHub Copilot, Cline, Continue, Roo Code, Windsurf, and VS Code chat agents", - "version": "0.0.1", + "version": "0.0.2", "publisher": "AxmeAI", "repository": { "type": "git", @@ -80,6 +80,21 @@ "command": "axme.showStatus", "title": "AXME: Show status", "category": "AXME" + }, + { + "command": "axme.showStatusText", + "title": "AXME: Show status (text output)", + "category": "AXME" + }, + { + "command": "axme.reauthAuditor", + "title": "AXME: Reauth auditor (paste new API key)", + "category": "AXME" + }, + { + "command": "axme.reset", + "title": "AXME: Reset (clear hooks + auth on this machine)", + "category": "AXME" } ] }, diff --git a/extension/src/activation-report.ts b/extension/src/activation-report.ts new file mode 100644 index 0000000..bdd1682 --- /dev/null +++ b/extension/src/activation-report.ts @@ -0,0 +1,82 @@ +/** + * Activation status report — tracks success/failure of each step during + * `activate()` so we can surface a clear summary at the end. + * + * Without this, a half-broken install (e.g. hooks failed to write, auditor + * auth never persisted) leaves the user with no visible signal that + * something is wrong — they only find out hours later when an action that + * should have been blocked goes through, or when audit doesn't run. + * + * The report is rendered as: + * - one-line summary toast when everything succeeded: + * "AXME Code ready: ✓ MCP ✓ Hooks ✓ Auth (claude) ✓ KB (33 dec, 6 mems)" + * - notification with [Show output] button when ANY step failed: + * "AXME Code: partial install — Hooks ✗ (see output)" + * + * Each step records (kind, ok, detail). The summary is built when + * activation completes. + */ + +import * as vscode from "vscode"; +import { show as showOutput } from "./log.js"; + +export type StepKind = "mcp" | "hooks" | "auth" | "setup" | "binary"; + +interface Step { + kind: StepKind; + ok: boolean; + detail: string; +} + +const LABELS: Record = { + binary: "Binary", + mcp: "MCP", + hooks: "Hooks", + auth: "Auth", + setup: "KB", +}; + +export class ActivationReport { + private readonly steps: Step[] = []; + + record(kind: StepKind, ok: boolean, detail: string): void { + this.steps.push({ kind, ok, detail }); + } + + /** Render success/failure summary as a single line. */ + summary(): string { + return this.steps + .map((s) => `${s.ok ? "✓" : "✗"} ${LABELS[s.kind]}${s.detail ? ` (${s.detail})` : ""}`) + .join(" "); + } + + hasFailure(): boolean { + return this.steps.some((s) => !s.ok); + } + + failedSteps(): Step[] { + return this.steps.filter((s) => !s.ok); + } + + /** + * Show the result to the user. Non-modal in both branches. Success path + * is dismissed automatically after VS Code's notification timeout; failure + * path has a "Show output" button so the user can inspect the AXME Code + * channel for the actual error. + */ + async present(): Promise { + const text = this.summary(); + if (!this.hasFailure()) { + // Success — short info notification. + void vscode.window.showInformationMessage(`AXME Code ready: ${text}`); + return; + } + const failedNames = this.failedSteps().map((s) => LABELS[s.kind]).join(", "); + const choice = await vscode.window.showWarningMessage( + `AXME Code: partial install — ${failedNames} ${this.failedSteps().length === 1 ? "failed" : "failed"}. ${text}`, + "Show output", + "Dismiss", + ); + if (choice === "Show output") showOutput(); + } +} diff --git a/extension/src/commands.ts b/extension/src/commands.ts index 88cb277..3764b2a 100644 --- a/extension/src/commands.ts +++ b/extension/src/commands.ts @@ -14,6 +14,8 @@ import { IdeKind } from "./ide-detect.js"; import { runSetup } from "./setup-controller.js"; import { ensureAuditorAuth } from "./auditor-auth.js"; import { AxmeStatusBar } from "./status-bar.js"; +import { openStatusWebview } from "./status-webview.js"; +import { runReset } from "./reset.js"; import { log, logError, show as showOutput } from "./log.js"; function workspaceRoot(): string | undefined { @@ -67,6 +69,14 @@ export function registerCommands( }), vscode.commands.registerCommand("axme.showStatus", async () => { + // v0.0.2: replace plain-text output dump with a full healthcheck + // webview (status of binary, MCP, hooks, auth, KB per workspace). + // The old "axme-code status" output dump is still accessible via the + // axme.showStatusText fallback command for power users. + await openStatusWebview(binary); + }), + + vscode.commands.registerCommand("axme.showStatusText", async () => { const root = workspaceRoot(); if (!root) { void vscode.window.showWarningMessage("AXME Code: open a folder first."); @@ -116,5 +126,9 @@ export function registerCommands( await vscode.window.showTextDocument(doc); } }), + + vscode.commands.registerCommand("axme.reset", async () => { + await runReset(); + }), ]; } diff --git a/extension/src/extension.ts b/extension/src/extension.ts index 1fdc346..60a3402 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -1,5 +1,5 @@ /** - * AXME Code — Cursor extension entry point (v0.0.1, Cursor-only). + * AXME Code — Cursor extension entry point (v0.0.2, Cursor-only). * * Activation flow: * 1. Detect Cursor (vs VS Code or other fork). Bail out with a friendly @@ -11,12 +11,17 @@ * 6. If the workspace is not initialised yet, offer to run `axme-code setup`. * 7. Attach the AXME status bar and register commands. * + * Every step's outcome is recorded in an ActivationReport; at the end we + * show a single summary toast with ✓/✗ per step so the user can see at a + * glance that everything is wired up (or which step needs attention). + * Without this, partial installs used to fail silently — the user only + * noticed hours later when a safety hook didn't fire or an audit didn't + * run. + * * Deactivation disposes the MCP registration (Cursor unregisters the * server), the status bar, the FS watcher, and all commands. User-level * hooks at ~/.cursor/hooks.json are NOT removed on deactivate — the user - * can remove them manually if they uninstall the extension. (VS Code's - * deactivate fires on plain window-close too, so blanket-removing hooks - * there would be wrong.) + * can clear them via `AXME: Reset` command if uninstalling. */ import * as vscode from "vscode"; @@ -25,26 +30,59 @@ import { findAxmeBinary } from "./binary-detect.js"; import { registerMcpServer } from "./mcp-register.js"; import { installUserHooks } from "./hooks-install.js"; import { ensureAuditorAuth } from "./auditor-auth.js"; -import { offerSetupIfMissing } from "./setup-controller.js"; +import { offerSetupIfMissing, isAxmeInitialized } from "./setup-controller.js"; import { AxmeStatusBar } from "./status-bar.js"; import { registerCommands } from "./commands.js"; +import { readCounts } from "./kb-watcher.js"; +import { ActivationReport, StepKind } from "./activation-report.js"; import { log, logError, show as showOutput, dispose as disposeLog } from "./log.js"; declare const __EXTENSION_VERSION__: string; let statusBar: AxmeStatusBar | undefined; +/** + * Run an activation step inside a try/catch. On failure: record the step + * as failed in the report AND surface a non-modal warning notification + * with a [Show output] button so the user is not left guessing. The + * activation flow continues past the failure — partial functionality is + * better than total failure (e.g. MCP registered but hooks failed → user + * still gets axme tools, just no machine-wide safety blocks). + */ +async function runStep( + report: ActivationReport, + kind: StepKind, + successDetail: (result: T) => string, + body: () => Promise, +): Promise { + try { + const result = await body(); + report.record(kind, true, successDetail(result)); + return result; + } catch (err) { + logError(`Step ${kind}`, err); + report.record(kind, false, (err as Error).message.slice(0, 80)); + void vscode.window + .showWarningMessage( + `AXME Code: ${kind} step failed — ${(err as Error).message}`, + "Show output", + ) + .then((c) => { if (c === "Show output") showOutput(); }); + return undefined; + } +} + export async function activate(context: vscode.ExtensionContext): Promise { log(`AXME Code v${__EXTENSION_VERSION__} activating…`); - // ---- Step 1: Cursor gate ------------------------------------------------- + // ---- Step 1: Cursor gate ----------------------------------------------- const ide: IdeKind = detectIde(); log(` Host IDE: ${ide}`); if (ide !== "cursor") { log(" Not running in Cursor — extension will not register any tools."); void vscode.window .showWarningMessage( - "AXME Code v0.0.1 requires Cursor. VS Code / Copilot / Cline support is " + + "AXME Code v0.0.x requires Cursor. VS Code / Copilot / Cline support is " + "on the roadmap once Microsoft adds chat-tool interception + chat-end " + "lifecycle APIs.", "Open output", @@ -55,66 +93,95 @@ export async function activate(context: vscode.ExtensionContext): Promise return; } - // ---- Step 2: binary ----------------------------------------------------- - const binary = await findAxmeBinary(context); + const report = new ActivationReport(); + + // ---- Step 2: binary detection ------------------------------------------ + const binary = await runStep(report, "binary", (b) => b.split("/").pop() ?? "ok", async () => { + const path = await findAxmeBinary(context); + if (!path) { + throw new Error( + "bundled axme-code binary not found inside this .vsix. " + + "Reinstall the extension; if the problem persists, file an issue.", + ); + } + log(` Binary: ${path}`); + return path; + }); if (!binary) { - log(" axme-code binary not found."); - void vscode.window.showErrorMessage( - "AXME Code: bundled axme-code binary not found inside this .vsix. " + - "Please file an issue at github.com/AxmeAI/axme-code/issues. As a " + - "workaround, install axme-code separately and set `axme.binaryPath`.", - ); + // Binary missing is fatal — every other step needs it. Show summary + // anyway so user sees the failure in toast form. + await report.present(); return; } - log(` Binary: ${binary}`); // ---- Step 3: MCP registration ------------------------------------------ - try { - const mcpDisposable = await registerMcpServer(binary); - context.subscriptions.push(mcpDisposable); - } catch (err) { - logError("MCP register", err); - void vscode.window.showErrorMessage( - `AXME Code: MCP registration failed — ${(err as Error).message}. ` + - "See AXME Code output channel.", - ); - // Continue activation so user can still see output + try Reauth / Setup. - } + await runStep(report, "mcp", () => "registered", async () => { + const disposable = await registerMcpServer(binary); + context.subscriptions.push(disposable); + }); // ---- Step 4: hooks ------------------------------------------------------ const enableHooks = vscode.workspace .getConfiguration("axme") .get("enableHooks", true); if (enableHooks) { - try { - installUserHooks("cursor", binary); - } catch (err) { - logError("Hooks install", err); - } + await runStep(report, "hooks", () => "user-level", async () => { + const ok = installUserHooks("cursor", binary); + if (!ok) throw new Error("hooks install returned false"); + }); } else { log("Hooks: disabled by axme.enableHooks setting"); + report.record("hooks", true, "disabled by setting"); } // ---- Step 5: auditor auth ---------------------------------------------- - try { - await ensureAuditorAuth(binary); - } catch (err) { - logError("Auditor auth", err); - } + await runStep(report, "auth", (mode) => mode ?? "?", async () => { + const mode = await ensureAuditorAuth(binary); + return mode; + }); - // ---- Step 6: setup offer ----------------------------------------------- - void offerSetupIfMissing(binary, "cursor"); + // ---- Step 6: setup offer (non-blocking, fire-and-forget) --------------- + // Setup is the user's job, not part of activation. We only record whether + // the workspace is already initialised; the offer toast fires async and + // the user can decline. + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (workspaceFolder) { + const initialized = isAxmeInitialized(); + if (initialized) { + const counts = readCounts(workspaceFolder.uri.fsPath); + report.record("setup", true, `${counts.decisions} dec, ${counts.memories} mems`); + } else { + report.record("setup", true, "pending user action"); + void offerSetupIfMissing(binary, "cursor"); + } + } else { + report.record("setup", true, "no workspace open"); + } // ---- Step 7: status bar + commands ------------------------------------- statusBar = new AxmeStatusBar(); - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; if (workspaceFolder) statusBar.attach(workspaceFolder.uri.fsPath); context.subscriptions.push(statusBar); context.subscriptions.push( ...registerCommands(context, binary, "cursor", statusBar), ); + // ---- Step 7b: reflect report state in status bar ----------------------- + // The corner notification toast from report.present() auto-dismisses + // after 5s and is easy to miss. The status bar is always visible — + // setting it to "Setup required" (yellow) or "Activation failed" (red) + // when something needs attention is the user's continuous reminder. + if (report.hasFailure()) { + const labels = report.failedSteps().map((s) => s.kind).join(", "); + statusBar.setError(`${labels} failed`); + } else if (workspaceFolder && !isAxmeInitialized()) { + statusBar.setAttention("Setup required"); + } + log(`Activation complete. ${context.subscriptions.length} disposables registered.`); + + // ---- Step 8: present summary ------------------------------------------- + await report.present(); } export function deactivate(): void { diff --git a/extension/src/reset.ts b/extension/src/reset.ts new file mode 100644 index 0000000..fe4dada --- /dev/null +++ b/extension/src/reset.ts @@ -0,0 +1,165 @@ +/** + * `AXME: Reset` command — removes machine-level AXME state so the user + * can start over after a botched install or before reporting an issue. + * + * What this DOES touch: + * - `~/.cursor/hooks.json` axme entries (preserves user's non-axme entries) + * - `~/.config/axme-code/auth.yaml` (mode flag — `subscription` / `api_key` + * / `cursor_sdk`) + * - `~/.config/axme-code/cursor.yaml` (Cursor SDK API key — only when + * user explicitly opts in via the confirm dialog) + * + * What this does NOT touch: + * - per-project `.axme-code/` storage (memories, decisions — user's + * curated knowledge; removing them across all projects would be + * catastrophic) + * - extension itself (use Cursor's Extensions panel → Uninstall) + * - Cursor's own MCP server registration (Cursor unregisters on + * extension deactivate via the Disposable we return from + * mcp-register.ts) + * + * The confirm dialog has three options: + * - "Reset hooks + auth mode" — preserves cursor.yaml key (you can + * reuse the API key on next setup) + * - "Reset everything (incl. Cursor API key)" — also wipes + * cursor.yaml so the next install fully re-prompts + * - "Cancel" + */ + +import * as vscode from "vscode"; +import { existsSync, readFileSync, writeFileSync, rmSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { log, logError } from "./log.js"; + +interface ResetReport { + hooksTouched: boolean; + authRemoved: boolean; + cursorKeyRemoved: boolean; + errors: string[]; +} + +function userCursorHooksPath(): string { + return join(homedir(), ".cursor", "hooks.json"); +} + +function authYamlPath(): string { + return join(homedir(), ".config", "axme-code", "auth.yaml"); +} + +function cursorYamlPath(): string { + return join(homedir(), ".config", "axme-code", "cursor.yaml"); +} + +function removeAxmeHookEntries(): "removed" | "no-axme-entries" | "missing" | "parse-error" { + const path = userCursorHooksPath(); + if (!existsSync(path)) return "missing"; + try { + const cfg = JSON.parse(readFileSync(path, "utf-8")) as { + version?: number; + hooks?: Partial>>; + }; + if (!cfg.hooks) return "no-axme-entries"; + let removedAny = false; + for (const kind of ["preToolUse", "postToolUse", "sessionEnd"] as const) { + const arr = cfg.hooks[kind]; + if (!arr) continue; + const before = arr.length; + const after = arr.filter((e) => !String(e.command ?? "").includes("axme-code")); + if (after.length !== before) { + cfg.hooks[kind] = after; + removedAny = true; + } + } + if (!removedAny) return "no-axme-entries"; + writeFileSync(path, JSON.stringify(cfg, null, 2) + "\n", "utf-8"); + return "removed"; + } catch (err) { + logError("reset: hooks parse", err); + return "parse-error"; + } +} + +async function performReset(removeCursorKey: boolean): Promise { + const report: ResetReport = { hooksTouched: false, authRemoved: false, cursorKeyRemoved: false, errors: [] }; + + // 1. hooks.json axme entries + const hooksResult = removeAxmeHookEntries(); + if (hooksResult === "removed") { + report.hooksTouched = true; + log(`Reset: removed axme entries from ${userCursorHooksPath()}`); + } else if (hooksResult === "parse-error") { + report.errors.push(`hooks.json parse error — check ${userCursorHooksPath()}`); + } else { + log(`Reset: hooks ${hooksResult}, nothing to remove`); + } + + // 2. auth.yaml — always remove on reset (user is explicitly resetting) + const authPath = authYamlPath(); + if (existsSync(authPath)) { + try { + rmSync(authPath); + report.authRemoved = true; + log(`Reset: deleted ${authPath}`); + } catch (err) { + logError("reset: auth.yaml unlink", err); + report.errors.push(`failed to delete ${authPath}: ${(err as Error).message}`); + } + } + + // 3. cursor.yaml — only when user explicitly opted in + if (removeCursorKey) { + const ckPath = cursorYamlPath(); + if (existsSync(ckPath)) { + try { + rmSync(ckPath); + report.cursorKeyRemoved = true; + log(`Reset: deleted ${ckPath}`); + } catch (err) { + logError("reset: cursor.yaml unlink", err); + report.errors.push(`failed to delete ${ckPath}: ${(err as Error).message}`); + } + } + } + + return report; +} + +export async function runReset(): Promise { + const choice = await vscode.window.showWarningMessage( + "AXME Code Reset — what should we clear?\n\n" + + "• Hooks: ~/.cursor/hooks.json axme entries (your other hooks are preserved)\n" + + "• Auth mode flag: ~/.config/axme-code/auth.yaml\n" + + "• (optional) Cursor SDK key: ~/.config/axme-code/cursor.yaml\n\n" + + "Per-project .axme-code/ storage (memories, decisions) is NOT touched.", + { modal: true }, + "Reset hooks + auth mode", + "Reset everything (incl. Cursor API key)", + ); + + if (choice === undefined) { + log("Reset: cancelled by user"); + return; + } + + const includeCursorKey = choice === "Reset everything (incl. Cursor API key)"; + const report = await performReset(includeCursorKey); + + // Build a single user-facing summary line. + const parts: string[] = []; + if (report.hooksTouched) parts.push("hooks ✓"); + if (report.authRemoved) parts.push("auth mode ✓"); + if (report.cursorKeyRemoved) parts.push("Cursor API key ✓"); + if (parts.length === 0) parts.push("nothing to remove (state was already clean)"); + + if (report.errors.length === 0) { + void vscode.window.showInformationMessage( + `AXME Code reset complete: ${parts.join(", ")}. Reopen Cursor or run "AXME: Setup" on your next project to reinstall.`, + ); + } else { + void vscode.window.showWarningMessage( + `AXME Code reset partial: ${parts.join(", ")} (${report.errors.length} error${report.errors.length === 1 ? "" : "s"}). See output channel.`, + "Show output", + ); + } +} diff --git a/extension/src/status-bar.ts b/extension/src/status-bar.ts index 3e4a6bb..ebed707 100644 --- a/extension/src/status-bar.ts +++ b/extension/src/status-bar.ts @@ -1,9 +1,16 @@ /** * AXME status-bar item. * - * Format: `AXME ✓ mems, dec` (live counts). On click — quick-pick - * of recent decisions (read live from `.axme-code/decisions/index.md`). - * Hidden if no workspace is open or `.axme-code/` is absent. + * Three visual states (helps surface activation problems instead of + * leaving the user squinting at a faint notification toast): + * + * - ✓ healthy: "AXME ✓ N mems, D dec" (default color) + * - ⚠ attention: "AXME ⚠ Setup required" (warning background) + * - ✗ error: "AXME ✗ Activation failed" (error background) + * + * Click action depends on state: + * - healthy → quick-pick recent decisions + * - attention/error → open Show Status webview (so user can see what's wrong) */ import * as vscode from "vscode"; @@ -13,10 +20,13 @@ import { KbCounts, KbWatcher } from "./kb-watcher.js"; const PRIORITY = 100; +export type StatusBarState = "healthy" | "attention" | "error"; + export class AxmeStatusBar implements vscode.Disposable { private item: vscode.StatusBarItem; private watcher: KbWatcher; private workspaceRoot: string | undefined; + private state: StatusBarState = "healthy"; constructor() { this.item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, PRIORITY); @@ -32,7 +42,48 @@ export class AxmeStatusBar implements vscode.Disposable { } private render(counts: KbCounts): void { + if (this.state !== "healthy") return; // attention/error states own the text this.item.text = `AXME $(check) ${counts.memories} mems, ${counts.decisions} dec`; + this.item.backgroundColor = undefined; + } + + /** + * Switch to a non-healthy visual state. Used by activation to surface + * "Setup required" or "Activation failed" prominently — far more visible + * than a corner notification toast that auto-dismisses after 5 seconds. + * + * The status bar item also retargets its click command to `axme.showStatus` + * so the user can drill into the webview and see what's wrong. + */ + setAttention(message: string): void { + this.state = "attention"; + this.item.text = `$(warning) AXME ⚠ ${message}`; + this.item.tooltip = `AXME Code: ${message}. Click to open Show Status panel.`; + this.item.backgroundColor = new vscode.ThemeColor("statusBarItem.warningBackground"); + this.item.command = "axme.showStatus"; + this.item.show(); + } + + setError(message: string): void { + this.state = "error"; + this.item.text = `$(error) AXME ✗ ${message}`; + this.item.tooltip = `AXME Code: ${message}. Click to open Show Status panel.`; + this.item.backgroundColor = new vscode.ThemeColor("statusBarItem.errorBackground"); + this.item.command = "axme.showStatus"; + this.item.show(); + } + + setHealthy(): void { + this.state = "healthy"; + this.item.tooltip = "AXME Code — click to view recent decisions"; + this.item.backgroundColor = undefined; + this.item.command = "axme.showRecentDecisions"; + // The watcher will repaint the text on next event; force one now in + // case attach() already fired and we missed it. + if (this.workspaceRoot) { + const { readCounts } = require("./kb-watcher.js") as typeof import("./kb-watcher.js"); + this.render(readCounts(this.workspaceRoot)); + } } /** diff --git a/extension/src/status-webview.ts b/extension/src/status-webview.ts new file mode 100644 index 0000000..d9172e6 --- /dev/null +++ b/extension/src/status-webview.ts @@ -0,0 +1,339 @@ +/** + * AXME: Show Status — full-screen healthcheck webview. + * + * Old behaviour: command ran `axme-code status` and dumped text to the + * Output channel. Users had to read raw markdown. + * + * New behaviour: webview panel with a structured table of every install + * concern at a glance: + * - Extension version + Cursor host + * - Binary path + version (shells `axme-code --version`) + * - MCP server registration state (probed via `axme-code serve` stdio + * ping) + * - Hooks file path + axme entries count (parses ~/.cursor/hooks.json) + * - Auth mode + which provider is actually usable + * - Knowledge base stats per workspace folder (decisions, memories, + * safety rules) + * - Last audit timestamp (latest mtime in audit-worker-logs/) + * + * Each row has a status icon (✓ / ⚠ / ✗) and a one-line explanation. + * The webview refreshes on demand via a "Refresh" button — no polling. + */ + +import * as vscode from "vscode"; +import { spawn } from "node:child_process"; +import { existsSync, readFileSync, statSync, readdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { detectIde } from "./ide-detect.js"; +import { readCounts } from "./kb-watcher.js"; +import { log } from "./log.js"; + +declare const __EXTENSION_VERSION__: string; + +interface StatusRow { + label: string; + status: "ok" | "warn" | "fail"; + value: string; + detail?: string; +} + +async function exec(binary: string, args: string[], timeoutMs = 5000): Promise<{ stdout: string; stderr: string; code: number | null }> { + return new Promise((resolve) => { + const child = spawn(binary, args, { stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + let resolved = false; + const timer = setTimeout(() => { + if (resolved) return; + resolved = true; + child.kill(); + resolve({ stdout, stderr, code: null }); + }, timeoutMs); + child.stdout.on("data", (c) => (stdout += c.toString())); + child.stderr.on("data", (c) => (stderr += c.toString())); + child.on("exit", (code) => { + if (resolved) return; + resolved = true; + clearTimeout(timer); + resolve({ stdout, stderr, code }); + }); + child.on("error", () => { + if (resolved) return; + resolved = true; + clearTimeout(timer); + resolve({ stdout, stderr, code: -1 }); + }); + }); +} + +async function collectRows(binary: string): Promise { + const rows: StatusRow[] = []; + + // 1. Extension + rows.push({ + label: "Extension version", + status: "ok", + value: `v${__EXTENSION_VERSION__}`, + detail: `Host IDE: ${detectIde()}`, + }); + + // 2. Binary + if (!existsSync(binary)) { + rows.push({ label: "Binary", status: "fail", value: "not found", detail: binary }); + } else { + const { stdout, code } = await exec(binary, ["--version"]); + rows.push({ + label: "Binary", + status: code === 0 ? "ok" : "fail", + value: code === 0 ? `v${stdout.trim()}` : "version probe failed", + detail: binary, + }); + } + + // 3. MCP server boot (initialize handshake, exit quickly) + // Stdio mode requires we feed an initialize message. If the server answers, + // it's healthy; if it crashes immediately, we'd see a non-zero exit. + const mcpProbe = await probeMcp(binary); + rows.push({ + label: "MCP server", + status: mcpProbe.ok ? "ok" : "fail", + value: mcpProbe.ok ? "responds to initialize" : "no response", + detail: mcpProbe.detail, + }); + + // 4. Hooks + const hooksPath = join(homedir(), ".cursor", "hooks.json"); + if (!existsSync(hooksPath)) { + rows.push({ label: "Hooks", status: "fail", value: "not installed", detail: hooksPath }); + } else { + try { + const cfg = JSON.parse(readFileSync(hooksPath, "utf-8")); + const axmeEntries: number[] = []; + for (const kind of ["preToolUse", "postToolUse", "sessionEnd"] as const) { + const arr: unknown = cfg.hooks?.[kind]; + if (!Array.isArray(arr)) { + axmeEntries.push(0); + continue; + } + const count = (arr as Array<{ command?: string }>).filter( + (e) => String(e.command ?? "").includes("axme-code"), + ).length; + axmeEntries.push(count); + } + const all = axmeEntries.every((c) => c === 1); + rows.push({ + label: "Hooks", + status: all ? "ok" : "warn", + value: all + ? "preToolUse + postToolUse + sessionEnd" + : `entries: pre=${axmeEntries[0]} post=${axmeEntries[1]} end=${axmeEntries[2]}`, + detail: hooksPath, + }); + } catch (err) { + rows.push({ label: "Hooks", status: "fail", value: "parse error", detail: `${hooksPath}: ${(err as Error).message}` }); + } + } + + // 5. Auth + const authProbe = await exec(binary, ["auth", "status"]); + if (authProbe.code !== 0) { + rows.push({ label: "Auth", status: "fail", value: "axme-code auth status failed", detail: authProbe.stderr.slice(0, 200) }); + } else { + const modeMatch = /Current mode:\s*(\S+)/m.exec(authProbe.stdout); + if (!modeMatch) { + rows.push({ + label: "Auth", + status: "warn", + value: "not configured (heuristic fallback)", + detail: "Run `AXME: Reauth Auditor` to choose a credential", + }); + } else { + rows.push({ + label: "Auth", + status: "ok", + value: modeMatch[1], + detail: "Run `AXME: Reauth Auditor` to switch provider", + }); + } + } + + // 6. KB per workspace folder + const folders = vscode.workspace.workspaceFolders ?? []; + if (folders.length === 0) { + rows.push({ label: "Knowledge base", status: "warn", value: "no workspace open", detail: "Open a folder to see KB stats" }); + } else { + for (const f of folders) { + const root = f.uri.fsPath; + const axmeDir = join(root, ".axme-code"); + if (!existsSync(axmeDir)) { + rows.push({ + label: `KB · ${f.name}`, + status: "warn", + value: "not initialised", + detail: `${axmeDir} missing — run \`AXME: Setup\``, + }); + continue; + } + const counts = readCounts(root); + const safetyRules = existsSync(join(axmeDir, "safety", "rules.yaml")); + rows.push({ + label: `KB · ${f.name}`, + status: "ok", + value: `${counts.decisions} decisions, ${counts.memories} memories${safetyRules ? ", safety rules" : ""}`, + detail: axmeDir, + }); + + // 7. Last audit (per workspace) + const auditDir = join(axmeDir, "audit-worker-logs"); + if (existsSync(auditDir)) { + const files = readdirSync(auditDir) + .filter((n) => n.endsWith(".log")) + .map((n) => ({ n, mtime: statSync(join(auditDir, n)).mtimeMs })) + .sort((a, b) => b.mtime - a.mtime); + if (files.length > 0) { + const ageMin = Math.round((Date.now() - files[0].mtime) / 60000); + rows.push({ + label: `Last audit · ${f.name}`, + status: "ok", + value: `${files.length} log${files.length === 1 ? "" : "s"}, latest ${ageMin}min ago`, + detail: join(auditDir, files[0].n), + }); + } else { + rows.push({ + label: `Last audit · ${f.name}`, + status: "warn", + value: "no audit logs yet", + detail: "Audit fires when a chat session ends", + }); + } + } + } + } + + return rows; +} + +async function probeMcp(binary: string): Promise<{ ok: boolean; detail: string }> { + const initMsg = JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "axme-status", version: "0.0.2" } }, + }); + return new Promise((resolve) => { + const child = spawn(binary, ["serve"], { stdio: ["pipe", "pipe", "pipe"] }); + let stdout = ""; + let resolved = false; + const timer = setTimeout(() => { + if (resolved) return; + resolved = true; + child.kill(); + resolve({ ok: stdout.includes("\"protocolVersion\""), detail: stdout ? "partial response" : "timeout (5s)" }); + }, 5000); + child.stdout.on("data", (c) => { + stdout += c.toString(); + if (stdout.includes("\"protocolVersion\"")) { + if (resolved) return; + resolved = true; + clearTimeout(timer); + child.kill(); + resolve({ ok: true, detail: "stdio handshake ok" }); + } + }); + child.on("error", (err) => { + if (resolved) return; + resolved = true; + clearTimeout(timer); + resolve({ ok: false, detail: err.message }); + }); + child.stdin.write(initMsg + "\n"); + }); +} + +function escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +function renderHtml(rows: StatusRow[]): string { + const icon = (s: StatusRow["status"]) => (s === "ok" ? "✓" : s === "warn" ? "⚠" : "✗"); + const color = (s: StatusRow["status"]) => + s === "ok" ? "var(--vscode-charts-green)" : s === "warn" ? "var(--vscode-charts-yellow)" : "var(--vscode-errorForeground)"; + + const tbody = rows + .map( + (r) => ` + + ${icon(r.status)} + ${escapeHtml(r.label)} + ${escapeHtml(r.value)} + ${r.detail ? `${escapeHtml(r.detail)}` : ""} + `, + ) + .join(""); + + return ` + + + + + + +

AXME Code — status

+
As of ${new Date().toLocaleString()}
+ + + + + ${tbody} +
ComponentStatusDetail
+ + +`; +} + +let currentPanel: vscode.WebviewPanel | undefined; + +export async function openStatusWebview(binary: string): Promise { + if (currentPanel) { + currentPanel.reveal(); + await refresh(currentPanel, binary); + return; + } + const panel = vscode.window.createWebviewPanel( + "axmeStatus", + "AXME Code — Status", + vscode.ViewColumn.Active, + { enableScripts: true, retainContextWhenHidden: true }, + ); + currentPanel = panel; + panel.onDidDispose(() => { currentPanel = undefined; }); + panel.webview.onDidReceiveMessage(async (msg) => { + if (msg?.command === "refresh") await refresh(panel, binary); + }); + await refresh(panel, binary); +} + +async function refresh(panel: vscode.WebviewPanel, binary: string): Promise { + panel.webview.html = `Collecting status…`; + try { + const rows = await collectRows(binary); + panel.webview.html = renderHtml(rows); + } catch (err) { + log(`AXME: status webview refresh failed: ${(err as Error).message}`); + panel.webview.html = `Failed to collect status: ${escapeHtml((err as Error).message)}`; + } +} diff --git a/src/cli.ts b/src/cli.ts index 397e0ee..04d29d8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -347,6 +347,10 @@ Usage: for Claude Code, or .cursor/{mcp,hooks}.json + rules /axme-code.mdc for Cursor). Defaults to claude-code. axme-code serve Start MCP server (stdio transport) + axme-code self-test Run local healthcheck (storage write, + hook parse, MCP boot). Exits 0 on + pass, 1 on any failure. Use in CI or + from terminal when debugging install. axme-code status [path] Show project status axme-code --version | -v Print the installed version @@ -599,7 +603,15 @@ async function main() { setupOutcome = setupMethod === "llm" ? "success" : "fallback"; await sendSetupTelemetry(); - console.log("\nDone! Run 'claude' to start using AXME tools."); + // IDE-aware final message. The CLI is invoked both standalone + // (Claude Code users — `axme-code setup` from terminal) and via + // the Cursor extension's setup-controller (`--ide=cursor`). + // Tell each user what their next step actually is. + if (ide === "cursor") { + console.log("\nDone! Open a new chat in Cursor — AXME tools are now available."); + } else { + console.log("\nDone! Run 'claude' to start using AXME tools."); + } break; } @@ -608,6 +620,15 @@ async function main() { break; } + case "self-test": { + // v0.0.2: local healthcheck for storage / hook adapters / MCP boot. + // Used by `AXME: Show Status` webview indirectly + by power users + // running the binary from terminal when something looks wrong. + const { runSelfTest } = await import("./self-test.js"); + const code = await runSelfTest(); + process.exit(code); + } + case "status": { const projectPath = resolve(args[1] || "."); console.log(statusTool(projectPath)); diff --git a/src/self-test.ts b/src/self-test.ts new file mode 100644 index 0000000..df5b0c6 --- /dev/null +++ b/src/self-test.ts @@ -0,0 +1,201 @@ +/** + * `axme-code self-test` — a CI/CLI healthcheck that probes the four + * runtime contracts users will hit when the extension is alive: + * + * 1. Storage write — can we atomic-write a file to a temp .axme-code/? + * 2. Hook stdin parse — does the Cursor adapter normalize Shell→Bash + * and the Claude adapter pass-through Bash, both producing the + * expected NormalizedHookEvent? + * 3. Hook deny emit — do both IDE output adapters produce the right + * JSON shape + exit-code contract? + * 4. MCP server boot — does `axme-code serve` answer an initialize + * handshake on stdio within 5s? + * + * Output is plain text (one line per check), exit 0 on all-pass / exit 1 + * on any failure. Designed for `axme-code self-test && echo OK` in CI + * scripts and for users running the binary directly when something + * looks wrong. + * + * No LLM, no auth, no network — everything tested here is local + * infrastructure that should work on a fresh install with zero state. + */ + +import { mkdtempSync, rmSync, existsSync, writeFileSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawn } from "node:child_process"; + +interface CheckResult { + name: string; + ok: boolean; + detail: string; +} + +const results: CheckResult[] = []; + +function record(name: string, ok: boolean, detail: string): void { + results.push({ name, ok, detail }); + const icon = ok ? "✓" : "✗"; + process.stdout.write(` ${icon} ${name.padEnd(28)} ${detail}\n`); +} + +async function checkStorageWrite(): Promise { + const tmpDir = mkdtempSync(join(tmpdir(), "axme-selftest-")); + try { + const { atomicWrite } = await import("./storage/engine.js"); + const target = join(tmpDir, ".axme-code", "memory", "patterns", "selftest.md"); + atomicWrite(target, "selftest content\n"); + if (!existsSync(target)) { + record("storage write", false, "atomicWrite returned but file missing"); + return; + } + const back = readFileSync(target, "utf-8"); + if (back !== "selftest content\n") { + record("storage write", false, `content mismatch: ${JSON.stringify(back.slice(0, 40))}`); + return; + } + record("storage write", true, `${target}`); + } catch (err) { + record("storage write", false, (err as Error).message); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } +} + +async function checkHookParseAndDeny(): Promise { + try { + const { cursorInputAdapter, cursorOutputAdapter } = await import("./hooks/adapters/cursor.js"); + const { claudeCodeInputAdapter, claudeCodeOutputAdapter } = await import("./hooks/adapters/claude-code.js"); + + // Cursor: Shell must normalize to Bash; deny JSON + exit 2 + const cursorEvent = cursorInputAdapter.parse( + { + cursor_version: "1.7", + conversation_id: "selftest", + workspace_roots: ["/tmp"], + tool_name: "Shell", + tool_input: { command: "git push --force origin main" }, + }, + "preToolUse", + ); + if (cursorEvent.toolName !== "Bash") { + record("hook parse (Cursor)", false, `expected toolName=Bash, got=${cursorEvent.toolName}`); + return; + } + if (cursorEvent.ide !== "cursor") { + record("hook parse (Cursor)", false, `expected ide=cursor, got=${cursorEvent.ide}`); + return; + } + record("hook parse (Cursor)", true, "Shell→Bash + ide=cursor"); + + const cursorDeny = cursorOutputAdapter.emitDeny("test deny", "preToolUse"); + const cursorJson = JSON.parse(cursorDeny.stdout); + if (cursorDeny.exitCode !== 2 || cursorJson.permission !== "deny" || !cursorJson.user_message.includes("[AXME Safety]")) { + record("hook deny (Cursor)", false, `exit=${cursorDeny.exitCode}, json=${cursorDeny.stdout.slice(0, 100)}`); + return; + } + record("hook deny (Cursor)", true, `exit 2 + permission:deny + [AXME Safety]`); + + // Claude: Bash stays Bash; deny JSON via hookSpecificOutput + exit 0 + const claudeEvent = claudeCodeInputAdapter.parse( + { tool_name: "Bash", tool_input: { command: "git push --force origin main" }, session_id: "claude-x" }, + "preToolUse", + ); + if (claudeEvent.toolName !== "Bash" || claudeEvent.ide !== "claude-code") { + record("hook parse (Claude)", false, `tool=${claudeEvent.toolName} ide=${claudeEvent.ide}`); + return; + } + record("hook parse (Claude)", true, "Bash + ide=claude-code"); + + const claudeDeny = claudeCodeOutputAdapter.emitDeny("test deny", "preToolUse"); + const claudeJson = JSON.parse(claudeDeny.stdout); + if ( + claudeDeny.exitCode !== 0 || + claudeJson.hookSpecificOutput?.permissionDecision !== "deny" || + !String(claudeJson.hookSpecificOutput?.permissionDecisionReason ?? "").includes("[AXME Safety]") + ) { + record("hook deny (Claude)", false, `exit=${claudeDeny.exitCode}, json=${claudeDeny.stdout.slice(0, 100)}`); + return; + } + record("hook deny (Claude)", true, "exit 0 + hookSpecificOutput.permissionDecision:deny"); + } catch (err) { + record("hook parse/deny", false, (err as Error).message); + } +} + +async function checkMcpServerBoot(binaryPath: string): Promise { + // Spawn `axme-code serve`, send initialize, expect `protocolVersion` in + // reply within 5s. Then kill the subprocess. + return new Promise((resolve) => { + const child = spawn(binaryPath, ["serve"], { stdio: ["pipe", "pipe", "pipe"] }); + let stdout = ""; + let resolved = false; + const finish = (ok: boolean, detail: string) => { + if (resolved) return; + resolved = true; + try { child.kill(); } catch { /* swallow */ } + record("MCP server boot", ok, detail); + resolve(); + }; + const timer = setTimeout(() => finish(false, "no response after 5s"), 5000); + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + if (stdout.includes("\"protocolVersion\"")) { + clearTimeout(timer); + finish(true, "stdio handshake ok"); + } + }); + child.on("error", (err) => { + clearTimeout(timer); + finish(false, err.message); + }); + child.on("exit", (code) => { + if (resolved) return; + clearTimeout(timer); + finish(false, `exited prematurely (code ${code})`); + }); + child.stdin.write( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "selftest", version: "0.0.2" } }, + }) + "\n", + ); + }); +} + +/** + * Entry point invoked from cli.ts when the user runs `axme-code self-test`. + * Picks the binary path off process.argv[1] for the MCP boot check. + */ +export async function runSelfTest(): Promise { + process.stdout.write("axme-code self-test\n"); + process.stdout.write("====================\n\n"); + + await checkStorageWrite(); + await checkHookParseAndDeny(); + + // For the MCP boot, we need a path to our own binary. process.argv[1] + // is the CLI entry that's currently running this code. Spawning it + // recursively as a child is safe — child does `axme-code serve` not + // `axme-code self-test`, so no infinite recursion. + const selfPath = process.argv[1]; + if (selfPath && existsSync(selfPath)) { + await checkMcpServerBoot(selfPath); + } else { + record("MCP server boot", false, "cannot locate own binary at process.argv[1]"); + } + + const failed = results.filter((r) => !r.ok); + process.stdout.write("\n"); + if (failed.length === 0) { + process.stdout.write(`All ${results.length} checks passed.\n`); + return 0; + } + process.stdout.write(`${failed.length} of ${results.length} checks FAILED:\n`); + for (const f of failed) { + process.stdout.write(` - ${f.name}: ${f.detail}\n`); + } + return 1; +}