From db687ab40215bdfac410bba4ae92afbba6777072 Mon Sep 17 00:00:00 2001 From: geobelsky Date: Mon, 11 May 2026 14:21:48 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(extension):=20v0.0.2=20=E2=80=94=20act?= =?UTF-8?q?ivation=20summary,=20healthcheck=20webview,=20self-test,=20rese?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five quality-of-life features that close the silent-failure UX hole surfaced during v0.0.1 install (users had no clear signal when hooks or auth quietly failed during activation): 1. ActivationReport + per-step failure surfacing (extension) - extension/src/activation-report.ts: small class that tracks ok/fail per step (binary, mcp, hooks, auth, setup). At the end of activate(), shows a single info notification on full success ("AXME Code ready: ✓ MCP ✓ Hooks ✓ Auth (cursor_sdk) ✓ KB (33 dec, 6 mems)") OR a warning with [Show output] button when anything failed. - extension.ts: every step now wrapped in runStep() helper that records to the report AND surfaces a per-step warning toast on failure. Previously hooks/auth/setup errors landed only in the output channel with no user-visible signal. 2. AXME: Show Status → webview (extension) - extension/src/status-webview.ts: structured healthcheck panel with rows for extension version, binary, MCP server boot probe (spawns serve + initialize handshake), hooks file (parses ~/.cursor/hooks.json, counts axme entries per kind), auth mode, KB stats per workspace, last audit timestamp. Each row has a ✓/⚠/✗ status icon. Refresh button on the panel. - commands.ts: axme.showStatus now opens the webview instead of dumping to output channel. Old text-dump is preserved as axme.showStatusText command for power users. 3. axme-code self-test CLI subcommand (core) - src/self-test.ts: probes four runtime contracts — • atomicWrite to a temp .axme-code/ path • Cursor + Claude hook adapter parse (verifies Shell→Bash normalization) and deny emit shapes (Cursor: exit 2 + flat JSON; Claude: exit 0 + hookSpecificOutput) • MCP server boot via stdio initialize handshake (5s timeout) - cli.ts: 'self-test' case routes to runSelfTest(). Exits 0 on pass, 1 on any failure. Designed for CI scripts and for users debugging an install from terminal. - Smoke-tested: bundled .vsix binary passes 6/6 checks (storage write, parse Cursor, deny Cursor, parse Claude, deny Claude, MCP boot). 4. AXME: Reset command (extension) - extension/src/reset.ts: confirm-dialog command that clears ~/.cursor/hooks.json axme entries + ~/.config/axme-code/auth.yaml. Optional flag to also wipe ~/.config/axme-code/cursor.yaml (Cursor SDK key). Per-project .axme-code/ storage is NEVER touched — that's the user's curated knowledge. - commands.ts + package.json contribute axme.reset. 5. Misc package.json polish - extension version bump 0.0.1 → 0.0.2. - New commands surfaced in command palette: axme.showStatusText, axme.reauthAuditor, axme.reset. What this does NOT include (deferred to v0.0.3): - VS Code branch with cooperative axme_safety_check tool - @cursor/sdk bundled into per-platform .vsix - vscode-test framework + headless e2e - Welcome view / walkthrough Verified locally: bundled binary self-test 6/6 pass. tsc clean. .vsix builds at 519 KB. #!axme pr=none repo=AxmeAI/axme-code --- extension/package.json | 17 +- extension/src/activation-report.ts | 82 +++++++ extension/src/commands.ts | 14 ++ extension/src/extension.ts | 135 ++++++++---- extension/src/reset.ts | 165 ++++++++++++++ extension/src/status-webview.ts | 339 +++++++++++++++++++++++++++++ src/cli.ts | 13 ++ src/self-test.ts | 201 +++++++++++++++++ 8 files changed, 925 insertions(+), 41 deletions(-) create mode 100644 extension/src/activation-report.ts create mode 100644 extension/src/reset.ts create mode 100644 extension/src/status-webview.ts create mode 100644 src/self-test.ts 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..55e6325 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,59 +93,73 @@ 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( @@ -115,6 +167,9 @@ export async function activate(context: vscode.ExtensionContext): Promise ); 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-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..c1ee411 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 @@ -608,6 +612,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; +} From 698b89b482a9d5b6846b4eb89f85a4ee1eb59dda Mon Sep 17 00:00:00 2001 From: geobelsky Date: Tue, 12 May 2026 07:58:10 +0000 Subject: [PATCH 2/2] fix(extension): IDE-aware setup completion + visible status bar attention/error states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - setup CLI now prints "Open a new chat in Cursor" when invoked with --ide=cursor instead of "Run 'claude'". Eliminates user confusion when the extension's setup-controller wraps the CLI. - Status bar adds two non-healthy visual states: yellow "Setup required" warning (when workspace has no .axme-code/) and red "Activation failed" error (when any step in the activation report failed). Each retargets click to axme.showStatus so the user can drill into details. Addresses user feedback that the corner notification toast is barely visible — the status bar is always-on and prominent when colored. #!axme pr=132 repo=AxmeAI/axme-code --- extension/src/extension.ts | 12 ++++++++ extension/src/status-bar.ts | 57 +++++++++++++++++++++++++++++++++++-- src/cli.ts | 10 ++++++- 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/extension/src/extension.ts b/extension/src/extension.ts index 55e6325..60a3402 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -166,6 +166,18 @@ export async function activate(context: vscode.ExtensionContext): Promise ...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 ------------------------------------------- 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/src/cli.ts b/src/cli.ts index c1ee411..04d29d8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -603,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; }