Skip to content

feat(cursor): Phase 1 foundation (IDE abstraction + AgentSdk factory) — held until Phase 2 extension validates#129

Open
George-iam wants to merge 6 commits intomainfrom
feat/cursor-full-support-20260510
Open

feat(cursor): Phase 1 foundation (IDE abstraction + AgentSdk factory) — held until Phase 2 extension validates#129
George-iam wants to merge 6 commits intomainfrom
feat/cursor-full-support-20260510

Conversation

@George-iam
Copy link
Copy Markdown
Contributor

@George-iam George-iam commented May 10, 2026

Status: Phase 1 foundation — held open until Phase 2 (VS Code Extension) validates the full pipeline end-to-end

This PR contains commits PR-1 + PR-2 of the original three-PR Phase 1 plan. After PR-2 spec-check by a Cursor agent, we discovered that Cursor 0.42+ does not auto-spawn project-level MCP servers from .cursor/mcp.json even when they are well-formed. Cursor's security model requires manual enable in Settings → MCP for any new project-level server, as a safeguard against malicious .cursor/mcp.json in cloned repos. Diagnostic from ~/.cursor-server/data/logs/<ts>/exthost*/anysphere.cursor-mcp/MCP project-*.log:

[V2] Handling DeleteClient action, reason: config_server_modified

This is documented Cursor behaviour, not an axme-code bug. The fix is to distribute axme-code as a VS Code Extension (Open VSX) that calls Cursor's proprietary cursor.mcp.registerServer() API at activation time, bypassing the per-project Enable gate via the extension-install trust boundary. Tracked as Phase 2 / feat/vscode-extension-20260510.

What's in this PR

PR-1 commits (foundation — reused by extension)

  • feat(cursor): add IDE abstraction layer and Cursor hook adapterIdeKind type, hook adapter pattern (NormalizedHookEvent), Cursor stdin parser, deny-output emitter (exit code 2 + flat permission JSON), --ide flag in CLI hook dispatcher and audit-spawner.
  • fix(cursor): unify conversation_id session-key precedence across all hook events — fix surfaced by PR-1 spec-check.

PR-2 commits

  • feat(cursor): setup writers + cursor_sdk auth + AgentSdk factory
    • .cursor/{mcp,hooks}.json + rules/axme-code.mdc writers (idempotent merge).
    • --ide=cursor branch in axme-code setup.
    • Third AuthMode cursor_sdk + ~/.config/axme-code/cursor.yaml (chmod 600).
    • AgentSdk factory (src/utils/agent-sdk.ts + Claude/Cursor wrappers).
    • @cursor/sdk@1.0.12 as optional dep.

Why this PR is held open

The Phase 2 Extension reuses everything here. Final E2E validation happens once Phase 2 lands and a real Cursor user installs the .vsix. Merging this PR alone, without that validation, would put unverified config-writer logic on main. We keep the branch alive so the Extension can build directly on top.

Tests

  • 595 / 595 unit tests pass.
  • tsc --noEmit clean.
  • npm run build clean.

Phase 2 (parallel work)

Branch: feat/vscode-extension-20260510. Target: one .vsix on Open VSX covering Cursor / VS Code Copilot / Cline / Continue / Roo Code / Windsurf — register MCP via Cursor's cursor.mcp.registerServer (Cursor branch) or vscode.lm.registerMcpServerDefinitionProvider (VS Code 1.101+ branch). User installs from Extensions panel → AXME works without Enable gate.

🤖 Generated with Claude Code

Phase 1 / 3 of cursor-full-support series — purely additive abstractions
that let later PRs ship Cursor setup writers and a Cursor SDK adapter
without re-touching the hook hot path.

- New src/utils/ide-detect.ts: IdeKind type + parseIdeFlag(argv) +
  detectIdeFromEnv + detectIdeFromHookStdin + resolveIde precedence
  (argv → env → stdin heuristic → claude-code default).
- New src/hooks/adapters/{types,claude-code,cursor}.ts: NormalizedHookEvent
  + HookInputAdapter / HookOutputAdapter contracts; per-IDE stdin parsers
  (Cursor: conversation_id, cursor_version, transcript_path|null);
  per-IDE deny emitters (Claude: hookSpecificOutput JSON, exit 0;
  Cursor: flat permission/user_message JSON, exit 2).
- Refactor src/hooks/{pre,post,session-end}.ts to consume
  NormalizedHookEvent + a HookOutputAdapter. Safety-checking core in
  pre-tool-use.ts is byte-identical for Claude Code users.
- src/cli.ts case "hook" now parses --ide and forwards into the right
  adapter; defaults to claude-code so existing setups are unaffected.
- src/audit-spawner.ts forwards --ide to the detached worker argv (used
  by PR-3 to dispatch the correct transcript parser).
- src/types.ts: add IdeKind type; ClaudeSessionRef gains optional
  ide?: IdeKind. ensureAxmeSessionForClaude / attachClaudeSession in
  src/storage/sessions.ts thread the optional ide param through.

Tests (+30): test/ide-detect.test.ts covers flag/env/stdin precedence;
test/cursor-hook-adapter.test.ts covers Cursor stdin parsing (incl.
session_id vs conversation_id selection per hook kind, transcript_path
null-preservation) and exit-code-2 deny emission; test/
claude-code-hook-adapter.test.ts asserts byte-for-byte regression of
the existing deny JSON.

Full suite: 572 / 572 (was 542). tsc --noEmit clean. npm run build
clean. No behaviour change for Claude Code users.

Decisions: D-145 (Phase 1 ships before VS Code Extension). Memories
saved: cursor-sdk-system-prompt-via-inline-agent-definition,
cursor-hook-protocol-exit-code-2-deny, cursor-sdk-1-0-12-no-win-arm64,
cursor-agent-transcript-format-jsonl-with-top-level-role.

#!axme pr=none repo=AxmeAI/axme-code
…hook events

Surfaced by Cursor agent spec-check on PR #129 (2026-05-10). Previous
behaviour was: pre/postToolUse mapped by conversation_id, sessionEnd
preferred session_id with conversation_id fallback. Cursor's
sessionEnd payload contains BOTH (conversation_id from common-base
fields + session_id from event-specific fields), and the two IDs
identify different scopes (conversation_id = chat thread; session_id
= broader SDK session that may span multiple conversations).

The mismatch would orphan AXME sessions: pre/postToolUse creates
.axme-code/active-sessions/<conversation_id>.txt, then sessionEnd
looks up by session_id and finds nothing — clearClaudeSessionMapping
no-ops, the original mapping leaks, and the audit worker doesn't
fire on the right session.

Fix: always prefer conversation_id (present in every Cursor hook
payload via common-base fields), fall back to session_id only if
conversation_id is somehow absent. Apply to all three hook kinds
uniformly.

Tests updated: existing "uses session_id for sessionEnd" assertion
inverted to "uses conversation_id for sessionEnd"; new test for
session_id fallback when conversation_id absent.

Full suite: 573 / 573 (was 572 before fix; +1 new fallback test).
tsc clean. build clean.

Also supersedes memory cursor-sdk-system-prompt-via-inline-agent-...
(deleted) with cursor-sdk-system-prompt-prepend-to-first-send-...
after the same spec-check found that Agent.create() has no top-level
systemPrompt option and the agents+agentId pattern injects subagents,
not the outer agent's system prompt. PR-2 implementation will use
agent.send(`<system>\n\${SYSTEM}\n</system>\n\n\${user}`) instead.
This fix touches PR-1 code only; the PR-2 SDK adapter design is
already updated in the plan file.

#!axme pr=129 repo=AxmeAI/axme-code
…2 of #129)

Phase 1 / 3 second pass — adds the user-visible Cursor pieces on top of
the IDE abstraction landed earlier in this PR:

Setup writers (src/setup/cursor-writers.ts, ~155 LOC)
- writeCursorMcpJson / writeCursorHooksJson / writeCursorRulesMdc
- All idempotent: re-runs preserve user-added entries, dedupe axme
  entries by command-string match. version:1 hooks.json + frontmatter
  on .cursor/rules/axme-code.mdc (alwaysApply: true).
- Hook commands include "--ide cursor" so the spawned subprocess picks
  the right adapter without re-detecting.

CLI setup branch (src/cli.ts)
- Parse --ide=<claude-code|cursor> flag (default claude-code).
- Cursor branch: write .cursor/{mcp,hooks}.json + rules/axme-code.mdc;
  skip CLAUDE.md (D-080) and .claude/settings.json. Root .mcp.json is
  always written so a repo can be opened in either IDE.
- Reject --ide=cursor + --plugin combination (Phase 2 follow-up).
- usage() documents --ide.

AuthMode = subscription | api_key | cursor_sdk (src/types.ts)
- New CursorApiKeyConfig type for ~/.config/axme-code/cursor.yaml.
- auth-config.ts: loadCursorApiKey / saveCursorApiKey (chmod 600 on
  POSIX), heuristicMode prefers cursor_sdk only when nothing else
  detected, validate-mode whitelist extended.
- auth-detect.ts: detectCursorSdk reads CURSOR_API_KEY env or
  ~/.config/axme-code/cursor.yaml, surfaces as third AuthOption.
- auth-prompt.ts: option [3] Cursor SDK in formatDetectionBlock and
  promptAuthChoice; new promptCursorApiKey() paste-once flow.
- cli.ts auth subcommand: cursor_sdk handled in both interactive
  (paste-on-pick) and "auth use cursor_sdk" non-interactive paths.

AgentSdk factory (src/utils/agent-sdk.ts + agent-sdk-{claude,cursor}.ts)
- IDE-agnostic interface mirroring the Claude SDK message envelope.
- createAgentSdk(role) selects IDE via opts → AXME_IDE env →
  authImpliedIde → "claude-code" default. Fallback chain: cursor →
  claude (if findClaudePath() or ANTHROPIC_API_KEY). Throws
  AgentSdkUnavailableError when neither usable; the detached audit
  worker (PR-3) will catch and skip.
- win-arm64 short-circuit: never attempts @cursor/sdk import there
  (no native binary in 1.0.12).
- Cursor wrapper translates Cursor stream events (assistant /
  thinking / status / tool_call) into the shared AgentMessage shape
  and synthesizes a terminal "result" message.
- System-prompt injection: prepend <system>\n...\n</system>\n\n to
  the first agent.send() — Cursor SDK has no top-level systemPrompt
  option (verified via Cursor agent spec-check on PR-1).

agent-options.ts
- buildAgentEnv(): mode=cursor_sdk hydrates CURSOR_API_KEY from
  cursor.yaml and deletes ANTHROPIC_API_KEY (preempt dual-provider
  surprise if Cursor ever adds one).
- mapClaudeToolsToCursor(): Bash → Shell, drops NotebookEdit / Agent
  / Skill / TodoWrite / WebFetch / WebSearch / ToolSearch (not in
  Cursor tool taxonomy).

@cursor/sdk@1.0.12 added to optionalDependencies (exact pin), so npm
install succeeds on win-arm64 (no native binary) without erroring.

Tests (+22):
- test/cursor-setup-writers.test.ts: idempotent merge for hooks/mcp,
  user-entry preservation, frontmatter on rules.mdc.
- test/cursor-auth-config.test.ts: cursor_sdk YAML round-trip,
  cursor.yaml chmod 600, missing/empty key handling.
- test/agent-sdk-factory.test.ts: IDE selection precedence,
  fallback chain when CURSOR_API_KEY missing, mapClaudeToolsToCursor.

Full suite: 595 / 595 (was 573). tsc clean. build clean.

This commit is PR-2 of the cursor-full-support series, all of which
land in PR #129 (per user direction: don't merge until full Cursor E2E).
PR-3 will wire the session-auditor + scanners through the new
AgentSdk factory and add a Cursor-aware branch to transcript-parser.

#!axme pr=129 repo=AxmeAI/axme-code
@George-iam George-iam changed the title feat(cursor): add IDE abstraction layer and Cursor hook adapter (PR 1/3) feat(cursor): Phase 1 foundation (IDE abstraction + AgentSdk factory) — held until Phase 2 extension validates May 10, 2026
Replaces every direct `await import("@anthropic-ai/claude-agent-sdk")`
in src/agents/* with `await createAgentSdk(role, { cwd })` so the
auditor, scanners, kb-auditor, and memory-extractor pick the right SDK
(Claude or Cursor) at runtime based on auth.yaml mode + AXME_IDE env.

Touched call sites (8 in 7 files):
- src/agents/session-auditor.ts: runAuditChunk + formatAuditResult
- src/agents/kb-auditor.ts: runKbAudit
- src/agents/memory-extractor.ts: extractMemories
- src/agents/scanners/oracle.ts: runOracleScan
- src/agents/scanners/safety.ts: runSafetyScan
- src/agents/scanners/decision.ts: runDecisionScan
- src/agents/scanners/deploy.ts: runDeployScan

Each call site retains its existing buildAgentQueryOptions/manual
queryOpts construction unchanged — only the SDK access path swaps to
the factory. Loop body (`for await msg`) shape is identical because
AgentMessage mirrors Claude SDK's message envelope.

src/utils/agent-sdk.ts:
- Renamed inline `options` shape to exported `AgentQueryOptions`.
- Made every field except `prompt` optional. Reason: existing callers
  pass Claude SDK's `Options` type (which has most fields optional),
  and the Cursor adapter only needs `cwd` + `model` + `systemPrompt` —
  the rest is forwarded as-is to whichever wrapper.
- `systemPrompt` widened to `string | string[] | { preset object }` to
  match Claude SDK 0.2.112+ which now accepts string arrays.
- `permissionMode` widened to `string` (Claude SDK has more values
  than just "bypassPermissions").

src/utils/agent-sdk-cursor.ts:
- resolveSystemPrompt now handles `undefined` and `string[]` inputs.
- Cursor wrapper falls back to `process.cwd()` and "composer-2" when
  options.cwd / options.model are absent (defensive — shouldn't
  happen in practice, but type-correctness matters).

Required for the upcoming Cursor extension: when an .vsix-installed
extension running in Cursor invokes an audit, the factory selects
@cursor/sdk via cursor_sdk auth mode and the auditor uses Cursor's
billing instead of falling through to Claude. Without this commit
the auditor would skip-or-fall-back even with a valid Cursor key.

Tests: 595/595 pass (no test changes needed — call shape unchanged).
tsc --noEmit clean. Build clean.

#!axme pr=129 repo=AxmeAI/axme-code
Cursor's stored agent transcripts at
~/.cursor/projects/<slug>/agent-transcripts/<uuid>/<uuid>.jsonl
use a different shape from Claude Code's:
  Cursor:  {"role":"user","message":{"content":[{"type":"text","text":"..."}]}}
  Claude:  {"message":{"role":"user","content":[{"type":"text","text":"..."}]}}

Cursor puts `role` at the TOP level; Claude nests it inside `message`.
Cursor's stored transcripts contain ONLY type=text blocks — no
tool_use, thinking, or tool_result (verified empirically against
~/.cursor/projects/home-georgeb-axme-workspace-AxmeAI-code-workspace
on 2026-05-10).

Changes
-------
src/transcript-parser.ts:
- Add optional `ide?: IdeKind` parameter to parseTranscriptFromOffset,
  parseTranscript, parseAndRenderTranscript. Default "claude-code"
  preserves existing behaviour for every current caller.
- Role extraction now reads `event.role` (Cursor) when ide==="cursor",
  falls back to `msg.role` (Claude) otherwise. The fallback chain
  `event.role ?? msg.role` is defensive — a Cursor transcript with
  ide=undefined (older ClaudeSessionRef without ide field) still
  parses correctly.
- parseAndRenderTranscripts threads `ref.ide` into the per-ref call
  so multi-agent Cursor sessions get the right shape per ref.
- Imported IdeKind from ../types.js.

test/transcript-parser.test.ts:
- New `Cursor JSONL transcript parsing` describe block with 4 tests:
  * Cursor format with ide="cursor" — top-level role honored
  * Cursor format without ide param — defensive fallback works
  * Text-only Cursor transcript — zero tool_use/thinking turns
  * Claude format with ide="claude-code" — regression check

This commit completes PR-3 wiring on the parser side. The session
auditor (after commit 1/3 of this batch routes through AgentSdk)
already reads ClaudeSessionRef.ide from session metadata; it now
hands that value to the parser at audit time.

Tests: 599 / 599 pass (was 595, +4 new in this commit).
tsc --noEmit clean.

#!axme pr=129 repo=AxmeAI/axme-code
… absent

User-level Cursor hooks installed at ~/.cursor/hooks.json by the VS
Code extension cannot hard-code a workspace path because they apply
machine-wide. Cursor's hook stdin payload always includes
workspace_roots[] (a Cursor common-base field, present in every event
type), so hook handlers now fall back to workspace_roots[0] when the
--workspace flag is absent.

Precedence order, all three handlers:
  1. explicit --workspace flag (existing Claude Code path — unchanged)
  2. stdin workspace_roots[0] (new — Cursor user-level hook path)
  3. process.cwd() (last-resort fallback — unchanged)

Files
-----
src/hooks/pre-tool-use.ts: read stdin first, resolve workspace path
  AFTER, dispatch to handlePreToolUse with the resolved path.
src/hooks/post-tool-use.ts: same.
src/hooks/session-end.ts: same; preserves the existing tolerance for
  empty/invalid stdin (sessionEnd is sometimes fired with no payload).

build.mjs: add @cursor/sdk to the plugin bundle's external list. The
plugin bundle uses packages: "bundle" and was choking on @cursor/sdk's
internal @anysphere/* sub-package imports. Marking it external keeps
the plugin distribution buildable; the cursor_sdk path is meaningless
in plugin context anyway (Claude Code marketplace users use the Claude
Agent SDK), and the AgentSdk factory's fallback already handles the
missing-module case gracefully.

test/hooks-workspace-fallback.test.ts: new — spawns real `tsx` hook
subprocess with no --workspace flag, feeds Cursor-shaped stdin via
pipe, asserts each of the three handlers exits cleanly with the
right resolved path. 5 tests.

Test suite: 604 / 604 pass (was 599; +5 new). tsc clean. Build clean.

This commit completes PR-3 wiring (the third of three additional
foundation commits requested by the rewritten Phase 2 plan). PR #129
foundation is now genuinely complete: AgentSdk factory, Cursor
transcript parser, hook workspace fallback. The Phase 2 extension
branch can rely on these without further core changes.

#!axme pr=129 repo=AxmeAI/axme-code
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant