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 2eba5a007c
commit dca07892ff
50 changed files with 1292 additions and 3 deletions

View File

@@ -0,0 +1,58 @@
import { describe, expect, it, vi } from "vitest";
import type { Router } from "express";
import { registerModelRoutes } from "../routes/register-model-routes.js";
function setup(useCursorCli?: boolean) {
const getHandlers = new Map<string, (req: unknown, res: { json: (body: unknown) => void }) => Promise<void>>();
const router = {
get: vi.fn((path: string, handler: (req: unknown, res: { json: (body: unknown) => void }) => Promise<void>) => {
getHandlers.set(path, handler);
}),
} as unknown as Router;
const store = {
getGlobalSettingsStore: () => ({
getSettings: vi.fn().mockResolvedValue({ useCursorCli }),
}),
getSettingsFast: vi.fn().mockResolvedValue({}),
};
const runtimeLogger = {
child: vi.fn(() => ({ warn: vi.fn() })),
};
const modelRegistry = {
refresh: vi.fn(),
getAvailable: vi.fn(() => [
{ provider: "cursor-cli", id: "cursor/gpt-5", name: "Cursor GPT-5", reasoning: true, contextWindow: 128000 },
{ provider: "openai", id: "gpt-5", name: "GPT-5", reasoning: true, contextWindow: 128000 },
]),
};
registerModelRoutes({
router,
store: store as never,
runtimeLogger: runtimeLogger as never,
options: { modelRegistry } as never,
} as never);
return getHandlers.get("/models")!;
}
describe("registerModelRoutes cursor-cli filter", () => {
it("filters cursor-cli models when useCursorCli is false", async () => {
const handler = setup(false);
const json = vi.fn();
await handler({}, { json });
const response = json.mock.calls[0][0] as { models: Array<{ provider: string }> };
expect(response.models.some((model) => model.provider === "cursor-cli")).toBe(false);
});
it("includes cursor-cli models when useCursorCli is true", async () => {
const handler = setup(true);
const json = vi.fn();
await handler({}, { json });
const response = json.mock.calls[0][0] as { models: Array<{ provider: string }> };
expect(response.models.some((model) => model.provider === "cursor-cli")).toBe(true);
});
});

View File

@@ -32,6 +32,7 @@ import * as usageModule from "../usage.js";
import * as claudeCliProbeModule from "../claude-cli-probe.js";
import * as droidCliProbeModule from "../droid-cli-probe.js";
import * as llamaCppProbeModule from "../llama-cpp-probe.js";
import * as runtimeProviderProbesModule from "../runtime-provider-probes.js";
import * as projectStoreResolver from "../project-store-resolver.js";
import * as terminalServiceModule from "../terminal-service.js";
import { get as performGet, request as performRequest } from "../test-request.js";
@@ -563,7 +564,7 @@ describe("GET /auth/status", () => {
expect(res.status).toBe(200);
// Filter out synthetic CLI providers — they have dedicated route tests.
// Structural assertions here are about OAuth + API-key paths only.
const providers = res.body.providers.filter((p: any) => p.id !== "claude-cli" && p.id !== "droid-cli" && p.id !== "llama-cpp");
const providers = res.body.providers.filter((p: any) => p.id !== "claude-cli" && p.id !== "droid-cli" && p.id !== "cursor-cli" && p.id !== "llama-cpp");
expect(providers).toEqual([
{ id: "github-copilot", name: "GitHub Copilot", authenticated: true, type: "oauth", loginInProgress: false },
{ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" },
@@ -607,7 +608,7 @@ describe("GET /auth/status", () => {
const res = await GET(buildApp(), "/api/auth/status");
expect(res.status).toBe(200);
const providers = res.body.providers.filter((p: any) => p.id !== "claude-cli" && p.id !== "droid-cli" && p.id !== "llama-cpp");
const providers = res.body.providers.filter((p: any) => p.id !== "claude-cli" && p.id !== "droid-cli" && p.id !== "cursor-cli" && p.id !== "llama-cpp");
expect(providers).toEqual([
{ id: "github-copilot", name: "GitHub Copilot", authenticated: true, type: "oauth", loginInProgress: false },
{ id: "openai-codex", name: "OpenAI Codex", authenticated: false, type: "oauth", loginInProgress: false },
@@ -1025,6 +1026,85 @@ describe("Droid CLI auth routes", () => {
);
});
it("POST /auth/cursor-cli enables when cursor binary is available", async () => {
vi.spyOn(runtimeProviderProbesModule, "probeCursorCliProvider").mockResolvedValue({
available: true,
version: "cursor-agent 1.0.0",
probeDurationMs: 8,
});
store.updateGlobalSettings = vi.fn().mockResolvedValue({ useCursorCli: true });
const res = await REQUEST(buildApp(), "POST", "/api/auth/cursor-cli", JSON.stringify({ enabled: true }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(res.body).toEqual({ enabled: true, restartRequired: false });
expect(store.updateGlobalSettings).toHaveBeenCalledWith({ useCursorCli: true });
});
it("POST /auth/cursor-cli returns 400 when enabling without binary", async () => {
vi.spyOn(runtimeProviderProbesModule, "probeCursorCliProvider").mockResolvedValue({
available: false,
reason: "cursor-agent not found",
probeDurationMs: 8,
});
const res = await REQUEST(buildApp(), "POST", "/api/auth/cursor-cli", JSON.stringify({ enabled: true }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("Cannot enable Cursor CLI routing");
});
it("POST /auth/cursor-cli disables without probing binary", async () => {
const probeSpy = vi.spyOn(runtimeProviderProbesModule, "probeCursorCliProvider");
store.updateGlobalSettings = vi.fn().mockResolvedValue({ useCursorCli: false });
const res = await REQUEST(buildApp(), "POST", "/api/auth/cursor-cli", JSON.stringify({ enabled: false }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(res.body).toEqual({ enabled: false, restartRequired: false });
expect(probeSpy).not.toHaveBeenCalled();
});
it("GET /providers/cursor-cli/status returns readiness from toggle and binary", async () => {
vi.spyOn(runtimeProviderProbesModule, "probeCursorCliProvider").mockResolvedValue({
available: true,
version: "cursor-agent 1.0.0",
probeDurationMs: 8,
});
store.getGlobalSettingsStore = vi.fn().mockReturnValue({
...createMockGlobalSettingsStore(),
getSettings: vi.fn().mockResolvedValue({ useCursorCli: true }),
});
const res = await GET(buildApp(), "/api/providers/cursor-cli/status");
expect(res.status).toBe(200);
expect(res.body.ready).toBe(true);
expect(res.body.enabled).toBe(true);
expect(res.body.binary.available).toBe(true);
});
it("GET /providers/cursor-cli/status returns ready false when binary unavailable", async () => {
vi.spyOn(runtimeProviderProbesModule, "probeCursorCliProvider").mockResolvedValue({
available: false,
reason: "missing",
probeDurationMs: 8,
});
store.getGlobalSettingsStore = vi.fn().mockReturnValue({
...createMockGlobalSettingsStore(),
getSettings: vi.fn().mockResolvedValue({ useCursorCli: true }),
});
const res = await GET(buildApp(), "/api/providers/cursor-cli/status");
expect(res.status).toBe(200);
expect(res.body.ready).toBe(false);
});
it("PUT /settings/global with useDroidCli fires onUseDroidCliToggled", async () => {
const onUseDroidCliToggled = vi.fn();
store.updateGlobalSettings = vi.fn().mockResolvedValue({ useDroidCli: true });