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:
5
.changeset/fn-3396-cursor-cli-plugin.md
Normal file
5
.changeset/fn-3396-cursor-cli-plugin.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add Cursor CLI runtime plugin as a bundled installable provider, including staged plugin artifacts in the published CLI bundle and install-path resolution support.
|
||||||
71
docs/cursor-cli-contract.md
Normal file
71
docs/cursor-cli-contract.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Cursor CLI Contract (FN-3396 Step 0)
|
||||||
|
|
||||||
|
Date: 2026-05-07
|
||||||
|
|
||||||
|
## Research method
|
||||||
|
|
||||||
|
- Local runtime inspection in the task environment (`which`, direct command execution).
|
||||||
|
- Local binary wrapper inspection (`cursor`, `cursor-agent` launch scripts and install layout).
|
||||||
|
- Bounded `fn_research_run` was attempted but failed in this environment with: `table research_runs has no column named projectId`.
|
||||||
|
|
||||||
|
## Confirmed invocation and binary detection
|
||||||
|
|
||||||
|
- **Primary executable aliases found on PATH:**
|
||||||
|
- `cursor`
|
||||||
|
- `cursor-agent`
|
||||||
|
- **Not found on PATH:**
|
||||||
|
- `cursor-cli`
|
||||||
|
- `cursor` is a wrapper that can delegate to agent mode and emits a targeted message when IDE install is missing.
|
||||||
|
- `cursor-agent` is the direct CLI runtime entrypoint and is symlinked to a versioned install under:
|
||||||
|
- `~/.local/share/cursor-agent/versions/<version>/cursor-agent`
|
||||||
|
|
||||||
|
### Detection strategy to implement
|
||||||
|
|
||||||
|
1. Probe `cursor-agent` first.
|
||||||
|
2. Probe `cursor` second.
|
||||||
|
3. Persist the resolved path and executable name in probe results.
|
||||||
|
4. Report explicit failure reason when neither exists.
|
||||||
|
|
||||||
|
## Confirmed error/auth/runtime signals
|
||||||
|
|
||||||
|
Observed command behavior in this environment:
|
||||||
|
|
||||||
|
- `cursor --help` (without IDE install):
|
||||||
|
- `Error: No Cursor IDE installation found. Use 'cursor agent' or 'agent' to run the agent.`
|
||||||
|
- `cursor-agent --help` and `cursor agent --help` (with locked keychain):
|
||||||
|
- `Error: Your macOS login keychain is locked.`
|
||||||
|
- `Run security unlock-keychain and try again.`
|
||||||
|
|
||||||
|
### Auth/readiness implications
|
||||||
|
|
||||||
|
- Keychain-locked is a distinct, expected failure mode and must be surfaced as an auth/runtime-blocked state (not as unknown crash).
|
||||||
|
- Missing IDE install is a distinct expected failure mode from missing binary.
|
||||||
|
|
||||||
|
## Structured output and model discovery
|
||||||
|
|
||||||
|
- **No stable model-list command was conclusively confirmed in this preflight** due CLI gating by keychain lock and inability to complete bounded remote research in this run.
|
||||||
|
- No contract evidence yet for a guaranteed `--json` or dedicated model enumeration command.
|
||||||
|
|
||||||
|
### Fallback model discovery strategy (to use in implementation)
|
||||||
|
|
||||||
|
1. Attempt known structured/listing command variants with short timeouts (plugin-defined sequence).
|
||||||
|
2. If structured output is unavailable but text output exists, parse tolerant line-based IDs.
|
||||||
|
3. Normalize and dedupe model IDs.
|
||||||
|
4. If discovery is unavailable/fails, return an empty discovered set with:
|
||||||
|
- `source` marking probe mode,
|
||||||
|
- `fallbackUsed: true`,
|
||||||
|
- machine-readable reason.
|
||||||
|
5. Host should only surface Cursor models when provider readiness + discovery usability conditions are met.
|
||||||
|
|
||||||
|
## Provider ID decision
|
||||||
|
|
||||||
|
- Use **`cursor-cli`** as the provider ID.
|
||||||
|
- Rationale: aligns with task requirement; no conflicting provider ID observed in current codebase scan.
|
||||||
|
|
||||||
|
## Contract freeze for FN-3396
|
||||||
|
|
||||||
|
Implementation should treat the following as canonical for this task unless stronger evidence is found during code-level integration tests:
|
||||||
|
|
||||||
|
- Binary candidates: `cursor-agent`, `cursor`.
|
||||||
|
- Expected failure states include: missing binary, missing IDE installation, keychain locked, unauthenticated/not-ready CLI.
|
||||||
|
- Model discovery must be dynamic-first with resilient fallback and no hardcoded static catalog by default.
|
||||||
@@ -6,6 +6,7 @@ export const cliRoot = join(__dirname, "..", "..");
|
|||||||
export const workspaceRoot = join(cliRoot, "..", "..");
|
export const workspaceRoot = join(cliRoot, "..", "..");
|
||||||
export const bundlePath = join(cliRoot, "dist", "bin.js");
|
export const bundlePath = join(cliRoot, "dist", "bin.js");
|
||||||
export const clientIndexPath = join(cliRoot, "dist", "client", "index.html");
|
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";
|
export const dashboardClientStubMarker = "Dashboard assets not built";
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ function runBuildCommand(command: string, cwd: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hasBuiltDashboardAssets(): boolean {
|
function hasBuiltDashboardAssets(): boolean {
|
||||||
if (!existsSync(bundlePath) || !existsSync(clientIndexPath)) {
|
if (!existsSync(bundlePath) || !existsSync(clientIndexPath) || !existsSync(cursorPluginManifestPath)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -169,6 +169,17 @@ describe("CLI bundle output", () => {
|
|||||||
expect(manifest.name?.length).toBeGreaterThan(0);
|
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", () => {
|
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");
|
const processManagerSource = readFileSync(join(cliRoot, "dist", "pi-claude-cli", "src", "process-manager.ts"), "utf-8");
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ vi.mock("@fusion/core", () => ({
|
|||||||
// Import SUT after mocks are in place
|
// Import SUT after mocks are in place
|
||||||
import {
|
import {
|
||||||
ensureBundledDependencyGraphPluginInstalled,
|
ensureBundledDependencyGraphPluginInstalled,
|
||||||
|
ensureBundledCursorRuntimePluginInstalled,
|
||||||
ensureBundledPluginInstalled,
|
ensureBundledPluginInstalled,
|
||||||
resolvePluginEntryPath,
|
resolvePluginEntryPath,
|
||||||
} from "../bundled-plugin-install.js";
|
} from "../bundled-plugin-install.js";
|
||||||
@@ -32,6 +33,7 @@ import {
|
|||||||
|
|
||||||
const BUNDLED_PLUGIN_ID = "fusion-plugin-dependency-graph";
|
const BUNDLED_PLUGIN_ID = "fusion-plugin-dependency-graph";
|
||||||
const HERMES_PLUGIN_ID = "fusion-plugin-hermes-runtime";
|
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 }>) {
|
function makeManifest(overrides?: Partial<{ id: string; version: string; name: string }>) {
|
||||||
return {
|
return {
|
||||||
@@ -356,6 +358,30 @@ describe("ensureBundledDependencyGraphPluginInstalled", () => {
|
|||||||
).rejects.toThrow("Invalid plugin manifest");
|
).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 () => {
|
it("registers Hermes from source entry when both src and dist entries exist", async () => {
|
||||||
const manifest = makeManifest({ id: HERMES_PLUGIN_ID, name: "Hermes Runtime" });
|
const manifest = makeManifest({ id: HERMES_PLUGIN_ID, name: "Hermes Runtime" });
|
||||||
mockExistsSync.mockImplementation((p: string) => {
|
mockExistsSync.mockImplementation((p: string) => {
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import { fileURLToPath } from "node:url";
|
|||||||
import { validatePluginManifest, type PluginInstallation, type PluginLoader, type PluginManifest, type PluginStore } from "@fusion/core";
|
import { validatePluginManifest, type PluginInstallation, type PluginLoader, type PluginManifest, type PluginStore } from "@fusion/core";
|
||||||
|
|
||||||
const DEPENDENCY_GRAPH_PLUGIN_ID = "fusion-plugin-dependency-graph";
|
const DEPENDENCY_GRAPH_PLUGIN_ID = "fusion-plugin-dependency-graph";
|
||||||
|
const CURSOR_RUNTIME_PLUGIN_ID = "fusion-plugin-cursor-runtime";
|
||||||
|
|
||||||
export const BUNDLED_PLUGIN_IDS = [
|
export const BUNDLED_PLUGIN_IDS = [
|
||||||
"fusion-plugin-dependency-graph",
|
"fusion-plugin-dependency-graph",
|
||||||
"fusion-plugin-hermes-runtime",
|
"fusion-plugin-hermes-runtime",
|
||||||
"fusion-plugin-openclaw-runtime",
|
"fusion-plugin-openclaw-runtime",
|
||||||
"fusion-plugin-paperclip-runtime",
|
"fusion-plugin-paperclip-runtime",
|
||||||
|
"fusion-plugin-cursor-runtime",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type BundledPluginId = (typeof BUNDLED_PLUGIN_IDS)[number];
|
export type BundledPluginId = (typeof BUNDLED_PLUGIN_IDS)[number];
|
||||||
@@ -156,3 +158,10 @@ export async function ensureBundledDependencyGraphPluginInstalled(
|
|||||||
): Promise<EnsureBundledResult> {
|
): Promise<EnsureBundledResult> {
|
||||||
return ensureBundledPluginInstalled(pluginStore, pluginLoader, DEPENDENCY_GRAPH_PLUGIN_ID);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const RUNTIME_PLUGIN_IDS = [
|
|||||||
"fusion-plugin-hermes-runtime",
|
"fusion-plugin-hermes-runtime",
|
||||||
"fusion-plugin-openclaw-runtime",
|
"fusion-plugin-openclaw-runtime",
|
||||||
"fusion-plugin-paperclip-runtime",
|
"fusion-plugin-paperclip-runtime",
|
||||||
|
"fusion-plugin-cursor-runtime",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|||||||
@@ -2004,6 +2004,7 @@ export default plugin;
|
|||||||
it("returns empty arrays when no contribution types are present", async () => {
|
it("returns empty arrays when no contribution types are present", async () => {
|
||||||
await pluginStore.init();
|
await pluginStore.init();
|
||||||
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
|
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
|
||||||
|
expect(loader.getCliProviderContributions()).toEqual([]);
|
||||||
expect(loader.getPluginSkills()).toEqual([]);
|
expect(loader.getPluginSkills()).toEqual([]);
|
||||||
expect(loader.getPluginWorkflowSteps()).toEqual([]);
|
expect(loader.getPluginWorkflowSteps()).toEqual([]);
|
||||||
expect(loader.getPluginWorkflowStepTemplates()).toEqual([]);
|
expect(loader.getPluginWorkflowStepTemplates()).toEqual([]);
|
||||||
@@ -2011,6 +2012,39 @@ export default plugin;
|
|||||||
expect(loader.getPluginSetupInfo()).toEqual([]);
|
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 () => {
|
it("getPluginSkills returns skills with pluginId", async () => {
|
||||||
await pluginStore.init();
|
await pluginStore.init();
|
||||||
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
|
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
|
||||||
|
|||||||
@@ -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", () => {
|
describe("plugin contribution types", () => {
|
||||||
it("accepts a minimal PluginSkillContribution shape", () => {
|
it("accepts a minimal PluginSkillContribution shape", () => {
|
||||||
const skill: PluginSkillContribution = {
|
const skill: PluginSkillContribution = {
|
||||||
|
|||||||
@@ -166,6 +166,12 @@ export type {
|
|||||||
PluginRuntimeManifestMetadata,
|
PluginRuntimeManifestMetadata,
|
||||||
PluginRuntimeFactory,
|
PluginRuntimeFactory,
|
||||||
PluginRuntimeRegistration,
|
PluginRuntimeRegistration,
|
||||||
|
CliProviderType,
|
||||||
|
CliProviderActionMetadata,
|
||||||
|
CliProviderProbeResult,
|
||||||
|
CliProviderModelDiscoveryResult,
|
||||||
|
CliProviderRuntimeRegistration,
|
||||||
|
CliProviderContribution,
|
||||||
PluginContext,
|
PluginContext,
|
||||||
CreateAiSessionOptions,
|
CreateAiSessionOptions,
|
||||||
AiSessionResult,
|
AiSessionResult,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import type {
|
|||||||
PluginDashboardViewDefinition,
|
PluginDashboardViewDefinition,
|
||||||
PluginOnSchemaInit,
|
PluginOnSchemaInit,
|
||||||
PluginRuntimeRegistration,
|
PluginRuntimeRegistration,
|
||||||
|
CliProviderContribution,
|
||||||
PluginInstallation,
|
PluginInstallation,
|
||||||
PluginSkillContribution,
|
PluginSkillContribution,
|
||||||
PluginWorkflowStepContribution,
|
PluginWorkflowStepContribution,
|
||||||
@@ -968,6 +969,20 @@ export class PluginLoader extends EventEmitter<{
|
|||||||
return runtimes;
|
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.
|
* Get all skill contributions from loaded plugins.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -436,6 +436,60 @@ export interface PluginRuntimeRegistration {
|
|||||||
factory: PluginRuntimeFactory;
|
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 ───────────────────────────────────────
|
// ── Plugin Contribution Types ───────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -598,6 +652,8 @@ export interface FusionPlugin {
|
|||||||
dashboardViews?: PluginDashboardViewDefinition[];
|
dashboardViews?: PluginDashboardViewDefinition[];
|
||||||
/** Agent runtime registration for providing custom runtime implementations */
|
/** Agent runtime registration for providing custom runtime implementations */
|
||||||
runtime?: PluginRuntimeRegistration;
|
runtime?: PluginRuntimeRegistration;
|
||||||
|
/** CLI-backed provider metadata and integration hooks. */
|
||||||
|
cliProviders?: CliProviderContribution[];
|
||||||
/** Plugin-contributed skills surfaced by the skill resolver. */
|
/** Plugin-contributed skills surfaced by the skill resolver. */
|
||||||
skills?: PluginSkillContribution[];
|
skills?: PluginSkillContribution[];
|
||||||
/** Plugin-contributed workflow step templates. */
|
/** Plugin-contributed workflow step templates. */
|
||||||
|
|||||||
@@ -1339,6 +1339,19 @@ export interface DroidCliStatus {
|
|||||||
ready: boolean;
|
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 {
|
export interface LlamaCppStatus {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
extension: {
|
extension: {
|
||||||
@@ -1407,6 +1420,10 @@ export function fetchDroidCliStatus(): Promise<DroidCliStatus> {
|
|||||||
return api<DroidCliStatus>("/providers/droid-cli/status");
|
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. */
|
/** Probe llama.cpp server + setting + extension state. */
|
||||||
export function fetchLlamaCppStatus(): Promise<LlamaCppStatus> {
|
export function fetchLlamaCppStatus(): Promise<LlamaCppStatus> {
|
||||||
return api<LlamaCppStatus>("/providers/llama-cpp/status");
|
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. */
|
/** Enable or disable the llama.cpp provider. */
|
||||||
export function setLlamaCppEnabled(
|
export function setLlamaCppEnabled(
|
||||||
enabled: boolean,
|
enabled: boolean,
|
||||||
|
|||||||
13
packages/dashboard/app/components/CursorCliProviderCard.css
Normal file
13
packages/dashboard/app/components/CursorCliProviderCard.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
115
packages/dashboard/app/components/CursorCliProviderCard.tsx
Normal file
115
packages/dashboard/app/components/CursorCliProviderCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
|||||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||||
import { ProviderIcon } from "./ProviderIcon";
|
import { ProviderIcon } from "./ProviderIcon";
|
||||||
import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard";
|
import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard";
|
||||||
|
import { CursorCliProviderCard } from "./CursorCliProviderCard";
|
||||||
import { LlamaCppProviderCard } from "./LlamaCppProviderCard";
|
import { LlamaCppProviderCard } from "./LlamaCppProviderCard";
|
||||||
import { LoginInstructions } from "./LoginInstructions";
|
import { LoginInstructions } from "./LoginInstructions";
|
||||||
import { OAuthManualCodeForm } from "./OAuthManualCodeForm";
|
import { OAuthManualCodeForm } from "./OAuthManualCodeForm";
|
||||||
@@ -199,6 +200,7 @@ const ONBOARDING_CURATED_PROVIDER_FAMILY_ORDER = [
|
|||||||
"anthropic",
|
"anthropic",
|
||||||
"claude-cli",
|
"claude-cli",
|
||||||
"droid-cli",
|
"droid-cli",
|
||||||
|
"cursor-cli",
|
||||||
"llama-cpp",
|
"llama-cpp",
|
||||||
"openai-codex",
|
"openai-codex",
|
||||||
"gemini",
|
"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") {
|
if (provider.type === "api_key") {
|
||||||
const providerInfo = getProviderInfo(provider.id);
|
const providerInfo = getProviderInfo(provider.id);
|
||||||
const apiKeyInfo = getApiKeyInfo(provider);
|
const apiKeyInfo = getApiKeyInfo(provider);
|
||||||
|
|||||||
@@ -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<
|
const providerConfig: Record<
|
||||||
string,
|
string,
|
||||||
{ component: typeof AnthropicIcon; color: string; label?: 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" },
|
"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" },
|
"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" },
|
"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-cpp": { component: LlamaCppIcon, color: "var(--provider-ollama)", label: "llama.cpp" },
|
||||||
"llama-server": { component: LlamaCppIcon, color: "var(--provider-ollama)", label: "llama.cpp" },
|
"llama-server": { component: LlamaCppIcon, color: "var(--provider-ollama)", label: "llama.cpp" },
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
|||||||
const PluginManager = lazy(() => import("./PluginManager").then((m) => ({ default: m.PluginManager })));
|
const PluginManager = lazy(() => import("./PluginManager").then((m) => ({ default: m.PluginManager })));
|
||||||
const PiExtensionsManager = lazy(() => import("./PiExtensionsManager").then((m) => ({ default: m.PiExtensionsManager })));
|
const PiExtensionsManager = lazy(() => import("./PiExtensionsManager").then((m) => ({ default: m.PiExtensionsManager })));
|
||||||
import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard";
|
import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard";
|
||||||
|
import { CursorCliProviderCard } from "./CursorCliProviderCard";
|
||||||
import { CliBinaryPanel } from "./CliBinaryPanel";
|
import { CliBinaryPanel } from "./CliBinaryPanel";
|
||||||
import { LlamaCppProviderCard } from "./LlamaCppProviderCard";
|
import { LlamaCppProviderCard } from "./LlamaCppProviderCard";
|
||||||
import { HermesRuntimeCard } from "./HermesRuntimeCard";
|
import { HermesRuntimeCard } from "./HermesRuntimeCard";
|
||||||
@@ -5074,6 +5075,7 @@ export function SettingsModal({
|
|||||||
// CLI-backed providers live in whichever bucket matches their current
|
// CLI-backed providers live in whichever bucket matches their current
|
||||||
// auth state (Authenticated when signed in, Available otherwise).
|
// auth state (Authenticated when signed in, Available otherwise).
|
||||||
const claudeCliProvider = cliAuthProviders.find((p) => p.id === "claude-cli");
|
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 llamaCppProvider = cliAuthProviders.find((p) => p.id === "llama-cpp");
|
||||||
const claudeCliCard = claudeCliProvider ? (
|
const claudeCliCard = claudeCliProvider ? (
|
||||||
<ClaudeCliProviderCard
|
<ClaudeCliProviderCard
|
||||||
@@ -5084,6 +5086,15 @@ export function SettingsModal({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
|
const cursorCliCard = cursorCliProvider ? (
|
||||||
|
<CursorCliProviderCard
|
||||||
|
compact
|
||||||
|
authenticated={cursorCliProvider.authenticated}
|
||||||
|
onToggled={() => {
|
||||||
|
void loadAuthStatus();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
const llamaCppCard = llamaCppProvider ? (
|
const llamaCppCard = llamaCppProvider ? (
|
||||||
<LlamaCppProviderCard
|
<LlamaCppProviderCard
|
||||||
compact
|
compact
|
||||||
@@ -5096,10 +5107,12 @@ export function SettingsModal({
|
|||||||
const showAuthenticatedGroup =
|
const showAuthenticatedGroup =
|
||||||
authenticatedProviders.length > 0
|
authenticatedProviders.length > 0
|
||||||
|| (claudeCliProvider?.authenticated ?? false)
|
|| (claudeCliProvider?.authenticated ?? false)
|
||||||
|
|| (cursorCliProvider?.authenticated ?? false)
|
||||||
|| (llamaCppProvider?.authenticated ?? false);
|
|| (llamaCppProvider?.authenticated ?? false);
|
||||||
const showAvailableGroup =
|
const showAvailableGroup =
|
||||||
unauthenticatedProviders.length > 0
|
unauthenticatedProviders.length > 0
|
||||||
|| (claudeCliProvider && !claudeCliProvider.authenticated)
|
|| (claudeCliProvider && !claudeCliProvider.authenticated)
|
||||||
|
|| (cursorCliProvider && !cursorCliProvider.authenticated)
|
||||||
|| (llamaCppProvider && !llamaCppProvider.authenticated);
|
|| (llamaCppProvider && !llamaCppProvider.authenticated);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -5133,6 +5146,7 @@ export function SettingsModal({
|
|||||||
<div className="auth-provider-group">
|
<div className="auth-provider-group">
|
||||||
<div className="auth-group-label">Authenticated</div>
|
<div className="auth-group-label">Authenticated</div>
|
||||||
{claudeCliProvider?.authenticated && claudeCliCard}
|
{claudeCliProvider?.authenticated && claudeCliCard}
|
||||||
|
{cursorCliProvider?.authenticated && cursorCliCard}
|
||||||
{llamaCppProvider?.authenticated && llamaCppCard}
|
{llamaCppProvider?.authenticated && llamaCppCard}
|
||||||
{authenticatedProviders.map((provider) => (
|
{authenticatedProviders.map((provider) => (
|
||||||
<div key={provider.id} className="auth-provider-card auth-provider-card--authenticated">
|
<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-provider-group">
|
||||||
<div className="auth-group-label">Available</div>
|
<div className="auth-group-label">Available</div>
|
||||||
{claudeCliProvider && !claudeCliProvider.authenticated && claudeCliCard}
|
{claudeCliProvider && !claudeCliProvider.authenticated && claudeCliCard}
|
||||||
|
{cursorCliProvider && !cursorCliProvider.authenticated && cursorCliCard}
|
||||||
{llamaCppProvider && !llamaCppProvider.authenticated && llamaCppCard}
|
{llamaCppProvider && !llamaCppProvider.authenticated && llamaCppCard}
|
||||||
{unauthenticatedProviders.map((provider) => (
|
{unauthenticatedProviders.map((provider) => (
|
||||||
<div key={provider.id} className="auth-provider-card">
|
<div key={provider.id} className="auth-provider-card">
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ const mockUpdateGlobalSettings = vi.fn();
|
|||||||
const mockCreateTask = vi.fn();
|
const mockCreateTask = vi.fn();
|
||||||
const mockFetchCustomProviders = vi.fn();
|
const mockFetchCustomProviders = vi.fn();
|
||||||
const mockCreateCustomProvider = vi.fn();
|
const mockCreateCustomProvider = vi.fn();
|
||||||
|
const mockFetchCursorCliStatus = vi.fn();
|
||||||
|
const mockSetCursorCliEnabled = vi.fn();
|
||||||
|
|
||||||
vi.mock("../../api", () => ({
|
vi.mock("../../api", () => ({
|
||||||
fetchAuthStatus: (...args: unknown[]) => mockFetchAuthStatus(...args),
|
fetchAuthStatus: (...args: unknown[]) => mockFetchAuthStatus(...args),
|
||||||
@@ -34,6 +36,8 @@ vi.mock("../../api", () => ({
|
|||||||
createTask: (...args: unknown[]) => mockCreateTask(...args),
|
createTask: (...args: unknown[]) => mockCreateTask(...args),
|
||||||
fetchCustomProviders: (...args: unknown[]) => mockFetchCustomProviders(...args),
|
fetchCustomProviders: (...args: unknown[]) => mockFetchCustomProviders(...args),
|
||||||
createCustomProvider: (...args: unknown[]) => mockCreateCustomProvider(...args),
|
createCustomProvider: (...args: unknown[]) => mockCreateCustomProvider(...args),
|
||||||
|
fetchCursorCliStatus: (...args: unknown[]) => mockFetchCursorCliStatus(...args),
|
||||||
|
setCursorCliEnabled: (...args: unknown[]) => mockSetCursorCliEnabled(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock CustomModelDropdown since it has complex portal behavior
|
// Mock CustomModelDropdown since it has complex portal behavior
|
||||||
@@ -188,6 +192,13 @@ beforeEach(() => {
|
|||||||
mockSubmitProviderManualCode.mockResolvedValue({ success: true, submitted: true });
|
mockSubmitProviderManualCode.mockResolvedValue({ success: true, submitted: true });
|
||||||
mockSaveApiKey.mockResolvedValue({ success: true });
|
mockSaveApiKey.mockResolvedValue({ success: true });
|
||||||
mockClearApiKey.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)
|
// Default to no persisted state (start at ai-setup)
|
||||||
mockGetOnboardingState.mockReturnValue(null);
|
mockGetOnboardingState.mockReturnValue(null);
|
||||||
mockSaveOnboardingState.mockImplementation(() => {});
|
mockSaveOnboardingState.mockImplementation(() => {});
|
||||||
@@ -906,6 +917,19 @@ describe("ModelOnboardingModal", () => {
|
|||||||
expect(description.closest(".onboarding-provider-card")).toBeTruthy();
|
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 () => {
|
it("renders stable onboarding-provider-icon wrappers for provider cards", async () => {
|
||||||
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
|
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,16 @@ describe("ProviderIcon", () => {
|
|||||||
expect(svg.parentElement).toHaveStyle({ color: "var(--provider-openai)" });
|
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", () => {
|
it("normalizes PI-Claude-CLI provider name to lowercase alias", () => {
|
||||||
render(<ProviderIcon provider="PI-Claude-CLI" />);
|
render(<ProviderIcon provider="PI-Claude-CLI" />);
|
||||||
const svg = screen.getByTestId("claude-cli-icon");
|
const svg = screen.getByTestId("claude-cli-icon");
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ const mockTriggerMemoryDreams = vi.fn();
|
|||||||
const mockFetchPluginUiSlots = vi.fn();
|
const mockFetchPluginUiSlots = vi.fn();
|
||||||
const mockFetchDroidCliStatus = vi.fn();
|
const mockFetchDroidCliStatus = vi.fn();
|
||||||
const mockSetDroidCliEnabled = vi.fn();
|
const mockSetDroidCliEnabled = vi.fn();
|
||||||
|
const mockFetchCursorCliStatus = vi.fn();
|
||||||
|
const mockSetCursorCliEnabled = vi.fn();
|
||||||
const mockUseWorkspaceFileBrowser = vi.fn();
|
const mockUseWorkspaceFileBrowser = vi.fn();
|
||||||
|
|
||||||
vi.mock("../../api", async (importOriginal) => {
|
vi.mock("../../api", async (importOriginal) => {
|
||||||
@@ -107,6 +109,8 @@ vi.mock("../../api", async (importOriginal) => {
|
|||||||
fetchPluginUiSlots: (...args: unknown[]) => mockFetchPluginUiSlots(...args),
|
fetchPluginUiSlots: (...args: unknown[]) => mockFetchPluginUiSlots(...args),
|
||||||
fetchDroidCliStatus: (...args: unknown[]) => mockFetchDroidCliStatus(...args),
|
fetchDroidCliStatus: (...args: unknown[]) => mockFetchDroidCliStatus(...args),
|
||||||
setDroidCliEnabled: (...args: unknown[]) => mockSetDroidCliEnabled(...args),
|
setDroidCliEnabled: (...args: unknown[]) => mockSetDroidCliEnabled(...args),
|
||||||
|
fetchCursorCliStatus: (...args: unknown[]) => mockFetchCursorCliStatus(...args),
|
||||||
|
setCursorCliEnabled: (...args: unknown[]) => mockSetCursorCliEnabled(...args),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -377,6 +381,13 @@ describe("SettingsModal", () => {
|
|||||||
ready: false,
|
ready: false,
|
||||||
});
|
});
|
||||||
mockSetDroidCliEnabled.mockResolvedValue({ enabled: true, restartRequired: true });
|
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({
|
mockUseWorkspaceFileBrowser.mockReturnValue({
|
||||||
entries: [],
|
entries: [],
|
||||||
currentPath: ".",
|
currentPath: ".",
|
||||||
@@ -1188,6 +1199,37 @@ describe("SettingsModal", () => {
|
|||||||
expect(screen.getAllByTestId("droid-cli-provider-card")).toHaveLength(1);
|
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 () => {
|
it("does not render droid auth card when plugin slot is not present", async () => {
|
||||||
mockFetchAuthStatus.mockResolvedValueOnce({
|
mockFetchAuthStatus.mockResolvedValueOnce({
|
||||||
providers: [{ id: "droid-cli", name: "Factory AI (via Droid CLI)", authenticated: false, type: "cli" }],
|
providers: [{ id: "droid-cli", name: "Factory AI (via Droid CLI)", authenticated: false, type: "cli" }],
|
||||||
|
|||||||
@@ -272,6 +272,7 @@ html {
|
|||||||
--provider-groq: #f55036;
|
--provider-groq: #f55036;
|
||||||
--provider-vercel: var(--text);
|
--provider-vercel: var(--text);
|
||||||
--provider-droid-cli: var(--text);
|
--provider-droid-cli: var(--text);
|
||||||
|
--provider-cursor-cli: #7c3aed;
|
||||||
--provider-ollama: #d4a27f;
|
--provider-ollama: #d4a27f;
|
||||||
/* Runtime-plugin marks. */
|
/* Runtime-plugin marks. */
|
||||||
--provider-hermes: #d4961c;
|
--provider-hermes: #d4961c;
|
||||||
|
|||||||
@@ -53,6 +53,7 @@
|
|||||||
"@fusion-plugin-examples/hermes-runtime": "workspace:*",
|
"@fusion-plugin-examples/hermes-runtime": "workspace:*",
|
||||||
"@fusion-plugin-examples/openclaw-runtime": "workspace:*",
|
"@fusion-plugin-examples/openclaw-runtime": "workspace:*",
|
||||||
"@fusion-plugin-examples/droid-runtime": "workspace:*",
|
"@fusion-plugin-examples/droid-runtime": "workspace:*",
|
||||||
|
"@fusion-plugin-examples/cursor-runtime": "workspace:*",
|
||||||
"@fusion-plugin-examples/paperclip-runtime": "workspace:*",
|
"@fusion-plugin-examples/paperclip-runtime": "workspace:*",
|
||||||
"@fusion/core": "workspace:*",
|
"@fusion/core": "workspace:*",
|
||||||
"@fusion/engine": "workspace:*",
|
"@fusion/engine": "workspace:*",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -32,6 +32,7 @@ import * as usageModule from "../usage.js";
|
|||||||
import * as claudeCliProbeModule from "../claude-cli-probe.js";
|
import * as claudeCliProbeModule from "../claude-cli-probe.js";
|
||||||
import * as droidCliProbeModule from "../droid-cli-probe.js";
|
import * as droidCliProbeModule from "../droid-cli-probe.js";
|
||||||
import * as llamaCppProbeModule from "../llama-cpp-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 projectStoreResolver from "../project-store-resolver.js";
|
||||||
import * as terminalServiceModule from "../terminal-service.js";
|
import * as terminalServiceModule from "../terminal-service.js";
|
||||||
import { get as performGet, request as performRequest } from "../test-request.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);
|
expect(res.status).toBe(200);
|
||||||
// Filter out synthetic CLI providers — they have dedicated route tests.
|
// Filter out synthetic CLI providers — they have dedicated route tests.
|
||||||
// Structural assertions here are about OAuth + API-key paths only.
|
// 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([
|
expect(providers).toEqual([
|
||||||
{ id: "github-copilot", name: "GitHub Copilot", authenticated: true, type: "oauth", loginInProgress: false },
|
{ id: "github-copilot", name: "GitHub Copilot", authenticated: true, type: "oauth", loginInProgress: false },
|
||||||
{ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" },
|
{ 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");
|
const res = await GET(buildApp(), "/api/auth/status");
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
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([
|
expect(providers).toEqual([
|
||||||
{ id: "github-copilot", name: "GitHub Copilot", authenticated: true, type: "oauth", loginInProgress: false },
|
{ id: "github-copilot", name: "GitHub Copilot", authenticated: true, type: "oauth", loginInProgress: false },
|
||||||
{ id: "openai-codex", name: "OpenAI Codex", authenticated: false, 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 () => {
|
it("PUT /settings/global with useDroidCli fires onUseDroidCliToggled", async () => {
|
||||||
const onUseDroidCliToggled = vi.fn();
|
const onUseDroidCliToggled = vi.fn();
|
||||||
store.updateGlobalSettings = vi.fn().mockResolvedValue({ useDroidCli: true });
|
store.updateGlobalSettings = vi.fn().mockResolvedValue({ useDroidCli: true });
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Request } from "express";
|
|||||||
import { isGhAvailable, isGhAuthenticated } from "@fusion/core";
|
import { isGhAvailable, isGhAuthenticated } from "@fusion/core";
|
||||||
import { probeClaudeCli } from "../claude-cli-probe.js";
|
import { probeClaudeCli } from "../claude-cli-probe.js";
|
||||||
import { probeDroidCli } from "../droid-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 { probeLlamaCpp } from "../llama-cpp-probe.js";
|
||||||
import { ApiError, badRequest, conflict } from "../api-error.js";
|
import { ApiError, badRequest, conflict } from "../api-error.js";
|
||||||
import { clearUsageCache } from "../usage.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.
|
// Inject synthetic llama.cpp provider.
|
||||||
if (store) {
|
if (store) {
|
||||||
let llamaEnabled = false;
|
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) => {
|
router.post("/auth/llama-cpp", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!store) {
|
if (!store) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
let useClaudeCli = false;
|
let useClaudeCli = false;
|
||||||
let useDroidCli = false;
|
let useDroidCli = false;
|
||||||
let useLlamaCpp = false;
|
let useLlamaCpp = false;
|
||||||
|
let useCursorCli = false;
|
||||||
let resolvedPlanningProvider: string | undefined;
|
let resolvedPlanningProvider: string | undefined;
|
||||||
let resolvedPlanningModelId: string | undefined;
|
let resolvedPlanningModelId: string | undefined;
|
||||||
if (store) {
|
if (store) {
|
||||||
@@ -27,6 +28,7 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
useClaudeCli = globalSettings.useClaudeCli === true;
|
useClaudeCli = globalSettings.useClaudeCli === true;
|
||||||
useDroidCli = globalSettings.useDroidCli === true;
|
useDroidCli = globalSettings.useDroidCli === true;
|
||||||
useLlamaCpp = globalSettings.useLlamaCpp === true;
|
useLlamaCpp = globalSettings.useLlamaCpp === true;
|
||||||
|
useCursorCli = (globalSettings as Record<string, unknown>).useCursorCli === true;
|
||||||
|
|
||||||
const mergedSettings = await store.getSettingsFast();
|
const mergedSettings = await store.getSettingsFast();
|
||||||
const resolvedPlanningModel = resolvePlanningSettingsModel(mergedSettings);
|
const resolvedPlanningModel = resolvePlanningSettingsModel(mergedSettings);
|
||||||
@@ -87,6 +89,9 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
if (!useLlamaCpp) {
|
if (!useLlamaCpp) {
|
||||||
models = models.filter((m) => m.provider !== "llama-server");
|
models = models.filter((m) => m.provider !== "llama-server");
|
||||||
}
|
}
|
||||||
|
if (!useCursorCli) {
|
||||||
|
models = models.filter((m) => m.provider !== "cursor-cli");
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
models,
|
models,
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ import {
|
|||||||
type OpenClawBinaryStatus,
|
type OpenClawBinaryStatus,
|
||||||
} from "@fusion-plugin-examples/openclaw-runtime";
|
} from "@fusion-plugin-examples/openclaw-runtime";
|
||||||
|
|
||||||
|
import {
|
||||||
|
probeCursorBinary,
|
||||||
|
type CursorBinaryStatus,
|
||||||
|
} from "@fusion-plugin-examples/cursor-runtime";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
agentsMe,
|
agentsMe,
|
||||||
discoverPaperclipCliConfig,
|
discoverPaperclipCliConfig,
|
||||||
@@ -48,6 +53,7 @@ export type {
|
|||||||
MintCliKeyOptions,
|
MintCliKeyOptions,
|
||||||
MintedApiKey,
|
MintedApiKey,
|
||||||
OpenClawBinaryStatus,
|
OpenClawBinaryStatus,
|
||||||
|
CursorBinaryStatus,
|
||||||
PaperclipAgentSummary,
|
PaperclipAgentSummary,
|
||||||
PaperclipCliDiscoveryResult,
|
PaperclipCliDiscoveryResult,
|
||||||
PaperclipCompanySummary,
|
PaperclipCompanySummary,
|
||||||
@@ -55,6 +61,10 @@ export type {
|
|||||||
};
|
};
|
||||||
export { mintAgentApiKeyViaCli };
|
export { mintAgentApiKeyViaCli };
|
||||||
|
|
||||||
|
export async function probeCursorCliProvider(opts?: { binaryPath?: string }): Promise<CursorBinaryStatus> {
|
||||||
|
return probeCursorBinary(opts);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Probe the local Hermes binary.
|
* Probe the local Hermes binary.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ describe("PluginRunner", () => {
|
|||||||
getPluginUiSlots: ReturnType<typeof vi.fn>;
|
getPluginUiSlots: ReturnType<typeof vi.fn>;
|
||||||
getPluginUiContributions: ReturnType<typeof vi.fn>;
|
getPluginUiContributions: ReturnType<typeof vi.fn>;
|
||||||
getPluginRuntimes: ReturnType<typeof vi.fn>;
|
getPluginRuntimes: ReturnType<typeof vi.fn>;
|
||||||
|
getCliProviderContributions: ReturnType<typeof vi.fn>;
|
||||||
getPluginSkills: ReturnType<typeof vi.fn>;
|
getPluginSkills: ReturnType<typeof vi.fn>;
|
||||||
getPluginWorkflowSteps: ReturnType<typeof vi.fn>;
|
getPluginWorkflowSteps: ReturnType<typeof vi.fn>;
|
||||||
getPluginWorkflowStepTemplates: ReturnType<typeof vi.fn>;
|
getPluginWorkflowStepTemplates: ReturnType<typeof vi.fn>;
|
||||||
@@ -100,6 +101,7 @@ describe("PluginRunner", () => {
|
|||||||
getPluginUiSlots: vi.fn().mockReturnValue([]),
|
getPluginUiSlots: vi.fn().mockReturnValue([]),
|
||||||
getPluginUiContributions: vi.fn().mockReturnValue([]),
|
getPluginUiContributions: vi.fn().mockReturnValue([]),
|
||||||
getPluginRuntimes: vi.fn().mockReturnValue([]),
|
getPluginRuntimes: vi.fn().mockReturnValue([]),
|
||||||
|
getCliProviderContributions: vi.fn().mockReturnValue([]),
|
||||||
getPluginSkills: vi.fn().mockReturnValue([]),
|
getPluginSkills: vi.fn().mockReturnValue([]),
|
||||||
getPluginWorkflowSteps: vi.fn().mockReturnValue([]),
|
getPluginWorkflowSteps: vi.fn().mockReturnValue([]),
|
||||||
getPluginWorkflowStepTemplates: vi.fn().mockReturnValue([]),
|
getPluginWorkflowStepTemplates: vi.fn().mockReturnValue([]),
|
||||||
@@ -832,6 +834,28 @@ describe("PluginRunner", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("new plugin contribution accessors", () => {
|
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 () => {
|
it("getPluginSkills returns empty array initially", async () => {
|
||||||
await pluginRunner.init();
|
await pluginRunner.init();
|
||||||
expect(pluginRunner.getPluginSkills()).toEqual([]);
|
expect(pluginRunner.getPluginSkills()).toEqual([]);
|
||||||
@@ -887,6 +911,7 @@ describe("PluginRunner", () => {
|
|||||||
|
|
||||||
it("invalidates new contribution caches on state change and loader events", async () => {
|
it("invalidates new contribution caches on state change and loader events", async () => {
|
||||||
await pluginRunner.init();
|
await pluginRunner.init();
|
||||||
|
pluginRunner.getCliProviderContributions();
|
||||||
pluginRunner.getPluginSkills();
|
pluginRunner.getPluginSkills();
|
||||||
pluginRunner.getPluginWorkflowSteps();
|
pluginRunner.getPluginWorkflowSteps();
|
||||||
pluginRunner.getPluginWorkflowStepTemplates();
|
pluginRunner.getPluginWorkflowStepTemplates();
|
||||||
@@ -895,6 +920,7 @@ describe("PluginRunner", () => {
|
|||||||
|
|
||||||
const stateChanged = mockPluginStore.on.mock.calls.find((call) => call[0] === "plugin:stateChanged")?.[1];
|
const stateChanged = mockPluginStore.on.mock.calls.find((call) => call[0] === "plugin:stateChanged")?.[1];
|
||||||
stateChanged?.();
|
stateChanged?.();
|
||||||
|
pluginRunner.getCliProviderContributions();
|
||||||
pluginRunner.getPluginSkills();
|
pluginRunner.getPluginSkills();
|
||||||
pluginRunner.getPluginWorkflowSteps();
|
pluginRunner.getPluginWorkflowSteps();
|
||||||
pluginRunner.getPluginWorkflowStepTemplates();
|
pluginRunner.getPluginWorkflowStepTemplates();
|
||||||
@@ -903,12 +929,14 @@ describe("PluginRunner", () => {
|
|||||||
|
|
||||||
const loaded = mockPluginLoader.on.mock.calls.find((call) => call[0] === "plugin:loaded")?.[1];
|
const loaded = mockPluginLoader.on.mock.calls.find((call) => call[0] === "plugin:loaded")?.[1];
|
||||||
loaded?.({ pluginId: "test-plugin" });
|
loaded?.({ pluginId: "test-plugin" });
|
||||||
|
pluginRunner.getCliProviderContributions();
|
||||||
pluginRunner.getPluginSkills();
|
pluginRunner.getPluginSkills();
|
||||||
pluginRunner.getPluginWorkflowSteps();
|
pluginRunner.getPluginWorkflowSteps();
|
||||||
pluginRunner.getPluginWorkflowStepTemplates();
|
pluginRunner.getPluginWorkflowStepTemplates();
|
||||||
pluginRunner.getPluginPromptContributions();
|
pluginRunner.getPluginPromptContributions();
|
||||||
pluginRunner.getPluginSetupInfo();
|
pluginRunner.getPluginSetupInfo();
|
||||||
|
|
||||||
|
expect(mockPluginLoader.getCliProviderContributions).toHaveBeenCalledTimes(3);
|
||||||
expect(mockPluginLoader.getPluginSkills).toHaveBeenCalledTimes(3);
|
expect(mockPluginLoader.getPluginSkills).toHaveBeenCalledTimes(3);
|
||||||
expect(mockPluginLoader.getPluginWorkflowSteps).toHaveBeenCalledTimes(3);
|
expect(mockPluginLoader.getPluginWorkflowSteps).toHaveBeenCalledTimes(3);
|
||||||
expect(mockPluginLoader.getPluginWorkflowStepTemplates).toHaveBeenCalledTimes(3);
|
expect(mockPluginLoader.getPluginWorkflowStepTemplates).toHaveBeenCalledTimes(3);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type {
|
|||||||
PluginUiSlotDefinition,
|
PluginUiSlotDefinition,
|
||||||
PluginUiContributionDefinition,
|
PluginUiContributionDefinition,
|
||||||
PluginRuntimeRegistration,
|
PluginRuntimeRegistration,
|
||||||
|
CliProviderContribution,
|
||||||
PluginContext,
|
PluginContext,
|
||||||
PluginSkillContribution,
|
PluginSkillContribution,
|
||||||
PluginWorkflowStepContribution,
|
PluginWorkflowStepContribution,
|
||||||
@@ -83,6 +84,11 @@ interface CachedRuntimes {
|
|||||||
version: number;
|
version: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CachedCliProviderContributions {
|
||||||
|
contributions: Array<{ pluginId: string; contribution: CliProviderContribution }>;
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface CachedSkills {
|
interface CachedSkills {
|
||||||
skills: Array<{ pluginId: string; skill: PluginSkillContribution }>;
|
skills: Array<{ pluginId: string; skill: PluginSkillContribution }>;
|
||||||
version: number;
|
version: number;
|
||||||
@@ -121,6 +127,7 @@ export class PluginRunner {
|
|||||||
private cachedUiSlots: CachedUiSlots | null = null;
|
private cachedUiSlots: CachedUiSlots | null = null;
|
||||||
private cachedUiContributions: CachedUiContributions | null = null;
|
private cachedUiContributions: CachedUiContributions | null = null;
|
||||||
private cachedRuntimes: CachedRuntimes | null = null;
|
private cachedRuntimes: CachedRuntimes | null = null;
|
||||||
|
private cachedCliProviderContributions: CachedCliProviderContributions | null = null;
|
||||||
private cachedSkills: CachedSkills | null = null;
|
private cachedSkills: CachedSkills | null = null;
|
||||||
private cachedWorkflowSteps: CachedWorkflowSteps | null = null;
|
private cachedWorkflowSteps: CachedWorkflowSteps | null = null;
|
||||||
private cachedWorkflowStepTemplates: CachedWorkflowStepTemplates | null = null;
|
private cachedWorkflowStepTemplates: CachedWorkflowStepTemplates | null = null;
|
||||||
@@ -131,6 +138,7 @@ export class PluginRunner {
|
|||||||
private uiSlotsCacheVersion = 0;
|
private uiSlotsCacheVersion = 0;
|
||||||
private uiContributionsCacheVersion = 0;
|
private uiContributionsCacheVersion = 0;
|
||||||
private runtimesCacheVersion = 0;
|
private runtimesCacheVersion = 0;
|
||||||
|
private cliProviderContributionsCacheVersion = 0;
|
||||||
private skillsCacheVersion = 0;
|
private skillsCacheVersion = 0;
|
||||||
private workflowStepsCacheVersion = 0;
|
private workflowStepsCacheVersion = 0;
|
||||||
private workflowStepTemplatesCacheVersion = 0;
|
private workflowStepTemplatesCacheVersion = 0;
|
||||||
@@ -207,6 +215,7 @@ export class PluginRunner {
|
|||||||
this.invalidateUiSlotsCache();
|
this.invalidateUiSlotsCache();
|
||||||
this.invalidateUiContributionsCache();
|
this.invalidateUiContributionsCache();
|
||||||
this.invalidateRuntimesCache();
|
this.invalidateRuntimesCache();
|
||||||
|
this.invalidateCliProviderContributionsCache();
|
||||||
this.invalidateSkillsCache();
|
this.invalidateSkillsCache();
|
||||||
this.invalidateWorkflowStepsCache();
|
this.invalidateWorkflowStepsCache();
|
||||||
this.invalidateWorkflowStepTemplatesCache();
|
this.invalidateWorkflowStepTemplatesCache();
|
||||||
@@ -318,6 +327,16 @@ export class PluginRunner {
|
|||||||
return this.cachedRuntimes.runtimes;
|
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 }> {
|
getPluginSkills(): Array<{ pluginId: string; skill: PluginSkillContribution }> {
|
||||||
if (!this.cachedSkills || this.cachedSkills.version !== this.skillsCacheVersion) {
|
if (!this.cachedSkills || this.cachedSkills.version !== this.skillsCacheVersion) {
|
||||||
this.cachedSkills = {
|
this.cachedSkills = {
|
||||||
@@ -475,6 +494,7 @@ export class PluginRunner {
|
|||||||
this.invalidateUiSlotsCache();
|
this.invalidateUiSlotsCache();
|
||||||
this.invalidateUiContributionsCache();
|
this.invalidateUiContributionsCache();
|
||||||
this.invalidateRuntimesCache();
|
this.invalidateRuntimesCache();
|
||||||
|
this.invalidateCliProviderContributionsCache();
|
||||||
this.invalidateSkillsCache();
|
this.invalidateSkillsCache();
|
||||||
this.invalidateWorkflowStepsCache();
|
this.invalidateWorkflowStepsCache();
|
||||||
this.invalidateWorkflowStepTemplatesCache();
|
this.invalidateWorkflowStepTemplatesCache();
|
||||||
@@ -495,6 +515,7 @@ export class PluginRunner {
|
|||||||
this.invalidateUiSlotsCache();
|
this.invalidateUiSlotsCache();
|
||||||
this.invalidateUiContributionsCache();
|
this.invalidateUiContributionsCache();
|
||||||
this.invalidateRuntimesCache();
|
this.invalidateRuntimesCache();
|
||||||
|
this.invalidateCliProviderContributionsCache();
|
||||||
this.invalidateSkillsCache();
|
this.invalidateSkillsCache();
|
||||||
this.invalidateWorkflowStepsCache();
|
this.invalidateWorkflowStepsCache();
|
||||||
this.invalidateWorkflowStepTemplatesCache();
|
this.invalidateWorkflowStepTemplatesCache();
|
||||||
@@ -520,6 +541,7 @@ export class PluginRunner {
|
|||||||
this.invalidateUiSlotsCache();
|
this.invalidateUiSlotsCache();
|
||||||
this.invalidateUiContributionsCache();
|
this.invalidateUiContributionsCache();
|
||||||
this.invalidateRuntimesCache();
|
this.invalidateRuntimesCache();
|
||||||
|
this.invalidateCliProviderContributionsCache();
|
||||||
this.invalidateSkillsCache();
|
this.invalidateSkillsCache();
|
||||||
this.invalidateWorkflowStepsCache();
|
this.invalidateWorkflowStepsCache();
|
||||||
this.invalidateWorkflowStepTemplatesCache();
|
this.invalidateWorkflowStepTemplatesCache();
|
||||||
@@ -545,6 +567,7 @@ export class PluginRunner {
|
|||||||
this.invalidateUiSlotsCache();
|
this.invalidateUiSlotsCache();
|
||||||
this.invalidateUiContributionsCache();
|
this.invalidateUiContributionsCache();
|
||||||
this.invalidateRuntimesCache();
|
this.invalidateRuntimesCache();
|
||||||
|
this.invalidateCliProviderContributionsCache();
|
||||||
this.invalidateSkillsCache();
|
this.invalidateSkillsCache();
|
||||||
this.invalidateWorkflowStepsCache();
|
this.invalidateWorkflowStepsCache();
|
||||||
this.invalidateWorkflowStepTemplatesCache();
|
this.invalidateWorkflowStepTemplatesCache();
|
||||||
@@ -569,6 +592,7 @@ export class PluginRunner {
|
|||||||
this.invalidateUiSlotsCache();
|
this.invalidateUiSlotsCache();
|
||||||
this.invalidateUiContributionsCache();
|
this.invalidateUiContributionsCache();
|
||||||
this.invalidateRuntimesCache();
|
this.invalidateRuntimesCache();
|
||||||
|
this.invalidateCliProviderContributionsCache();
|
||||||
this.invalidateSkillsCache();
|
this.invalidateSkillsCache();
|
||||||
this.invalidateWorkflowStepsCache();
|
this.invalidateWorkflowStepsCache();
|
||||||
this.invalidateWorkflowStepTemplatesCache();
|
this.invalidateWorkflowStepTemplatesCache();
|
||||||
@@ -585,6 +609,7 @@ export class PluginRunner {
|
|||||||
this.invalidateUiSlotsCache();
|
this.invalidateUiSlotsCache();
|
||||||
this.invalidateUiContributionsCache();
|
this.invalidateUiContributionsCache();
|
||||||
this.invalidateRuntimesCache();
|
this.invalidateRuntimesCache();
|
||||||
|
this.invalidateCliProviderContributionsCache();
|
||||||
this.invalidateSkillsCache();
|
this.invalidateSkillsCache();
|
||||||
this.invalidateWorkflowStepsCache();
|
this.invalidateWorkflowStepsCache();
|
||||||
this.invalidateWorkflowStepTemplatesCache();
|
this.invalidateWorkflowStepTemplatesCache();
|
||||||
@@ -601,6 +626,7 @@ export class PluginRunner {
|
|||||||
this.invalidateUiSlotsCache();
|
this.invalidateUiSlotsCache();
|
||||||
this.invalidateUiContributionsCache();
|
this.invalidateUiContributionsCache();
|
||||||
this.invalidateRuntimesCache();
|
this.invalidateRuntimesCache();
|
||||||
|
this.invalidateCliProviderContributionsCache();
|
||||||
this.invalidateSkillsCache();
|
this.invalidateSkillsCache();
|
||||||
this.invalidateWorkflowStepsCache();
|
this.invalidateWorkflowStepsCache();
|
||||||
this.invalidateWorkflowStepTemplatesCache();
|
this.invalidateWorkflowStepTemplatesCache();
|
||||||
@@ -617,6 +643,7 @@ export class PluginRunner {
|
|||||||
this.invalidateUiSlotsCache();
|
this.invalidateUiSlotsCache();
|
||||||
this.invalidateUiContributionsCache();
|
this.invalidateUiContributionsCache();
|
||||||
this.invalidateRuntimesCache();
|
this.invalidateRuntimesCache();
|
||||||
|
this.invalidateCliProviderContributionsCache();
|
||||||
this.invalidateSkillsCache();
|
this.invalidateSkillsCache();
|
||||||
this.invalidateWorkflowStepsCache();
|
this.invalidateWorkflowStepsCache();
|
||||||
this.invalidateWorkflowStepTemplatesCache();
|
this.invalidateWorkflowStepTemplatesCache();
|
||||||
@@ -633,6 +660,7 @@ export class PluginRunner {
|
|||||||
this.invalidateUiSlotsCache();
|
this.invalidateUiSlotsCache();
|
||||||
this.invalidateUiContributionsCache();
|
this.invalidateUiContributionsCache();
|
||||||
this.invalidateRuntimesCache();
|
this.invalidateRuntimesCache();
|
||||||
|
this.invalidateCliProviderContributionsCache();
|
||||||
this.invalidateSkillsCache();
|
this.invalidateSkillsCache();
|
||||||
this.invalidateWorkflowStepsCache();
|
this.invalidateWorkflowStepsCache();
|
||||||
this.invalidateWorkflowStepTemplatesCache();
|
this.invalidateWorkflowStepTemplatesCache();
|
||||||
@@ -848,6 +876,11 @@ export class PluginRunner {
|
|||||||
this.log.log(`Runtimes cache invalidated (version: ${this.runtimesCacheVersion})`);
|
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 {
|
private invalidateSkillsCache(): void {
|
||||||
this.skillsCacheVersion++;
|
this.skillsCacheVersion++;
|
||||||
this.log.log(`Skills cache invalidated (version: ${this.skillsCacheVersion})`);
|
this.log.log(`Skills cache invalidated (version: ${this.skillsCacheVersion})`);
|
||||||
|
|||||||
@@ -180,6 +180,18 @@ describe("Plugin SDK", () => {
|
|||||||
expect(plugin.manifest.id).toBe("test");
|
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", () => {
|
it("exports PluginContext type", () => {
|
||||||
const ctx: import("../../../core/src/plugin-types.js").PluginContext = {
|
const ctx: import("../../../core/src/plugin-types.js").PluginContext = {
|
||||||
pluginId: "test",
|
pluginId: "test",
|
||||||
|
|||||||
@@ -66,6 +66,12 @@ export type {
|
|||||||
PluginRuntimeManifestMetadata,
|
PluginRuntimeManifestMetadata,
|
||||||
PluginRuntimeFactory,
|
PluginRuntimeFactory,
|
||||||
PluginRuntimeRegistration,
|
PluginRuntimeRegistration,
|
||||||
|
CliProviderType,
|
||||||
|
CliProviderActionMetadata,
|
||||||
|
CliProviderProbeResult,
|
||||||
|
CliProviderModelDiscoveryResult,
|
||||||
|
CliProviderRuntimeRegistration,
|
||||||
|
CliProviderContribution,
|
||||||
PluginContext,
|
PluginContext,
|
||||||
PluginLogger,
|
PluginLogger,
|
||||||
PluginSkillContribution,
|
PluginSkillContribution,
|
||||||
|
|||||||
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",
|
||||||
|
},
|
||||||
|
});
|
||||||
25
pnpm-lock.yaml
generated
25
pnpm-lock.yaml
generated
@@ -190,6 +190,9 @@ importers:
|
|||||||
'@codemirror/view':
|
'@codemirror/view':
|
||||||
specifier: ^6.36.4
|
specifier: ^6.36.4
|
||||||
version: 6.40.0
|
version: 6.40.0
|
||||||
|
'@fusion-plugin-examples/cursor-runtime':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../plugins/fusion-plugin-cursor-runtime
|
||||||
'@fusion-plugin-examples/dependency-graph':
|
'@fusion-plugin-examples/dependency-graph':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../plugins/fusion-plugin-dependency-graph
|
version: link:../../plugins/fusion-plugin-dependency-graph
|
||||||
@@ -609,6 +612,28 @@ importers:
|
|||||||
specifier: ^3.2.4
|
specifier: ^3.2.4
|
||||||
version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(jiti@2.6.1)(jsdom@29.0.1)(tsx@4.21.0)(yaml@2.8.3)
|
version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(jiti@2.6.1)(jsdom@29.0.1)(tsx@4.21.0)(yaml@2.8.3)
|
||||||
|
|
||||||
|
plugins/fusion-plugin-cursor-runtime:
|
||||||
|
dependencies:
|
||||||
|
'@fusion/plugin-sdk':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/plugin-sdk
|
||||||
|
'@mariozechner/pi-ai':
|
||||||
|
specifier: '*'
|
||||||
|
version: 0.73.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
|
||||||
|
'@mariozechner/pi-coding-agent':
|
||||||
|
specifier: '*'
|
||||||
|
version: 0.73.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
|
||||||
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^25.5.2
|
||||||
|
version: 25.5.2
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.7.0
|
||||||
|
version: 5.9.3
|
||||||
|
vitest:
|
||||||
|
specifier: ^3.2.4
|
||||||
|
version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(jiti@2.6.1)(jsdom@29.0.1)(tsx@4.21.0)(yaml@2.8.3)
|
||||||
|
|
||||||
plugins/fusion-plugin-dependency-graph:
|
plugins/fusion-plugin-dependency-graph:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@fusion/core':
|
'@fusion/core':
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ packages:
|
|||||||
- "plugins/fusion-plugin-openclaw-runtime"
|
- "plugins/fusion-plugin-openclaw-runtime"
|
||||||
- "plugins/fusion-plugin-hermes-runtime"
|
- "plugins/fusion-plugin-hermes-runtime"
|
||||||
- "plugins/fusion-plugin-droid-runtime"
|
- "plugins/fusion-plugin-droid-runtime"
|
||||||
|
- "plugins/fusion-plugin-cursor-runtime"
|
||||||
- "plugins/fusion-plugin-agent-browser"
|
- "plugins/fusion-plugin-agent-browser"
|
||||||
|
|||||||
Reference in New Issue
Block a user