From cd1dc59a6c48d23df2a8eec62a4cc9050034d138 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Jun 2026 20:24:22 +0000 Subject: [PATCH 1/2] feat: user-level Cursor plugin install via authenticated download Replace the manual Team Marketplace URL flow with an automated install that downloads the Cursor plugin tarball from /api/cursor and extracts it into ~/.cursor/plugins/local/archgate/. Changes: - Add cursorPluginsLocalDir() to paths.ts for user-scope path resolution - Add installCursorPlugin(token) to plugin-install.ts (mirrors opencode flow) - Update cursor-settings.ts to return user-scope path instead of project-level - Update init-project.ts tryInstallPlugin to auto-install with fallback to marketplace URL on failure - Update commands/plugin/install.ts to call installCursorPlugin directly - Update commands/init.ts EDITOR_DIRS and printManualInstructions for cursor - Update all cursor-related tests to match new auto-install behavior Co-authored-by: rhuan --- src/commands/init.ts | 20 ++++++---- src/commands/plugin/install.ts | 22 +++++------ src/helpers/cursor-settings.ts | 28 +++++++------- src/helpers/init-project.ts | 16 +++++--- src/helpers/paths.ts | 12 ++++++ src/helpers/plugin-install.ts | 55 ++++++++++++++++++++++++++- tests/commands/plugin/install.test.ts | 15 ++++---- tests/helpers/cursor-settings.test.ts | 14 ++++--- tests/helpers/init-project.test.ts | 54 ++++++++++++++++++++------ tests/helpers/plugin-install.test.ts | 45 ++++++++++++++++++++++ 10 files changed, 219 insertions(+), 62 deletions(-) diff --git a/src/commands/init.ts b/src/commands/init.ts index 47090df7..fd6ee4a6 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -31,9 +31,9 @@ import { isTlsError, tlsHintMessage } from "../helpers/tls"; const EDITOR_DIRS: Record = { claude: ".claude/", - // Cursor plugin is embedded in the VSIX extension — no project-level - // files are written. Shown as a label in the init summary. - cursor: "(VSIX)", + // Cursor plugin installs to user-scope ~/.cursor/plugins/local/, not the + // project tree. Shown as a shorthand in the init summary. + cursor: "(user-scope)", vscode: ".vscode/", copilot: ".github/copilot/", // Opencode agents install to a user-scope directory, not the project tree. @@ -350,16 +350,20 @@ function printManualInstructions(editor: EditorTarget, detail?: string): void { } break; case "cursor": - if (detail && !detail.startsWith("download")) { - // detail is the VSIX path or the error message from installCursorPlugin - logWarn("Cursor CLI not found. The VSIX has been downloaded:"); + if (detail) { + logWarn( + "Failed to install Cursor plugin locally. You can add the team marketplace URL instead:" + ); console.log(` ${styleText("bold", detail)}`); console.log( - ` Open Cursor → Ctrl+Shift+P → ${styleText("bold", "Extensions: Install from VSIX...")} → select the file above` + ` Cursor Settings → Extensions → Team Private Plugin Marketplaces → Add URL` + ); + console.log( + ` Or retry: ${styleText("bold", "archgate plugin install --editor cursor")}` ); } else { logWarn( - "Could not download the VSIX. Retry with:", + "Could not install the Cursor plugin. Retry with:", ` ${styleText("bold", "archgate plugin install --editor cursor")}` ); } diff --git a/src/commands/plugin/install.ts b/src/commands/plugin/install.ts index 10f9fc8f..cfa12505 100644 --- a/src/commands/plugin/install.ts +++ b/src/commands/plugin/install.ts @@ -21,6 +21,7 @@ import { buildVscodeMarketplaceUrl, installClaudePlugin, installCopilotPlugin, + installCursorPlugin, installOpencodePlugin, installVscodeExtension, isClaudeCliAvailable, @@ -83,16 +84,8 @@ export async function installForEditor( break; } case "cursor": { - // Cursor supports plugins via Team Private Marketplaces — not VSIX. - // See https://cursor.com/docs/plugins#team-marketplaces - const url = buildCursorMarketplaceUrl(); - logInfo( - `To install the Archgate plugin for ${label}, add the team marketplace URL in Cursor Settings:` - ); - console.log(` ${styleText("bold", url)}`); - console.log( - ` Cursor Settings → Extensions → Team Private Plugin Marketplaces → Add URL` - ); + await installCursorPlugin(token); + logInfo(`Archgate plugin installed for ${label}.`); break; } case "opencode": { @@ -166,8 +159,15 @@ export function printManualInstructions(editor: EditorTarget): void { break; } case "cursor": { + logInfo( + "Retry the install, or refresh your credentials if they have expired:" + ); + console.log(` ${styleText("bold", "archgate login refresh")}`); + console.log( + ` ${styleText("bold", "archgate plugin install --editor cursor")}` + ); const url = buildCursorMarketplaceUrl(); - logInfo("Add the team marketplace URL in Cursor Settings:"); + logInfo("Or add the team marketplace URL in Cursor Settings:"); console.log(` ${styleText("bold", url)}`); console.log( ` Cursor Settings → Extensions → Team Private Plugin Marketplaces → Add URL` diff --git a/src/helpers/cursor-settings.ts b/src/helpers/cursor-settings.ts index a5b18693..15517f00 100644 --- a/src/helpers/cursor-settings.ts +++ b/src/helpers/cursor-settings.ts @@ -3,27 +3,27 @@ /** * Cursor editor integration. * - * The archgate Cursor plugin (skills, agents, governance rules) is now - * embedded inside the VS Code extension (.vsix). When the extension - * activates in Cursor it calls `vscode.cursor.plugins.registerPath()` - * to expose the plugin — no project-level files are needed. + * The archgate Cursor plugin is installed to the user-scope local plugins + * directory (`~/.cursor/plugins/local/archgate/`). Cursor automatically + * discovers plugins from this directory — no project-level files are needed. * - * `configureCursorSettings` is kept as a no-op for call-site - * compatibility (init-project.ts, etc.) and returns the `.cursor/` - * directory path for the init summary output. + * `configureCursorSettings` returns the resolved user-scope plugins + * directory so the init summary has something meaningful to print (matching + * the opencode pattern where user-scope paths replace project-tree paths). */ -import { join } from "node:path"; +import { cursorPluginsLocalDir } from "./paths"; /** * Configure Cursor settings for archgate integration. * - * No-op — the archgate VSIX extension embeds the Cursor plugin and - * registers it via `vscode.cursor.plugins.registerPath()` at runtime. - * No project-level files are written. + * No project-level files are written — the Cursor plugin is delivered to + * the user-scope `~/.cursor/plugins/local/` directory by + * `installCursorPlugin()`. Returns the resolved local plugins directory + * path for the init summary display. * - * @returns Path to the `.cursor/` directory (for init summary display). + * @returns Path to the `~/.cursor/plugins/local/` directory. */ -export function configureCursorSettings(projectRoot: string): string { - return join(projectRoot, ".cursor"); +export function configureCursorSettings(): string { + return cursorPluginsLocalDir(); } diff --git a/src/helpers/init-project.ts b/src/helpers/init-project.ts index 19a77c77..4a09a7be 100644 --- a/src/helpers/init-project.ts +++ b/src/helpers/init-project.ts @@ -152,7 +152,7 @@ async function configureEditorSettings( ): Promise { switch (editor) { case "cursor": - return configureCursorSettings(projectRoot); + return configureCursorSettings(); case "vscode": { // VS Code: marketplace URL to user settings (credentials provided by git credential manager) const { loadCredentials } = await import("./credential-store"); @@ -264,10 +264,16 @@ async function tryInstallPlugin(editor: EditorTarget): Promise { } if (editor === "cursor") { - // Cursor uses Team Private Plugin Marketplaces — not VSIX or CLI install. - // The user must add the marketplace URL manually in Cursor Settings. - const { buildCursorMarketplaceUrl } = await import("./plugin-install"); - return { installed: true, detail: buildCursorMarketplaceUrl() }; + const { installCursorPlugin, buildCursorMarketplaceUrl } = + await import("./plugin-install"); + + try { + await installCursorPlugin(credentials.token); + return { installed: true, autoInstalled: true }; + } catch (error) { + logDebug("Failed to install Cursor plugin:", error); + return { installed: true, detail: buildCursorMarketplaceUrl() }; + } } if (editor === "vscode") { diff --git a/src/helpers/paths.ts b/src/helpers/paths.ts index 1427cf8f..c0f7a9ae 100644 --- a/src/helpers/paths.ts +++ b/src/helpers/paths.ts @@ -78,6 +78,18 @@ export function copilotSessionStateDir(): string { return join(archgateHomeDir(), ".copilot", "session-state"); } +/** + * Resolve the Cursor user-scope local plugins directory. + * + * Cursor discovers local plugins from `~/.cursor/plugins/local//`. + * The archgate plugin extracts into `archgate/` under this directory. + * + * Resolved at call time (not cached) so tests can override HOME. + */ +export function cursorPluginsLocalDir(): string { + return join(archgateHomeDir(), ".cursor", "plugins", "local"); +} + /** * Resolve the opencode SQLite database path. * diff --git a/src/helpers/plugin-install.ts b/src/helpers/plugin-install.ts index 251bf990..3eabf4ac 100644 --- a/src/helpers/plugin-install.ts +++ b/src/helpers/plugin-install.ts @@ -5,7 +5,11 @@ import { mkdirSync, unlinkSync } from "node:fs"; import { logDebug } from "./log"; -import { internalPath, opencodeAgentsDir } from "./paths"; +import { + cursorPluginsLocalDir, + internalPath, + opencodeAgentsDir, +} from "./paths"; import { resolveCommand } from "./platform"; const PLUGINS_API = "https://plugins.archgate.dev"; @@ -120,6 +124,55 @@ export async function installClaudePlugin(): Promise { } } +// --------------------------------------------------------------------------- +// Cursor — download plugin tarball into user-scope plugins dir +// --------------------------------------------------------------------------- + +/** + * Install the archgate Cursor plugin into the user-scope local plugins + * directory (`~/.cursor/plugins/local/archgate/`). + * + * Cursor discovers local plugins from `~/.cursor/plugins/local//` + * where each plugin has a `.cursor-plugin/plugin.json` manifest. The + * archgate plugin is downloaded as an authenticated tarball from + * `/api/cursor` and extracted directly into the target directory. + * + * The tarball contains files rooted at `archgate/` (the plugin directory + * name), so extracting with `-C ~/.cursor/plugins/local/` places everything + * at `~/.cursor/plugins/local/archgate/`. + * + * Throws on download or extraction failure so callers can surface a manual + * retry hint. + */ +export async function installCursorPlugin(token: string): Promise { + const tarballPath = internalPath("archgate-cursor.tar.gz"); + const pluginsDir = cursorPluginsLocalDir(); + + const buffer = await downloadPluginAsset("/api/cursor", token); + logDebug( + `Downloaded Cursor plugin bundle (${Math.round(buffer.byteLength / 1024)} KB)` + ); + await Bun.write(tarballPath, buffer); + + try { + mkdirSync(pluginsDir, { recursive: true }); + + logDebug(`Extracting Cursor plugin into ${pluginsDir}`); + const result = await run(["tar", "-xzf", tarballPath, "-C", pluginsDir]); + if (result.exitCode !== 0) { + throw new Error( + `tar -xzf failed (exit ${result.exitCode}) while extracting Cursor plugin` + ); + } + } finally { + try { + unlinkSync(tarballPath); + } catch { + // Ignore cleanup errors + } + } +} + // --------------------------------------------------------------------------- // Shared — authenticated asset download // --------------------------------------------------------------------------- diff --git a/tests/commands/plugin/install.test.ts b/tests/commands/plugin/install.test.ts index e612d8f5..8a3620c8 100644 --- a/tests/commands/plugin/install.test.ts +++ b/tests/commands/plugin/install.test.ts @@ -21,6 +21,7 @@ let mockLoadCredentials: ReturnType; const mockInstallClaudePlugin = mock(() => Promise.resolve()); const mockInstallCopilotPlugin = mock(() => Promise.resolve()); +const mockInstallCursorPlugin = mock((_token: string) => Promise.resolve()); const mockInstallVscodeExtension = mock((_token: string) => Promise.resolve()); const mockInstallOpencodePlugin = mock((_token: string) => Promise.resolve()); const mockIsClaudeCliAvailable = mock(() => Promise.resolve(false)); @@ -35,6 +36,7 @@ mock.module("../../../src/helpers/plugin-install", () => ({ "https://plugins.archgate.dev/archgate/cursor.git", installClaudePlugin: mockInstallClaudePlugin, installCopilotPlugin: mockInstallCopilotPlugin, + installCursorPlugin: mockInstallCursorPlugin, installVscodeExtension: mockInstallVscodeExtension, installOpencodePlugin: mockInstallOpencodePlugin, isClaudeCliAvailable: mockIsClaudeCliAvailable, @@ -117,6 +119,7 @@ beforeEach(() => { // Reset all mocks mockInstallClaudePlugin.mockReset(); mockInstallCopilotPlugin.mockReset(); + mockInstallCursorPlugin.mockReset(); mockInstallVscodeExtension.mockReset(); mockInstallOpencodePlugin.mockReset(); mockIsClaudeCliAvailable.mockReset(); @@ -130,6 +133,9 @@ beforeEach(() => { // Default implementations mockInstallClaudePlugin.mockImplementation(() => Promise.resolve()); mockInstallCopilotPlugin.mockImplementation(() => Promise.resolve()); + mockInstallCursorPlugin.mockImplementation((_token: string) => + Promise.resolve() + ); mockInstallVscodeExtension.mockImplementation((_token: string) => Promise.resolve() ); @@ -229,19 +235,14 @@ describe("plugin install action", () => { expect(warnSpy).toHaveBeenCalled(); }); - test("prints cursor marketplace URL for --editor cursor", async () => { + test("installs cursor plugin via authenticated download", async () => { mockLoadCredentials.mockImplementation(() => Promise.resolve({ token: "tok", github_user: "user" }) ); await runInstall(["--editor", "cursor"]); - // Cursor case prints URL, never calls an install function - expect(logSpy).toHaveBeenCalled(); - const allLogOutput = logSpy.mock.calls - .map((c: unknown[]) => String(c[0])) - .join("\n"); - expect(allLogOutput).toContain("Cursor Settings"); + expect(mockInstallCursorPlugin).toHaveBeenCalledWith("tok"); }); test("installs copilot plugin when CLI is available", async () => { diff --git a/tests/helpers/cursor-settings.test.ts b/tests/helpers/cursor-settings.test.ts index 04738412..9e24bb88 100644 --- a/tests/helpers/cursor-settings.test.ts +++ b/tests/helpers/cursor-settings.test.ts @@ -9,22 +9,26 @@ import { configureCursorSettings } from "../../src/helpers/cursor-settings"; describe("configureCursorSettings", () => { let tempDir: string; + let savedHome: string | undefined; beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), "archgate-cursor-settings-test-")); + savedHome = Bun.env.HOME; + Bun.env.HOME = tempDir; }); afterEach(() => { + Bun.env.HOME = savedHome; rmSync(tempDir, { recursive: true, force: true }); }); - test("returns .cursor/ directory path (no files written)", () => { - const result = configureCursorSettings(tempDir); - expect(result).toBe(join(tempDir, ".cursor")); + test("returns user-scope ~/.cursor/plugins/local/ path", () => { + const result = configureCursorSettings(); + expect(result).toBe(join(tempDir, ".cursor", "plugins", "local")); }); - test("does not create .cursor/ directory", () => { - configureCursorSettings(tempDir); + test("does not create directories", () => { + configureCursorSettings(); expect(existsSync(join(tempDir, ".cursor"))).toBe(false); }); }); diff --git a/tests/helpers/init-project.test.ts b/tests/helpers/init-project.test.ts index 7d2762a9..24b98b30 100644 --- a/tests/helpers/init-project.test.ts +++ b/tests/helpers/init-project.test.ts @@ -72,19 +72,26 @@ describe("initProject", () => { ); }); - test("configures Cursor settings when editor is cursor (no project files)", async () => { - const result = await initProject(tempDir, { editor: "cursor" }); + test("configures Cursor settings when editor is cursor (user-scope path)", async () => { + const savedHome = Bun.env.HOME; + try { + Bun.env.HOME = tempDir; + const result = await initProject(tempDir, { editor: "cursor" }); - // Cursor plugin is embedded in the VSIX — no project-level files written - expect(existsSync(join(tempDir, ".cursor"))).toBe(false); + // Cursor plugin installs to user-scope — no project-level files written + expect(existsSync(join(tempDir, ".cursor"))).toBe(false); - // Claude settings should NOT exist - expect(existsSync(join(tempDir, ".claude", "settings.local.json"))).toBe( - false - ); + // Claude settings should NOT exist + expect(existsSync(join(tempDir, ".claude", "settings.local.json"))).toBe( + false + ); - // Result should point to .cursor/ directory - expect(result.editorSettingsPath).toBe(join(tempDir, ".cursor")); + // Result should point to user-scope plugins directory + const expectedDir = join(tempDir, ".cursor", "plugins", "local"); + expect(result.editorSettingsPath).toBe(expectedDir); + } finally { + Bun.env.HOME = savedHome; + } }); test("skips example ADR when ADRs already exist", async () => { @@ -267,8 +274,31 @@ describe("tryInstallPlugin via initProject", () => { expect(result.plugin!.detail).toContain("No stored credentials"); }); - test("cursor returns marketplace URL", async () => { + test("cursor auto-installs plugin on success", async () => { + credSpy.mockResolvedValue({ token: "tok", github_user: "user" }); + const installSpy = spyOn( + pluginInstall, + "installCursorPlugin" + ).mockResolvedValue(); + try { + const result = await initProject(tempDir, { + installPlugin: true, + editor: "cursor", + }); + expect(result.plugin!.installed).toBe(true); + expect(result.plugin!.autoInstalled).toBe(true); + expect(installSpy).toHaveBeenCalledTimes(1); + } finally { + installSpy.mockRestore(); + } + }); + + test("cursor falls back to marketplace URL on install failure", async () => { credSpy.mockResolvedValue({ token: "tok", github_user: "user" }); + const installSpy = spyOn( + pluginInstall, + "installCursorPlugin" + ).mockRejectedValue(new Error("download failed")); const urlSpy = spyOn( pluginInstall, "buildCursorMarketplaceUrl" @@ -280,7 +310,9 @@ describe("tryInstallPlugin via initProject", () => { }); expect(result.plugin!.installed).toBe(true); expect(result.plugin!.detail).toBe("https://cursor.example"); + expect(result.plugin!.autoInstalled).toBeUndefined(); } finally { + installSpy.mockRestore(); urlSpy.mockRestore(); } }); diff --git a/tests/helpers/plugin-install.test.ts b/tests/helpers/plugin-install.test.ts index 731b86be..6a8ab845 100644 --- a/tests/helpers/plugin-install.test.ts +++ b/tests/helpers/plugin-install.test.ts @@ -32,6 +32,7 @@ import { buildVscodeMarketplaceUrl, installClaudePlugin, installCopilotPlugin, + installCursorPlugin, installOpencodePlugin, installVscodeExtension, isClaudeCliAvailable, @@ -447,4 +448,48 @@ describe("plugin-install", () => { ); }); }); + + // ----------------------------------------------------------------------- + // installCursorPlugin + // ----------------------------------------------------------------------- + + describe("installCursorPlugin", () => { + test("downloads tarball and extracts via tar on success", async () => { + const tarContent = new ArrayBuffer(256); + mockFetch(200, tarContent); + spawnSpy.mockImplementation(() => fakeSpawnResult(0)); + + await installCursorPlugin("test-token"); + + expect(spawnSpy).toHaveBeenCalledTimes(1); + const callArgs = spawnSpy.mock.calls[0][0] as string[]; + expect(callArgs[0]).toBe("tar"); + expect(callArgs).toContain("-xzf"); + }); + + test("throws when tar extraction fails", async () => { + mockFetch(200, new ArrayBuffer(64)); + spawnSpy.mockImplementation(() => fakeSpawnResult(2)); + + await expect(installCursorPlugin("test-token")).rejects.toThrow( + "tar -xzf failed" + ); + }); + + test("throws re-login message on 401 download", async () => { + mockFetch(401); + + await expect(installCursorPlugin("expired-token")).rejects.toThrow( + "expired" + ); + }); + + test("throws generic error on non-401 HTTP failure", async () => { + mockFetch(503); + + await expect(installCursorPlugin("test-token")).rejects.toThrow( + "Download failed (HTTP 503)" + ); + }); + }); }); From 063e346afa7d94ffec4311317f163ac034a332e6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Jun 2026 20:26:42 +0000 Subject: [PATCH 2/2] fix: update .cursor/rules governance rule to use CLI commands Replace stale MCP tool references (review_context, check, list_adrs) with the current CLI command equivalents (archgate review-context, archgate check --staged, archgate adr list). Co-authored-by: rhuan --- .cursor/rules/archgate-governance.mdc | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.cursor/rules/archgate-governance.mdc b/.cursor/rules/archgate-governance.mdc index 853a1dd6..3fc34d8c 100644 --- a/.cursor/rules/archgate-governance.mdc +++ b/.cursor/rules/archgate-governance.mdc @@ -10,20 +10,20 @@ This project uses Archgate to enforce Architecture Decision Records (ADRs). ## Before writing code -- Use the `review_context` MCP tool to get applicable ADR briefings for changed files +- Run `archgate review-context` to get applicable ADR briefings for changed files - Review the Decision and Do's/Don'ts sections of each applicable ADR ## After writing code -- Run the `check` MCP tool to validate compliance with all ADR rules +- Run `archgate check --staged` to validate compliance with all ADR rules - Fix any violations before considering work complete ## ADR commands -- `list_adrs` — List all active ADRs with metadata -- `check` — Run automated compliance checks (use `staged: true` for pre-commit) -- `review_context` — Get changed files grouped by domain with ADR briefings +- `archgate adr list` — List all active ADRs with metadata +- `archgate check --staged` — Run automated compliance checks +- `archgate review-context` — Get changed files grouped by domain with ADR briefings ## Key principle -Architectural decisions are enforced, not suggested. If `check` reports violations, they must be fixed. +Architectural decisions are enforced, not suggested. If `archgate check` reports violations, they must be fixed.