Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .cursor/rules/archgate-governance.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
20 changes: 12 additions & 8 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,9 @@ import { isTlsError, tlsHintMessage } from "../helpers/tls";

const EDITOR_DIRS: Record<EditorTarget, string> = {
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.
Expand Down Expand Up @@ -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")}`
);
}
Expand Down
22 changes: 11 additions & 11 deletions src/commands/plugin/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
buildVscodeMarketplaceUrl,
installClaudePlugin,
installCopilotPlugin,
installCursorPlugin,
installOpencodePlugin,
installVscodeExtension,
isClaudeCliAvailable,
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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`
Expand Down
28 changes: 14 additions & 14 deletions src/helpers/cursor-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
16 changes: 11 additions & 5 deletions src/helpers/init-project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ async function configureEditorSettings(
): Promise<string> {
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");
Expand Down Expand Up @@ -264,10 +264,16 @@ async function tryInstallPlugin(editor: EditorTarget): Promise<PluginResult> {
}

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") {
Expand Down
12 changes: 12 additions & 0 deletions src/helpers/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/`.
* 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.
*
Expand Down
55 changes: 54 additions & 1 deletion src/helpers/plugin-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -120,6 +124,55 @@ export async function installClaudePlugin(): Promise<void> {
}
}

// ---------------------------------------------------------------------------
// 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/<name>/`
* 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<void> {
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
// ---------------------------------------------------------------------------
Expand Down
15 changes: 8 additions & 7 deletions tests/commands/plugin/install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ let mockLoadCredentials: ReturnType<typeof spyOn>;

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));
Expand All @@ -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,
Expand Down Expand Up @@ -117,6 +119,7 @@ beforeEach(() => {
// Reset all mocks
mockInstallClaudePlugin.mockReset();
mockInstallCopilotPlugin.mockReset();
mockInstallCursorPlugin.mockReset();
mockInstallVscodeExtension.mockReset();
mockInstallOpencodePlugin.mockReset();
mockIsClaudeCliAvailable.mockReset();
Expand All @@ -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()
);
Expand Down Expand Up @@ -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 () => {
Expand Down
14 changes: 9 additions & 5 deletions tests/helpers/cursor-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading
Loading