Skip to content

Commit 88937a9

Browse files
committed
feat(cursor): setup writers + cursor_sdk auth + AgentSdk factory (PR-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
1 parent 5ff0a9e commit 88937a9

15 files changed

Lines changed: 2779 additions & 43 deletions

package-lock.json

Lines changed: 1595 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
"@modelcontextprotocol/sdk": "^1.29.0",
2323
"js-yaml": "^4.1.0"
2424
},
25+
"optionalDependencies": {
26+
"@cursor/sdk": "1.0.12"
27+
},
2528
"devDependencies": {
2629
"@types/js-yaml": "^4.0.9",
2730
"@types/node": "^22.0.0",

src/cli.ts

Lines changed: 94 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,18 @@ async function ensureAuthConfiguredForSetup(): Promise<void> {
197197
console.log(" Auth selection cancelled. Heuristic fallback will be used.");
198198
return;
199199
}
200+
if (choice === "cursor_sdk" && !options.cursorSdk?.present) {
201+
// User picked Cursor SDK but no key is detected yet — paste flow.
202+
const { promptCursorApiKey } = await import("./utils/auth-prompt.js");
203+
const { saveCursorApiKey } = await import("./utils/auth-config.js");
204+
const key = await promptCursorApiKey();
205+
if (!key) {
206+
console.log(" Cursor API key paste cancelled. Auth not saved.");
207+
return;
208+
}
209+
saveCursorApiKey(key);
210+
console.log(` Saved Cursor API key: ~/.config/axme-code/cursor.yaml (chmod 600)`);
211+
}
200212
saveAuthConfig(choice);
201213
console.log(` Saved auth mode: ${choice} (${authConfigPath()})`);
202214
}
@@ -320,7 +332,10 @@ function usage(): void {
320332
console.log(`AXME Code - Persistent memory, decisions, and safety guardrails for Claude Code
321333
322334
Usage:
323-
axme-code setup [path] [--force] Initialize project (LLM scan + .mcp.json + CLAUDE.md)
335+
axme-code setup [path] [--force] [--ide=<claude-code|cursor>]
336+
Initialize project (LLM scan + .mcp.json + CLAUDE.md
337+
for Claude Code, or .cursor/{mcp,hooks}.json + rules
338+
/axme-code.mdc for Cursor). Defaults to claude-code.
324339
axme-code serve Start MCP server (stdio transport)
325340
axme-code status [path] Show project status
326341
axme-code --version | -v Print the installed version
@@ -335,9 +350,11 @@ Usage:
335350
axme-code reindex [path] Force a full re-embed of all memories + decisions
336351
into .axme-code/_index/embeddings.json (search mode only)
337352
338-
axme-code auth Re-detect and choose auth mode (subscription/api_key)
353+
axme-code auth Re-detect and choose auth mode
354+
(subscription / api_key / cursor_sdk)
339355
axme-code auth status Show current auth mode + detected options
340-
axme-code auth use <subscription|api_key> Set auth mode non-interactively
356+
axme-code auth use <subscription|api_key|cursor_sdk>
357+
Set auth mode non-interactively
341358
axme-code cleanup legacy-artifacts [--dry-run] Remove pre-PR#7 sessions/logs
342359
axme-code cleanup decisions-normalize [--dry-run] Add status:active to decisions
343360
axme-code audit-kb [path] [--all-repos] KB audit: dedup, conflicts, compaction
@@ -367,7 +384,22 @@ async function main() {
367384
const setupStartMs = Date.now();
368385
const forceSetup = args.includes("--force");
369386
const pluginMode = args.includes("--plugin") || !!process.env.CLAUDE_PLUGIN_ROOT;
370-
const setupArgs = args.filter(a => a !== "--force" && a !== "--plugin");
387+
const { parseIdeFlag } = await import("./utils/ide-detect.js");
388+
const ide = parseIdeFlag(args) ?? "claude-code";
389+
if (ide === "cursor" && pluginMode) {
390+
console.error("Error: --ide=cursor is not supported with --plugin in this release.");
391+
console.error("Run setup without --plugin (Cursor plugin packaging is a Phase 2 follow-up).");
392+
process.exit(1);
393+
}
394+
// Strip --force, --plugin, --ide and its value from positional args.
395+
const setupArgs: string[] = [];
396+
for (let i = 0; i < args.length; i++) {
397+
const a = args[i];
398+
if (a === "--force" || a === "--plugin") continue;
399+
if (a === "--ide") { i++; continue; } // also skip the value
400+
if (a.startsWith("--ide=")) continue;
401+
setupArgs.push(a);
402+
}
371403
const projectPath = resolve(setupArgs[1] || ".");
372404
const hasGitDir = existsSync(join(projectPath, ".git"));
373405
const ws = detectWorkspace(projectPath);
@@ -481,7 +513,10 @@ async function main() {
481513
const isPlugin = pluginMode;
482514

483515
if (!isPlugin) {
484-
// Create or update .mcp.json (workspace root + each child repo)
516+
// Create or update .mcp.json (workspace root + each child repo).
517+
// Cursor reads BOTH .mcp.json AND .cursor/mcp.json — we always write
518+
// the root file regardless of --ide so a repo configured for Cursor
519+
// can also be opened in Claude Code without re-running setup.
485520
const mcpEntry = { command: "axme-code", args: ["serve"] };
486521
const mcpPaths = [projectPath];
487522
if (isWorkspace) {
@@ -504,14 +539,30 @@ async function main() {
504539
console.log(` .mcp.json: skipped (plugin provides MCP server)`);
505540
}
506541

507-
// Generate CLAUDE.md
508-
generateClaudeMd(projectPath, isWorkspace);
509-
510-
if (!isPlugin) {
511-
// Configure Claude Code hooks in .claude/settings.json
512-
configureHooks(projectPath);
542+
if (ide === "cursor") {
543+
// Cursor branch: write .cursor/{mcp,hooks}.json + rules/axme-code.mdc.
544+
// Skip .claude/CLAUDE.md (D-080: agent must never write to it) and
545+
// .claude/settings.json (Cursor doesn't read it).
546+
const { writeCursorMcpJson, writeCursorHooksJson, writeCursorRulesMdc } =
547+
await import("./setup/cursor-writers.js");
548+
const cursorPaths = [projectPath];
549+
if (isWorkspace) {
550+
for (const p of ws.projects) cursorPaths.push(join(projectPath, p.path));
551+
}
552+
for (const dir of cursorPaths) writeCursorMcpJson(dir);
553+
console.log(` .cursor/mcp.json: updated (${cursorPaths.length} locations)`);
554+
writeCursorHooksJson(projectPath, buildHookCommand);
555+
console.log(" .cursor/hooks.json: hooks configured (preToolUse + postToolUse + sessionEnd)");
556+
writeCursorRulesMdc(projectPath, isWorkspace);
557+
console.log(" .cursor/rules/axme-code.mdc: created");
513558
} else {
514-
console.log(` Hooks: skipped (plugin provides hooks)`);
559+
// Claude Code branch (unchanged): CLAUDE.md + .claude/settings.json
560+
generateClaudeMd(projectPath, isWorkspace);
561+
if (!isPlugin) {
562+
configureHooks(projectPath);
563+
} else {
564+
console.log(` Hooks: skipped (plugin provides hooks)`);
565+
}
515566
}
516567

517568
// Add .axme-code/ to .gitignore
@@ -774,10 +825,26 @@ Do NOT skip — without context you will miss critical project rules.
774825
}
775826
if (sub === "use" || sub === "set") {
776827
const mode = args[2];
777-
if (mode !== "subscription" && mode !== "api_key") {
778-
console.error("Usage: axme-code auth use <subscription|api_key>");
828+
if (mode !== "subscription" && mode !== "api_key" && mode !== "cursor_sdk") {
829+
console.error("Usage: axme-code auth use <subscription|api_key|cursor_sdk>");
779830
process.exit(1);
780831
}
832+
if (mode === "cursor_sdk") {
833+
// Non-interactive set still requires a key — read from
834+
// CURSOR_API_KEY env or refuse politely.
835+
const { loadCursorApiKey, saveCursorApiKey } = await import("./utils/auth-config.js");
836+
if (!loadCursorApiKey() && !process.env.CURSOR_API_KEY) {
837+
console.error(
838+
"cursor_sdk mode requires a key. Set CURSOR_API_KEY in env, or run\n" +
839+
" axme-code auth (interactive — paste key when prompted)",
840+
);
841+
process.exit(1);
842+
}
843+
if (process.env.CURSOR_API_KEY && !loadCursorApiKey()) {
844+
saveCursorApiKey(process.env.CURSOR_API_KEY);
845+
console.log(" Saved Cursor API key from env to ~/.config/axme-code/cursor.yaml (chmod 600)");
846+
}
847+
}
781848
saveAuthConfig(mode as AuthMode);
782849
console.log(`Saved auth mode: ${mode} (${authConfigPath()})`);
783850
break;
@@ -794,20 +861,31 @@ Do NOT skip — without context you will miss critical project rules.
794861
process.exit(1);
795862
}
796863
if (!process.stdin.isTTY) {
797-
console.error("`axme-code auth` requires an interactive terminal. Use `axme-code auth use <subscription|api_key>` non-interactively.");
864+
console.error("`axme-code auth` requires an interactive terminal. Use `axme-code auth use <subscription|api_key|cursor_sdk>` non-interactively.");
798865
process.exit(1);
799866
}
800867
const choice = await promptAuthChoice(options);
801868
if (!choice) {
802869
console.log("Cancelled. No change.");
803870
break;
804871
}
872+
if (choice === "cursor_sdk" && !options.cursorSdk?.present) {
873+
const { promptCursorApiKey } = await import("./utils/auth-prompt.js");
874+
const { saveCursorApiKey } = await import("./utils/auth-config.js");
875+
const key = await promptCursorApiKey();
876+
if (!key) {
877+
console.log("Cursor API key paste cancelled. No change.");
878+
break;
879+
}
880+
saveCursorApiKey(key);
881+
console.log(" Saved Cursor API key: ~/.config/axme-code/cursor.yaml (chmod 600)");
882+
}
805883
saveAuthConfig(choice);
806884
console.log(`Saved auth mode: ${choice} (${authConfigPath()})`);
807885
break;
808886
}
809887
console.error(`Unknown 'auth' subcommand: ${sub}`);
810-
console.error("Available: (none)|choose, status|show, use|set <subscription|api_key>");
888+
console.error("Available: (none)|choose, status|show, use|set <subscription|api_key|cursor_sdk>");
811889
process.exit(1);
812890
}
813891

src/setup/cursor-writers.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/**
2+
* Idempotent writers for Cursor-specific config files.
3+
*
4+
* Layout:
5+
* .cursor/mcp.json — Cursor's MCP server registration (mirrors .mcp.json)
6+
* .cursor/hooks.json — preToolUse / postToolUse / sessionEnd commands
7+
* .cursor/rules/axme-code.mdc — auto-applied rule (Cursor's CLAUDE.md analogue)
8+
*
9+
* All three writers preserve user-added entries on re-run: the merge logic
10+
* filters out previous axme entries by string match on `axme-code` in the
11+
* command field, then re-adds fresh ones — exactly the pattern
12+
* configureHooks() uses for `.claude/settings.json`.
13+
*
14+
* D-080 forbids writing `.claude/CLAUDE.md`. Cursor's analogue is
15+
* `.cursor/rules/<rule>.mdc` with YAML frontmatter (`alwaysApply: true`).
16+
* The body content mirrors the Claude Code template but is reworded for
17+
* Cursor terminology ("Cursor session" not "Claude session").
18+
*/
19+
20+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
21+
import { dirname, join } from "node:path";
22+
23+
const HOOK_TIMEOUT_MS: Record<HookKind, number> = {
24+
preToolUse: 5,
25+
postToolUse: 10,
26+
sessionEnd: 120,
27+
};
28+
29+
type HookKind = "preToolUse" | "postToolUse" | "sessionEnd";
30+
31+
interface CursorHookEntry {
32+
command: string;
33+
type?: "command";
34+
timeout?: number;
35+
matcher?: string;
36+
failClosed?: boolean;
37+
loop_limit?: number | null;
38+
[key: string]: unknown;
39+
}
40+
41+
interface CursorHooksFile {
42+
version?: number;
43+
hooks?: Partial<Record<HookKind, CursorHookEntry[]>>;
44+
[key: string]: unknown;
45+
}
46+
47+
interface CursorMcpFile {
48+
mcpServers?: Record<string, { command: string; args?: string[] }>;
49+
[key: string]: unknown;
50+
}
51+
52+
function readJsonOr<T extends object>(path: string, fallback: T): T {
53+
if (!existsSync(path)) return fallback;
54+
try {
55+
return JSON.parse(readFileSync(path, "utf-8")) as T;
56+
} catch {
57+
return fallback;
58+
}
59+
}
60+
61+
function writeJsonAtomic(path: string, value: unknown): void {
62+
mkdirSync(dirname(path), { recursive: true });
63+
writeFileSync(path, JSON.stringify(value, null, 2) + "\n", "utf-8");
64+
}
65+
66+
/**
67+
* Write `.cursor/mcp.json`, merging with any existing servers. Cursor reads
68+
* `.cursor/mcp.json` AND root `.mcp.json` — we always write the Cursor
69+
* one too, so a repo configured with `--ide=cursor` keeps working even
70+
* if the user later removes `.mcp.json`.
71+
*/
72+
export function writeCursorMcpJson(projectPath: string): void {
73+
const path = join(projectPath, ".cursor", "mcp.json");
74+
const cfg = readJsonOr<CursorMcpFile>(path, {});
75+
if (!cfg.mcpServers) cfg.mcpServers = {};
76+
cfg.mcpServers.axme = { command: "axme-code", args: ["serve"] };
77+
writeJsonAtomic(path, cfg);
78+
}
79+
80+
/**
81+
* Write `.cursor/hooks.json` with three event arrays. Each entry uses
82+
* `buildHookCommand()` with `--ide cursor` appended so the hook
83+
* subprocess can pick the right adapter without re-detecting.
84+
*
85+
* Idempotency: existing entries whose command contains "axme-code" are
86+
* dropped before re-insert; user-added entries are preserved verbatim.
87+
*/
88+
export function writeCursorHooksJson(
89+
projectPath: string,
90+
buildHookCommand: (hookName: string, projectPath: string) => string,
91+
): void {
92+
const path = join(projectPath, ".cursor", "hooks.json");
93+
const cfg = readJsonOr<CursorHooksFile>(path, { version: 1 });
94+
if (!cfg.version) cfg.version = 1;
95+
if (!cfg.hooks) cfg.hooks = {};
96+
97+
const hookKinds: HookKind[] = ["preToolUse", "postToolUse", "sessionEnd"];
98+
const cliHookNames: Record<HookKind, string> = {
99+
preToolUse: "pre-tool-use",
100+
postToolUse: "post-tool-use",
101+
sessionEnd: "session-end",
102+
};
103+
104+
for (const kind of hookKinds) {
105+
const existing = cfg.hooks[kind] ?? [];
106+
const preserved = existing.filter((e) => !String(e.command ?? "").includes("axme-code"));
107+
const fresh: CursorHookEntry = {
108+
command: `${buildHookCommand(cliHookNames[kind], projectPath)} --ide cursor`,
109+
type: "command",
110+
timeout: HOOK_TIMEOUT_MS[kind],
111+
};
112+
cfg.hooks[kind] = [...preserved, fresh];
113+
}
114+
115+
writeJsonAtomic(path, cfg);
116+
}
117+
118+
const RULE_FRONTMATTER = `---
119+
name: axme-code
120+
description: AXME Code session start ritual + safety reminders for Cursor
121+
alwaysApply: true
122+
---
123+
`;
124+
125+
const RULE_BODY = `## AXME Code
126+
127+
### Session Start (MANDATORY)
128+
Call \`axme_context\` tool with this project's path at the start of every Cursor session.
129+
This loads: oracle, decisions, safety rules, memories, test plan, active plans.
130+
Do NOT skip — without context you will miss critical project rules.
131+
132+
### During Work
133+
- Error pattern or successful approach discovered → call \`axme_save_memory\` immediately.
134+
- Architectural decision made or discovered → call \`axme_save_decision\` immediately.
135+
- New safety constraint found → call \`axme_update_safety\` immediately.
136+
137+
### Git commit/push gate
138+
Every \`git commit\` and \`git push\` command MUST end with the marker:
139+
\`\`\`
140+
#!axme pr=<NUMBER|none> repo=<OWNER/REPO>
141+
\`\`\`
142+
Without this suffix the pre-tool-use hook blocks the command.
143+
144+
### Available AXME tools
145+
\`axme_context\`, \`axme_save_memory\`, \`axme_save_decision\`, \`axme_update_safety\`,
146+
\`axme_safety\`, \`axme_status\`, \`axme_worklog\`, \`axme_workspace\`,
147+
\`axme_oracle\`, \`axme_decisions\`, \`axme_memories\`.
148+
`;
149+
150+
/**
151+
* Write `.cursor/rules/axme-code.mdc` (Cursor's auto-applied rule format).
152+
* Always overwrites — the rule body is canonical and small. Frontmatter
153+
* `alwaysApply: true` makes Cursor inject this content into every chat
154+
* session's system context.
155+
*/
156+
export function writeCursorRulesMdc(projectPath: string, _isWorkspace: boolean): void {
157+
const path = join(projectPath, ".cursor", "rules", "axme-code.mdc");
158+
mkdirSync(dirname(path), { recursive: true });
159+
writeFileSync(path, RULE_FRONTMATTER + "\n" + RULE_BODY, "utf-8");
160+
}

src/types.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -398,9 +398,30 @@ export type E2EMode = "after-task" | "after-stage" | "manual";
398398

399399
// --- Auth ---
400400

401-
export type AuthMode = "subscription" | "api_key";
401+
/**
402+
* Authentication mode for LLM agent invocations.
403+
*
404+
* - `subscription`: Claude Code OAuth subscription. The factory deletes
405+
* ANTHROPIC_API_KEY from the subprocess env so Claude Code doesn't pick
406+
* an empty-balance key over OAuth.
407+
* - `api_key`: Direct ANTHROPIC_API_KEY in env, passed through unchanged.
408+
* - `cursor_sdk`: Cursor SDK API key issued at cursor.com (Integrations).
409+
* The actual key is stored at ~/.config/axme-code/cursor.yaml (chmod 600);
410+
* `auth.yaml` only carries the mode flag.
411+
*/
412+
export type AuthMode = "subscription" | "api_key" | "cursor_sdk";
402413

403414
export interface AuthConfig {
404415
mode: AuthMode;
405416
chosenAt: string;
406417
}
418+
419+
/**
420+
* Cursor SDK API key payload, stored separately at
421+
* ~/.config/axme-code/cursor.yaml so the secret is not co-mingled with
422+
* the auth-mode flag and can be permission-locked (chmod 600) on its own.
423+
*/
424+
export interface CursorApiKeyConfig {
425+
apiKey: string;
426+
chosenAt: string;
427+
}

0 commit comments

Comments
 (0)