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

@@ -6,6 +6,7 @@ export const cliRoot = join(__dirname, "..", "..");
export const workspaceRoot = join(cliRoot, "..", "..");
export const bundlePath = join(cliRoot, "dist", "bin.js");
export const clientIndexPath = join(cliRoot, "dist", "client", "index.html");
const cursorPluginManifestPath = join(cliRoot, "dist", "plugins", "fusion-plugin-cursor-runtime", "manifest.json");
export const dashboardClientStubMarker = "Dashboard assets not built";
@@ -28,7 +29,7 @@ function runBuildCommand(command: string, cwd: string) {
}
function hasBuiltDashboardAssets(): boolean {
if (!existsSync(bundlePath) || !existsSync(clientIndexPath)) {
if (!existsSync(bundlePath) || !existsSync(clientIndexPath) || !existsSync(cursorPluginManifestPath)) {
return false;
}

View File

@@ -169,6 +169,17 @@ describe("CLI bundle output", () => {
expect(manifest.name?.length).toBeGreaterThan(0);
});
it("dist/plugins/fusion-plugin-cursor-runtime/ is staged with a valid manifest", () => {
const stagedRoot = join(cliRoot, "dist", "plugins", "fusion-plugin-cursor-runtime");
const manifestPath = join(stagedRoot, "manifest.json");
expect(existsSync(manifestPath)).toBe(true);
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as { id?: string; name?: string };
expect(manifest.id).toBe("fusion-plugin-cursor-runtime");
expect(typeof manifest.name).toBe("string");
expect(manifest.name?.length).toBeGreaterThan(0);
});
it("pi-claude-cli source imports child process helpers from node:child_process", () => {
const processManagerSource = readFileSync(join(cliRoot, "dist", "pi-claude-cli", "src", "process-manager.ts"), "utf-8");

View File

@@ -24,6 +24,7 @@ vi.mock("@fusion/core", () => ({
// Import SUT after mocks are in place
import {
ensureBundledDependencyGraphPluginInstalled,
ensureBundledCursorRuntimePluginInstalled,
ensureBundledPluginInstalled,
resolvePluginEntryPath,
} from "../bundled-plugin-install.js";
@@ -32,6 +33,7 @@ import {
const BUNDLED_PLUGIN_ID = "fusion-plugin-dependency-graph";
const HERMES_PLUGIN_ID = "fusion-plugin-hermes-runtime";
const CURSOR_PLUGIN_ID = "fusion-plugin-cursor-runtime";
function makeManifest(overrides?: Partial<{ id: string; version: string; name: string }>) {
return {
@@ -356,6 +358,30 @@ describe("ensureBundledDependencyGraphPluginInstalled", () => {
).rejects.toThrow("Invalid plugin manifest");
});
it("registers Cursor runtime through the dedicated helper", async () => {
const manifest = makeManifest({ id: CURSOR_PLUGIN_ID, name: "Cursor Runtime" });
mockExistsSync.mockImplementation((p: string) => {
if (p.endsWith("manifest.json") && p.includes(CURSOR_PLUGIN_ID)) return true;
if (p.endsWith("/src/index.ts") && p.includes(CURSOR_PLUGIN_ID)) return true;
return false;
});
mockReadFile.mockResolvedValue(JSON.stringify(manifest));
mockValidatePluginManifest.mockReturnValue({ valid: true, errors: [] });
const store = makePluginStore();
const loader = makePluginLoader();
const result = await ensureBundledCursorRuntimePluginInstalled(
store as unknown as import("@fusion/core").PluginStore,
loader as unknown as import("@fusion/core").PluginLoader,
);
expect(result).toBe("installed");
expect(store.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({ manifest: expect.objectContaining({ id: CURSOR_PLUGIN_ID }) }),
);
});
it("registers Hermes from source entry when both src and dist entries exist", async () => {
const manifest = makeManifest({ id: HERMES_PLUGIN_ID, name: "Hermes Runtime" });
mockExistsSync.mockImplementation((p: string) => {

View File

@@ -5,12 +5,14 @@ import { fileURLToPath } from "node:url";
import { validatePluginManifest, type PluginInstallation, type PluginLoader, type PluginManifest, type PluginStore } from "@fusion/core";
const DEPENDENCY_GRAPH_PLUGIN_ID = "fusion-plugin-dependency-graph";
const CURSOR_RUNTIME_PLUGIN_ID = "fusion-plugin-cursor-runtime";
export const BUNDLED_PLUGIN_IDS = [
"fusion-plugin-dependency-graph",
"fusion-plugin-hermes-runtime",
"fusion-plugin-openclaw-runtime",
"fusion-plugin-paperclip-runtime",
"fusion-plugin-cursor-runtime",
] as const;
export type BundledPluginId = (typeof BUNDLED_PLUGIN_IDS)[number];
@@ -156,3 +158,10 @@ export async function ensureBundledDependencyGraphPluginInstalled(
): Promise<EnsureBundledResult> {
return ensureBundledPluginInstalled(pluginStore, pluginLoader, DEPENDENCY_GRAPH_PLUGIN_ID);
}
export async function ensureBundledCursorRuntimePluginInstalled(
pluginStore: PluginStore,
pluginLoader: PluginLoader,
): Promise<EnsureBundledResult> {
return ensureBundledPluginInstalled(pluginStore, pluginLoader, CURSOR_RUNTIME_PLUGIN_ID);
}

View File

@@ -12,6 +12,7 @@ const RUNTIME_PLUGIN_IDS = [
"fusion-plugin-hermes-runtime",
"fusion-plugin-openclaw-runtime",
"fusion-plugin-paperclip-runtime",
"fusion-plugin-cursor-runtime",
] as const;
const __dirname = dirname(fileURLToPath(import.meta.url));

View File

@@ -2004,6 +2004,7 @@ export default plugin;
it("returns empty arrays when no contribution types are present", async () => {
await pluginStore.init();
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
expect(loader.getCliProviderContributions()).toEqual([]);
expect(loader.getPluginSkills()).toEqual([]);
expect(loader.getPluginWorkflowSteps()).toEqual([]);
expect(loader.getPluginWorkflowStepTemplates()).toEqual([]);
@@ -2011,6 +2012,39 @@ export default plugin;
expect(loader.getPluginSetupInfo()).toEqual([]);
});
it("getCliProviderContributions returns contributed CLI providers with pluginId", async () => {
await pluginStore.init();
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
(loader as any).plugins.set("cli-provider-plugin", {
manifest: makeManifest({ id: "cli-provider-plugin" }),
state: "started",
hooks: {},
cliProviders: [
{
providerId: "cursor-cli",
displayName: "Cursor CLI",
binaryName: "cursor-agent",
providerType: "cli",
statusRoute: "/providers/cursor-cli/status",
authRoute: "/auth/cursor-cli",
},
],
} as FusionPlugin);
expect(loader.getCliProviderContributions()).toEqual([
{
pluginId: "cli-provider-plugin",
contribution: {
providerId: "cursor-cli",
displayName: "Cursor CLI",
binaryName: "cursor-agent",
providerType: "cli",
statusRoute: "/providers/cursor-cli/status",
authRoute: "/auth/cursor-cli",
},
},
]);
});
it("getPluginSkills returns skills with pluginId", async () => {
await pluginStore.init();
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });

View File

@@ -1189,6 +1189,39 @@ describe("plugin ui contribution normalization", () => {
});
});
describe("CLI provider contribution types", () => {
it("accepts a reusable CLI provider contribution contract", async () => {
const plugin: FusionPlugin = {
manifest: { id: "cli-provider-plugin", name: "CLI Provider Plugin", version: "1.0.0" },
state: "installed",
hooks: {},
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", route: "/auth/cursor-cli", method: "POST" },
],
probe: async () => ({ available: true, authenticated: true, binaryName: "cursor-agent", binaryPath: "/usr/local/bin/cursor-agent" }),
discoverModels: async () => ({ models: [{ id: "cursor/default" }], source: "cli", fallbackUsed: false }),
},
],
};
const contribution = plugin.cliProviders?.[0];
const probe = await contribution?.probe?.({} as any);
const discovery = await contribution?.discoverModels?.({} as any);
expect(contribution?.providerId).toBe("cursor-cli");
expect(probe?.available).toBe(true);
expect(discovery?.models[0]?.id).toBe("cursor/default");
});
});
describe("plugin contribution types", () => {
it("accepts a minimal PluginSkillContribution shape", () => {
const skill: PluginSkillContribution = {

View File

@@ -166,6 +166,12 @@ export type {
PluginRuntimeManifestMetadata,
PluginRuntimeFactory,
PluginRuntimeRegistration,
CliProviderType,
CliProviderActionMetadata,
CliProviderProbeResult,
CliProviderModelDiscoveryResult,
CliProviderRuntimeRegistration,
CliProviderContribution,
PluginContext,
CreateAiSessionOptions,
AiSessionResult,

View File

@@ -27,6 +27,7 @@ import type {
PluginDashboardViewDefinition,
PluginOnSchemaInit,
PluginRuntimeRegistration,
CliProviderContribution,
PluginInstallation,
PluginSkillContribution,
PluginWorkflowStepContribution,
@@ -968,6 +969,20 @@ export class PluginLoader extends EventEmitter<{
return runtimes;
}
/**
* Get all CLI-backed provider contributions from loaded plugins.
*/
getCliProviderContributions(): Array<{ pluginId: string; contribution: CliProviderContribution }> {
const contributions: Array<{ pluginId: string; contribution: CliProviderContribution }> = [];
for (const [pluginId, plugin] of this.plugins) {
if (!plugin.cliProviders) continue;
for (const contribution of plugin.cliProviders) {
contributions.push({ pluginId, contribution });
}
}
return contributions;
}
/**
* Get all skill contributions from loaded plugins.
*/

View File

@@ -436,6 +436,60 @@ export interface PluginRuntimeRegistration {
factory: PluginRuntimeFactory;
}
export type CliProviderType = "cli" | "oauth" | "api_key" | "custom";
export interface CliProviderActionMetadata {
actionId: string;
label: string;
actionType: "enable" | "disable" | "test" | "open-url" | "custom";
route?: string;
method?: "GET" | "POST";
}
export interface CliProviderProbeResult {
available: boolean;
authenticated?: boolean;
binaryPath?: string;
binaryName?: string;
version?: string;
reason?: string;
hostReady?: boolean;
pluginReady?: boolean;
}
export interface CliProviderModelDiscoveryResult {
models: Array<{ id: string; label?: string; metadata?: Record<string, unknown> }>;
source: string;
fallbackUsed: boolean;
reason?: string;
}
export interface CliProviderRuntimeRegistration {
runtimeId?: string;
createAdapter?: PluginRuntimeFactory;
}
export interface CliProviderContribution {
providerId: string;
displayName: string;
binaryName: string;
providerType: CliProviderType;
statusRoute: string;
authRoute: string;
onboarding?: {
title?: string;
description?: string;
};
settings?: {
sectionId?: string;
pluginSettingKeys?: string[];
};
actions?: CliProviderActionMetadata[];
probe?: (ctx: PluginContext) => Promise<CliProviderProbeResult>;
discoverModels?: (ctx: PluginContext) => Promise<CliProviderModelDiscoveryResult>;
runtime?: CliProviderRuntimeRegistration;
}
// ── Plugin Contribution Types ───────────────────────────────────────
/**
@@ -598,6 +652,8 @@ export interface FusionPlugin {
dashboardViews?: PluginDashboardViewDefinition[];
/** Agent runtime registration for providing custom runtime implementations */
runtime?: PluginRuntimeRegistration;
/** CLI-backed provider metadata and integration hooks. */
cliProviders?: CliProviderContribution[];
/** Plugin-contributed skills surfaced by the skill resolver. */
skills?: PluginSkillContribution[];
/** Plugin-contributed workflow step templates. */

View File

@@ -1339,6 +1339,19 @@ export interface DroidCliStatus {
ready: boolean;
}
export interface CursorCliStatus {
binary: {
available: boolean;
version?: string;
binaryPath?: string;
reason?: string;
probeDurationMs: number;
};
enabled: boolean;
extension: null;
ready: boolean;
}
export interface LlamaCppStatus {
enabled: boolean;
extension: {
@@ -1407,6 +1420,10 @@ export function fetchDroidCliStatus(): Promise<DroidCliStatus> {
return api<DroidCliStatus>("/providers/droid-cli/status");
}
export function fetchCursorCliStatus(): Promise<CursorCliStatus> {
return api<CursorCliStatus>("/providers/cursor-cli/status");
}
/** Probe llama.cpp server + setting + extension state. */
export function fetchLlamaCppStatus(): Promise<LlamaCppStatus> {
return api<LlamaCppStatus>("/providers/llama-cpp/status");
@@ -1671,6 +1688,15 @@ export function setDroidCliEnabled(
});
}
export function setCursorCliEnabled(
enabled: boolean,
): Promise<{ enabled: boolean; restartRequired: boolean }> {
return api<{ enabled: boolean; restartRequired: boolean }>("/auth/cursor-cli", {
method: "POST",
body: JSON.stringify({ enabled }),
});
}
/** Enable or disable the llama.cpp provider. */
export function setLlamaCppEnabled(
enabled: boolean,

View File

@@ -0,0 +1,13 @@
.cursor-cli-provider-card .auth-provider-cli-actions,
.cursor-cli-provider-card .onboarding-provider-card__actions {
display: flex;
gap: var(--space-sm);
align-items: center;
}
@media (max-width: 768px) {
.cursor-cli-provider-card .auth-provider-cli-actions,
.cursor-cli-provider-card .onboarding-provider-card__actions {
flex-wrap: wrap;
}
}

View File

@@ -0,0 +1,115 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Loader2 } from "lucide-react";
import { fetchCursorCliStatus, setCursorCliEnabled, type CursorCliStatus } from "../api";
import { ProviderIcon } from "./ProviderIcon";
import "./CursorCliProviderCard.css";
interface CursorCliProviderCardProps {
authenticated: boolean;
compact?: boolean;
onToggled?: (nextEnabled: boolean) => void;
}
export function CursorCliProviderCard({ authenticated, compact = false, onToggled }: CursorCliProviderCardProps) {
const [status, setStatus] = useState<CursorCliStatus | null>(null);
const [busy, setBusy] = useState<"enabling" | "disabling" | "testing" | null>(null);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
const refresh = useCallback(async () => {
try {
const next = await fetchCursorCliStatus();
if (mountedRef.current) setStatus(next);
return next;
} catch {
return null;
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
const handleToggle = useCallback(
async (next: boolean) => {
setBusy(next ? "enabling" : "disabling");
try {
const result = await setCursorCliEnabled(next);
onToggled?.(result.enabled);
await refresh();
} finally {
if (mountedRef.current) setBusy(null);
}
},
[onToggled, refresh],
);
const currentlyEnabled = status?.enabled ?? authenticated;
const binaryAvailable = status?.binary.available ?? false;
const actions = (
<>
<button type="button" className="btn btn-sm" onClick={() => {
setBusy("testing");
void refresh().finally(() => {
if (mountedRef.current) setBusy(null);
});
}} disabled={busy !== null}>
{busy === "testing" ? <><Loader2 size={12} className="animate-spin" /> Testing…</> : "Test"}
</button>
{currentlyEnabled ? (
<button type="button" className="btn btn-sm" onClick={() => void handleToggle(false)} disabled={busy !== null}>
{busy === "disabling" ? "Disabling…" : "Disable"}
</button>
) : (
<button type="button" className="btn btn-primary btn-sm" onClick={() => void handleToggle(true)} disabled={busy !== null || !binaryAvailable}>
{busy === "enabling" ? "Enabling…" : "Enable"}
</button>
)}
</>
);
const statusText = !status
? "Probing local CLI…"
: !status.binary.available
? status.binary.reason ?? "`cursor-agent` not found on PATH"
: currentlyEnabled
? `Connected${status.binary.version ? ` — ${status.binary.version}` : ""}`
: "Detected. Click Enable to route calls through Cursor CLI.";
if (compact) {
return (
<div className={`cursor-cli-provider-card auth-provider-card auth-provider-card--cli${authenticated ? " auth-provider-card--authenticated" : ""}`} data-testid="cursor-cli-provider-card">
<div className="auth-provider-header">
<div className="auth-provider-info">
<ProviderIcon provider="cursor-cli" size="sm" />
<strong>Cursor — via Cursor CLI</strong>
<span className={`auth-status-badge ${currentlyEnabled ? "authenticated" : "not-authenticated"}`}>{currentlyEnabled ? "✓ Active" : "✗ Not connected"}</span>
</div>
<div className="auth-provider-cli-actions">{actions}</div>
</div>
<small className="settings-muted">{statusText}</small>
</div>
);
}
return (
<div className={`cursor-cli-provider-card onboarding-provider-card${authenticated ? " onboarding-provider-card--connected" : ""}`} data-testid="cursor-cli-provider-card">
<div className="onboarding-provider-card__icon">
<ProviderIcon provider="cursor-cli" size="md" />
</div>
<div className="onboarding-provider-card__body">
<strong className="onboarding-provider-card__name">Cursor — via Cursor CLI</strong>
<span className="onboarding-provider-card__description">Route AI calls through your local Cursor agent runtime.</span>
<small className="settings-muted">{statusText}</small>
</div>
<div className="onboarding-provider-card__actions">{actions}</div>
</div>
);
}

View File

@@ -23,6 +23,7 @@ import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { ProviderIcon } from "./ProviderIcon";
import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard";
import { CursorCliProviderCard } from "./CursorCliProviderCard";
import { LlamaCppProviderCard } from "./LlamaCppProviderCard";
import { LoginInstructions } from "./LoginInstructions";
import { OAuthManualCodeForm } from "./OAuthManualCodeForm";
@@ -199,6 +200,7 @@ const ONBOARDING_CURATED_PROVIDER_FAMILY_ORDER = [
"anthropic",
"claude-cli",
"droid-cli",
"cursor-cli",
"llama-cpp",
"openai-codex",
"gemini",
@@ -1783,6 +1785,18 @@ export function ModelOnboardingModal({
);
}
if (provider.id === "cursor-cli" && provider.type === "cli") {
return (
<CursorCliProviderCard
key={provider.id}
authenticated={provider.authenticated}
onToggled={() => {
void loadAuthStatus();
}}
/>
);
}
if (provider.type === "api_key") {
const providerInfo = getProviderInfo(provider.id);
const apiKeyInfo = getApiKeyInfo(provider);

View File

@@ -680,6 +680,38 @@ function DroidCliIcon({ size, color, label = "Factory AI — via Droid CLI" }: {
);
}
function CursorCliIcon({ size, color, label = "Cursor — via Cursor CLI" }: { size: number; color: string; label?: string }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
data-testid="cursor-cli-icon"
aria-label={label}
>
<rect x="2" y="3" width="14" height="14" rx="3" fill={color} />
<path
d="M10.8 7.2a3.6 3.6 0 1 0 0 5.6"
stroke="var(--provider-icon-contrast)"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
<rect x="13" y="13" width="10" height="9" rx="1.5" fill={color} />
<path
d="M15.2 16.2l1.6 1.4-1.6 1.4M18.6 19.6h2.4"
stroke="var(--provider-icon-contrast)"
strokeWidth="1.2"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</svg>
);
}
const providerConfig: Record<
string,
{ component: typeof AnthropicIcon; color: string; label?: string }
@@ -689,6 +721,7 @@ const providerConfig: Record<
"claude-cli": { component: ClaudeCliIcon, color: "var(--provider-anthropic)", label: "Anthropic — via Claude CLI" },
"pi-claude-cli": { component: ClaudeCliIcon, color: "var(--provider-anthropic)", label: "Anthropic — via Claude CLI" },
"droid-cli": { component: DroidCliIcon, color: "var(--provider-openai)", label: "Factory AI — via Droid CLI" },
"cursor-cli": { component: CursorCliIcon, color: "var(--provider-cursor-cli)", label: "Cursor — via Cursor CLI" },
"llama-cpp": { component: LlamaCppIcon, color: "var(--provider-ollama)", label: "llama.cpp" },
"llama-server": { component: LlamaCppIcon, color: "var(--provider-ollama)", label: "llama.cpp" },

View File

@@ -26,6 +26,7 @@ import { useModalResizePersist } from "../hooks/useModalResizePersist";
const PluginManager = lazy(() => import("./PluginManager").then((m) => ({ default: m.PluginManager })));
const PiExtensionsManager = lazy(() => import("./PiExtensionsManager").then((m) => ({ default: m.PiExtensionsManager })));
import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard";
import { CursorCliProviderCard } from "./CursorCliProviderCard";
import { CliBinaryPanel } from "./CliBinaryPanel";
import { LlamaCppProviderCard } from "./LlamaCppProviderCard";
import { HermesRuntimeCard } from "./HermesRuntimeCard";
@@ -5074,6 +5075,7 @@ export function SettingsModal({
// CLI-backed providers live in whichever bucket matches their current
// auth state (Authenticated when signed in, Available otherwise).
const claudeCliProvider = cliAuthProviders.find((p) => p.id === "claude-cli");
const cursorCliProvider = cliAuthProviders.find((p) => p.id === "cursor-cli");
const llamaCppProvider = cliAuthProviders.find((p) => p.id === "llama-cpp");
const claudeCliCard = claudeCliProvider ? (
<ClaudeCliProviderCard
@@ -5084,6 +5086,15 @@ export function SettingsModal({
}}
/>
) : null;
const cursorCliCard = cursorCliProvider ? (
<CursorCliProviderCard
compact
authenticated={cursorCliProvider.authenticated}
onToggled={() => {
void loadAuthStatus();
}}
/>
) : null;
const llamaCppCard = llamaCppProvider ? (
<LlamaCppProviderCard
compact
@@ -5096,10 +5107,12 @@ export function SettingsModal({
const showAuthenticatedGroup =
authenticatedProviders.length > 0
|| (claudeCliProvider?.authenticated ?? false)
|| (cursorCliProvider?.authenticated ?? false)
|| (llamaCppProvider?.authenticated ?? false);
const showAvailableGroup =
unauthenticatedProviders.length > 0
|| (claudeCliProvider && !claudeCliProvider.authenticated)
|| (cursorCliProvider && !cursorCliProvider.authenticated)
|| (llamaCppProvider && !llamaCppProvider.authenticated);
return (
<>
@@ -5133,6 +5146,7 @@ export function SettingsModal({
<div className="auth-provider-group">
<div className="auth-group-label">Authenticated</div>
{claudeCliProvider?.authenticated && claudeCliCard}
{cursorCliProvider?.authenticated && cursorCliCard}
{llamaCppProvider?.authenticated && llamaCppCard}
{authenticatedProviders.map((provider) => (
<div key={provider.id} className="auth-provider-card auth-provider-card--authenticated">
@@ -5227,6 +5241,7 @@ export function SettingsModal({
<div className="auth-provider-group">
<div className="auth-group-label">Available</div>
{claudeCliProvider && !claudeCliProvider.authenticated && claudeCliCard}
{cursorCliProvider && !cursorCliProvider.authenticated && cursorCliCard}
{llamaCppProvider && !llamaCppProvider.authenticated && llamaCppCard}
{unauthenticatedProviders.map((provider) => (
<div key={provider.id} className="auth-provider-card">

View File

@@ -19,6 +19,8 @@ const mockUpdateGlobalSettings = vi.fn();
const mockCreateTask = vi.fn();
const mockFetchCustomProviders = vi.fn();
const mockCreateCustomProvider = vi.fn();
const mockFetchCursorCliStatus = vi.fn();
const mockSetCursorCliEnabled = vi.fn();
vi.mock("../../api", () => ({
fetchAuthStatus: (...args: unknown[]) => mockFetchAuthStatus(...args),
@@ -34,6 +36,8 @@ vi.mock("../../api", () => ({
createTask: (...args: unknown[]) => mockCreateTask(...args),
fetchCustomProviders: (...args: unknown[]) => mockFetchCustomProviders(...args),
createCustomProvider: (...args: unknown[]) => mockCreateCustomProvider(...args),
fetchCursorCliStatus: (...args: unknown[]) => mockFetchCursorCliStatus(...args),
setCursorCliEnabled: (...args: unknown[]) => mockSetCursorCliEnabled(...args),
}));
// Mock CustomModelDropdown since it has complex portal behavior
@@ -188,6 +192,13 @@ beforeEach(() => {
mockSubmitProviderManualCode.mockResolvedValue({ success: true, submitted: true });
mockSaveApiKey.mockResolvedValue({ success: true });
mockClearApiKey.mockResolvedValue({ success: true });
mockFetchCursorCliStatus.mockResolvedValue({
binary: { available: true, version: "0.1.0", binaryPath: "/usr/local/bin/cursor-agent", probeDurationMs: 8 },
enabled: false,
extension: null,
ready: false,
});
mockSetCursorCliEnabled.mockResolvedValue({ enabled: true, restartRequired: false });
// Default to no persisted state (start at ai-setup)
mockGetOnboardingState.mockReturnValue(null);
mockSaveOnboardingState.mockImplementation(() => {});
@@ -906,6 +917,19 @@ describe("ModelOnboardingModal", () => {
expect(description.closest(".onboarding-provider-card")).toBeTruthy();
});
it("renders cursor cli provider card when cursor provider is present", async () => {
mockFetchAuthStatus.mockResolvedValue({
providers: [
{ id: "cursor-cli", name: "Cursor — via Cursor CLI", authenticated: true, type: "cli" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
expect(await screen.findByTestId("cursor-cli-provider-card")).toBeInTheDocument();
expect(screen.getByText("Cursor — via Cursor CLI")).toBeInTheDocument();
});
it("renders stable onboarding-provider-icon wrappers for provider cards", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);

View File

@@ -58,6 +58,16 @@ describe("ProviderIcon", () => {
expect(svg.parentElement).toHaveStyle({ color: "var(--provider-openai)" });
});
it("renders cursor-cli icon with provider token color", () => {
render(<ProviderIcon provider="cursor-cli" />);
const svg = screen.getByTestId("cursor-cli-icon");
expect(svg).toBeInTheDocument();
expect(screen.getByLabelText("Cursor — via Cursor CLI")).toBeInTheDocument();
const badgeGlyph = svg.querySelector('path[stroke="var(--provider-icon-contrast)"]');
expect(badgeGlyph).toBeInTheDocument();
expect(svg.parentElement).toHaveStyle({ color: "var(--provider-cursor-cli)" });
});
it("normalizes PI-Claude-CLI provider name to lowercase alias", () => {
render(<ProviderIcon provider="PI-Claude-CLI" />);
const svg = screen.getByTestId("claude-cli-icon");

View File

@@ -54,6 +54,8 @@ const mockTriggerMemoryDreams = vi.fn();
const mockFetchPluginUiSlots = vi.fn();
const mockFetchDroidCliStatus = vi.fn();
const mockSetDroidCliEnabled = vi.fn();
const mockFetchCursorCliStatus = vi.fn();
const mockSetCursorCliEnabled = vi.fn();
const mockUseWorkspaceFileBrowser = vi.fn();
vi.mock("../../api", async (importOriginal) => {
@@ -107,6 +109,8 @@ vi.mock("../../api", async (importOriginal) => {
fetchPluginUiSlots: (...args: unknown[]) => mockFetchPluginUiSlots(...args),
fetchDroidCliStatus: (...args: unknown[]) => mockFetchDroidCliStatus(...args),
setDroidCliEnabled: (...args: unknown[]) => mockSetDroidCliEnabled(...args),
fetchCursorCliStatus: (...args: unknown[]) => mockFetchCursorCliStatus(...args),
setCursorCliEnabled: (...args: unknown[]) => mockSetCursorCliEnabled(...args),
});
});
@@ -377,6 +381,13 @@ describe("SettingsModal", () => {
ready: false,
});
mockSetDroidCliEnabled.mockResolvedValue({ enabled: true, restartRequired: true });
mockFetchCursorCliStatus.mockResolvedValue({
binary: { available: true, version: "0.1.0", binaryPath: "/usr/local/bin/cursor-agent", probeDurationMs: 8 },
enabled: false,
extension: null,
ready: false,
});
mockSetCursorCliEnabled.mockResolvedValue({ enabled: true, restartRequired: false });
mockUseWorkspaceFileBrowser.mockReturnValue({
entries: [],
currentPath: ".",
@@ -1188,6 +1199,37 @@ describe("SettingsModal", () => {
expect(screen.getAllByTestId("droid-cli-provider-card")).toHaveLength(1);
});
it("renders cursor cli auth card in authentication group", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [{ id: "cursor-cli", name: "Cursor — via Cursor CLI", authenticated: false, type: "cli" }],
});
mockFetchPluginUiSlots.mockResolvedValueOnce([]);
renderModal();
await waitForSettingsModalReady();
expect(await screen.findByTestId("cursor-cli-provider-card")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Enable" })).toBeInTheDocument();
});
it("disables cursor enable action when binary is unavailable", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [{ id: "cursor-cli", name: "Cursor — via Cursor CLI", authenticated: false, type: "cli" }],
});
mockFetchCursorCliStatus.mockResolvedValueOnce({
binary: { available: false, reason: "cursor-agent not found", probeDurationMs: 8 },
enabled: false,
extension: null,
ready: false,
});
renderModal();
await waitForSettingsModalReady();
expect(await screen.findByText("cursor-agent not found")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Enable" })).toBeDisabled();
});
it("does not render droid auth card when plugin slot is not present", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [{ id: "droid-cli", name: "Factory AI (via Droid CLI)", authenticated: false, type: "cli" }],

View File

@@ -272,6 +272,7 @@ html {
--provider-groq: #f55036;
--provider-vercel: var(--text);
--provider-droid-cli: var(--text);
--provider-cursor-cli: #7c3aed;
--provider-ollama: #d4a27f;
/* Runtime-plugin marks. */
--provider-hermes: #d4961c;

View File

@@ -53,6 +53,7 @@
"@fusion-plugin-examples/hermes-runtime": "workspace:*",
"@fusion-plugin-examples/openclaw-runtime": "workspace:*",
"@fusion-plugin-examples/droid-runtime": "workspace:*",
"@fusion-plugin-examples/cursor-runtime": "workspace:*",
"@fusion-plugin-examples/paperclip-runtime": "workspace:*",
"@fusion/core": "workspace:*",
"@fusion/engine": "workspace:*",

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 });

View File

@@ -2,6 +2,7 @@ import type { Request } from "express";
import { isGhAvailable, isGhAuthenticated } from "@fusion/core";
import { probeClaudeCli } from "../claude-cli-probe.js";
import { probeDroidCli } from "../droid-cli-probe.js";
import { probeCursorCliProvider } from "../runtime-provider-probes.js";
import { probeLlamaCpp } from "../llama-cpp-probe.js";
import { ApiError, badRequest, conflict } from "../api-error.js";
import { clearUsageCache } from "../usage.js";
@@ -310,6 +311,23 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
});
}
if (store) {
let cursorEnabled = false;
try {
const globalSettings = await store.getGlobalSettingsStore().getSettings();
cursorEnabled = (globalSettings as Record<string, unknown>).useCursorCli === true;
} catch {
// best effort
}
const cursorBinary = await probeCursorCliProvider();
providers.push({
id: "cursor-cli",
name: "Cursor — via Cursor CLI",
authenticated: cursorEnabled && cursorBinary.available,
type: "cli" as const,
});
}
// Inject synthetic llama.cpp provider.
if (store) {
let llamaEnabled = false;
@@ -564,6 +582,51 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
}
});
router.post("/auth/cursor-cli", async (req, res) => {
try {
if (!store) {
throw new ApiError(500, "Settings store unavailable");
}
const enabled = req.body?.enabled;
if (typeof enabled !== "boolean") {
throw badRequest("enabled must be a boolean");
}
if (enabled) {
const binary = await probeCursorCliProvider();
if (!binary.available) {
throw new ApiError(400, `Cannot enable Cursor CLI routing: ${binary.reason ?? "cursor binary not available"}`);
}
}
const settings = await store.updateGlobalSettings({ useCursorCli: enabled } as Record<string, unknown>);
invalidateAllGlobalSettingsCaches();
res.json({ enabled: (settings as Record<string, unknown>).useCursorCli === true, restartRequired: false });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
router.get("/providers/cursor-cli/status", async (_req, res) => {
try {
const binary = await probeCursorCliProvider();
let enabled = false;
if (store) {
try {
const globalSettings = await store.getGlobalSettingsStore().getSettings();
enabled = (globalSettings as Record<string, unknown>).useCursorCli === true;
} catch {
// best effort
}
}
res.json({ binary, enabled, extension: null, ready: enabled && binary.available });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
router.post("/auth/llama-cpp", async (req, res) => {
try {
if (!store) {

View File

@@ -14,6 +14,7 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
let useClaudeCli = false;
let useDroidCli = false;
let useLlamaCpp = false;
let useCursorCli = false;
let resolvedPlanningProvider: string | undefined;
let resolvedPlanningModelId: string | undefined;
if (store) {
@@ -27,6 +28,7 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
useClaudeCli = globalSettings.useClaudeCli === true;
useDroidCli = globalSettings.useDroidCli === true;
useLlamaCpp = globalSettings.useLlamaCpp === true;
useCursorCli = (globalSettings as Record<string, unknown>).useCursorCli === true;
const mergedSettings = await store.getSettingsFast();
const resolvedPlanningModel = resolvePlanningSettingsModel(mergedSettings);
@@ -87,6 +89,9 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
if (!useLlamaCpp) {
models = models.filter((m) => m.provider !== "llama-server");
}
if (!useCursorCli) {
models = models.filter((m) => m.provider !== "cursor-cli");
}
res.json({
models,

View File

@@ -24,6 +24,11 @@ import {
type OpenClawBinaryStatus,
} from "@fusion-plugin-examples/openclaw-runtime";
import {
probeCursorBinary,
type CursorBinaryStatus,
} from "@fusion-plugin-examples/cursor-runtime";
import {
agentsMe,
discoverPaperclipCliConfig,
@@ -48,6 +53,7 @@ export type {
MintCliKeyOptions,
MintedApiKey,
OpenClawBinaryStatus,
CursorBinaryStatus,
PaperclipAgentSummary,
PaperclipCliDiscoveryResult,
PaperclipCompanySummary,
@@ -55,6 +61,10 @@ export type {
};
export { mintAgentApiKeyViaCli };
export async function probeCursorCliProvider(opts?: { binaryPath?: string }): Promise<CursorBinaryStatus> {
return probeCursorBinary(opts);
}
/**
* Probe the local Hermes binary.
*

View File

@@ -36,6 +36,7 @@ describe("PluginRunner", () => {
getPluginUiSlots: ReturnType<typeof vi.fn>;
getPluginUiContributions: ReturnType<typeof vi.fn>;
getPluginRuntimes: ReturnType<typeof vi.fn>;
getCliProviderContributions: ReturnType<typeof vi.fn>;
getPluginSkills: ReturnType<typeof vi.fn>;
getPluginWorkflowSteps: ReturnType<typeof vi.fn>;
getPluginWorkflowStepTemplates: ReturnType<typeof vi.fn>;
@@ -100,6 +101,7 @@ describe("PluginRunner", () => {
getPluginUiSlots: vi.fn().mockReturnValue([]),
getPluginUiContributions: vi.fn().mockReturnValue([]),
getPluginRuntimes: vi.fn().mockReturnValue([]),
getCliProviderContributions: vi.fn().mockReturnValue([]),
getPluginSkills: vi.fn().mockReturnValue([]),
getPluginWorkflowSteps: vi.fn().mockReturnValue([]),
getPluginWorkflowStepTemplates: vi.fn().mockReturnValue([]),
@@ -832,6 +834,28 @@ describe("PluginRunner", () => {
});
describe("new plugin contribution accessors", () => {
it("getCliProviderContributions returns cached CLI-provider contributions", async () => {
const contributions = [
{
pluginId: "cursor-plugin",
contribution: {
providerId: "cursor-cli",
displayName: "Cursor CLI",
binaryName: "cursor-agent",
providerType: "cli",
statusRoute: "/providers/cursor-cli/status",
authRoute: "/auth/cursor-cli",
},
},
];
mockPluginLoader.getCliProviderContributions.mockReturnValue(contributions);
await pluginRunner.init();
const first = pluginRunner.getCliProviderContributions();
const second = pluginRunner.getCliProviderContributions();
expect(first).toEqual(contributions);
expect(second).toBe(first);
});
it("getPluginSkills returns empty array initially", async () => {
await pluginRunner.init();
expect(pluginRunner.getPluginSkills()).toEqual([]);
@@ -887,6 +911,7 @@ describe("PluginRunner", () => {
it("invalidates new contribution caches on state change and loader events", async () => {
await pluginRunner.init();
pluginRunner.getCliProviderContributions();
pluginRunner.getPluginSkills();
pluginRunner.getPluginWorkflowSteps();
pluginRunner.getPluginWorkflowStepTemplates();
@@ -895,6 +920,7 @@ describe("PluginRunner", () => {
const stateChanged = mockPluginStore.on.mock.calls.find((call) => call[0] === "plugin:stateChanged")?.[1];
stateChanged?.();
pluginRunner.getCliProviderContributions();
pluginRunner.getPluginSkills();
pluginRunner.getPluginWorkflowSteps();
pluginRunner.getPluginWorkflowStepTemplates();
@@ -903,12 +929,14 @@ describe("PluginRunner", () => {
const loaded = mockPluginLoader.on.mock.calls.find((call) => call[0] === "plugin:loaded")?.[1];
loaded?.({ pluginId: "test-plugin" });
pluginRunner.getCliProviderContributions();
pluginRunner.getPluginSkills();
pluginRunner.getPluginWorkflowSteps();
pluginRunner.getPluginWorkflowStepTemplates();
pluginRunner.getPluginPromptContributions();
pluginRunner.getPluginSetupInfo();
expect(mockPluginLoader.getCliProviderContributions).toHaveBeenCalledTimes(3);
expect(mockPluginLoader.getPluginSkills).toHaveBeenCalledTimes(3);
expect(mockPluginLoader.getPluginWorkflowSteps).toHaveBeenCalledTimes(3);
expect(mockPluginLoader.getPluginWorkflowStepTemplates).toHaveBeenCalledTimes(3);

View File

@@ -17,6 +17,7 @@ import type {
PluginUiSlotDefinition,
PluginUiContributionDefinition,
PluginRuntimeRegistration,
CliProviderContribution,
PluginContext,
PluginSkillContribution,
PluginWorkflowStepContribution,
@@ -83,6 +84,11 @@ interface CachedRuntimes {
version: number;
}
interface CachedCliProviderContributions {
contributions: Array<{ pluginId: string; contribution: CliProviderContribution }>;
version: number;
}
interface CachedSkills {
skills: Array<{ pluginId: string; skill: PluginSkillContribution }>;
version: number;
@@ -121,6 +127,7 @@ export class PluginRunner {
private cachedUiSlots: CachedUiSlots | null = null;
private cachedUiContributions: CachedUiContributions | null = null;
private cachedRuntimes: CachedRuntimes | null = null;
private cachedCliProviderContributions: CachedCliProviderContributions | null = null;
private cachedSkills: CachedSkills | null = null;
private cachedWorkflowSteps: CachedWorkflowSteps | null = null;
private cachedWorkflowStepTemplates: CachedWorkflowStepTemplates | null = null;
@@ -131,6 +138,7 @@ export class PluginRunner {
private uiSlotsCacheVersion = 0;
private uiContributionsCacheVersion = 0;
private runtimesCacheVersion = 0;
private cliProviderContributionsCacheVersion = 0;
private skillsCacheVersion = 0;
private workflowStepsCacheVersion = 0;
private workflowStepTemplatesCacheVersion = 0;
@@ -207,6 +215,7 @@ export class PluginRunner {
this.invalidateUiSlotsCache();
this.invalidateUiContributionsCache();
this.invalidateRuntimesCache();
this.invalidateCliProviderContributionsCache();
this.invalidateSkillsCache();
this.invalidateWorkflowStepsCache();
this.invalidateWorkflowStepTemplatesCache();
@@ -318,6 +327,16 @@ export class PluginRunner {
return this.cachedRuntimes.runtimes;
}
getCliProviderContributions(): Array<{ pluginId: string; contribution: CliProviderContribution }> {
if (!this.cachedCliProviderContributions || this.cachedCliProviderContributions.version !== this.cliProviderContributionsCacheVersion) {
this.cachedCliProviderContributions = {
contributions: this.options.pluginLoader.getCliProviderContributions(),
version: this.cliProviderContributionsCacheVersion,
};
}
return this.cachedCliProviderContributions.contributions;
}
getPluginSkills(): Array<{ pluginId: string; skill: PluginSkillContribution }> {
if (!this.cachedSkills || this.cachedSkills.version !== this.skillsCacheVersion) {
this.cachedSkills = {
@@ -475,6 +494,7 @@ export class PluginRunner {
this.invalidateUiSlotsCache();
this.invalidateUiContributionsCache();
this.invalidateRuntimesCache();
this.invalidateCliProviderContributionsCache();
this.invalidateSkillsCache();
this.invalidateWorkflowStepsCache();
this.invalidateWorkflowStepTemplatesCache();
@@ -495,6 +515,7 @@ export class PluginRunner {
this.invalidateUiSlotsCache();
this.invalidateUiContributionsCache();
this.invalidateRuntimesCache();
this.invalidateCliProviderContributionsCache();
this.invalidateSkillsCache();
this.invalidateWorkflowStepsCache();
this.invalidateWorkflowStepTemplatesCache();
@@ -520,6 +541,7 @@ export class PluginRunner {
this.invalidateUiSlotsCache();
this.invalidateUiContributionsCache();
this.invalidateRuntimesCache();
this.invalidateCliProviderContributionsCache();
this.invalidateSkillsCache();
this.invalidateWorkflowStepsCache();
this.invalidateWorkflowStepTemplatesCache();
@@ -545,6 +567,7 @@ export class PluginRunner {
this.invalidateUiSlotsCache();
this.invalidateUiContributionsCache();
this.invalidateRuntimesCache();
this.invalidateCliProviderContributionsCache();
this.invalidateSkillsCache();
this.invalidateWorkflowStepsCache();
this.invalidateWorkflowStepTemplatesCache();
@@ -569,6 +592,7 @@ export class PluginRunner {
this.invalidateUiSlotsCache();
this.invalidateUiContributionsCache();
this.invalidateRuntimesCache();
this.invalidateCliProviderContributionsCache();
this.invalidateSkillsCache();
this.invalidateWorkflowStepsCache();
this.invalidateWorkflowStepTemplatesCache();
@@ -585,6 +609,7 @@ export class PluginRunner {
this.invalidateUiSlotsCache();
this.invalidateUiContributionsCache();
this.invalidateRuntimesCache();
this.invalidateCliProviderContributionsCache();
this.invalidateSkillsCache();
this.invalidateWorkflowStepsCache();
this.invalidateWorkflowStepTemplatesCache();
@@ -601,6 +626,7 @@ export class PluginRunner {
this.invalidateUiSlotsCache();
this.invalidateUiContributionsCache();
this.invalidateRuntimesCache();
this.invalidateCliProviderContributionsCache();
this.invalidateSkillsCache();
this.invalidateWorkflowStepsCache();
this.invalidateWorkflowStepTemplatesCache();
@@ -617,6 +643,7 @@ export class PluginRunner {
this.invalidateUiSlotsCache();
this.invalidateUiContributionsCache();
this.invalidateRuntimesCache();
this.invalidateCliProviderContributionsCache();
this.invalidateSkillsCache();
this.invalidateWorkflowStepsCache();
this.invalidateWorkflowStepTemplatesCache();
@@ -633,6 +660,7 @@ export class PluginRunner {
this.invalidateUiSlotsCache();
this.invalidateUiContributionsCache();
this.invalidateRuntimesCache();
this.invalidateCliProviderContributionsCache();
this.invalidateSkillsCache();
this.invalidateWorkflowStepsCache();
this.invalidateWorkflowStepTemplatesCache();
@@ -848,6 +876,11 @@ export class PluginRunner {
this.log.log(`Runtimes cache invalidated (version: ${this.runtimesCacheVersion})`);
}
private invalidateCliProviderContributionsCache(): void {
this.cliProviderContributionsCacheVersion++;
this.log.log(`CLI provider contributions cache invalidated (version: ${this.cliProviderContributionsCacheVersion})`);
}
private invalidateSkillsCache(): void {
this.skillsCacheVersion++;
this.log.log(`Skills cache invalidated (version: ${this.skillsCacheVersion})`);

View File

@@ -180,6 +180,18 @@ describe("Plugin SDK", () => {
expect(plugin.manifest.id).toBe("test");
});
it("exports CLI provider contract types", () => {
const cliProvider: import("../../../core/src/plugin-types.js").CliProviderContribution = {
providerId: "cursor-cli",
displayName: "Cursor CLI",
binaryName: "cursor-agent",
providerType: "cli",
statusRoute: "/providers/cursor-cli/status",
authRoute: "/auth/cursor-cli",
};
expect(cliProvider.providerId).toBe("cursor-cli");
});
it("exports PluginContext type", () => {
const ctx: import("../../../core/src/plugin-types.js").PluginContext = {
pluginId: "test",

View File

@@ -66,6 +66,12 @@ export type {
PluginRuntimeManifestMetadata,
PluginRuntimeFactory,
PluginRuntimeRegistration,
CliProviderType,
CliProviderActionMetadata,
CliProviderProbeResult,
CliProviderModelDiscoveryResult,
CliProviderRuntimeRegistration,
CliProviderContribution,
PluginContext,
PluginLogger,
PluginSkillContribution,