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:
14
plugins/fusion-plugin-cursor-runtime/README.md
Normal file
14
plugins/fusion-plugin-cursor-runtime/README.md
Normal 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`.
|
||||
6
plugins/fusion-plugin-cursor-runtime/manifest.json
Normal file
6
plugins/fusion-plugin-cursor-runtime/manifest.json
Normal 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"
|
||||
}
|
||||
28
plugins/fusion-plugin-cursor-runtime/package.json
Normal file
28
plugins/fusion-plugin-cursor-runtime/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
25
plugins/fusion-plugin-cursor-runtime/src/cli-spawn.ts
Normal file
25
plugins/fusion-plugin-cursor-runtime/src/cli-spawn.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
}
|
||||
65
plugins/fusion-plugin-cursor-runtime/src/index.ts
Normal file
65
plugins/fusion-plugin-cursor-runtime/src/index.ts
Normal 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";
|
||||
58
plugins/fusion-plugin-cursor-runtime/src/probe.ts
Normal file
58
plugins/fusion-plugin-cursor-runtime/src/probe.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
46
plugins/fusion-plugin-cursor-runtime/src/process-manager.ts
Normal file
46
plugins/fusion-plugin-cursor-runtime/src/process-manager.ts
Normal 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" };
|
||||
}
|
||||
16
plugins/fusion-plugin-cursor-runtime/src/provider.ts
Normal file
16
plugins/fusion-plugin-cursor-runtime/src/provider.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
25
plugins/fusion-plugin-cursor-runtime/src/runtime-adapter.ts
Normal file
25
plugins/fusion-plugin-cursor-runtime/src/runtime-adapter.ts
Normal 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"}`;
|
||||
}
|
||||
}
|
||||
9
plugins/fusion-plugin-cursor-runtime/src/types.ts
Normal file
9
plugins/fusion-plugin-cursor-runtime/src/types.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface CursorBinaryStatus {
|
||||
available: boolean;
|
||||
authenticated?: boolean;
|
||||
binaryPath?: string;
|
||||
binaryName?: string;
|
||||
version?: string;
|
||||
reason?: string;
|
||||
probeDurationMs: number;
|
||||
}
|
||||
10
plugins/fusion-plugin-cursor-runtime/tsconfig.json
Normal file
10
plugins/fusion-plugin-cursor-runtime/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"declarationMap": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
7
plugins/fusion-plugin-cursor-runtime/vitest.config.ts
Normal file
7
plugins/fusion-plugin-cursor-runtime/vitest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user