feat(FN-3396): bundle Cursor CLI as a plugin provider with dashboard auth w

Merges FN-3396's full Cursor CLI provider integration (Steps 1–4): defines a CLI-backed provider contract, adds the `fusion-plugin-cursor-runtime` plugin package with process management and runtime probes, wires dashboard auth flows and UI (ProviderCard, onboarding modal, settings), and bundles the

Fusion-Task-Id: FN-3396
This commit is contained in:
Fusion
2026-05-07 04:10:15 -07:00
committed by gsxdsm
parent f3c99eb4fa
commit 3735e0565a
50 changed files with 1292 additions and 3 deletions

View File

@@ -0,0 +1,14 @@
# fusion-plugin-cursor-runtime
Cursor CLI-backed provider/runtime plugin for Fusion.
## Contract summary
- Provider ID: `cursor-cli`
- Binary probes: `cursor-agent`, then `cursor`
- Expected failure states: missing binary, missing Cursor IDE install, locked macOS keychain, unauthenticated runtime
- Model discovery: dynamic command probing (`models --json`, fallbacks) with dedupe + fallback metadata
## Notes
Status/auth and model discovery behavior follows `docs/cursor-cli-contract.md`.

View File

@@ -0,0 +1,6 @@
{
"id": "fusion-plugin-cursor-runtime",
"name": "Cursor Runtime Plugin",
"version": "0.1.0",
"description": "Provides Cursor CLI-backed model provider and runtime integration"
}

View File

@@ -0,0 +1,28 @@
{
"name": "@fusion-plugin-examples/cursor-runtime",
"version": "0.1.0",
"type": "module",
"description": "Cursor CLI runtime plugin for Fusion",
"keywords": ["fusion-plugin", "cursor", "runtime"],
"exports": {
".": { "types": "./src/index.ts", "import": "./src/index.ts" },
"./probe": { "types": "./src/probe.ts", "import": "./src/probe.ts" }
},
"private": true,
"scripts": {
"build": "tsc",
"test": "vitest run --silent=passed-only --reporter=dot"
},
"dependencies": {
"@fusion/plugin-sdk": "workspace:*"
},
"peerDependencies": {
"@mariozechner/pi-ai": "*",
"@mariozechner/pi-coding-agent": "*"
},
"devDependencies": {
"@types/node": "^25.5.2",
"typescript": "^5.7.0",
"vitest": "^3.2.4"
}
}

View File

@@ -0,0 +1,10 @@
import { describe, expect, it } from "vitest";
import plugin from "../index.js";
describe("cursor plugin export", () => {
it("declares cursor-cli provider contribution", () => {
expect(plugin.manifest.id).toBe("fusion-plugin-cursor-runtime");
expect(plugin.cliProviders?.[0]?.providerId).toBe("cursor-cli");
expect(plugin.cliProviders?.[0]?.statusRoute).toBe("/providers/cursor-cli/status");
});
});

View File

@@ -0,0 +1,41 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("../cli-spawn.js", () => ({ runCursorCommand: vi.fn() }));
import { runCursorCommand } from "../cli-spawn.js";
import { probeCursorBinary } from "../probe.js";
describe("probeCursorBinary", () => {
it("reports available when probe succeeds", async () => {
vi.mocked(runCursorCommand).mockResolvedValue({ code: 0, stdout: "1.2.3", stderr: "" });
const result = await probeCursorBinary({ binaryPath: "cursor-agent" });
expect(result.available).toBe(true);
expect(result.version).toBe("1.2.3");
});
it("reports keychain lock as auth failure", async () => {
vi.mocked(runCursorCommand).mockResolvedValue({ code: 1, stdout: "", stderr: "Error: Your macOS login keychain is locked." });
const result = await probeCursorBinary({ binaryPath: "cursor-agent" });
expect(result.available).toBe(true);
expect(result.authenticated).toBe(false);
expect(result.reason).toContain("keychain");
});
it("reports ide-not-installed as unavailable auth state", async () => {
vi.mocked(runCursorCommand).mockResolvedValue({ code: 1, stdout: "", stderr: "Error: No Cursor IDE installation found." });
const result = await probeCursorBinary({ binaryPath: "cursor" });
expect(result.available).toBe(true);
expect(result.authenticated).toBe(false);
expect(result.reason).toContain("installation not found");
});
it("reports binary unavailable when all candidates fail", async () => {
vi.mocked(runCursorCommand)
.mockResolvedValueOnce({ code: 127, stdout: "", stderr: "" })
.mockResolvedValueOnce({ code: 127, stdout: "", stderr: "" });
const result = await probeCursorBinary();
expect(result.available).toBe(false);
expect(result.reason).toContain("not found");
});
});

View File

@@ -0,0 +1,25 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("../cli-spawn.js", () => ({ runCursorCommand: vi.fn() }));
import { runCursorCommand } from "../cli-spawn.js";
import { discoverCursorModels } from "../process-manager.js";
describe("discoverCursorModels", () => {
it("uses json list when available", async () => {
vi.mocked(runCursorCommand).mockResolvedValueOnce({ code: 0, stdout: '[{"id":"cursor/a"},{"id":"cursor/b"}]', stderr: "" });
const result = await discoverCursorModels("cursor-agent");
expect(result.models).toEqual(["cursor/a", "cursor/b"]);
expect(result.fallbackUsed).toBe(false);
});
it("falls back to text parsing", async () => {
vi.mocked(runCursorCommand)
.mockResolvedValueOnce({ code: 1, stdout: "", stderr: "" })
.mockResolvedValueOnce({ code: 1, stdout: "", stderr: "" })
.mockResolvedValueOnce({ code: 0, stdout: "cursor/x\ncursor/y", stderr: "" });
const result = await discoverCursorModels("cursor-agent");
expect(result.models).toEqual(["cursor/x", "cursor/y"]);
expect(result.fallbackUsed).toBe(true);
});
});

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { CursorRuntimeAdapter } from "../runtime-adapter.js";
describe("CursorRuntimeAdapter", () => {
it("creates a session with default model fallback", async () => {
const adapter = new CursorRuntimeAdapter();
const result = await adapter.createSession({ systemPrompt: "sys" });
expect(result.session.model).toBe("cursor/default");
expect(result.session.systemPrompt).toBe("sys");
});
it("promptWithFallback resolves without throwing", async () => {
const adapter = new CursorRuntimeAdapter();
await expect(adapter.promptWithFallback()).resolves.toBeUndefined();
});
it("describeModel formats cursor prefix", () => {
const adapter = new CursorRuntimeAdapter();
expect(adapter.describeModel({ model: "cursor/pro" })).toBe("cursor/cursor/pro");
});
});

View File

@@ -0,0 +1,25 @@
import { spawn } from "node:child_process";
export async function runCursorCommand(binary: string, args: string[], timeoutMs: number): Promise<{ code: number | null; stdout: string; stderr: string }> {
return new Promise((resolve) => {
const child = spawn(binary, args, { stdio: ["ignore", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
const timer = setTimeout(() => {
try { child.kill("SIGKILL"); } catch {
// best effort
}
resolve({ code: 124, stdout, stderr });
}, timeoutMs);
child.stdout?.on("data", (c: Buffer) => { stdout += c.toString("utf-8"); });
child.stderr?.on("data", (c: Buffer) => { stderr += c.toString("utf-8"); });
child.once("error", () => {
clearTimeout(timer);
resolve({ code: 127, stdout, stderr });
});
child.once("close", (code) => {
clearTimeout(timer);
resolve({ code, stdout, stderr });
});
});
}

View File

@@ -0,0 +1,65 @@
import { definePlugin } from "@fusion/plugin-sdk";
import type { FusionPlugin } from "@fusion/plugin-sdk";
import { probeCursorBinary } from "./probe.js";
import { discoverCursorProviderModels } from "./provider.js";
import { CursorRuntimeAdapter } from "./runtime-adapter.js";
const plugin: FusionPlugin = definePlugin({
manifest: {
id: "fusion-plugin-cursor-runtime",
name: "Cursor Runtime Plugin",
version: "0.1.0",
description: "Cursor CLI runtime support for Fusion",
runtime: {
runtimeId: "cursor",
name: "Cursor Runtime",
version: "0.1.0",
},
},
state: "installed",
hooks: {},
runtime: {
metadata: {
runtimeId: "cursor",
name: "Cursor Runtime",
version: "0.1.0",
},
factory: async () => new CursorRuntimeAdapter(),
},
cliProviders: [
{
providerId: "cursor-cli",
displayName: "Cursor CLI",
binaryName: "cursor-agent",
providerType: "cli",
statusRoute: "/providers/cursor-cli/status",
authRoute: "/auth/cursor-cli",
actions: [
{ actionId: "enable", label: "Enable", actionType: "enable", method: "POST", route: "/auth/cursor-cli" },
{ actionId: "disable", label: "Disable", actionType: "disable", method: "POST", route: "/auth/cursor-cli" },
{ actionId: "test", label: "Test", actionType: "test", method: "GET", route: "/providers/cursor-cli/status" }
],
probe: async () => {
const status = await probeCursorBinary();
return {
available: status.available,
authenticated: status.authenticated,
binaryPath: status.binaryPath,
binaryName: status.binaryName,
version: status.version,
reason: status.reason,
};
},
discoverModels: discoverCursorProviderModels,
runtime: {
runtimeId: "cursor",
createAdapter: async () => new CursorRuntimeAdapter(),
},
},
],
});
export default plugin;
export { probeCursorBinary } from "./probe.js";
export { discoverCursorProviderModels } from "./provider.js";
export type { CursorBinaryStatus } from "./types.js";

View File

@@ -0,0 +1,58 @@
import { runCursorCommand } from "./cli-spawn.js";
import type { CursorBinaryStatus } from "./types.js";
const CANDIDATES = ["cursor-agent", "cursor"] as const;
export async function probeCursorBinary(options?: { timeoutMs?: number; binaryPath?: string }): Promise<CursorBinaryStatus> {
const startedAt = Date.now();
const timeoutMs = options?.timeoutMs ?? 3000;
const candidates = options?.binaryPath ? [options.binaryPath] : [...CANDIDATES];
for (const binary of candidates) {
const version = await runCursorCommand(binary, ["--version"], timeoutMs);
if (version.code === 0) {
// NOTE: Cursor CLI currently lacks a stable auth-status contract we can
// invoke without side effects. Treating successful --version as ready is
// a best-effort heuristic; keychain/auth errors are handled by fallback
// probes below when surfaced in stderr/stdout.
return {
available: true,
authenticated: true,
binaryName: binary,
binaryPath: binary,
version: version.stdout.trim() || undefined,
probeDurationMs: Date.now() - startedAt,
};
}
const combined = `${version.stdout}\n${version.stderr}`.toLowerCase();
if (combined.includes("keychain is locked")) {
return {
available: true,
authenticated: false,
binaryName: binary,
binaryPath: binary,
reason: "macOS login keychain is locked",
probeDurationMs: Date.now() - startedAt,
};
}
if (combined.includes("no cursor ide installation found")) {
return {
available: true,
authenticated: false,
binaryName: binary,
binaryPath: binary,
reason: "Cursor IDE installation not found",
probeDurationMs: Date.now() - startedAt,
};
}
}
return {
available: false,
authenticated: false,
reason: "cursor-agent/cursor not found on PATH",
probeDurationMs: Date.now() - startedAt,
};
}

View File

@@ -0,0 +1,46 @@
import { runCursorCommand } from "./cli-spawn.js";
function parseModelLines(raw: string): string[] {
return raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.filter((line) => !line.toLowerCase().startsWith("usage"));
}
export async function discoverCursorModels(binary: string, timeoutMs = 5000): Promise<{ models: string[]; source: string; fallbackUsed: boolean; reason?: string }> {
const attempts: Array<{ args: string[]; source: string; structured: boolean }> = [
{ args: ["models", "--json"], source: "models-json", structured: true },
{ args: ["model", "list", "--json"], source: "model-list-json", structured: true },
{ args: ["models"], source: "models-text", structured: false },
];
for (const attempt of attempts) {
const res = await runCursorCommand(binary, attempt.args, timeoutMs);
if (res.code !== 0) continue;
const output = (res.stdout || "").trim();
if (!output) continue;
try {
const parsed = JSON.parse(output);
if (Array.isArray(parsed)) {
const ids = parsed
.map((entry) => (typeof entry === "string" ? entry : typeof entry?.id === "string" ? entry.id : undefined))
.filter((id): id is string => Boolean(id));
if (ids.length > 0) {
return { models: Array.from(new Set(ids)), source: attempt.source, fallbackUsed: !attempt.structured };
}
}
} catch {
// output is not JSON; continue with line-based fallback
}
const ids = Array.from(new Set(parseModelLines(output)));
if (ids.length > 0) {
return { models: ids, source: attempt.source, fallbackUsed: !attempt.structured };
}
}
return { models: [], source: "none", fallbackUsed: true, reason: "model discovery command unavailable" };
}

View File

@@ -0,0 +1,16 @@
import { discoverCursorModels } from "./process-manager.js";
import { probeCursorBinary } from "./probe.js";
export async function discoverCursorProviderModels() {
const probe = await probeCursorBinary();
if (!probe.available || !probe.binaryName) {
return { models: [], source: "probe", fallbackUsed: true, reason: probe.reason ?? "binary unavailable" };
}
const result = await discoverCursorModels(probe.binaryName);
return {
models: result.models.map((id) => ({ id, label: id })),
source: result.source,
fallbackUsed: result.fallbackUsed,
reason: result.reason,
};
}

View File

@@ -0,0 +1,25 @@
export class CursorRuntimeAdapter {
readonly id = "cursor";
readonly name = "Cursor Runtime";
async createSession(options: { defaultModelId?: string; systemPrompt?: string }) {
return {
session: {
model: options.defaultModelId ?? "cursor/default",
systemPrompt: options.systemPrompt,
messages: [],
},
sessionFile: undefined,
};
}
async promptWithFallback(): Promise<void> {
// TODO(FN-3396): Implement Cursor agent prompt streaming once a stable
// invocation contract beyond probe/discovery commands is confirmed.
return;
}
describeModel(session: { model?: string }) {
return `cursor/${session.model ?? "default"}`;
}
}

View File

@@ -0,0 +1,9 @@
export interface CursorBinaryStatus {
available: boolean;
authenticated?: boolean;
binaryPath?: string;
binaryName?: string;
version?: string;
reason?: string;
probeDurationMs: number;
}

View File

@@ -0,0 +1,10 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"declarationMap": true
},
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
},
});