diff --git a/.github/workflows/publish-extension.yml b/.github/workflows/publish-extension.yml new file mode 100644 index 0000000..088790e --- /dev/null +++ b/.github/workflows/publish-extension.yml @@ -0,0 +1,163 @@ +name: Publish extension + +# Build a platform-specific .vsix on every push to the extension branch +# (artifact-only, no publish). Publish to Open VSX only when a human pushes +# a tag matching `extension-v*` — agents must never push tags per D-024. + +on: + push: + branches: + - feat/vscode-extension-** + tags: + - "extension-v*" + pull_request: + paths: + - "extension/**" + - ".github/workflows/publish-extension.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: Build .vsix (${{ matrix.target }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - target: linux-x64 + runner: ubuntu-latest + binary_name: axme-code-linux-x64 + extension_bin: axme-code + - target: linux-arm64 + runner: ubuntu-latest + binary_name: axme-code-linux-arm64 + extension_bin: axme-code + - target: darwin-x64 + runner: macos-13 + binary_name: axme-code-darwin-x64 + extension_bin: axme-code + - target: darwin-arm64 + runner: macos-latest + binary_name: axme-code-darwin-arm64 + extension_bin: axme-code + - target: win32-x64 + runner: windows-latest + binary_name: axme-code-windows-x64.exe + extension_bin: axme-code.exe + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install core deps + run: npm install + + - name: Build core + run: npm run build + + - name: Bundle core CLI to a single platform-specific file + shell: bash + run: | + mkdir -p extension/bin + # Bundle dist/cli.mjs into a single CJS file with all deps inlined + # EXCEPT @cursor/sdk. claude-agent-sdk is always required (used by + # LLM scanners during setup and by the session auditor); it must + # be inside the binary so Node doesn't try to resolve it from a + # node_modules/ dir that doesn't ship with the .vsix. + # + # @cursor/sdk stays external because (a) it carries ~15 MB of + # platform-specific native binaries that bloat the .vsix, and + # (b) the AgentSdk factory's fallback gracefully degrades to the + # Claude path on MODULE_NOT_FOUND. v0.0.1 users use Claude for + # the auditor; Cursor SDK as a first-class in-extension option + # is a v0.0.2 follow-up. + # + # WHY CJS, not ESM: the output is a shebang script with no file + # extension (Windows uses .exe — see matrix.extension_bin). + # Without ".mjs" extension AND without a sibling package.json + # declaring "type":"module", Node loads the file as CJS and + # ESM import statements throw at runtime. + npx esbuild dist/cli.mjs \ + --bundle \ + --platform=node \ + --target=node20 \ + --format=cjs \ + --external:@cursor/sdk \ + --outfile=extension/bin/axme-code.cjs + # Wrap in a shebang shim so it's executable as a binary. + { + printf '#!/usr/bin/env node\n' + cat extension/bin/axme-code.cjs + } > extension/bin/${{ matrix.extension_bin }} + rm extension/bin/axme-code.cjs + chmod +x extension/bin/${{ matrix.extension_bin }} || true + + - name: Install extension deps + working-directory: extension + run: npm install + + - name: Build extension bundle + working-directory: extension + run: npm run build + + - name: Package .vsix + working-directory: extension + run: npx vsce package --target ${{ matrix.target }} --no-dependencies -o ../axme-code-${{ matrix.target }}.vsix + + - uses: actions/upload-artifact@v4 + with: + name: axme-code-${{ matrix.target }} + path: axme-code-${{ matrix.target }}.vsix + retention-days: 14 + + publish: + name: Publish to Open VSX + needs: build + if: startsWith(github.ref, 'refs/tags/extension-v') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Download all .vsix artifacts + uses: actions/download-artifact@v4 + with: + path: vsix + merge-multiple: true + + - name: Publish per-target to Open VSX + env: + OVSX_TOKEN: ${{ secrets.OVSX_TOKEN }} + run: | + set -euo pipefail + for target in linux-x64 linux-arm64 darwin-x64 darwin-arm64 win32-x64; do + file="vsix/axme-code-${target}.vsix" + if [ ! -f "$file" ]; then + echo "Warning: $file missing; skipping $target." + continue + fi + echo "Publishing $file (target=$target)" + npx ovsx publish "$file" --target "$target" --pat "$OVSX_TOKEN" + done + + - name: Attach .vsix files to GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + # Create release if missing, then attach all 5 .vsix files for + # sideload distribution alongside Open VSX. + tag="${GITHUB_REF#refs/tags/}" + gh release view "$tag" >/dev/null 2>&1 || \ + gh release create "$tag" --title "$tag" --notes "Extension $tag — six platform-specific .vsix files attached. See README for install instructions." + for f in vsix/axme-code-*.vsix; do + gh release upload "$tag" "$f" --clobber + done diff --git a/README.md b/README.md index cabb39c..10717a0 100644 --- a/README.md +++ b/README.md @@ -80,9 +80,21 @@ Decisions enforce verification requirements: agent must run tests and show proof ## Quick Start -**Requires [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (CLI or VS Code extension).** +AXME Code supports three IDE paths today, ranked by lowest install friction: -### Option 1: Claude Code plugin (recommended) +### Option 0: Cursor extension (1-click install — recommended for Cursor users) + +For **Cursor 0.42+** users — install the `AXME Code` extension from the Extensions panel (Open VSX). The extension bundles the binary, registers the MCP server programmatically (no manual Enable click), installs user-level safety hooks at `~/.cursor/hooks.json` (apply to every project on your machine), and offers a one-click "Run setup" notification the first time you open a project without `.axme-code/`. + +``` +Cursor → Extensions → search "AXME Code" → Install +``` + +Or sideload the .vsix attached to the [latest release](https://github.com/AxmeAI/axme-code/releases) (`Extensions → ... menu → "Install from VSIX..."`). + +On first activation a modal asks for an LLM credential for the session auditor: paste an Anthropic API key, a Cursor SDK key (cursor.com → Integrations), or skip the auditor. If `claude` CLI is logged in (`claude login`), the extension auto-uses your Claude subscription — no paste needed. + +### Option 1: Claude Code plugin (recommended for Claude Code users) In Claude Code, run: diff --git a/extension/.gitignore b/extension/.gitignore new file mode 100644 index 0000000..b78270c --- /dev/null +++ b/extension/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +out/ +*.vsix +package-lock.json diff --git a/extension/.vscodeignore b/extension/.vscodeignore new file mode 100644 index 0000000..b1e0fb2 --- /dev/null +++ b/extension/.vscodeignore @@ -0,0 +1,13 @@ +.vscode/** +.vscode-test/** +src/** +**/*.map +**/*.ts +**/tsconfig.json +**/.eslintrc* +**/.gitignore +**/build.mjs +**/node_modules/** +.github/** +.git/** +**/*.vsix diff --git a/extension/README.md b/extension/README.md new file mode 100644 index 0000000..dbcba03 --- /dev/null +++ b/extension/README.md @@ -0,0 +1,37 @@ +# AXME Code + +Persistent memory, decisions, and safety guardrails for AI coding agents — Cursor, GitHub Copilot, Cline, Continue, Roo Code, Windsurf, and any VS Code chat agent that respects the Language Model API. + +## What this extension does + +- Registers `axme-code` as an MCP server so the agent has access to the project knowledge base — `axme_context`, `axme_save_memory`, `axme_save_decision`, `axme_safety`, and ~15 more tools. +- Installs safety hooks at the user level (`~/.cursor/hooks.json`) so dangerous operations (force-push to main, `rm -rf` on protected paths, secret-file edits) are blocked **before** the agent runs them. +- Auto-spawns the session auditor at chat end — extracts non-obvious patterns / decisions / safety rules from your conversation and saves them to `.axme-code/` for the next session to load. + +## Requirements + +- Cursor 0.42+ **or** VS Code 1.96+ (Copilot Agent Mode, Cline, Continue, Roo Code, Windsurf — anything that consumes MCP servers via the standard discovery API). +- The `axme-code` CLI installed on your `$PATH`. Get it: `curl -fsSL https://raw.githubusercontent.com/AxmeAI/axme-code/main/install.sh | bash`. The extension auto-detects it on activation; if your install is non-standard, set `axme.binaryPath` in settings. + +## Settings + +| Setting | Default | Description | +| --- | --- | --- | +| `axme.binaryPath` | `""` | Absolute path to the `axme-code` binary. Leave empty for auto-detect. | +| `axme.contextMode` | `"full"` | `full` loads every memory into agent context. `search` uses semantic search at scale. | +| `axme.enableHooks` | `true` | Register safety hooks. Turn off if you don't want machine-wide guardrails. | + +## Commands + +- **AXME: Set up workspace** — runs `axme-code setup` against the current workspace folder. +- **AXME: Open dashboard** — opens the worklog / decisions / memories view. +- **AXME: Reindex semantic search** — rebuilds the embeddings index. +- **AXME: Show status** — shows session count, audit health, recent worklog entries. + +## How this differs from the CLI install + +The `axme-code` CLI alone writes `.cursor/mcp.json` and `.cursor/hooks.json` per project. Cursor 0.42+ requires a manual **Enable** click in Settings → MCP for any new project-level server (security feature). This extension registers MCP via Cursor's extension API directly, bypassing the per-project Enable gate — install the extension once, every project just works. + +## License + +MIT — see [LICENSE](https://github.com/AxmeAI/axme-code/blob/main/LICENSE). diff --git a/extension/build.mjs b/extension/build.mjs new file mode 100644 index 0000000..0eab0ec --- /dev/null +++ b/extension/build.mjs @@ -0,0 +1,38 @@ +/** + * Bundle the AXME Code VS Code extension entry point. + * + * VS Code extensions are loaded as CommonJS, with `vscode` provided by the + * host (must stay external). Everything else is inlined into a single file + * so the .vsix has no node_modules at runtime — that keeps the artifact + * small (well under 1 MB) and avoids platform-specific binary surprises. + */ + +import { build, context } from "esbuild"; +import { readFileSync } from "fs"; + +const watch = process.argv.includes("--watch"); + +const pkg = JSON.parse(readFileSync("package.json", "utf-8")); + +const buildOptions = { + entryPoints: ["src/extension.ts"], + bundle: true, + platform: "node", + target: "node20", + format: "cjs", + external: ["vscode"], + outfile: "out/extension.js", + sourcemap: true, + define: { + __EXTENSION_VERSION__: JSON.stringify(pkg.version), + }, +}; + +if (watch) { + const ctx = await context(buildOptions); + await ctx.watch(); + console.log("Watching extension/..."); +} else { + await build(buildOptions); + console.log(`Built extension v${pkg.version} → out/extension.js`); +} diff --git a/extension/package.json b/extension/package.json new file mode 100644 index 0000000..376b149 --- /dev/null +++ b/extension/package.json @@ -0,0 +1,100 @@ +{ + "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", + "publisher": "AxmeAI", + "repository": { + "type": "git", + "url": "https://github.com/AxmeAI/axme-code.git", + "directory": "extension" + }, + "homepage": "https://github.com/AxmeAI/axme-code", + "bugs": { + "url": "https://github.com/AxmeAI/axme-code/issues" + }, + "license": "MIT", + "engines": { + "vscode": "^1.96.0" + }, + "categories": [ + "AI", + "Other" + ], + "keywords": [ + "ai", + "agent", + "cursor", + "copilot", + "cline", + "mcp", + "memory", + "safety" + ], + "main": "./out/extension.js", + "activationEvents": [ + "onStartupFinished" + ], + "contributes": { + "configuration": { + "title": "AXME Code", + "properties": { + "axme.binaryPath": { + "type": "string", + "default": "", + "markdownDescription": "Absolute path to the `axme-code` binary. Leave empty to auto-detect via `$PATH`, `~/.local/bin/axme-code`, or platform standards. Override only if you have a non-standard install." + }, + "axme.contextMode": { + "type": "string", + "enum": [ + "full", + "search" + ], + "default": "full", + "markdownDescription": "Knowledge-base loading mode. `full` loads every memory + decision into agent context (default, simple). `search` loads only catalog + uses semantic search (saves tokens at scale; requires the search-mode runtime — install via `axme-code config set context.mode search`)." + }, + "axme.enableHooks": { + "type": "boolean", + "default": true, + "markdownDescription": "Register safety hooks (`preToolUse` / `postToolUse` / `sessionEnd`) at activation. When ON, force-pushes / dangerous bash / file-write violations are blocked across every project on this machine." + } + } + }, + "commands": [ + { + "command": "axme.setup", + "title": "AXME: Set up workspace (writes .axme-code/ + hooks)", + "category": "AXME" + }, + { + "command": "axme.openDashboard", + "title": "AXME: Open dashboard (worklog + decisions + memories)", + "category": "AXME" + }, + { + "command": "axme.reindex", + "title": "AXME: Reindex semantic search", + "category": "AXME" + }, + { + "command": "axme.showStatus", + "title": "AXME: Show status", + "category": "AXME" + } + ] + }, + "scripts": { + "build": "node build.mjs", + "watch": "node build.mjs --watch", + "package": "vsce package --no-dependencies", + "publish:ovsx": "ovsx publish --pat $OVSX_TOKEN" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/vscode": "^1.96.0", + "@vscode/vsce": "^3.2.0", + "esbuild": "^0.25.0", + "ovsx": "^0.10.0", + "typescript": "^5.9.0" + } +} diff --git a/extension/src/auditor-auth.ts b/extension/src/auditor-auth.ts new file mode 100644 index 0000000..6c7943c --- /dev/null +++ b/extension/src/auditor-auth.ts @@ -0,0 +1,208 @@ +/** + * First-run auditor credential prompt. + * + * The session auditor is a separate process spawned at chat end. It needs + * its own LLM credential because vscode.lm.selectChatModels() is NOT + * implemented in Cursor for third-party extensions (verified Jan 2025). + * + * Three modes: + * 1. Anthropic API key — auditor uses Claude Agent SDK with this key. + * 2. Cursor SDK API key — auditor uses @cursor/sdk with the user's + * Cursor account billing. + * 3. Skip — auditor is disabled; user gets MCP tools + hooks but no + * automatic memory/decision extraction at chat end. + * + * The credential is persisted via the existing core auth-config helpers + * (~/.config/axme-code/auth.yaml + ~/.config/axme-code/cursor.yaml). + * That same storage is read by the audit worker spawned later, so the + * extension and CLI share one auth state. + */ + +import * as vscode from "vscode"; +import { spawn } from "node:child_process"; +import { log, logError } from "./log.js"; + +export type AuditorAuthMode = "api_key" | "cursor_sdk" | "subscription" | "disabled"; + +const SETTING_KEY = "axme.auditor.authMode"; + +/** + * Run the first-run auth flow if no credential is configured yet. + * - If `axme-code auth status` reports a saved mode, this is a no-op. + * - If unset, show a modal prompt with three buttons: paste Anthropic + * key / paste Cursor SDK key / skip auditor. + * - On choice (other than skip), shell out to `axme-code auth use ...` + * to persist via the core flow (which knows where to put each key). + */ +export async function ensureAuditorAuth(binary: string): Promise { + // Check current state via `axme-code auth status` JSON output (ad-hoc + // string parse — `auth status` prints human-readable text, but contains + // "Current mode: " on one line if saved). + const current = await detectCurrentMode(binary); + if (current && current !== "disabled") { + log(`Auditor auth: already configured (mode=${current})`); + return current; + } + + const choice = await vscode.window.showInformationMessage( + "AXME Code session auditor needs an LLM credential. It runs once at the end of each chat to " + + "extract memories, decisions, and safety rules from your conversation. Pick a provider:\n\n" + + "• Anthropic API key — pay-per-token via console.anthropic.com\n" + + "• Cursor SDK key — uses your existing Cursor account (Pro users have included quota)\n" + + "• Skip — MCP tools and safety hooks still work; just no automatic memory extraction", + { modal: true }, + "Anthropic API key", + "Cursor SDK key", + "Skip auditor", + ); + + if (choice === "Skip auditor" || choice === undefined) { + await vscode.workspace + .getConfiguration() + .update(SETTING_KEY, "disabled", vscode.ConfigurationTarget.Global); + log("Auditor auth: skipped by user"); + return "disabled"; + } + + if (choice === "Anthropic API key") { + const key = await collectKey({ + label: "Anthropic API key", + placeholder: "sk-ant-api03-...", + dashboardUrl: "https://console.anthropic.com/settings/keys", + dashboardLabel: "Open console.anthropic.com → API Keys", + instruction: + "On console.anthropic.com → API Keys, click 'Create Key', copy the key (starts with sk-ant-), come back here and paste.", + }); + if (!key) return "disabled"; + const ok = await runShell(binary, ["auth", "use", "api_key"], { ANTHROPIC_API_KEY: key }); + if (ok) { + log("Auditor auth: saved as api_key (Anthropic)"); + return "api_key"; + } + void vscode.window.showErrorMessage("AXME Code: failed to save Anthropic API key. Check the AXME Code output channel."); + return "disabled"; + } + + if (choice === "Cursor SDK key") { + const key = await collectKey({ + label: "Cursor SDK API key", + placeholder: "key_...", + dashboardUrl: "https://cursor.com/dashboard/integrations", + dashboardLabel: "Open cursor.com/dashboard/integrations", + instruction: + "On the Integrations page, click 'Create new API key', copy the key (starts with key_), come back here and paste. Billing for auditor calls goes through your Cursor account (Pro users have included quota).", + }); + if (!key) return "disabled"; + const ok = await runShell(binary, ["auth", "use", "cursor_sdk"], { CURSOR_API_KEY: key }); + if (ok) { + log("Auditor auth: saved as cursor_sdk"); + return "cursor_sdk"; + } + void vscode.window.showErrorMessage("AXME Code: failed to save Cursor SDK API key. Check the AXME Code output channel."); + return "disabled"; + } + + return "disabled"; +} + +/** + * Two-step credential prompt: + * 1. Show info message with "Open dashboard" button → opens browser. + * User leaves Cursor, generates a key on the provider's site, + * copies it, comes back. Cursor input box stays open + * (ignoreFocusOut: true) so the paste lands when they return. + * 2. Show password-style input box with the generation instructions + * as `prompt` text below the title. + * + * If the user picks "I already have a key", step 1 is skipped. + */ +async function collectKey(opts: { + label: string; + placeholder: string; + dashboardUrl: string; + dashboardLabel: string; + instruction: string; +}): Promise { + const action = await vscode.window.showInformationMessage( + `AXME Code needs your ${opts.label}. ${opts.instruction}`, + { modal: false }, + opts.dashboardLabel, + "I already have a key — let me paste", + "Cancel", + ); + + if (action === "Cancel" || action === undefined) return undefined; + + if (action === opts.dashboardLabel) { + try { + await vscode.env.openExternal(vscode.Uri.parse(opts.dashboardUrl)); + log(`Auditor auth: opened ${opts.dashboardUrl} in browser`); + } catch (err) { + logError(`openExternal(${opts.dashboardUrl})`, err); + } + // Brief delay so the browser tab is visible before our input box steals + // focus back. User can still ignore and continue in the browser; we keep + // ignoreFocusOut: true so the input box stays alive. + await new Promise((r) => setTimeout(r, 800)); + } + + const value = await vscode.window.showInputBox({ + title: `AXME Code — paste ${opts.label}`, + prompt: opts.instruction, + placeHolder: opts.placeholder, + password: true, + ignoreFocusOut: true, + validateInput: (v) => { + const t = v.trim(); + if (!t) return "Cannot be empty"; + if (t.length < 20) return "Looks too short to be a valid API key"; + if (/\s/.test(t)) return "API keys do not contain whitespace"; + return null; + }, + }); + return value?.trim() || undefined; +} + +async function runShell( + binary: string, + args: string[], + envExtra: Record = {}, +): Promise { + return new Promise((resolve) => { + const child = spawn(binary, args, { + env: { ...process.env, ...envExtra }, + stdio: "ignore", + }); + let resolved = false; + child.on("exit", (code) => { + if (resolved) return; + resolved = true; + resolve(code === 0); + }); + child.on("error", (err) => { + if (resolved) return; + resolved = true; + logError(`runShell ${binary} ${args.join(" ")}`, err); + resolve(false); + }); + }); +} + +async function detectCurrentMode(binary: string): Promise { + return new Promise((resolve) => { + const child = spawn(binary, ["auth", "status"], { stdio: ["ignore", "pipe", "ignore"] }); + let stdout = ""; + child.stdout.on("data", (chunk) => (stdout += chunk.toString())); + child.on("exit", () => { + const m = /Current mode:\s*(\w+)/m.exec(stdout); + if (!m) return resolve(undefined); + const mode = m[1]; + if (mode === "subscription" || mode === "api_key" || mode === "cursor_sdk") { + resolve(mode); + } else { + resolve(undefined); + } + }); + child.on("error", () => resolve(undefined)); + }); +} diff --git a/extension/src/binary-detect.ts b/extension/src/binary-detect.ts new file mode 100644 index 0000000..7b01e58 --- /dev/null +++ b/extension/src/binary-detect.ts @@ -0,0 +1,84 @@ +/** + * Locate the axme-code binary on the user's machine. + * + * Resolution order: + * 1. `axme.binaryPath` setting (explicit user override). + * 2. `AXME_CLAUDE_EXECUTABLE` env var (undocumented CI override). + * 3. Bundled binary inside the .vsix at /bin/axme-code + * (or axme-code.exe on win32-x64). This is the post-install primary + * path for v0.0.1 — CI ships six platform-specific .vsix files, each + * with the matching binary in bin/, so this resolves unambiguously. + * 4. PATH lookup via `which` / `where.exe`. + * 5. Standard install locations: ~/.local/bin/axme-code, + * /usr/local/bin/axme-code, /opt/homebrew/bin/axme-code, /usr/bin/. + * + * The bundled path (3) is preferred over PATH (4) so users don't + * accidentally run a stale system install when the extension is up to + * date. + * + * Returns absolute path or undefined. Caller surfaces a user-facing + * error when undefined. + */ + +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import * as vscode from "vscode"; + +const execFileAsync = promisify(execFile); + +async function whichLookup(name: string): Promise { + const cmd = process.platform === "win32" ? "where.exe" : "which"; + try { + const { stdout } = await execFileAsync(cmd, [name], { timeout: 3000 }); + const first = stdout.split(/\r?\n/).map((s) => s.trim()).find(Boolean); + if (first && existsSync(first)) return first; + } catch { + /* not found via PATH */ + } + return undefined; +} + +function bundledBinaryPath(context: vscode.ExtensionContext): string { + const ext = process.platform === "win32" ? ".exe" : ""; + return join(context.extensionPath, "bin", `axme-code${ext}`); +} + +function standardInstallLocations(): string[] { + const home = homedir(); + const ext = process.platform === "win32" ? ".cmd" : ""; + return [ + join(home, ".local", "bin", `axme-code${ext}`), + "/usr/local/bin/axme-code", + "/opt/homebrew/bin/axme-code", + "/usr/bin/axme-code", + ]; +} + +export async function findAxmeBinary(context: vscode.ExtensionContext): Promise { + // 1. Settings override + const cfg = vscode.workspace.getConfiguration("axme"); + const explicit = cfg.get("binaryPath", "").trim(); + if (explicit && existsSync(explicit)) return explicit; + + // 2. Env override + const envOverride = process.env.AXME_CLAUDE_EXECUTABLE; + if (envOverride && existsSync(envOverride)) return envOverride; + + // 3. Bundled binary inside the .vsix + const bundled = bundledBinaryPath(context); + if (existsSync(bundled)) return bundled; + + // 4. PATH lookup + const fromPath = await whichLookup("axme-code"); + if (fromPath) return fromPath; + + // 5. Standard install locations + for (const candidate of standardInstallLocations()) { + if (existsSync(candidate)) return candidate; + } + + return undefined; +} diff --git a/extension/src/commands.ts b/extension/src/commands.ts new file mode 100644 index 0000000..88cb277 --- /dev/null +++ b/extension/src/commands.ts @@ -0,0 +1,120 @@ +/** + * Command palette entries. + * + * Each command's full implementation lives in a dedicated file (setup- + * controller.ts, auditor-auth.ts). This module just registers them and + * routes to the right handler. + */ + +import * as vscode from "vscode"; +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +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 { log, logError, show as showOutput } from "./log.js"; + +function workspaceRoot(): string | undefined { + const folders = vscode.workspace.workspaceFolders; + return folders && folders.length > 0 ? folders[0].uri.fsPath : undefined; +} + +export function registerCommands( + context: vscode.ExtensionContext, + binary: string, + ide: IdeKind, + statusBar: AxmeStatusBar, +): vscode.Disposable[] { + return [ + vscode.commands.registerCommand("axme.setup", async () => { + await runSetup(binary, ide); + }), + + vscode.commands.registerCommand("axme.reauthAuditor", async () => { + // Force re-prompt by stubbing the saved state via env override on the + // `auth status` call would be ugly — simplest: shell out to + // `axme-code auth` (interactive) or run our prompt flow regardless. + // We re-run the prompt and let the user pick again. + await ensureAuditorAuth(binary); + }), + + vscode.commands.registerCommand("axme.reindex", async () => { + const root = workspaceRoot(); + if (!root) { + void vscode.window.showWarningMessage("AXME Code: open a folder first."); + return; + } + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "AXME Code: reindexing semantic search", + cancellable: false, + }, + () => + new Promise((resolve) => { + const child = spawn(binary, ["reindex", root], { cwd: root }); + child.stdout.on("data", (c) => log(`reindex: ${String(c).trimEnd()}`)); + child.stderr.on("data", (c) => log(`reindex stderr: ${String(c).trimEnd()}`)); + child.on("error", (err) => { logError("reindex", err); resolve(); }); + child.on("exit", (code) => { + if (code !== 0) showOutput(); + resolve(); + }); + }), + ); + }), + + vscode.commands.registerCommand("axme.showStatus", async () => { + const root = workspaceRoot(); + if (!root) { + void vscode.window.showWarningMessage("AXME Code: open a folder first."); + return; + } + const child = spawn(binary, ["status", root], { stdio: ["ignore", "pipe", "pipe"] }); + let out = ""; + child.stdout.on("data", (c) => (out += c.toString())); + child.stderr.on("data", (c) => (out += c.toString())); + await new Promise((resolve) => { + child.on("exit", () => resolve()); + child.on("error", () => resolve()); + }); + log(`axme-code status output:\n${out.trimEnd()}`); + showOutput(); + }), + + vscode.commands.registerCommand("axme.openDashboard", async () => { + const root = workspaceRoot(); + if (!root) return; + const dir = join(root, ".axme-code"); + if (!existsSync(dir)) { + void vscode.window.showInformationMessage( + "AXME Code: workspace not initialised. Run AXME: Setup first.", + ); + return; + } + const uri = vscode.Uri.file(dir); + await vscode.commands.executeCommand("revealInExplorer", uri); + }), + + vscode.commands.registerCommand("axme.showRecentDecisions", async () => { + const items = statusBar.recentDecisions().map((d) => ({ + label: `${d.id}: ${d.title}`, + description: d.path, + path: d.path, + })); + if (items.length === 0) { + void vscode.window.showInformationMessage("AXME Code: no decisions yet."); + return; + } + const picked = await vscode.window.showQuickPick(items, { + placeHolder: "Recent AXME decisions (most recent first)", + }); + if (picked) { + const doc = await vscode.workspace.openTextDocument(picked.path); + await vscode.window.showTextDocument(doc); + } + }), + ]; +} diff --git a/extension/src/extension.ts b/extension/src/extension.ts new file mode 100644 index 0000000..1fdc346 --- /dev/null +++ b/extension/src/extension.ts @@ -0,0 +1,126 @@ +/** + * AXME Code — Cursor extension entry point (v0.0.1, Cursor-only). + * + * Activation flow: + * 1. Detect Cursor (vs VS Code or other fork). Bail out with a friendly + * message if not running in Cursor. + * 2. Locate the bundled `axme-code` binary at /bin/axme-code. + * 3. Register the MCP server via Cursor's proprietary extension API. + * 4. Install user-level safety hooks at ~/.cursor/hooks.json. + * 5. Ensure auditor LLM credential is configured (modal on first run). + * 6. If the workspace is not initialised yet, offer to run `axme-code setup`. + * 7. Attach the AXME status bar and register commands. + * + * 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.) + */ + +import * as vscode from "vscode"; +import { detectIde, IdeKind } from "./ide-detect.js"; +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 { AxmeStatusBar } from "./status-bar.js"; +import { registerCommands } from "./commands.js"; +import { log, logError, show as showOutput, dispose as disposeLog } from "./log.js"; + +declare const __EXTENSION_VERSION__: string; + +let statusBar: AxmeStatusBar | undefined; + +export async function activate(context: vscode.ExtensionContext): Promise { + log(`AXME Code v${__EXTENSION_VERSION__} activating…`); + + // ---- 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 " + + "on the roadmap once Microsoft adds chat-tool interception + chat-end " + + "lifecycle APIs.", + "Open output", + ) + .then((c) => { + if (c === "Open output") showOutput(); + }); + return; + } + + // ---- Step 2: binary ----------------------------------------------------- + const binary = await findAxmeBinary(context); + 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`.", + ); + 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. + } + + // ---- Step 4: hooks ------------------------------------------------------ + const enableHooks = vscode.workspace + .getConfiguration("axme") + .get("enableHooks", true); + if (enableHooks) { + try { + installUserHooks("cursor", binary); + } catch (err) { + logError("Hooks install", err); + } + } else { + log("Hooks: disabled by axme.enableHooks setting"); + } + + // ---- Step 5: auditor auth ---------------------------------------------- + try { + await ensureAuditorAuth(binary); + } catch (err) { + logError("Auditor auth", err); + } + + // ---- Step 6: setup offer ----------------------------------------------- + void offerSetupIfMissing(binary, "cursor"); + + // ---- 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), + ); + + log(`Activation complete. ${context.subscriptions.length} disposables registered.`); +} + +export function deactivate(): void { + log("AXME Code deactivating…"); + // VS Code disposes context.subscriptions automatically. Our singleton + // output channel is disposed here so the deactivation log line lands + // before it goes silent. + disposeLog(); +} diff --git a/extension/src/hooks-install.ts b/extension/src/hooks-install.ts new file mode 100644 index 0000000..726864d --- /dev/null +++ b/extension/src/hooks-install.ts @@ -0,0 +1,149 @@ +/** + * Install AXME safety hooks at the user level by writing + * `~/.cursor/hooks.json`. User-level hooks apply to every project the + * user opens in Cursor — install once via the extension, get safety + * enforcement everywhere. + * + * VS Code has no equivalent hooks API (verified via VS Code 1.119 + * release notes 2026-05-10 — no chat-tool interception primitive). + * For VS Code users this function is a no-op; safety degrades to the + * cooperative `axme_safety_check` MCP tool that the agent can call + * before risky operations. + * + * Hook commands include `--ide cursor` so the spawned subprocess + * (axme-code hook ...) picks the Cursor adapter and reads its stdin + * with the right schema. The handler core falls back to stdin's + * `workspace_roots[0]` for the workspace path (PR #129 third commit), + * so we don't need to embed a project-specific path here. + * + * Idempotency: read existing hooks.json, drop any prior axme entries by + * string-matching `axme-code` in the command, push fresh entries. User- + * added entries are preserved verbatim. + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { homedir } from "node:os"; +import { IdeKind } from "./ide-detect.js"; +import { log, logError } from "./log.js"; + +type HookKind = "preToolUse" | "postToolUse" | "sessionEnd"; + +interface CursorHookEntry { + command: string; + type?: "command"; + timeout?: number; + matcher?: string; + failClosed?: boolean; + loop_limit?: number | null; + [key: string]: unknown; +} + +interface CursorHooksFile { + version?: number; + hooks?: Partial>; + [key: string]: unknown; +} + +const HOOK_TIMEOUT_MS: Record = { + preToolUse: 5, + postToolUse: 10, + sessionEnd: 120, +}; + +function quote(s: string): string { + // Cursor hook commands run via shell. Quote with double-quotes; escape + // any embedded double-quotes. Paths with spaces survive sh -c / cmd.exe /c + // unchanged. + return `"${s.replace(/"/g, '\\"')}"`; +} + +function buildHookCommand(binary: string, hookName: string): string { + // No --workspace flag — handler core resolves it from stdin + // workspace_roots[0] (PR #129 commit d267b82). + return `${quote(binary)} hook ${hookName} --ide cursor`; +} + +export function userCursorHooksPath(): string { + return join(homedir(), ".cursor", "hooks.json"); +} + +/** + * Write user-level Cursor hooks. No-op for VS Code (which has no hooks API). + * Returns true when the file was written, false when skipped (VS Code or + * setting disabled). + */ +export function installUserHooks(ide: IdeKind, binary: string): boolean { + if (ide !== "cursor") { + log("Hooks: skipped (not running in Cursor — VS Code has no hooks API)"); + return false; + } + + const path = userCursorHooksPath(); + let cfg: CursorHooksFile = { version: 1, hooks: {} }; + if (existsSync(path)) { + try { + const raw = readFileSync(path, "utf-8"); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object") cfg = parsed as CursorHooksFile; + } catch (err) { + logError(`Hooks: existing ${path} is malformed; will overwrite`, err); + cfg = { version: 1, hooks: {} }; + } + } + if (!cfg.version) cfg.version = 1; + if (!cfg.hooks) cfg.hooks = {}; + + const cliNames: Record = { + preToolUse: "pre-tool-use", + postToolUse: "post-tool-use", + sessionEnd: "session-end", + }; + + for (const kind of ["preToolUse", "postToolUse", "sessionEnd"] as HookKind[]) { + const existing = cfg.hooks[kind] ?? []; + const preserved = existing.filter( + (e) => !String(e.command ?? "").includes("axme-code"), + ); + const fresh: CursorHookEntry = { + command: buildHookCommand(binary, cliNames[kind]), + type: "command", + timeout: HOOK_TIMEOUT_MS[kind], + }; + cfg.hooks[kind] = [...preserved, fresh]; + } + + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(cfg, null, 2) + "\n", "utf-8"); + log(`Hooks: installed at ${path} (preToolUse + postToolUse + sessionEnd)`); + return true; +} + +/** + * Remove all axme entries from the user-level hooks file. Called on + * extension uninstall / disable. User entries are preserved. + */ +export function uninstallUserHooks(): void { + const path = userCursorHooksPath(); + if (!existsSync(path)) return; + try { + const cfg = JSON.parse(readFileSync(path, "utf-8")) as CursorHooksFile; + if (!cfg.hooks) return; + let touched = false; + for (const kind of ["preToolUse", "postToolUse", "sessionEnd"] as HookKind[]) { + const arr = cfg.hooks[kind]; + if (!arr) continue; + const preserved = arr.filter((e) => !String(e.command ?? "").includes("axme-code")); + if (preserved.length !== arr.length) { + cfg.hooks[kind] = preserved; + touched = true; + } + } + if (touched) { + writeFileSync(path, JSON.stringify(cfg, null, 2) + "\n", "utf-8"); + log(`Hooks: removed axme entries from ${path}`); + } + } catch (err) { + logError("Hooks: uninstall failed", err); + } +} diff --git a/extension/src/ide-detect.ts b/extension/src/ide-detect.ts new file mode 100644 index 0000000..4648813 --- /dev/null +++ b/extension/src/ide-detect.ts @@ -0,0 +1,34 @@ +/** + * Detect whether the extension is running in Cursor (vs vanilla VS Code or + * any other VS Code fork). + * + * Cursor exposes a proprietary namespace `vscode.cursor` that's the primary + * signal. As fallback we check `vscode.env.appName` for the literal string. + * + * Why Cursor-only for v0.0.1: full axme-code functionality (MCP tools, + * safety hooks, auto-audit at chat end) requires three Cursor-specific + * APIs — `cursor.mcp.registerServer`, `~/.cursor/hooks.json`, and a + * sessionEnd lifecycle event for the auditor. VS Code 1.119 (the latest + * at extension v0.0.1 ship time) has no equivalent for hooks or chat-end + * lifecycle, so shipping there would deliver MCP tools only and confuse + * users. We document the Cursor requirement upfront in the README and + * bail out cleanly when activated elsewhere. + */ + +import * as vscode from "vscode"; + +export type IdeKind = "cursor" | "vscode"; + +export function detectIde(): IdeKind { + const v = vscode as unknown as { cursor?: unknown }; + if (v.cursor !== undefined) return "cursor"; + + const appName = (vscode.env.appName ?? "").toLowerCase(); + if (appName.includes("cursor")) return "cursor"; + + return "vscode"; +} + +export function isCursor(): boolean { + return detectIde() === "cursor"; +} diff --git a/extension/src/kb-watcher.ts b/extension/src/kb-watcher.ts new file mode 100644 index 0000000..f361c2c --- /dev/null +++ b/extension/src/kb-watcher.ts @@ -0,0 +1,83 @@ +/** + * Watches `.axme-code/{memory,decisions}` in the active workspace and + * reports counts to a callback whenever they change. The status bar + * subscribes to this — counts update live as the agent saves new + * memories / decisions during the chat. + */ + +import * as vscode from "vscode"; +import { existsSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +export interface KbCounts { + memories: number; + decisions: number; +} + +function countFilesIn(dir: string, suffix = ".md"): number { + if (!existsSync(dir)) return 0; + try { + return readdirSync(dir).filter((f) => f.endsWith(suffix) && f !== "index.md").length; + } catch { + return 0; + } +} + +function countMemoriesUnder(memoryDir: string): number { + if (!existsSync(memoryDir)) return 0; + // Two subdirs: feedback/ and patterns/. Count *.md in each. + let total = 0; + for (const sub of ["feedback", "patterns"]) { + total += countFilesIn(join(memoryDir, sub)); + } + return total; +} + +export function readCounts(workspaceRoot: string): KbCounts { + const axmeDir = join(workspaceRoot, ".axme-code"); + return { + memories: countMemoriesUnder(join(axmeDir, "memory")), + decisions: countFilesIn(join(axmeDir, "decisions")), + }; +} + +export class KbWatcher implements vscode.Disposable { + private watcher: vscode.FileSystemWatcher | undefined; + private listener: ((counts: KbCounts) => void) | undefined; + private workspaceRoot: string | undefined; + + attach(workspaceRoot: string, onChange: (counts: KbCounts) => void): void { + this.detach(); + this.workspaceRoot = workspaceRoot; + this.listener = onChange; + if (!existsSync(join(workspaceRoot, ".axme-code"))) { + onChange({ memories: 0, decisions: 0 }); + return; + } + const pattern = new vscode.RelativePattern(workspaceRoot, ".axme-code/{memory,decisions}/**/*.md"); + this.watcher = vscode.workspace.createFileSystemWatcher(pattern); + const refresh = () => { + try { + if (!this.workspaceRoot || !this.listener) return; + // statSync to throw early if dir was deleted + try { statSync(join(this.workspaceRoot, ".axme-code")); } catch { return; } + this.listener(readCounts(this.workspaceRoot)); + } catch { /* swallow */ } + }; + this.watcher.onDidCreate(refresh); + this.watcher.onDidDelete(refresh); + this.watcher.onDidChange(refresh); + onChange(readCounts(workspaceRoot)); + } + + detach(): void { + this.watcher?.dispose(); + this.watcher = undefined; + this.listener = undefined; + this.workspaceRoot = undefined; + } + + dispose(): void { + this.detach(); + } +} diff --git a/extension/src/log.ts b/extension/src/log.ts new file mode 100644 index 0000000..0c965f8 --- /dev/null +++ b/extension/src/log.ts @@ -0,0 +1,34 @@ +/** + * AXME Code output channel logger. Lazy-creates a single VS Code + * `OutputChannel` and prepends an ISO timestamp to each line. Use for + * activation diagnostics, MCP register lifecycle, hook install results. + */ + +import * as vscode from "vscode"; + +let channel: vscode.OutputChannel | undefined; + +function getChannel(): vscode.OutputChannel { + if (!channel) channel = vscode.window.createOutputChannel("AXME Code"); + return channel; +} + +export function log(message: string): void { + const ts = new Date().toISOString(); + getChannel().appendLine(`[${ts}] ${message}`); +} + +export function logError(message: string, err: unknown): void { + const detail = err instanceof Error ? `${err.name}: ${err.message}` : String(err); + log(`ERROR ${message}: ${detail}`); + if (err instanceof Error && err.stack) getChannel().appendLine(err.stack); +} + +export function show(): void { + getChannel().show(); +} + +export function dispose(): void { + channel?.dispose(); + channel = undefined; +} diff --git a/extension/src/mcp-register.ts b/extension/src/mcp-register.ts new file mode 100644 index 0000000..90761ae --- /dev/null +++ b/extension/src/mcp-register.ts @@ -0,0 +1,60 @@ +/** + * Register the AXME MCP server with Cursor at activation time. + * + * Cursor exposes `(vscode as any).cursor.mcp.registerServer(config)` — + * an undocumented-but-stable extension API (verified empirically against + * the production extension `serkan-ozal/browser-devtools-mcp-vscode`). + * Calling this bypasses Cursor's project-level `.cursor/mcp.json` Enable + * gate because the trust boundary moves to "user installed this + * extension". + * + * Returns a Disposable the caller (extension.ts deactivate) must dispose + * so the server unregisters cleanly when the extension is disabled or + * uninstalled. + */ + +import * as vscode from "vscode"; +import { log, logError } from "./log.js"; + +interface CursorMcpApi { + registerServer(config: { + name: string; + server: + | { command: string; args: string[]; env?: Record } + | { url: string; headers?: Record }; + }): void; + unregisterServer(name: string): void; +} + +function getCursorMcpApi(): CursorMcpApi | undefined { + const v = vscode as unknown as { cursor?: { mcp?: CursorMcpApi } }; + return v.cursor?.mcp; +} + +export async function registerMcpServer(binary: string): Promise { + const cursor = getCursorMcpApi(); + if (!cursor?.registerServer) { + throw new Error( + "Cursor MCP extension API not available. Update Cursor to 0.42+ " + + "or check that you're not running the extension in a VS Code fork " + + "that lacks vscode.cursor.mcp.", + ); + } + cursor.registerServer({ + name: "axme", + server: { command: binary, args: ["serve"], env: {} }, + }); + log(`MCP: registered 'axme' (binary=${binary})`); + // Cursor needs ~3s to process the registration before tools become + // available to the chat agent. Verified empirically against the + // browser-devtools-mcp reference implementation. + await new Promise((r) => setTimeout(r, 3000)); + return new vscode.Disposable(() => { + try { + cursor.unregisterServer("axme"); + log("MCP: unregistered 'axme'"); + } catch (err) { + logError("MCP unregister", err); + } + }); +} diff --git a/extension/src/setup-controller.ts b/extension/src/setup-controller.ts new file mode 100644 index 0000000..030fadc --- /dev/null +++ b/extension/src/setup-controller.ts @@ -0,0 +1,94 @@ +/** + * Wraps `axme-code setup --ide=` for the current workspace. + * + * On extension activation: if the workspace folder is missing + * `.axme-code/`, show a non-modal info notification with a "Run setup" + * button. On click — execute setup with progress UI, stream output to + * the AXME Code output channel. + * + * The same flow is exposed as the `AXME: Setup` command for explicit + * invocation later. + */ + +import * as vscode from "vscode"; +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { IdeKind } from "./ide-detect.js"; +import { log, logError, show as showOutput } from "./log.js"; + +function workspaceRoot(): string | undefined { + const folders = vscode.workspace.workspaceFolders; + if (!folders || folders.length === 0) return undefined; + return folders[0].uri.fsPath; +} + +export function isAxmeInitialized(): boolean { + const root = workspaceRoot(); + if (!root) return false; + return existsSync(join(root, ".axme-code")); +} + +export async function offerSetupIfMissing(binary: string, ide: IdeKind): Promise { + if (isAxmeInitialized()) return; + const root = workspaceRoot(); + if (!root) return; + + const choice = await vscode.window.showInformationMessage( + `AXME Code is not initialised in ${root.split("/").pop()}. Run setup now?`, + "Run setup", + "Not now", + ); + if (choice !== "Run setup") return; + await runSetup(binary, ide); +} + +export async function runSetup(binary: string, ide: IdeKind): Promise { + const root = workspaceRoot(); + if (!root) { + void vscode.window.showWarningMessage("AXME Code: open a folder first."); + return; + } + + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "AXME Code: running setup", + cancellable: false, + }, + async (progress) => { + const args = ["setup", root, "--ide", ide]; + log(`setup: spawn ${binary} ${args.join(" ")}`); + progress.report({ message: "scanning project + writing .axme-code/ ..." }); + + const exitCode = await new Promise((resolve) => { + const child = spawn(binary, args, { + cwd: root, + env: { ...process.env, AXME_TELEMETRY_DISABLED: "1" }, + }); + child.stdout.on("data", (chunk) => log(`setup stdout: ${String(chunk).trimEnd()}`)); + child.stderr.on("data", (chunk) => log(`setup stderr: ${String(chunk).trimEnd()}`)); + child.on("error", (err) => { + logError("setup spawn", err); + resolve(1); + }); + child.on("exit", (code) => resolve(code ?? 1)); + }); + + if (exitCode === 0) { + progress.report({ message: "done" }); + const open = await vscode.window.showInformationMessage( + "AXME Code setup complete. Open a new chat to start using axme tools.", + "Show output", + "Dismiss", + ); + if (open === "Show output") showOutput(); + } else { + void vscode.window.showErrorMessage( + `AXME Code setup failed (exit ${exitCode}). Check the AXME Code output channel.`, + ); + showOutput(); + } + }, + ); +} diff --git a/extension/src/status-bar.ts b/extension/src/status-bar.ts new file mode 100644 index 0000000..3e4a6bb --- /dev/null +++ b/extension/src/status-bar.ts @@ -0,0 +1,86 @@ +/** + * 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. + */ + +import * as vscode from "vscode"; +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { KbCounts, KbWatcher } from "./kb-watcher.js"; + +const PRIORITY = 100; + +export class AxmeStatusBar implements vscode.Disposable { + private item: vscode.StatusBarItem; + private watcher: KbWatcher; + private workspaceRoot: string | undefined; + + constructor() { + this.item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, PRIORITY); + this.item.command = "axme.showRecentDecisions"; + this.item.tooltip = "AXME Code — click to view recent decisions"; + this.watcher = new KbWatcher(); + } + + attach(workspaceRoot: string): void { + this.workspaceRoot = workspaceRoot; + this.watcher.attach(workspaceRoot, (counts) => this.render(counts)); + this.item.show(); + } + + private render(counts: KbCounts): void { + this.item.text = `AXME $(check) ${counts.memories} mems, ${counts.decisions} dec`; + } + + /** + * Read up to 10 most-recent decisions for the quick-pick. Decisions are + * named `D-NNN-.md` and sorted by file mtime descending. + */ + recentDecisions(): Array<{ id: string; title: string; path: string }> { + if (!this.workspaceRoot) return []; + const dir = join(this.workspaceRoot, ".axme-code", "decisions"); + if (!existsSync(dir)) return []; + let files: string[]; + try { + files = readdirSync(dir).filter((f) => /^D-\d+-.*\.md$/.test(f)); + } catch { + return []; + } + const withTimes = files.map((f) => { + const path = join(dir, f); + let mtime = 0; + try { mtime = statSync(path).mtimeMs; } catch { /* skip */ } + return { f, path, mtime }; + }); + withTimes.sort((a, b) => b.mtime - a.mtime); + return withTimes.slice(0, 10).map(({ f, path }) => { + const id = (f.match(/^D-\d+/)?.[0]) ?? f; + const title = parseTitleFromMd(path) ?? f; + return { id, title, path }; + }); + } + + dispose(): void { + this.watcher.dispose(); + this.item.dispose(); + } +} + +function parseTitleFromMd(path: string): string | undefined { + try { + const content = readFileSync(path, "utf-8"); + // Try YAML frontmatter `title: "..."` first. + const fm = /^---\n([\s\S]*?)\n---/.exec(content); + if (fm) { + const t = /^title:\s*(.+)$/m.exec(fm[1]); + if (t) return t[1].trim().replace(/^["']|["']$/g, ""); + } + // Else first H1. + const h1 = /^#\s+(.+)$/m.exec(content); + if (h1) return h1[1].trim(); + } catch { /* skip */ } + return undefined; +} diff --git a/extension/tsconfig.json b/extension/tsconfig.json new file mode 100644 index 0000000..6830f2a --- /dev/null +++ b/extension/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "isolatedModules": true, + "outDir": "out", + "rootDir": "src", + "noEmit": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "out", "**/*.test.ts"] +} diff --git a/package.json b/package.json index a637a2a..2e841e3 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ }, "scripts": { "build": "node build.mjs", + "build:extension": "cd extension && npm run build", + "package:extension": "cd extension && npm run package", "start": "node dist/server.js", "dev": "tsx src/cli.ts", "test": "node scripts/run-tests.mjs", diff --git a/src/cli.ts b/src/cli.ts index da579ba..397e0ee 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -21,6 +21,16 @@ import { authConfigPath, loadAuthConfig, saveAuthConfig } from "./utils/auth-con import { formatDetectionBlock, hasAnyAuth, promptAuthChoice } from "./utils/auth-prompt.js"; import type { AuthMode, WorkspaceInfo } from "./types.js"; import { AXME_CODE_DIR, AXME_CODE_VERSION } from "./types.js"; +import { EventEmitter, setMaxListeners as setMaxEventTargetListeners } from "node:events"; + +// Suppress Node's MaxListenersExceededWarning that occasionally fires when +// claude-agent-sdk attaches several abort-signal listeners during long LLM +// runs (e.g. 4 parallel scanners during setup). Default cap of 10 is too +// low for our usage. Three knobs to cover both EventTarget and EventEmitter +// limits across the SDK call paths: +process.setMaxListeners(50); +setMaxEventTargetListeners(50); // EventTarget default for new instances +EventEmitter.defaultMaxListeners = 50; // legacy EventEmitter default const args = process.argv.slice(2); const command = args[0]; diff --git a/src/hooks/adapters/cursor.ts b/src/hooks/adapters/cursor.ts index 6d165ae..7c108f2 100644 --- a/src/hooks/adapters/cursor.ts +++ b/src/hooks/adapters/cursor.ts @@ -28,10 +28,25 @@ function asObject(v: unknown): Record | undefined { : undefined; } +/** + * Normalize Cursor's tool names to Claude Code's vocabulary so the + * IDE-agnostic safety/file-tracking core (which switches on `Bash`, + * `Edit`, `Read`, etc.) treats them uniformly. Cursor renames Bash → + * Shell; the rest of the taxonomy overlaps. Surfaced empirically: + * `tool_name: "Shell"` payload from Cursor preToolUse fell through + * pre-tool-use.ts's switch, allowing `git push --force` past the deny + * rule (2026-05-11 E2E test, check 6). + */ +function normalizeCursorToolName(name: string | undefined): string | undefined { + if (!name) return name; + if (name === "Shell") return "Bash"; + return name; +} + export const cursorInputAdapter: HookInputAdapter = { parse(raw, kind): NormalizedHookEvent { const obj = (raw && typeof raw === "object" ? raw : {}) as Record; - const toolName = asString(obj.tool_name); + const toolName = normalizeCursorToolName(asString(obj.tool_name)); const toolInput = asObject(obj.tool_input); // Use conversation_id as the stable AXME session key across ALL three diff --git a/src/utils/agent-sdk-cursor.ts b/src/utils/agent-sdk-cursor.ts index ec74885..39777ec 100644 --- a/src/utils/agent-sdk-cursor.ts +++ b/src/utils/agent-sdk-cursor.ts @@ -122,7 +122,11 @@ export async function createCursorAgentSdk( apiKey, model: { id: modelId }, local: { cwd, settingSources: [], mcpServers: [] }, - agentId: `axme-${_role}`, + // Cursor SDK stores agents in a local SQLite with UNIQUE(agent_id); + // multiple scanner-role calls in one setup would all reuse + // "axme-scanner" and fail with SQLITE_CONSTRAINT. Append a per-call + // suffix so each agent.create gets a fresh row. + agentId: `axme-${_role}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, }); let accumulatedText = ""; diff --git a/src/utils/agent-sdk.ts b/src/utils/agent-sdk.ts index b6eeddf..fef41fb 100644 --- a/src/utils/agent-sdk.ts +++ b/src/utils/agent-sdk.ts @@ -137,9 +137,7 @@ export async function createAgentSdk( if (ide === "cursor") { if (process.platform === "win32" && process.arch === "arm64") { - process.stderr.write( - "AXME: @cursor/sdk has no win-arm64 native binary; falling back to Claude SDK\n", - ); + logFallback("@cursor/sdk has no win-arm64 native binary"); return await createClaudeFallback(role); } try { @@ -147,9 +145,7 @@ export async function createAgentSdk( const cursor = await createCursorAgentSdk(role, opts); return cursor; } catch (err) { - process.stderr.write( - `AXME: Cursor SDK unavailable (${(err as Error).message}); falling back to Claude SDK\n`, - ); + logFallback(`@cursor/sdk import failed: ${(err as Error).message}`); return await createClaudeFallback(role); } } @@ -157,6 +153,19 @@ export async function createAgentSdk( return await createClaudeFallback(role); } +/** + * Log a quiet, single-line message about an expected-fallback case (e.g. + * @cursor/sdk not bundled in the VS Code extension, so we route to the + * Claude SDK instead). Hidden by default — only surfaces when the + * AXME_VERBOSE_FALLBACK env var is set. Users almost never need to see + * these; they're meaningful only when debugging the factory routing. + */ +function logFallback(reason: string): void { + if (process.env.AXME_VERBOSE_FALLBACK) { + process.stderr.write(`AXME: routing through Claude SDK (${reason})\n`); + } +} + async function createClaudeFallback(role: AgentRole): Promise { const haveBinary = !!findClaudePath(); const haveKey = !!process.env.ANTHROPIC_API_KEY;