FN-7705: Add Grok CLI runtime support as a bundled plugin

Adds a new bundled Grok CLI runtime plugin, wiring it end-to-end into settings, auth routes, model discovery, and the dashboard authentication UI.

- New `fusion-plugin-grok-runtime` package with CLI spawn, probe, provider, process-manager, and runtime-adapter modules plus tests
- Bundled-plugin install list (CLI + core) updated to auto-install the grok-cli plugin
- New `useGrokCli`/`grokCliBinaryPath` settings in `settings-schema.ts` and `types.ts`
- Dashboard: `GrokCliProviderCard` component/styles, `ProviderIcon` grok entry, `AuthenticationSection` wiring
- New `grok-model-cache.ts` for caching `grok models` discovery results, registered model/auth routes for `/auth/grok-cli` and `/providers/grok-cli/status`, merged into `/api/models`
- `runtime-provider-probes.ts` extended with Grok CLI probe/model-discovery delegation
- Docs updated (`PLUGIN_AUTHORING.md`, `settings-reference.md`) and changeset added (minor, feature)
- Workspace config (`pnpm-workspace.yaml`, `pnpm-lock.yaml`) updated to register the new plugin package

Files changed:
 .changeset/fn-7705-grok-cli-runtime.md             |   7 +
 docs/PLUGIN_AUTHORING.md                           |   2 +-
 docs/settings-reference.md                         |   4 +
 packages/cli/src/plugins/bundled-plugin-install.ts |   8 +
 .../cli/src/plugins/staged-bundled-plugin-ids.ts   |   1 +
 packages/cli/vitest.config.ts                      |  12 +
 .../core/src/__tests__/grok-cli-settings.test.ts   |  34 +++
 packages/core/src/index.ts                         |   1 +
 .../core/src/plugins/bundled-plugin-install.ts     |  10 +
 packages/core/src/settings-schema.ts               |   6 +
 packages/core/src/types.ts                         |   9 +
 packages/dashboard/app/api/legacy.ts               |  40 ++++
 .../app/components/GrokCliProviderCard.css         |  65 ++++++
 .../app/components/GrokCliProviderCard.tsx         | 204 ++++++++++++++++
 packages/dashboard/app/components/ProviderIcon.tsx |   5 +
 .../__tests__/GrokCliProviderCard.test.tsx         | 105 +++++++++
 .../app/components/__tests__/ProviderIcon.test.tsx |   8 +
 .../settings/sections/AuthenticationSection.tsx    |   8 +-
 packages/dashboard/package.json                    |   1 +
 .../src/__tests__/grok-model-cache.test.ts         | 152 ++++++++++++
 .../register-model-routes-grok-cli.test.ts         | 214 +++++++++++++++++
 .../dashboard/src/__tests__/routes-auth.test.ts    | 258 ++++++++++++++++++++-
 packages/dashboard/src/grok-model-cache.ts         | 166 +++++++++++++
 packages/dashboard/src/routes.ts                   |   1 +
 .../dashboard/src/routes/register-auth-routes.ts   | 134 ++++++++++-
 .../dashboard/src/routes/register-model-routes.ts  |  51 ++++
 packages/dashboard/src/runtime-provider-probes.ts  |  43 ++++
 packages/dashboard/vitest.config.ts                |  12 +
 packages/desktop/scripts/workspace-tools.ts        |   3 +-
 plugins/fusion-plugin-grok-runtime/CHANGELOG.md    |   7 +
 plugins/fusion-plugin-grok-runtime/README.md       |  54 +++++
 plugins/fusion-plugin-grok-runtime/manifest.json   |   6 +
 plugins/fusion-plugin-grok-runtime/package.json    |  40 ++++
 .../src/__tests__/cli-spawn.test.ts                | 103 ++++++++
 .../src/__tests__/index.test.ts                    |  12 +
 .../src/__tests__/probe.test.ts                    | 135 +++++++++++
 .../src/__tests__/process-manager.test.ts          |  96 ++++++++
 .../src/__tests__/provider.test.ts                 |  57 +++++
 .../src/__tests__/runtime-adapter.test.ts          |  21 ++
 .../fusion-plugin-grok-runtime/src/cli-spawn.ts    |  50 ++++
 plugins/fusion-plugin-grok-runtime/src/index.ts    |  74 ++++++
 plugins/fusion-plugin-grok-runtime/src/probe.ts    | 107 +++++++++
 .../src/process-manager.ts                         |  86 +++++++
 plugins/fusion-plugin-grok-runtime/src/provider.ts |  25 ++
 .../src/runtime-adapter.ts                         |  25 ++
 plugins/fusion-plugin-grok-runtime/src/types.ts    |  12 +
 plugins/fusion-plugin-grok-runtime/tsconfig.json   |  10 +
 .../fusion-plugin-grok-runtime/vitest.config.ts    |  22 ++
 pnpm-lock.yaml                                     |  25 ++
 pnpm-workspace.yaml                                |   1 +
 50 files changed, 2525 insertions(+), 7 deletions(-)

Fusion-Task-Id: FN-7705

Fusion-Task-Lineage: b8194ea8-c773-4199-a52a-b0e4e7347192

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-08 20:13:41 -07:00
parent 55dae49b37
commit 081dae0e0f
50 changed files with 2525 additions and 7 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add Grok CLI runtime support as a bundled plugin with a grok-cli model provider.
category: feature
dev: New plugin fusion-plugin-grok-runtime (auto-installed); settings useGrokCli/grokCliBinaryPath; routes /auth/grok-cli + /providers/grok-cli/status; grok-cli merged into /api/models.

View File

@@ -779,7 +779,7 @@ This catches stale `dist/` drift: `resolvePluginEntryPath` prefers `bundled.js`
<!-- FNXC:PluginLoader 2026-07-07-00:00: FN-7637 ported bundled-plugin auto-install into a shared, host-agnostic @fusion/core helper so the desktop embedded runtime auto-installs bundled runtime plugins the same way the CLI dashboard/serve/daemon commands do. Documented here so plugin authors and host maintainers know both hosts share one code path and only differ in bundle-directory resolution. -->
The install/update/fail-soft-load logic for `BUNDLED_PLUGIN_IDS` (Dependency Graph, Hermes, OpenClaw, Paperclip, Cursor, CLI Printing Press, Compound Engineering, Linear Import, Reports, WhatsApp Chat, Roadmap) lives once in `packages/core/src/plugins/bundled-plugin-install.ts` as `ensureBundledPluginInstalled(pluginStore, pluginLoader, pluginId, getCandidatePluginDirs)`. The only host-specific input is `getCandidatePluginDirs` — the ordered list of directories to probe for a plugin's `manifest.json` — because the CLI and desktop stage bundled plugins differently:
The install/update/fail-soft-load logic for `BUNDLED_PLUGIN_IDS` (Dependency Graph, Hermes, OpenClaw, Paperclip, Cursor, Grok, CLI Printing Press, Compound Engineering, Linear Import, Reports, WhatsApp Chat, Roadmap) lives once in `packages/core/src/plugins/bundled-plugin-install.ts` as `ensureBundledPluginInstalled(pluginStore, pluginLoader, pluginId, getCandidatePluginDirs)`. The only host-specific input is `getCandidatePluginDirs` — the ordered list of directories to probe for a plugin's `manifest.json` — because the CLI and desktop stage bundled plugins differently:
- **CLI** (`packages/cli/src/plugins/bundled-plugin-install.ts`): resolves `<cli>/dist/plugins/<manifest-id>/` (plus source/dev fallbacks) from its own `import.meta.url`. The CLI module is now a thin adapter that supplies this resolver and re-exports the same public surface (`ensureBundledPluginInstalled`, `ensureBundledDependencyGraphPluginInstalled`, `ensureBundledCursorRuntimePluginInstalled`, `isBundledPluginId`, `BUNDLED_PLUGIN_IDS`, `resolvePluginEntryPath`) that `dashboard.ts`, `serve.ts`, and `daemon.ts` already depend on — no behavior change for CLI hosts.
- **Desktop** (`packages/desktop/src/bundled-plugin-dirs.ts`): resolves each manifest id (`fusion-plugin-<short-name>`) to its staged `@fusion-plugin-examples/<short-name>` npm package directory via `import.meta.resolve` against the package's `"."` export, which works whether `node_modules` is flat/hoisted (the packaged `pnpm deploy` closure) or nested (workspace dev). These plugins are workspace dependencies of `@fusion/dashboard`, so `packages/desktop/scripts/workspace-tools.ts#stageDesktopDeploy` already materializes their `manifest.json` + `dist/index.js` into the desktop closure — no desktop-specific build/staging changes were needed. A bundled id desktop does not depend on (currently `reports`, `whatsapp-chat`, `linear-import`) resolves to no candidate directories and is correctly reported as `missing-bundle`, matching the CLI's "not found in this build" behavior for an unstaged plugin.

View File

@@ -104,6 +104,8 @@ Fusion automatically falls back to ntfy's JSON publish format when a notificatio
| `modelOnboardingComplete` | `boolean` | `undefined` | Whether AI onboarding has been completed or dismissed. |
| `useCursorCli` | `boolean` | `undefined` | Enables the `cursor-cli` provider in model pickers after Cursor CLI status validation. Toggle from Settings → Authentication. |
| `cursorCliBinaryPath` | `string` | `undefined` | Optional global, machine-local Cursor CLI executable override used by Settings → Authentication, status/enable validation, probes, and model discovery. Leave unset/blank to auto-detect `cursor-agent` then `cursor` on PATH. Use this when PATH points at the wrong Cursor install or Windows exposes a specific `.cmd`/`.bat` shim; invalid non-empty saves are rejected with bounded diagnostics. |
| `useGrokCli` | `boolean` | `undefined` | Enables the `grok-cli` provider in model pickers after Grok CLI status validation. Toggle from Settings → Authentication. Grok is API-key auth (`GROK_API_KEY` env var or `~/.grok/user-settings.json` `apiKey`) — there is no OAuth/session login flow. |
| `grokCliBinaryPath` | `string` | `undefined` | Optional global, machine-local Grok CLI executable override used by Settings → Authentication, status/enable validation, probes, and model discovery. Leave unset/blank to auto-detect `grok` on PATH. Invalid non-empty saves are rejected with bounded diagnostics. |
| `executionGlobalProvider` | `string` | `undefined` | Global baseline provider for task execution. Project `executionProvider` overrides this. |
| `executionGlobalModelId` | `string` | `undefined` | Global baseline model ID for task execution. |
| `planningGlobalProvider` | `string` | `undefined` | Global baseline provider for planning. Project `planningProvider` overrides this. |
@@ -968,6 +970,8 @@ When the Hermes Runtime plugin (`fusion-plugin-hermes-runtime`) is installed and
When the Cursor Runtime plugin (`fusion-plugin-cursor-runtime`) is installed and the `useCursorCli` toggle is enabled (Settings → Authentication), Cursor CLI-discovered models (`cursor-agent models --json`, with text/`model list` fallbacks) are surfaced additively in `/api/models` under the `cursor-cli` provider — id/name derived from the discovered model id/label. This surfacing is fetched through a short-TTL, single-flight cache so the model picker never spawns `cursor-agent` on every request; a missing/failed/unavailable Cursor CLI binary simply yields zero `cursor-cli` rows without affecting other providers. Disabling `useCursorCli` hides all `cursor-cli` rows.
When the Grok Runtime plugin (`fusion-plugin-grok-runtime`) is installed and the `useGrokCli` toggle is enabled (Settings → Authentication), Grok CLI-discovered models (`grok models`) are surfaced additively in `/api/models` under the `grok-cli` provider — id/name derived from the discovered model id/label. This surfacing is fetched through a short-TTL, single-flight cache so the model picker never spawns `grok` on every request; a missing/failed/unavailable Grok CLI binary simply yields zero `grok-cli` rows without affecting other providers. Disabling `useGrokCli` hides all `grok-cli` rows. Unlike Cursor (OAuth/session auth), Grok is API-key auth: the Settings card's status text guides operators to `GROK_API_KEY` or `~/.grok/user-settings.json` when the binary is available but no key is configured.
### Planning model
1. Per-task `planningModelProvider` + `planningModelId`

View File

@@ -16,6 +16,7 @@ import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
ensureBundledCursorRuntimePluginInstalled as coreEnsureBundledCursorRuntimePluginInstalled,
ensureBundledGrokRuntimePluginInstalled as coreEnsureBundledGrokRuntimePluginInstalled,
ensureBundledDependencyGraphPluginInstalled as coreEnsureBundledDependencyGraphPluginInstalled,
ensureBundledPluginInstalled as coreEnsureBundledPluginInstalled,
type EnsureBundledResult,
@@ -66,3 +67,10 @@ export async function ensureBundledCursorRuntimePluginInstalled(
): Promise<EnsureBundledResult> {
return coreEnsureBundledCursorRuntimePluginInstalled(pluginStore, pluginLoader, getCandidatePluginDirs);
}
export async function ensureBundledGrokRuntimePluginInstalled(
pluginStore: PluginStore,
pluginLoader: PluginLoader,
): Promise<EnsureBundledResult> {
return coreEnsureBundledGrokRuntimePluginInstalled(pluginStore, pluginLoader, getCandidatePluginDirs);
}

View File

@@ -3,6 +3,7 @@ export const RUNTIME_PLUGIN_IDS = [
"fusion-plugin-openclaw-runtime",
"fusion-plugin-paperclip-runtime",
"fusion-plugin-cursor-runtime",
"fusion-plugin-grok-runtime",
"fusion-plugin-droid-runtime",
"fusion-plugin-acp-runtime",
] as const;

View File

@@ -98,6 +98,18 @@ export default defineConfig({
replacement: resolve(__dirname, "../../plugins/fusion-plugin-cursor-runtime/src/index.ts"),
},
/*
FNXC:GrokCli 2026-07-08-00:00:
runtime-provider-probes.ts (transitively imported by dashboard) imports probeGrokBinary from @fusion-plugin-examples/grok-runtime (FN-7705, mirroring the Cursor alias above).
*/
{
find: /^@fusion-plugin-examples\/grok-runtime\/probe$/,
replacement: resolve(__dirname, "../../plugins/fusion-plugin-grok-runtime/src/probe.ts"),
},
{
find: /^@fusion-plugin-examples\/grok-runtime$/,
replacement: resolve(__dirname, "../../plugins/fusion-plugin-grok-runtime/src/index.ts"),
},
/*
FNXC:PluginTests 2026-07-04-09:30:
The roadmap plugin (@fusion-plugin-examples/roadmap) is imported by the CLI extension. Without source aliases, Vite resolves to the dist/ exports which don't exist in a source checkout.
*/

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import type { GlobalSettings } from "../types.js";
import {
DEFAULT_GLOBAL_SETTINGS,
GLOBAL_SETTINGS_KEYS,
isGlobalSettingsKey,
} from "../settings-schema.js";
describe("Grok CLI global settings", () => {
it("includes the enable toggle and binary path in GLOBAL_SETTINGS_KEYS", () => {
expect(GLOBAL_SETTINGS_KEYS).toContain("useGrokCli");
expect(GLOBAL_SETTINGS_KEYS).toContain("grokCliBinaryPath");
});
it("defaults both Grok CLI settings to undefined", () => {
expect(DEFAULT_GLOBAL_SETTINGS.useGrokCli).toBeUndefined();
expect(DEFAULT_GLOBAL_SETTINGS.grokCliBinaryPath).toBeUndefined();
});
it("recognizes grokCliBinaryPath as a global settings key", () => {
expect(isGlobalSettingsKey("grokCliBinaryPath")).toBe(true);
expect(isGlobalSettingsKey("useGrokCli")).toBe(true);
});
it("accepts a string binary override distinct from the enable toggle", () => {
const configured: GlobalSettings = {
useGrokCli: false,
grokCliBinaryPath: "C:\\Users\\A User\\AppData\\Roaming\\npm\\grok.cmd",
};
expect(configured.useGrokCli).toBe(false);
expect(configured.grokCliBinaryPath).toBe("C:\\Users\\A User\\AppData\\Roaming\\npm\\grok.cmd");
});
});

View File

@@ -1241,6 +1241,7 @@ export {
ensureBundledPluginInstalled,
ensureBundledDependencyGraphPluginInstalled,
ensureBundledCursorRuntimePluginInstalled,
ensureBundledGrokRuntimePluginInstalled,
} from "./plugins/bundled-plugin-install.js";
export type { BundledPluginId, EnsureBundledResult, BundledPluginDirResolver } from "./plugins/bundled-plugin-install.js";
export { scanPluginSecurity } from "./plugin-security-scan.js";

View File

@@ -23,6 +23,7 @@ import type { PluginStore } from "../plugin-store.js";
const DEPENDENCY_GRAPH_PLUGIN_ID = "fusion-plugin-dependency-graph";
const CURSOR_RUNTIME_PLUGIN_ID = "fusion-plugin-cursor-runtime";
const GROK_RUNTIME_PLUGIN_ID = "fusion-plugin-grok-runtime";
export const BUNDLED_PLUGIN_IDS = [
"fusion-plugin-dependency-graph",
@@ -33,6 +34,7 @@ export const BUNDLED_PLUGIN_IDS = [
"fusion-plugin-openclaw-runtime",
"fusion-plugin-paperclip-runtime",
"fusion-plugin-cursor-runtime",
"fusion-plugin-grok-runtime",
"fusion-plugin-cli-printing-press",
"fusion-plugin-compound-engineering",
"fusion-plugin-linear-import",
@@ -184,3 +186,11 @@ export async function ensureBundledCursorRuntimePluginInstalled(
): Promise<EnsureBundledResult> {
return ensureBundledPluginInstalled(pluginStore, pluginLoader, CURSOR_RUNTIME_PLUGIN_ID, getCandidatePluginDirs);
}
export async function ensureBundledGrokRuntimePluginInstalled(
pluginStore: PluginStore,
pluginLoader: PluginLoader,
getCandidatePluginDirs: BundledPluginDirResolver,
): Promise<EnsureBundledResult> {
return ensureBundledPluginInstalled(pluginStore, pluginLoader, GROK_RUNTIME_PLUGIN_ID, getCandidatePluginDirs);
}

View File

@@ -167,6 +167,12 @@ export const DEFAULT_GLOBAL_SETTINGS = {
Cursor CLI binary overrides are global operator settings because executable locations are machine-local. Blank/undefined preserves PATH auto-detection through cursor-agent and cursor.
*/
cursorCliBinaryPath: undefined,
useGrokCli: undefined,
/*
FNXC:GrokCli 2026-07-08-00:00:
Grok CLI binary overrides are global operator settings because executable locations are machine-local. Blank/undefined preserves PATH auto-detection through grok.
*/
grokCliBinaryPath: undefined,
// Global baseline lanes for per-role model selection
executionGlobalProvider: undefined,
executionGlobalModelId: undefined,

View File

@@ -3388,6 +3388,15 @@ export interface GlobalSettings {
* Operators need a global machine-local Cursor CLI executable override when PATH discovery resolves the wrong `cursor-agent`, `cursor`, `.cmd`, or `.bat` shim. Blank/undefined means Fusion must keep auto-detecting through PATH candidates.
*/
cursorCliBinaryPath?: string;
/** When true, enable Grok CLI model-provider support (provider ID: `grok-cli`)
* through an operator-local Grok CLI installation. Grok is API-key auth (not
* OAuth/session) — see `grokCliBinaryPath` below and the plugin's probe. */
useGrokCli?: boolean;
/**
* FNXC:GrokCli 2026-07-08-00:00:
* Operators need a global machine-local Grok CLI executable override when PATH discovery resolves the wrong `grok`/`.cmd`/`.bat` shim. Blank/undefined means Fusion must keep auto-detecting through PATH candidates.
*/
grokCliBinaryPath?: string;
/** Global baseline AI model provider for task execution (executor agent).
* This is the global lane that project-level `executionProvider` can override.
* Must be set together with `executionGlobalModelId`. Falls back to

View File

@@ -1968,6 +1968,24 @@ export interface CursorCliStatus {
ready: boolean;
}
export interface GrokCliStatus {
binary: {
available: boolean;
authenticated?: boolean;
version?: string;
binaryPath?: string;
configuredBinaryPath?: string;
usingConfiguredBinaryPath?: boolean;
diagnostics?: string[];
reason?: string;
probeDurationMs: number;
};
enabled: boolean;
binaryPath?: string;
extension: null;
ready: boolean;
}
export interface LlamaCppStatus {
enabled: boolean;
extension: {
@@ -2040,6 +2058,10 @@ export function fetchCursorCliStatus(): Promise<CursorCliStatus> {
return api<CursorCliStatus>("/providers/cursor-cli/status");
}
export function fetchGrokCliStatus(): Promise<GrokCliStatus> {
return api<GrokCliStatus>("/providers/grok-cli/status");
}
/** Probe llama.cpp server + setting + extension state. */
export function fetchLlamaCppStatus(): Promise<LlamaCppStatus> {
return api<LlamaCppStatus>("/providers/llama-cpp/status");
@@ -2322,6 +2344,24 @@ export function setCursorCliBinaryPath(
});
}
export function setGrokCliEnabled(
enabled: boolean,
): Promise<{ enabled: boolean; binaryPath?: string; restartRequired: boolean }> {
return api<{ enabled: boolean; binaryPath?: string; restartRequired: boolean }>("/auth/grok-cli", {
method: "POST",
body: JSON.stringify({ enabled }),
});
}
export function setGrokCliBinaryPath(
binaryPath: string | null,
): Promise<{ enabled: boolean; binaryPath?: string; restartRequired: boolean }> {
return api<{ enabled: boolean; binaryPath?: string; restartRequired: boolean }>("/auth/grok-cli", {
method: "POST",
body: JSON.stringify({ binaryPath }),
});
}
/** Enable or disable the llama.cpp provider. */
export function setLlamaCppEnabled(
enabled: boolean,

View File

@@ -0,0 +1,65 @@
.grok-cli-provider-card .auth-provider-cli-actions,
.grok-cli-provider-card .onboarding-provider-card__actions {
display: flex;
gap: var(--space-sm);
align-items: center;
}
/*
FNXC:GrokCli 2026-07-08-00:00:
Mirrors .cursor-cli-provider-card__body (FN-7695) — compact card body must be inset to match
`.auth-provider-header`'s horizontal padding (`var(--space-sm) var(--space-md)`), otherwise the
status line and binary-path control render flush against the card's left/right/bottom edges.
*/
.grok-cli-provider-card__body {
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding: 0 var(--space-md) var(--space-sm);
}
.grok-cli-binary-path-control {
display: grid;
gap: var(--space-xs);
margin-top: var(--space-sm);
}
.grok-cli-binary-path-label {
color: var(--text-secondary);
font-size: 0.78rem;
font-weight: 600;
}
.grok-cli-binary-path-row {
display: flex;
gap: var(--space-sm);
align-items: center;
}
.grok-cli-binary-path-input {
min-width: 0;
flex: 1 1 18rem;
height: 2rem;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: var(--surface-elevated);
color: var(--text-primary);
padding: 0 var(--space-sm);
}
@media (max-width: 768px) {
.grok-cli-provider-card .auth-provider-cli-actions,
.grok-cli-provider-card .onboarding-provider-card__actions,
.grok-cli-binary-path-row {
flex-wrap: wrap;
}
.grok-cli-binary-path-row .btn,
.grok-cli-binary-path-input {
width: 100%;
}
.grok-cli-provider-card__body {
padding: 0 var(--space-sm) var(--space-sm);
}
}

View File

@@ -0,0 +1,204 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Loader2 } from "lucide-react";
import { fetchGrokCliStatus, setGrokCliBinaryPath, setGrokCliEnabled, type GrokCliStatus } from "../api";
import { ProviderIcon } from "./ProviderIcon";
import "./GrokCliProviderCard.css";
interface GrokCliProviderCardProps {
authenticated: boolean;
compact?: boolean;
onToggled?: (nextEnabled: boolean) => void;
}
/*
FNXC:GrokCli 2026-07-08-00:00:
FN-7705: mirrors CursorCliProviderCard.tsx end to end. The one contract
difference is auth messaging — Grok is API-key auth (GROK_API_KEY env var or
~/.grok/user-settings.json apiKey), not OAuth/session, so the
not-authenticated status text references those setting locations instead of
a login flow.
*/
export function GrokCliProviderCard({ authenticated, compact = false, onToggled }: GrokCliProviderCardProps) {
const { t } = useTranslation("app");
const [status, setStatus] = useState<GrokCliStatus | null>(null);
const [busy, setBusy] = useState<"enabling" | "disabling" | "testing" | "saving-path" | null>(null);
const [binaryPathInput, setBinaryPathInput] = useState("");
const [pathMessage, setPathMessage] = useState<{ tone: "success" | "error"; text: string } | null>(null);
const pathDirtyRef = useRef(false);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
const refresh = useCallback(async () => {
try {
const next = await fetchGrokCliStatus();
if (mountedRef.current) {
setStatus(next);
setBinaryPathInput((current) => (pathDirtyRef.current ? current : (next.binaryPath ?? "")));
}
return next;
} catch {
return null;
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
const handleToggle = useCallback(
async (next: boolean) => {
setBusy(next ? "enabling" : "disabling");
try {
const result = await setGrokCliEnabled(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 apiKeyPresent = status?.binary.authenticated ?? false;
const trimmedBinaryPath = binaryPathInput.trim();
const savedBinaryPath = status?.binaryPath ?? "";
const binaryPathChanged = trimmedBinaryPath !== savedBinaryPath;
const handleBinaryPathChange = useCallback((value: string) => {
setBinaryPathInput(value);
pathDirtyRef.current = true;
setPathMessage(null);
}, []);
const handleSaveBinaryPath = useCallback(async () => {
setBusy("saving-path");
setPathMessage(null);
try {
await setGrokCliBinaryPath(trimmedBinaryPath || null);
if (!mountedRef.current) return;
pathDirtyRef.current = false;
const refreshed = await fetchGrokCliStatus();
if (mountedRef.current) {
setStatus(refreshed);
setBinaryPathInput(refreshed.binaryPath ?? "");
setPathMessage({
tone: "success",
text: trimmedBinaryPath
? t("setup.grokCli.pathSaved", "Binary path saved and tested.")
: t("setup.grokCli.pathCleared", "Binary path cleared; PATH auto-detection is active."),
});
}
} catch (error) {
if (mountedRef.current) {
const message = error instanceof Error ? error.message : String(error);
setPathMessage({ tone: "error", text: message });
}
} finally {
if (mountedRef.current) setBusy(null);
}
}, [t, trimmedBinaryPath]);
const binaryPathControl = compact ? (
<div className="grok-cli-binary-path-control">
<label className="grok-cli-binary-path-label" htmlFor="grok-cli-binary-path">
{t("setup.grokCli.binaryPathLabel", "Grok CLI binary path")}
</label>
<div className="grok-cli-binary-path-row">
<input
id="grok-cli-binary-path"
className="grok-cli-binary-path-input"
type="text"
value={binaryPathInput}
onChange={(event) => handleBinaryPathChange(event.target.value)}
placeholder={t("setup.grokCli.binaryPathPlaceholder", "/usr/local/bin/grok")}
disabled={busy !== null}
/>
<button type="button" className="btn btn-sm" onClick={() => void handleSaveBinaryPath()} disabled={busy !== null || !binaryPathChanged}>
{busy === "saving-path" ? t("setup.grokCli.savingPath", "Saving…") : t("setup.grokCli.saveAndTestPath", "Save & Test")}
</button>
</div>
<small className="settings-muted">{t("setup.grokCli.binaryPathHelp", "Leave blank to use PATH auto-detection (`grok`).")}</small>
{pathMessage ? <small className={pathMessage.tone === "error" ? "form-error" : "text-muted"}>{pathMessage.text}</small> : null}
</div>
) : null;
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" /> {t("setup.grokCli.testing", "Testing…")}</> : t("setup.grokCli.test", "Test")}
</button>
{currentlyEnabled ? (
<button type="button" className="btn btn-sm" onClick={() => void handleToggle(false)} disabled={busy !== null}>
{busy === "disabling" ? t("setup.grokCli.disabling", "Disabling…") : t("setup.grokCli.disable", "Disable")}
</button>
) : (
<button type="button" className="btn btn-primary btn-sm" onClick={() => void handleToggle(true)} disabled={busy !== null || !binaryAvailable}>
{busy === "enabling" ? t("setup.grokCli.enabling", "Enabling…") : t("setup.grokCli.enable", "Enable")}
</button>
)}
</>
);
/*
FNXC:GrokCli 2026-07-08-00:00:
Grok is API-key auth (no login flow), so a binary-available-but-no-key
state must guide the operator to GROK_API_KEY / ~/.grok/user-settings.json
rather than a generic "not connected" message.
*/
const statusText = !status
? t("setup.grokCli.probing", "Probing local CLI…")
: !status.binary.available
? status.binary.reason ?? t("setup.grokCli.binaryNotFound", "`grok` not found on PATH")
: !apiKeyPresent
? t("setup.grokCli.noApiKey", "Binary found, but no API key is configured. Set GROK_API_KEY or ~/.grok/user-settings.json.")
: currentlyEnabled
? t("setup.grokCli.connected", "Connected{{version}}", { version: status.binary.version ? ` — ${status.binary.version}` : "" })
: t("setup.grokCli.detectedPrompt", "Detected. Click Enable to route calls through Grok CLI.");
if (compact) {
return (
<div className={`grok-cli-provider-card auth-provider-card auth-provider-card--cli${authenticated ? " auth-provider-card--authenticated" : ""}`} data-testid="grok-cli-provider-card">
<div className="auth-provider-header">
<div className="auth-provider-info">
<ProviderIcon provider="grok-cli" size="sm" />
<strong>{t("setup.grokCli.providerName", "Grok — via Grok CLI")}</strong>
<span className={`auth-status-badge ${currentlyEnabled ? "authenticated" : "not-authenticated"}`}>{currentlyEnabled ? t("setup.grokCli.active", "✓ Active") : t("setup.grokCli.notConnected", "✗ Not connected")}</span>
</div>
<div className="auth-provider-cli-actions">{actions}</div>
</div>
<div className="grok-cli-provider-card__body" data-testid="grok-cli-provider-card-body">
<small className="settings-muted">{statusText}</small>
{binaryPathControl}
</div>
</div>
);
}
return (
<div className={`grok-cli-provider-card onboarding-provider-card${authenticated ? " onboarding-provider-card--connected" : ""}`} data-testid="grok-cli-provider-card">
<div className="onboarding-provider-card__icon">
<ProviderIcon provider="grok-cli" size="md" />
</div>
<div className="onboarding-provider-card__body">
<strong className="onboarding-provider-card__name">{t("setup.grokCli.providerName", "Grok — via Grok CLI")}</strong>
<span className="onboarding-provider-card__description">{t("setup.grokCli.description", "Route AI calls through your local Grok CLI runtime.")}</span>
<small className="settings-muted">{statusText}</small>
</div>
<div className="onboarding-provider-card__actions">{actions}</div>
</div>
);
}

View File

@@ -729,6 +729,11 @@ const providerConfig: Record<
"pi-claude-cli": { component: ClaudeCliIcon, color: "var(--provider-anthropic)", label: "Anthropic — via Claude CLI" },
"droid-cli": { component: DroidCliIcon, color: "var(--provider-openai)", label: "Factory AI — via Droid CLI" },
"cursor-cli": { component: CursorCliIcon, color: "var(--provider-cursor-cli)", label: "Cursor — via Cursor CLI" },
/*
FNXC:GrokCli 2026-07-08-00:00:
FN-7705: reuse the existing XaiIcon/brand color for the grok-cli synthetic auth provider — Grok is an xAI product, and no dedicated CLI-badge asset exists yet. Do not fabricate a new icon asset per AGENTS.md/PROMPT.md guidance.
*/
"grok-cli": { component: XaiIcon, color: "var(--text)", label: "Grok — via Grok CLI" },
"llama-cpp": { component: LlamaCppIcon, color: "var(--provider-ollama)", label: "llama.cpp" },
"llama-server": { component: LlamaCppIcon, color: "var(--provider-ollama)", label: "llama.cpp" },

View File

@@ -0,0 +1,105 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { GrokCliProviderCard } from "../GrokCliProviderCard";
const fetchGrokCliStatus = vi.fn();
const setGrokCliBinaryPath = vi.fn();
const setGrokCliEnabled = vi.fn();
vi.mock("../../api", () => ({
fetchGrokCliStatus: (...args: unknown[]) => fetchGrokCliStatus(...args),
setGrokCliBinaryPath: (...args: unknown[]) => setGrokCliBinaryPath(...args),
setGrokCliEnabled: (...args: unknown[]) => setGrokCliEnabled(...args),
}));
const baseStatus = {
binary: { available: true, authenticated: true, version: "1.0.0", binaryPath: "/usr/local/bin/grok", probeDurationMs: 5 },
enabled: true,
binaryPath: "/usr/local/bin/grok",
extension: null,
ready: true,
};
/*
FNXC:GrokCli 2026-07-08-00:00:
Regression coverage mirroring CursorCliProviderCard.test.tsx (FN-7695) for FN-7705: the compact
card's below-header content (status line + binary-path control) must be nested inside
`.grok-cli-provider-card__body` (data-testid="grok-cli-provider-card-body") rather than being a
bare direct child of `.auth-provider-card`. The non-compact onboarding layout must NOT render
this wrapper. Additionally covers the API-key-auth-specific "no API key configured" status text
that has no Cursor equivalent (Cursor is OAuth/session, not API-key).
*/
describe("GrokCliProviderCard", () => {
beforeEach(() => {
vi.clearAllMocks();
fetchGrokCliStatus.mockResolvedValue(baseStatus);
setGrokCliEnabled.mockResolvedValue({ enabled: true, binaryPath: baseStatus.binaryPath, restartRequired: true });
setGrokCliBinaryPath.mockResolvedValue({ enabled: true, binaryPath: baseStatus.binaryPath, restartRequired: true });
});
it("wraps compact status line + binary-path control in the padded body wrapper", async () => {
render(<GrokCliProviderCard authenticated compact />);
const body = await screen.findByTestId("grok-cli-provider-card-body");
expect(body).toHaveClass("grok-cli-provider-card__body");
const status = await screen.findByText(/Connected/i);
expect(body).toContainElement(status);
const label = screen.getByText("Grok CLI binary path");
expect(body).toContainElement(label);
const input = screen.getByLabelText("Grok CLI binary path");
expect(body).toContainElement(input);
const card = screen.getByTestId("grok-cli-provider-card");
expect(card).toContainElement(body);
});
it("keeps the body wrapper present before the status probe resolves (Probing…)", async () => {
fetchGrokCliStatus.mockReturnValue(new Promise(() => {}));
render(<GrokCliProviderCard authenticated={false} compact />);
const body = await screen.findByTestId("grok-cli-provider-card-body");
const status = await screen.findByText(/Probing local CLI/i);
expect(body).toContainElement(status);
});
it("shows an actionable no-API-key message when the binary is available but no key is configured", async () => {
fetchGrokCliStatus.mockResolvedValue({
...baseStatus,
binary: { ...baseStatus.binary, authenticated: false },
});
render(<GrokCliProviderCard authenticated={false} compact />);
const status = await screen.findByText(/GROK_API_KEY/i);
expect(status.textContent).toContain("~/.grok/user-settings.json");
});
it("keeps the body wrapper present when a pathMessage is shown after a failed save", async () => {
setGrokCliBinaryPath.mockRejectedValueOnce(new Error("binary not found"));
const { default: userEvent } = await import("@testing-library/user-event");
const user = userEvent.setup();
render(<GrokCliProviderCard authenticated compact />);
const input = await screen.findByLabelText("Grok CLI binary path");
await user.clear(input);
await user.type(input, "/tmp/does-not-exist");
const saveButton = screen.getByRole("button", { name: /Save & Test/i });
await user.click(saveButton);
const errorText = await screen.findByText("binary not found");
const body = screen.getByTestId("grok-cli-provider-card-body");
expect(body).toContainElement(errorText);
});
it("does not render the body wrapper in the non-compact onboarding layout", async () => {
render(<GrokCliProviderCard authenticated />);
const card = await screen.findByTestId("grok-cli-provider-card");
expect(card).toHaveClass("onboarding-provider-card");
await waitFor(() => expect(fetchGrokCliStatus).toHaveBeenCalled());
expect(screen.queryByTestId("grok-cli-provider-card-body")).not.toBeInTheDocument();
});
});

View File

@@ -68,6 +68,14 @@ describe("ProviderIcon", () => {
expect(svg.parentElement).toHaveStyle({ color: "var(--provider-cursor-cli)" });
});
it("renders grok-cli icon reusing the xAI brand mark", () => {
render(<ProviderIcon provider="grok-cli" />);
const svg = screen.getByTestId("xai-icon");
expect(svg).toBeInTheDocument();
expect(screen.getByLabelText("Grok — via Grok CLI")).toBeInTheDocument();
expect(svg.parentElement).toHaveStyle({ color: "var(--text)" });
});
it("normalizes PI-Claude-CLI provider name to lowercase alias", () => {
render(<ProviderIcon provider="PI-Claude-CLI" />);
const svg = screen.getByTestId("claude-cli-icon");

View File

@@ -4,6 +4,7 @@ import type { ToastType } from "../../../hooks/useToast";
import { useTranslation } from "react-i18next";
import { ClaudeCliProviderCard } from "../../ClaudeCliProviderCard";
import { CursorCliProviderCard } from "../../CursorCliProviderCard";
import { GrokCliProviderCard } from "../../GrokCliProviderCard";
import { LlamaCppProviderCard } from "../../LlamaCppProviderCard";
import { ProviderIcon } from "../../ProviderIcon";
import { PluginSlot } from "../../PluginSlot";
@@ -81,7 +82,7 @@ export function AuthenticationSection({ auth }: AuthenticationSectionProps) {
const visibleAuthProviders = hasSeparatedAnthropicProvider
? authProviders.filter((p) => p.id !== "anthropic")
: authProviders;
const isSupportedCliProvider = (provider: AuthProvider) => provider.id === "claude-cli" || provider.id === "cursor-cli" || provider.id === "llama-cpp";
const isSupportedCliProvider = (provider: AuthProvider) => provider.id === "claude-cli" || provider.id === "cursor-cli" || provider.id === "grok-cli" || provider.id === "llama-cpp";
/*
FNXC:ProviderAuth 2026-07-02-12:20:
Authentication ordering must sort supported CLI and non-CLI provider cards in one list so Cursor CLI or llama.cpp cannot split Claude CLI from Anthropic subscription/API-key entries.
@@ -102,6 +103,11 @@ export function AuthenticationSection({ auth }: AuthenticationSectionProps) {
void loadAuthStatus();
}}/>);
}
if (provider.id === "grok-cli") {
return (<GrokCliProviderCard key={provider.id} compact authenticated={provider.authenticated} onToggled={() => {
void loadAuthStatus();
}}/>);
}
return (<LlamaCppProviderCard key={provider.id} compact authenticated={provider.authenticated} onToggled={() => {
void loadAuthStatus();
}}/>);

View File

@@ -106,6 +106,7 @@
"@fusion-plugin-examples/cli-printing-press": "workspace:*",
"@fusion-plugin-examples/compound-engineering": "workspace:*",
"@fusion-plugin-examples/cursor-runtime": "workspace:*",
"@fusion-plugin-examples/grok-runtime": "workspace:*",
"@fusion-plugin-examples/dependency-graph": "workspace:*",
"@fusion-plugin-examples/droid-runtime": "workspace:*",
"@fusion-plugin-examples/hermes-runtime": "workspace:*",

View File

@@ -0,0 +1,152 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GrokModelDiscoveryResult } from "../runtime-provider-probes.js";
vi.mock("../runtime-provider-probes.js", () => ({
discoverGrokCliModels: vi.fn(),
}));
import { discoverGrokCliModels } from "../runtime-provider-probes.js";
import {
__resetGrokPickerModelsCacheForTests,
grokDiscoveryToModels,
getGrokPickerModels,
} from "../grok-model-cache.js";
const mockedDiscover = vi.mocked(discoverGrokCliModels);
afterEach(() => {
vi.clearAllMocks();
__resetGrokPickerModelsCacheForTests();
});
describe("grokDiscoveryToModels", () => {
it("maps a discovered model with a label", () => {
const models = grokDiscoveryToModels([{ id: "grok-4", label: "Grok 4" }]);
expect(models).toEqual([
{ provider: "grok-cli", id: "grok-4", name: "Grok 4", reasoning: false, contextWindow: 0 },
]);
});
it("falls back to the id as the name when no label is provided", () => {
const models = grokDiscoveryToModels([{ id: "grok-4-fast" }]);
expect(models).toEqual([
{ provider: "grok-cli", id: "grok-4-fast", name: "grok-4-fast", reasoning: false, contextWindow: 0 },
]);
});
it("de-duplicates entries that map to the same stable id, keeping the first occurrence", () => {
const models = grokDiscoveryToModels([
{ id: "grok-4", label: "First" },
{ id: "grok-4", label: "Second" },
]);
expect(models).toHaveLength(1);
expect(models[0]?.name).toBe("First");
});
it("returns an empty array for an empty model list", () => {
expect(grokDiscoveryToModels([])).toEqual([]);
});
});
describe("getGrokPickerModels caching", () => {
it("fetches once and returns mapped models", async () => {
mockedDiscover.mockResolvedValue({
models: [{ id: "grok-4", label: "Grok 4" }],
source: "models-text",
fallbackUsed: false,
});
const models = await getGrokPickerModels({ binaryPath: "grok-test-1" });
expect(models).toEqual([
{ provider: "grok-cli", id: "grok-4", name: "Grok 4", reasoning: false, contextWindow: 0 },
]);
expect(mockedDiscover).toHaveBeenCalledTimes(1);
});
it("serves subsequent requests within the TTL window from cache with no additional spawn", async () => {
mockedDiscover.mockResolvedValue({ models: [{ id: "grok-4" }], source: "models-text", fallbackUsed: false });
let clock = 1000;
const now = () => clock;
await getGrokPickerModels({ binaryPath: "grok-test-2", ttlMs: 60_000, now });
clock += 30_000;
await getGrokPickerModels({ binaryPath: "grok-test-2", ttlMs: 60_000, now });
expect(mockedDiscover).toHaveBeenCalledTimes(1);
});
it("refreshes after the TTL window expires", async () => {
mockedDiscover.mockResolvedValue({ models: [{ id: "grok-4" }], source: "models-text", fallbackUsed: false });
let clock = 1000;
const now = () => clock;
await getGrokPickerModels({ binaryPath: "grok-test-3", ttlMs: 1_000, now });
clock += 1_001;
await getGrokPickerModels({ binaryPath: "grok-test-3", ttlMs: 1_000, now });
expect(mockedDiscover).toHaveBeenCalledTimes(2);
});
it("single-flights concurrent requests for the same binaryPath", async () => {
let resolveFetch: (v: GrokModelDiscoveryResult) => void = () => {};
mockedDiscover.mockImplementation(
() =>
new Promise<GrokModelDiscoveryResult>((resolve) => {
resolveFetch = resolve;
}),
);
const p1 = getGrokPickerModels({ binaryPath: "grok-test-4" });
const p2 = getGrokPickerModels({ binaryPath: "grok-test-4" });
resolveFetch({ models: [{ id: "grok-4" }], source: "models-text", fallbackUsed: false });
const [r1, r2] = await Promise.all([p1, p2]);
expect(r1).toEqual(r2);
expect(mockedDiscover).toHaveBeenCalledTimes(1);
});
it("degrades to an empty array (never throws) when the CLI fetch rejects, and caches the empty result", async () => {
mockedDiscover.mockRejectedValue(new Error("grok models failed: binary not found"));
let clock = 1000;
const now = () => clock;
const first = await getGrokPickerModels({ binaryPath: "grok-test-5", ttlMs: 60_000, now });
expect(first).toEqual([]);
clock += 10;
const second = await getGrokPickerModels({ binaryPath: "grok-test-5", ttlMs: 60_000, now });
expect(second).toEqual([]);
expect(mockedDiscover).toHaveBeenCalledTimes(1);
});
it("degrades to an empty array when discovery reports the binary unavailable (fallbackUsed, empty models)", async () => {
mockedDiscover.mockResolvedValue({
models: [],
source: "probe",
fallbackUsed: true,
reason: "binary unavailable",
});
const models = await getGrokPickerModels({ binaryPath: "grok-test-6" });
expect(models).toEqual([]);
});
it("defaults binaryPath to grok when not explicitly provided", async () => {
mockedDiscover.mockResolvedValue({ models: [], source: "probe", fallbackUsed: true });
await getGrokPickerModels();
expect(mockedDiscover).toHaveBeenCalledWith({ binaryPath: "grok" });
});
it("caches distinct binaryPaths independently", async () => {
mockedDiscover.mockResolvedValue({ models: [{ id: "grok-4" }], source: "models-text", fallbackUsed: false });
await getGrokPickerModels({ binaryPath: "grok-test-7a" });
await getGrokPickerModels({ binaryPath: "grok-test-7b" });
expect(mockedDiscover).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,214 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
return {
...actual,
access: vi.fn().mockResolvedValue(undefined),
// FNXC:GrokCli 2026-07-08-00:05 (FN-7705): this fixture intentionally
// omits a "grok-cli" key so the toggle path (useGrokCli ->
// configuredProviders.add) is proven on its own, not masked by an
// auth.json entry, mirroring the Cursor CLI fixture.
readFile: vi.fn().mockResolvedValue('{"anthropic":{},"openai":{}}'),
};
});
vi.mock("../grok-model-cache.js", () => ({
getGrokPickerModels: vi.fn(),
GROK_PICKER_PROVIDER_ID: "grok-cli",
}));
import type { Router } from "express";
import { getGrokPickerModels } from "../grok-model-cache.js";
import { registerModelRoutes } from "../routes/register-model-routes.js";
const mockedGetGrokPickerModels = vi.mocked(getGrokPickerModels);
function setup(
useGrokCli?: boolean,
registryModels?: Array<{ provider: string; id: string; name: string; reasoning: boolean; contextWindow: number }>,
grokCliBinaryPath?: unknown,
) {
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({ useGrokCli, grokCliBinaryPath }),
}),
getSettingsFast: vi.fn().mockResolvedValue({}),
};
const runtimeLogger = {
child: vi.fn(() => ({ warn: vi.fn() })),
};
const modelRegistry = {
refresh: vi.fn(),
getAvailable: vi.fn(
() =>
registryModels ?? [{ 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")!;
}
async function invoke(handler: (req: unknown, res: { json: (body: unknown) => void }) => Promise<void>) {
const json = vi.fn();
await handler({}, { json });
return json.mock.calls[0][0] as { models: Array<{ provider: string; id: string; name: string }> };
}
afterEach(() => {
vi.clearAllMocks();
});
describe("registerModelRoutes grok-cli merge and filter", () => {
it("filters grok-cli models when useGrokCli is false, even when discovery would return some", async () => {
mockedGetGrokPickerModels.mockResolvedValue([
{ provider: "grok-cli", id: "grok-4", name: "Grok 4", reasoning: false, contextWindow: 0 },
]);
const handler = setup(false);
const response = await invoke(handler);
expect(response.models.some((model) => model.provider === "grok-cli")).toBe(false);
// Discovery must not even be attempted when the toggle is off.
expect(mockedGetGrokPickerModels).not.toHaveBeenCalled();
});
it("includes discovered grok-cli models when useGrokCli is true, via the toggle alone (no auth.json entry needed)", async () => {
mockedGetGrokPickerModels.mockResolvedValue([
{ provider: "grok-cli", id: "grok-4", name: "Grok 4", reasoning: false, contextWindow: 0 },
{ provider: "grok-cli", id: "grok-4-fast", name: "Grok 4 Fast", reasoning: false, contextWindow: 0 },
]);
const handler = setup(true);
const response = await invoke(handler);
const grokRows = response.models.filter((m) => m.provider === "grok-cli");
expect(grokRows.map((m) => m.id).sort()).toEqual(["grok-4", "grok-4-fast"]);
});
it("preserves all pre-existing rows (openai, droid-cli-style) alongside newly-surfaced grok-cli rows", async () => {
mockedGetGrokPickerModels.mockResolvedValue([
{ provider: "grok-cli", id: "grok-4", name: "Grok 4", reasoning: false, contextWindow: 0 },
]);
const registryModels = [
{ provider: "openai", id: "gpt-5", name: "GPT-5", reasoning: true, contextWindow: 128000 },
{ provider: "droid-cli", id: "droid-1", name: "Droid 1", reasoning: false, contextWindow: 0 },
];
const handler = setup(true, registryModels);
const response = await invoke(handler);
expect(response.models.some((m) => m.provider === "openai" && m.id === "gpt-5")).toBe(true);
expect(response.models.some((m) => m.provider === "grok-cli" && m.id === "grok-4")).toBe(true);
});
it("dedupes by provider/id when a discovered id collides with an existing registry row — existing row wins", async () => {
const registryModels = [
{ provider: "grok-cli", id: "grok-4", name: "Registry Grok 4 (pre-existing)", reasoning: true, contextWindow: 128000 },
];
mockedGetGrokPickerModels.mockResolvedValue([
{ provider: "grok-cli", id: "grok-4", name: "Discovered Grok 4 (should be dropped)", reasoning: false, contextWindow: 0 },
]);
const handler = setup(true, registryModels);
const response = await invoke(handler);
const grokRows = response.models.filter((m) => m.provider === "grok-cli" && m.id === "grok-4");
expect(grokRows).toHaveLength(1);
expect(grokRows[0]?.name).toBe("Registry Grok 4 (pre-existing)");
});
it("degrades to zero grok-cli rows (HTTP 200, existing rows intact) when discovery returns empty", async () => {
mockedGetGrokPickerModels.mockResolvedValue([]);
const handler = setup(true);
const response = await invoke(handler);
expect(response.models.some((m) => m.provider === "grok-cli")).toBe(false);
expect(response.models.some((m) => m.provider === "openai" && m.id === "gpt-5")).toBe(true);
});
it("degrades to zero grok-cli rows (never rejects the handler) when discovery throws", async () => {
mockedGetGrokPickerModels.mockRejectedValue(new Error("grok unavailable"));
const handler = setup(true);
const response = await invoke(handler);
expect(response.models.some((m) => m.provider === "grok-cli")).toBe(false);
expect(response.models.some((m) => m.provider === "openai" && m.id === "gpt-5")).toBe(true);
});
it("surfaces a single discovered model", async () => {
mockedGetGrokPickerModels.mockResolvedValue([
{ provider: "grok-cli", id: "grok-only", name: "Only", reasoning: false, contextWindow: 0 },
]);
const handler = setup(true);
const response = await invoke(handler);
expect(response.models.filter((m) => m.provider === "grok-cli")).toHaveLength(1);
});
it("final response is deduped by provider/id across all merged sources", async () => {
mockedGetGrokPickerModels.mockResolvedValue([
{ provider: "grok-cli", id: "grok-dup", name: "A", reasoning: false, contextWindow: 0 },
]);
const registryModels = [
{ provider: "openai", id: "gpt-5", name: "GPT-5", reasoning: true, contextWindow: 128000 },
{ provider: "openai", id: "gpt-5", name: "GPT-5 dup", reasoning: true, contextWindow: 128000 },
];
const handler = setup(true, registryModels);
const response = await invoke(handler);
const keys = response.models.map((m) => `${m.provider}/${m.id}`);
expect(new Set(keys).size).toBe(keys.length);
});
});
/*
FNXC:GrokCli 2026-07-08-00:20:
FN-7705: mirrors the Cursor CLI binaryPath threading coverage
(register-model-routes-cursor-cli.test.ts) so the machine-local
grokCliBinaryPath operator override also applies to model-picker discovery.
*/
describe("registerModelRoutes grokCliBinaryPath threading", () => {
it("threads a set grokCliBinaryPath override into getGrokPickerModels verbatim", async () => {
mockedGetGrokPickerModels.mockResolvedValue([
{ provider: "grok-cli", id: "grok-4", name: "Grok 4", reasoning: false, contextWindow: 0 },
]);
const handler = setup(true, undefined, "/opt/Grok/grok");
const response = await invoke(handler);
expect(mockedGetGrokPickerModels).toHaveBeenCalledWith({ binaryPath: "/opt/Grok/grok" });
expect(response.models.some((m) => m.provider === "grok-cli" && m.id === "grok-4")).toBe(true);
});
it("threads a Windows-shim-style override path verbatim, with no mangling", async () => {
mockedGetGrokPickerModels.mockResolvedValue([]);
const winPath = "C:\\Users\\A User\\AppData\\Roaming\\npm\\grok.cmd";
const handler = setup(true, undefined, winPath);
await invoke(handler);
expect(mockedGetGrokPickerModels).toHaveBeenCalledWith({ binaryPath: winPath });
});
it("passes binaryPath: undefined when grokCliBinaryPath is absent (PATH auto-detection preserved)", async () => {
mockedGetGrokPickerModels.mockResolvedValue([]);
const handler = setup(true, undefined, undefined);
await invoke(handler);
expect(mockedGetGrokPickerModels).toHaveBeenCalledWith({ binaryPath: undefined });
});
it("passes binaryPath: undefined when grokCliBinaryPath is blank/whitespace-only", async () => {
mockedGetGrokPickerModels.mockResolvedValue([]);
const handler = setup(true, undefined, " ");
await invoke(handler);
expect(mockedGetGrokPickerModels).toHaveBeenCalledWith({ binaryPath: undefined });
});
it("does not surface grok-cli rows or call getGrokPickerModels when useGrokCli is false, regardless of grokCliBinaryPath", async () => {
const handler = setup(false, undefined, "/opt/Grok/grok");
const response = await invoke(handler);
expect(mockedGetGrokPickerModels).not.toHaveBeenCalled();
expect(response.models.some((m) => m.provider === "grok-cli")).toBe(false);
});
});

View File

@@ -890,6 +890,12 @@ describe("GET /auth/status", () => {
reason: "mocked unavailable",
probeDurationMs: 0,
});
vi.spyOn(runtimeProviderProbesModule, "probeGrokCliProvider").mockResolvedValue({
available: false,
authenticated: false,
reason: "mocked unavailable",
probeDurationMs: 0,
});
vi.spyOn(llamaCppProbeModule, "probeLlamaCpp").mockResolvedValue({
available: false,
reason: "mocked unavailable",
@@ -931,7 +937,7 @@ describe("GET /auth/status", () => {
expect(res.status).toBe(200);
// Filter out synthetic CLI providers — they have dedicated route tests.
// Structural assertions here are about OAuth + API-key paths only.
const providers = res.body.providers.filter((p: any) => p.id !== "claude-cli" && p.id !== "droid-cli" && p.id !== "cursor-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 !== "grok-cli" && p.id !== "llama-cpp");
/*
FN-7625: the static catalog (anthropic-subscription/github-copilot/openai-codex
OAuth + the full API-key catalog) is always present, unioned with whatever the
@@ -1048,7 +1054,7 @@ describe("GET /auth/status", () => {
const res = await GET(app, "/api/auth/status");
expect(res.status).toBe(200);
const providers = res.body.providers.filter((p: any) => p.id !== "claude-cli" && p.id !== "droid-cli" && p.id !== "cursor-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 !== "grok-cli" && p.id !== "llama-cpp");
/*
FN-7625: catalog ids remain present even though storage only reported a
narrow subset, and a storage-reported id NOT in the catalog ("acme-extension")
@@ -1535,7 +1541,7 @@ describe("GET /auth/status", () => {
function nonCliProviderIds(res: any): string[] {
return res.body.providers
.filter((p: any) => p.id !== "claude-cli" && p.id !== "droid-cli" && p.id !== "cursor-cli" && p.id !== "llama-cpp")
.filter((p: any) => p.id !== "claude-cli" && p.id !== "droid-cli" && p.id !== "cursor-cli" && p.id !== "grok-cli" && p.id !== "llama-cpp")
.map((p: any) => p.id);
}
@@ -1811,6 +1817,12 @@ describe("Droid CLI auth routes", () => {
reason: "mocked unavailable",
probeDurationMs: 0,
});
vi.spyOn(runtimeProviderProbesModule, "probeGrokCliProvider").mockResolvedValue({
available: false,
authenticated: false,
reason: "mocked unavailable",
probeDurationMs: 0,
});
vi.spyOn(llamaCppProbeModule, "probeLlamaCpp").mockResolvedValue({
available: false,
reason: "mocked unavailable",
@@ -2220,6 +2232,246 @@ describe("Droid CLI auth routes", () => {
expect(res.body.ready).toBe(false);
});
it("POST /auth/grok-cli enables when grok binary is available", async () => {
vi.spyOn(runtimeProviderProbesModule, "probeGrokCliProvider").mockResolvedValue({
available: true,
authenticated: true,
version: "grok 1.0.0",
probeDurationMs: 8,
});
store.updateGlobalSettings = vi.fn().mockResolvedValue({ useGrokCli: true });
const res = await REQUEST(buildApp(), "POST", "/api/auth/grok-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({ useGrokCli: true });
});
it("POST /auth/grok-cli saves a validated binary path without toggling", async () => {
vi.spyOn(runtimeProviderProbesModule, "probeGrokCliProvider").mockResolvedValue({
available: true,
authenticated: true,
version: "grok 1.0.0",
binaryPath: "/opt/Grok/grok",
configuredBinaryPath: "/opt/Grok/grok",
usingConfiguredBinaryPath: true,
probeDurationMs: 8,
});
store.getGlobalSettingsStore = vi.fn().mockReturnValue({
...createMockGlobalSettingsStore(),
getSettings: vi.fn().mockResolvedValue({ useGrokCli: false }),
});
store.updateGlobalSettings = vi.fn().mockResolvedValue({ useGrokCli: false, grokCliBinaryPath: "/opt/Grok/grok" });
const res = await REQUEST(buildApp(), "POST", "/api/auth/grok-cli", JSON.stringify({ binaryPath: " /opt/Grok/grok " }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(res.body).toEqual({ enabled: false, binaryPath: "/opt/Grok/grok", restartRequired: false });
expect(runtimeProviderProbesModule.probeGrokCliProvider).toHaveBeenCalledWith({ binaryPath: "/opt/Grok/grok" });
expect(store.updateGlobalSettings).toHaveBeenCalledWith({ grokCliBinaryPath: "/opt/Grok/grok" });
});
it("POST /auth/grok-cli rejects invalid binaryPath values", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/auth/grok-cli", JSON.stringify({ enabled: false, binaryPath: 123 }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("binaryPath must be a string or null");
});
it("POST /auth/grok-cli rejects configured paths that only succeed via PATH fallback", async () => {
vi.spyOn(runtimeProviderProbesModule, "probeGrokCliProvider").mockResolvedValue({
available: true,
authenticated: true,
version: "grok 1.0.0",
binaryPath: "grok",
configuredBinaryPath: "/missing/grok",
usingConfiguredBinaryPath: false,
reason: "Configured Grok CLI binary '/missing/grok' failed; PATH fallback succeeded",
probeDurationMs: 8,
});
const res = await REQUEST(buildApp(), "POST", "/api/auth/grok-cli", JSON.stringify({ binaryPath: "/missing/grok" }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("Cannot save Grok CLI binary path");
expect(store.updateGlobalSettings).not.toHaveBeenCalled();
});
it("POST /auth/grok-cli clears the binary path and restores PATH auto-detection", async () => {
vi.spyOn(runtimeProviderProbesModule, "probeGrokCliProvider").mockResolvedValue({
available: true,
authenticated: true,
version: "grok 1.0.0",
binaryPath: "grok",
probeDurationMs: 8,
});
store.getGlobalSettingsStore = vi.fn().mockReturnValue({
...createMockGlobalSettingsStore(),
getSettings: vi.fn().mockResolvedValue({ useGrokCli: true, grokCliBinaryPath: "/opt/Grok/grok" }),
});
store.updateGlobalSettings = vi.fn().mockResolvedValue({ useGrokCli: true });
const res = await REQUEST(buildApp(), "POST", "/api/auth/grok-cli", JSON.stringify({ binaryPath: " " }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(res.body).toEqual({ enabled: true, restartRequired: false });
expect(runtimeProviderProbesModule.probeGrokCliProvider).toHaveBeenCalledWith({ binaryPath: undefined });
expect(store.updateGlobalSettings).toHaveBeenCalledWith({ grokCliBinaryPath: null });
});
it("POST /auth/grok-cli enables using the stored binary override", async () => {
vi.spyOn(runtimeProviderProbesModule, "probeGrokCliProvider").mockResolvedValue({
available: true,
authenticated: true,
version: "grok 1.0.0",
binaryPath: "/opt/Grok/grok",
configuredBinaryPath: "/opt/Grok/grok",
usingConfiguredBinaryPath: true,
probeDurationMs: 8,
});
store.getGlobalSettingsStore = vi.fn().mockReturnValue({
...createMockGlobalSettingsStore(),
getSettings: vi.fn().mockResolvedValue({ grokCliBinaryPath: "/opt/Grok/grok" }),
});
store.updateGlobalSettings = vi.fn().mockResolvedValue({ useGrokCli: true, grokCliBinaryPath: "/opt/Grok/grok" });
const res = await REQUEST(buildApp(), "POST", "/api/auth/grok-cli", JSON.stringify({ enabled: true }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(runtimeProviderProbesModule.probeGrokCliProvider).toHaveBeenCalledWith({ binaryPath: "/opt/Grok/grok" });
expect(store.updateGlobalSettings).toHaveBeenCalledWith({ useGrokCli: true });
});
it("POST /auth/grok-cli returns 400 when enabling without binary", async () => {
vi.spyOn(runtimeProviderProbesModule, "probeGrokCliProvider").mockResolvedValue({
available: false,
authenticated: false,
reason: "grok not found",
probeDurationMs: 8,
});
const res = await REQUEST(buildApp(), "POST", "/api/auth/grok-cli", JSON.stringify({ enabled: true }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("Cannot enable Grok CLI routing");
});
it("POST /auth/grok-cli disables without probing binary", async () => {
const probeSpy = vi.spyOn(runtimeProviderProbesModule, "probeGrokCliProvider");
probeSpy.mockClear();
store.updateGlobalSettings = vi.fn().mockResolvedValue({ useGrokCli: false });
const res = await REQUEST(buildApp(), "POST", "/api/auth/grok-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/grok-cli/status returns readiness from toggle and binary", async () => {
vi.spyOn(runtimeProviderProbesModule, "probeGrokCliProvider").mockResolvedValue({
available: true,
authenticated: true,
version: "grok 1.0.0",
probeDurationMs: 8,
});
store.getGlobalSettingsStore = vi.fn().mockReturnValue({
...createMockGlobalSettingsStore(),
getSettings: vi.fn().mockResolvedValue({ useGrokCli: true, grokCliBinaryPath: "/opt/Grok/grok" }),
});
const res = await GET(buildApp(), "/api/providers/grok-cli/status");
expect(res.status).toBe(200);
expect(runtimeProviderProbesModule.probeGrokCliProvider).toHaveBeenCalledWith({ binaryPath: "/opt/Grok/grok" });
expect(res.body.ready).toBe(true);
expect(res.body.enabled).toBe(true);
expect(res.body.binaryPath).toBe("/opt/Grok/grok");
expect(res.body.binary.available).toBe(true);
});
it("GET /auth/status probes Grok CLI with the stored override and requires API-key presence for authenticated:true", async () => {
vi.spyOn(runtimeProviderProbesModule, "probeGrokCliProvider").mockResolvedValue({
available: true,
authenticated: true,
version: "grok 1.0.0",
binaryPath: "/opt/Grok/grok",
configuredBinaryPath: "/opt/Grok/grok",
usingConfiguredBinaryPath: true,
probeDurationMs: 8,
});
store.getGlobalSettingsStore = vi.fn().mockReturnValue({
...createMockGlobalSettingsStore(),
getSettings: vi.fn().mockResolvedValue({ useGrokCli: true, grokCliBinaryPath: "/opt/Grok/grok" }),
});
const res = await GET(buildApp(), "/api/auth/status");
expect(res.status).toBe(200);
expect(runtimeProviderProbesModule.probeGrokCliProvider).toHaveBeenCalledWith({ binaryPath: "/opt/Grok/grok" });
expect(res.body.providers).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "grok-cli", authenticated: true }),
]),
);
});
it("GET /auth/status reports grok-cli authenticated:false when the binary is available but no API key is configured", async () => {
vi.spyOn(runtimeProviderProbesModule, "probeGrokCliProvider").mockResolvedValue({
available: true,
authenticated: false,
reason: "GROK_API_KEY is not set and ~/.grok/user-settings.json was not found",
version: "grok 1.0.0",
probeDurationMs: 8,
});
store.getGlobalSettingsStore = vi.fn().mockReturnValue({
...createMockGlobalSettingsStore(),
getSettings: vi.fn().mockResolvedValue({ useGrokCli: true }),
});
const res = await GET(buildApp(), "/api/auth/status");
expect(res.status).toBe(200);
expect(res.body.providers).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "grok-cli", authenticated: false }),
]),
);
});
it("GET /providers/grok-cli/status returns ready false when binary unavailable", async () => {
vi.spyOn(runtimeProviderProbesModule, "probeGrokCliProvider").mockResolvedValue({
available: false,
authenticated: false,
reason: "missing",
probeDurationMs: 8,
});
store.getGlobalSettingsStore = vi.fn().mockReturnValue({
...createMockGlobalSettingsStore(),
getSettings: vi.fn().mockResolvedValue({ useGrokCli: true }),
});
const res = await GET(buildApp(), "/api/providers/grok-cli/status");
expect(res.status).toBe(200);
expect(res.body.ready).toBe(false);
});
it("PUT /settings/global with useDroidCli fires onUseDroidCliToggled", async () => {
const onUseDroidCliToggled = vi.fn();
store.updateGlobalSettings = vi.fn().mockResolvedValue({ useDroidCli: true });

View File

@@ -0,0 +1,166 @@
/**
* Grok CLI discovery → model-picker mapping, behind a short-TTL,
* single-flight cache so `/api/models` never spawns the `grok` CLI per
* request.
*
* FNXC:GrokCli 2026-07-08-00:00:
* FN-7705: mirrors the landed Cursor picker cache (cursor-model-cache.ts,
* FN-7696) end to end. With the Grok Runtime plugin installed and the
* "Grok — via Grok CLI" provider toggle enabled (`useGrokCli === true`),
* this module owns two contracts:
* 1. A deterministic discovery→model-id mapping (id = discovered id; name
* = label ?? id) so picker selections remain stable across requests.
* 2. A per-binaryPath TTL cache (default 60s) with single-flight
* de-duplication of concurrent in-flight fetches, so parallel
* `/api/models` requests spawn `grok` at most once per TTL window.
* A missing/failed/unavailable `grok` binary (ENOENT, non-zero exit,
* timeout, no API key configured) must degrade to an empty model list —
* never throw — so `/api/models` always returns HTTP 200 with existing rows
* intact. The empty result is cached briefly too, so a persistently-
* unavailable binary does not turn into a spawn-per-request storm. Grok has
* its own settings toggle (`useGrokCli`); the toggle gate lives in the
* `/api/models` merge site (register-model-routes.ts), not in this module.
*/
import { discoverGrokCliModels } from "./runtime-provider-probes.js";
/** Stable model-picker row shape emitted for a Grok-discovered model. */
export interface GrokPickerModel {
provider: "grok-cli";
id: string;
name: string;
reasoning: boolean;
contextWindow: number;
}
/** The picker provider id used for all Grok-derived model rows. */
export const GROK_PICKER_PROVIDER_ID = "grok-cli" as const;
/** Default cache TTL for Grok model discovery, in milliseconds. */
const DEFAULT_TTL_MS = 60_000;
/**
* Map Grok CLI discovery output into the stable `/api/models` row shape.
*
* The discovered `id` is used as the stable model id. `name` falls back to
* `id` when no `label` is provided. `reasoning`/`contextWindow` default to
* `false`/`0` — the real `grok models` text output carries no such
* metadata today; this is pass-through only, never fabricated.
*
* Discovered entries that map to the same id are de-duplicated, keeping the
* first occurrence.
*/
export function grokDiscoveryToModels(
models: ReadonlyArray<{ id: string; label?: string; reasoning?: boolean; contextWindow?: number }>,
): GrokPickerModel[] {
const seen = new Set<string>();
const result: GrokPickerModel[] = [];
for (const model of models) {
const id = model.id?.trim();
if (!id || seen.has(id)) continue;
seen.add(id);
result.push({
provider: GROK_PICKER_PROVIDER_ID,
id,
name: model.label?.trim() || id,
reasoning: model.reasoning ?? false,
contextWindow: model.contextWindow ?? 0,
});
}
return result;
}
interface CacheEntry {
/** Timestamp (ms) at which this entry was populated. */
fetchedAt: number;
/** The resolved (possibly empty, on failure/unavailability) model list. */
models: GrokPickerModel[];
}
/** Per-binaryPath cache of the most recently resolved Grok picker models. */
const cache = new Map<string, CacheEntry>();
/** Per-binaryPath in-flight fetch promise, for single-flight de-duplication. */
const inFlight = new Map<string, Promise<GrokPickerModel[]>>();
/**
* Reset all cached/in-flight state. Test-only escape hatch — production code
* should never need this since entries expire naturally via TTL.
*/
export function __resetGrokPickerModelsCacheForTests(): void {
cache.clear();
inFlight.clear();
}
export interface GetGrokPickerModelsOptions {
/** Override the Grok CLI binary path. Defaults to `"grok"`. */
binaryPath?: string;
/** Cache TTL in milliseconds. Defaults to 60s. */
ttlMs?: number;
/** Injectable clock (ms epoch) for deterministic tests. Defaults to `Date.now`. */
now?: () => number;
}
/**
* Resolve the Grok CLI binary path: explicit override, then the bare
* `"grok"` command (resolved via PATH by the CLI spawn layer).
*/
function resolveBinaryPath(explicit?: string): string {
return explicit ?? "grok";
}
/**
* Fetch Grok CLI-discovered models for the model picker, behind a
* short-TTL, single-flight cache keyed by binary path.
*
* Never throws: a `discoverGrokCliModels` failure or an unavailable-binary
* result (empty models + `fallbackUsed: true`) resolves to `[]`, which is
* itself cached briefly (same TTL) so a persistently-unavailable binary does
* not spawn the CLI on every call.
*/
export async function getGrokPickerModels(
opts?: GetGrokPickerModelsOptions,
): Promise<GrokPickerModel[]> {
const binaryPath = resolveBinaryPath(opts?.binaryPath);
const ttlMs = opts?.ttlMs ?? DEFAULT_TTL_MS;
const now = opts?.now ?? Date.now;
const nowMs = now();
const cached = cache.get(binaryPath);
if (cached && nowMs - cached.fetchedAt < ttlMs) {
return cached.models;
}
const existingInFlight = inFlight.get(binaryPath);
if (existingInFlight) {
return existingInFlight;
}
const fetchPromise = (async (): Promise<GrokPickerModel[]> => {
try {
const result = await discoverGrokCliModels({ binaryPath });
if (!result || result.models.length === 0) {
return [];
}
return grokDiscoveryToModels(result.models);
} catch {
// Degrade to zero Grok rows on any spawn/parse failure (ENOENT,
// non-zero exit, timeout, no API key configured) — never let a Grok
// error propagate into /api/models. See FNXC:GrokCli comment above.
return [];
}
})();
inFlight.set(binaryPath, fetchPromise);
try {
const models = await fetchPromise;
cache.set(binaryPath, { fetchedAt: now(), models });
return models;
} finally {
inFlight.delete(binaryPath);
}
}

View File

@@ -101,6 +101,7 @@ const BUNDLED_PLUGIN_IDS = new Set([
"fusion-plugin-openclaw-runtime",
"fusion-plugin-paperclip-runtime",
"fusion-plugin-cursor-runtime",
"fusion-plugin-grok-runtime",
"fusion-plugin-cli-printing-press",
"fusion-plugin-compound-engineering",
]);

View File

@@ -5,7 +5,7 @@ import { existsSync, readFileSync } from "node:fs";
import { GIT_INSTALL_URL, isGhAvailable, isGhAuthenticated, probeGitCliStatus } from "@fusion/core";
import { probeClaudeCli } from "../claude-cli-probe.js";
import { probeDroidCli } from "../droid-cli-probe.js";
import { probeCursorCliProvider } from "../runtime-provider-probes.js";
import { probeCursorCliProvider, probeGrokCliProvider } from "../runtime-provider-probes.js";
import { probeLlamaCpp } from "../llama-cpp-probe.js";
import { ApiError, badRequest, conflict } from "../api-error.js";
import { clearUsageCache } from "../usage.js";
@@ -59,6 +59,24 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
return probeCursorCliProvider({ binaryPath: await readCursorCliBinaryPath() });
}
/*
FNXC:GrokCli 2026-07-08-00:00:
Mirrors normalizeCursorCliBinaryPath/readCursorCliBinaryPath/probeCursorCliWithStoredBinary above (FN-7705). Auth provider list, status, enable, and path-save validation must all probe the same trimmed global Grok CLI binary override before falling back to PATH candidates.
*/
function normalizeGrokCliBinaryPath(value: unknown): string | undefined {
return typeof value === "string" ? value.trim() || undefined : undefined;
}
async function readGrokCliBinaryPath(): Promise<string | undefined> {
if (!store) return undefined;
const globalSettings = await store.getGlobalSettingsStore().getSettings();
return normalizeGrokCliBinaryPath((globalSettings as Record<string, unknown>).grokCliBinaryPath);
}
async function probeGrokCliWithStoredBinary() {
return probeGrokCliProvider({ binaryPath: await readGrokCliBinaryPath() });
}
/**
* Mask an API key for safe display.
* - If key length <= 8: return 8 bullets (never reveal short keys)
@@ -665,6 +683,32 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
});
}
/*
FNXC:GrokCli 2026-07-08-00:00:
FN-7705: inject the synthetic "Grok — via Grok CLI" provider, mirroring
the cursor-cli injection above. Unlike Cursor (OAuth/session auth,
`authenticated` derived from toggle+binary availability only), Grok is
API-key auth — `authenticated` also requires the probe's own
`authenticated` flag (GROK_API_KEY / ~/.grok/user-settings.json apiKey
presence), per the PROMPT.md contract.
*/
if (store) {
let grokEnabled = false;
try {
const globalSettings = await store.getGlobalSettingsStore().getSettings();
grokEnabled = (globalSettings as Record<string, unknown>).useGrokCli === true;
} catch {
// best effort
}
const grokBinary = await probeGrokCliWithStoredBinary();
providers.push({
id: "grok-cli",
name: "Grok — via Grok CLI",
authenticated: grokEnabled && grokBinary.available && grokBinary.authenticated === true,
type: "cli" as const,
});
}
// Inject synthetic llama.cpp provider.
if (store) {
let llamaEnabled = false;
@@ -1045,6 +1089,94 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
}
});
/*
FNXC:GrokCli 2026-07-08-00:00:
FN-7705: POST /auth/grok-cli mirrors POST /auth/cursor-cli's enable/disable +
binaryPath contract exactly. "Cannot enable" only requires the binary to be
available (mirroring Cursor) — API-key presence is surfaced via the probe's
`authenticated`/`reason` fields on the status route rather than blocking
enable, since an operator may enable routing before setting GROK_API_KEY.
*/
router.post("/auth/grok-cli", async (req, res) => {
try {
if (!store) {
throw new ApiError(500, "Settings store unavailable");
}
const requestedEnabled = req.body?.enabled;
const hasEnabledPatch = Object.prototype.hasOwnProperty.call(req.body ?? {}, "enabled");
const requestedBinaryPath = req.body?.binaryPath;
const hasBinaryPathPatch = Object.prototype.hasOwnProperty.call(req.body ?? {}, "binaryPath");
if (!hasEnabledPatch && !hasBinaryPathPatch) {
throw badRequest("enabled or binaryPath is required");
}
if (hasEnabledPatch && typeof requestedEnabled !== "boolean") {
throw badRequest("enabled must be a boolean");
}
if (hasBinaryPathPatch && requestedBinaryPath !== null && typeof requestedBinaryPath !== "string") {
throw badRequest("binaryPath must be a string or null");
}
const currentSettings = await store.getGlobalSettingsStore().getSettings();
const enabled = hasEnabledPatch ? requestedEnabled : (currentSettings as Record<string, unknown>).useGrokCli === true;
const currentBinaryPath = normalizeGrokCliBinaryPath((currentSettings as Record<string, unknown>).grokCliBinaryPath);
const nextBinaryPath = hasBinaryPathPatch
? normalizeGrokCliBinaryPath(requestedBinaryPath)
: currentBinaryPath;
if (hasBinaryPathPatch && nextBinaryPath) {
const binary = await probeGrokCliProvider({ binaryPath: nextBinaryPath });
if (!binary.available || !binary.usingConfiguredBinaryPath) {
throw new ApiError(400, `Cannot save Grok CLI binary path: ${binary.reason ?? "configured binary not available"}`);
}
}
if (enabled) {
const binary = await probeGrokCliProvider({ binaryPath: nextBinaryPath });
if (!binary.available) {
throw new ApiError(400, `Cannot enable Grok CLI routing: ${binary.reason ?? "grok binary not available"}`);
}
}
const patch: Record<string, unknown> = {};
if (hasEnabledPatch) {
patch.useGrokCli = enabled;
}
if (hasBinaryPathPatch) {
patch.grokCliBinaryPath = nextBinaryPath ?? null;
}
const settings = await store.updateGlobalSettings(patch);
invalidateAllGlobalSettingsCaches();
res.json({
enabled: (settings as Record<string, unknown>).useGrokCli === true,
binaryPath: normalizeGrokCliBinaryPath((settings as Record<string, unknown>).grokCliBinaryPath),
restartRequired: false,
});
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
router.get("/providers/grok-cli/status", async (_req, res) => {
try {
const binaryPath = await readGrokCliBinaryPath();
const binary = await probeGrokCliProvider({ binaryPath });
let enabled = false;
if (store) {
try {
const globalSettings = await store.getGlobalSettingsStore().getSettings();
enabled = (globalSettings as Record<string, unknown>).useGrokCli === true;
} catch {
// best effort
}
}
res.json({ binary, enabled, binaryPath, extension: null, ready: enabled && binary.available });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
router.post("/auth/llama-cpp", async (req, res) => {
try {
if (!store) {

View File

@@ -5,6 +5,7 @@ import { customProviderRegistryKey, mergeSupplementalAnthropicModels, resolvePla
import type { CustomProvider } from "@fusion/core";
import { ApiError } from "../api-error.js";
import { getCursorPickerModels, CURSOR_PICKER_PROVIDER_ID } from "../cursor-model-cache.js";
import { getGrokPickerModels, GROK_PICKER_PROVIDER_ID } from "../grok-model-cache.js";
import { getHermesPickerModels, HERMES_PICKER_PROVIDER_ID } from "../hermes-model-cache.js";
import type { AuthStorageLike } from "../routes.js";
import type { ApiRouteRegistrar } from "./types.js";
@@ -163,6 +164,8 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
let useLlamaCpp = false;
let useCursorCli = false;
let cursorCliBinaryPath: string | undefined;
let useGrokCli = false;
let grokCliBinaryPath: string | undefined;
let resolvedPlanningProvider: string | undefined;
let resolvedPlanningModelId: string | undefined;
let customProviders: CustomProvider[] = [];
@@ -192,6 +195,16 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
const rawCursorCliBinaryPath = (globalSettings as Record<string, unknown>).cursorCliBinaryPath;
cursorCliBinaryPath =
typeof rawCursorCliBinaryPath === "string" ? rawCursorCliBinaryPath.trim() || undefined : undefined;
useGrokCli = (globalSettings as Record<string, unknown>).useGrokCli === true;
/*
FNXC:GrokCli 2026-07-08-00:20:
FN-7705: mirror the cursorCliBinaryPath override handling above so a
machine-local grokCliBinaryPath override applies to model-picker
discovery, not just the auth/probe/status paths.
*/
const rawGrokCliBinaryPath = (globalSettings as Record<string, unknown>).grokCliBinaryPath;
grokCliBinaryPath =
typeof rawGrokCliBinaryPath === "string" ? rawGrokCliBinaryPath.trim() || undefined : undefined;
customProviders = globalSettings.customProviders ?? [];
const mergedSettings = await store.getSettingsFast();
@@ -271,6 +284,9 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
if (!useCursorCli) {
models = models.filter((m) => m.provider !== "cursor-cli");
}
if (!useGrokCli) {
models = models.filter((m) => m.provider !== "grok-cli");
}
/*
FNXC:ModelCatalog 2026-07-07-09:05:
@@ -343,6 +359,38 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
}
}
/*
FNXC:GrokCli 2026-07-08-00:05:
FN-7705: additively surface Grok CLI-discovered models (`grok models`)
under the stable "grok-cli" provider id, mirroring the cursor-cli merge
above. Grok has its own settings toggle (useGrokCli) — the toggle IS the
signal here, so discovery is only attempted when useGrokCli is true.
Fetched through getGrokPickerModels, backed by a short-TTL, single-flight
cache keyed by binary path — this call NEVER spawns grok per request, and
NEVER throws (a missing/failed/unavailable binary degrades to []). Grok
rows are merged respecting the existing seenModelKeys provider/id dedup
so an existing row always wins over a colliding Grok row — purely
additive, must never displace, overwrite, or filter out an existing row.
*/
if (useGrokCli) {
// getGrokPickerModels never throws by contract (see
// grok-model-cache.ts), but this try/catch is a defensive belt so a
// Grok discovery failure can never reject the /models handler or drop
// existing rows — degrade to zero Grok rows instead.
try {
const grokModels = await getGrokPickerModels({ binaryPath: grokCliBinaryPath });
for (const grokModel of grokModels) {
const key = `${grokModel.provider}/${grokModel.id}`;
if (seenModelKeys.has(key)) continue;
seenModelKeys.add(key);
models.push(grokModel);
}
} catch (grokErr: unknown) {
const message = grokErr instanceof Error ? grokErr.message : String(grokErr);
runtimeLogger.child("models").warn(`Failed to load grok-cli models: ${message}`);
}
}
// Filter to only providers the user has explicitly configured in Fusion.
// getAvailable() checks supplemental credential stores (Codex CLI,
// Claude Code, env vars) which surface providers the user may not
@@ -374,6 +422,9 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
// previously-missing configuredProviders.add("cursor-cli") gap that
// silently dropped Cursor rows even when the plugin surfaced them.
if (useCursorCli) configuredProviders.add(CURSOR_PICKER_PROVIDER_ID);
// FNXC:GrokCli 2026-07-08-00:05 (FN-7705): allow-list "grok-cli" through
// the final filter whenever the toggle is on, mirroring cursor-cli above.
if (useGrokCli) configuredProviders.add(GROK_PICKER_PROVIDER_ID);
// FNXC:ModelCatalog 2026-07-07-09:05 (FN-7636): only allow-list "hermes"
// through the final filter when Hermes rows were actually contributed
// above, mirroring the useClaudeCli/useDroidCli toggle pattern (Hermes

View File

@@ -30,6 +30,12 @@ import {
type CursorBinaryStatus,
} from "@fusion-plugin-examples/cursor-runtime";
import {
discoverGrokProviderModels,
probeGrokBinary,
type GrokBinaryStatus,
} from "@fusion-plugin-examples/grok-runtime";
import {
agentsMe,
discoverPaperclipCliConfig,
@@ -55,6 +61,7 @@ export type {
MintedApiKey,
OpenClawBinaryStatus,
CursorBinaryStatus,
GrokBinaryStatus,
PaperclipAgentSummary,
PaperclipCliDiscoveryResult,
PaperclipCompanySummary,
@@ -66,6 +73,10 @@ export async function probeCursorCliProvider(opts?: { binaryPath?: string }): Pr
return probeCursorBinary(opts);
}
export async function probeGrokCliProvider(opts?: { binaryPath?: string }): Promise<GrokBinaryStatus> {
return probeGrokBinary(opts);
}
/**
* Result shape returned by the Cursor plugin's model-discovery contribution.
*
@@ -98,6 +109,38 @@ export async function discoverCursorCliModels(opts?: {
return discoverCursorProviderModels(opts) as Promise<CursorModelDiscoveryResult>;
}
/**
* Result shape returned by the Grok plugin's model-discovery contribution.
*
* FNXC:GrokCli 2026-07-08-00:00:
* FN-7705: mirrors CursorModelDiscoveryResult above; the Grok plugin's
* discovery never populates reasoning/contextWindow today (the real
* `grok models` output has no such fields), but the shape is kept
* consistent with the other CLI providers for a future enrichment pass.
*/
export interface GrokModelDiscoveryResult {
models: Array<{ id: string; label?: string; reasoning?: boolean; contextWindow?: number }>;
source: string;
fallbackUsed: boolean;
reason?: string;
}
/**
* Discover Grok CLI models via `grok models`, delegating to the Grok Runtime
* plugin's `discoverGrokProviderModels` cliProviders contribution.
*
* This is the stable mock/spy boundary for `grok-model-cache.ts` and its
* tests — never called directly per-request; see `getGrokPickerModels`.
* Never throws by contract of the underlying plugin function (a missing/
* unavailable binary resolves to `{ models: [], fallbackUsed: true, ... }`).
*/
export async function discoverGrokCliModels(opts?: {
binaryPath?: string;
timeoutMs?: number;
}): Promise<GrokModelDiscoveryResult> {
return discoverGrokProviderModels(opts) as Promise<GrokModelDiscoveryResult>;
}
/**
* Probe the local Hermes binary.
*

View File

@@ -530,6 +530,18 @@ export default defineConfig({
__dirname,
"../../plugins/fusion-plugin-cursor-runtime/src/index.ts",
),
/*
FNXC:GrokCli 2026-07-08-00:00:
runtime-provider-probes.ts imports probeGrokBinary from @fusion-plugin-examples/grok-runtime (FN-7705, mirroring the Cursor alias above). Without these source aliases, Vite tries to resolve the package's dist/ exports which don't exist in a source checkout.
*/
"@fusion-plugin-examples/grok-runtime/probe": resolve(
__dirname,
"../../plugins/fusion-plugin-grok-runtime/src/probe.ts",
),
"@fusion-plugin-examples/grok-runtime": resolve(
__dirname,
"../../plugins/fusion-plugin-grok-runtime/src/index.ts",
),
"@fusion-plugin-examples/roadmap/roadmap-suggestions": resolve(
__dirname,
"../../plugins/fusion-plugin-roadmap/src/roadmap-suggestions.ts",

View File

@@ -70,7 +70,7 @@ async function buildPackage(relativePath: string): Promise<void> {
// condition kept for the bun-compiled CLI), so a missing dist => the packaged
// app crashes on Local mode when @fusion/dashboard imports the plugin.
// routes.ts / runtime-provider-probes.ts / droid-cli-probe.ts / roadmap-routes.ts
// pull hermes, openclaw, paperclip, cursor, droid and roadmap; dependency-graph
// pull hermes, openclaw, paperclip, cursor, grok, droid and roadmap; dependency-graph
// backs a dashboard view. Keep this list in sync with dashboard's static plugin imports.
export async function buildDashboardRuntimePlugins(): Promise<void> {
await buildPackage("packages/plugin-sdk");
@@ -80,6 +80,7 @@ export async function buildDashboardRuntimePlugins(): Promise<void> {
buildPackage("plugins/fusion-plugin-openclaw-runtime"),
buildPackage("plugins/fusion-plugin-paperclip-runtime"),
buildPackage("plugins/fusion-plugin-cursor-runtime"),
buildPackage("plugins/fusion-plugin-grok-runtime"),
buildPackage("plugins/fusion-plugin-droid-runtime"),
buildPackage("plugins/fusion-plugin-roadmap"),
]);

View File

@@ -0,0 +1,7 @@
# @fusion-plugin-examples/grok-runtime
## 0.1.0
### Minor Changes
- FN-7705: initial release of the Grok CLI runtime plugin — `grok-cli` model provider, API-key-auth probe, and model discovery via `grok models`.

View File

@@ -0,0 +1,54 @@
# fusion-plugin-grok-runtime
Grok CLI-backed provider/runtime plugin for Fusion.
## Install
This plugin ships bundled with Fusion and is auto-installed like the other
built-in runtime plugins. It shells out to an **operator-installed** `grok`
binary on PATH — Fusion never downloads or bundles the CLI itself.
- Canonical upstream repo: https://github.com/superagent-ai/grok-cli
- Docs / homepage: https://github.com/superagent-ai/grok-cli#readme
- Install script: https://raw.githubusercontent.com/superagent-ai/grok-cli/main/install.sh
- npm alternative: `bun add -g grok-dev` (see https://github.com/superagent-ai/grok-cli/releases)
- Binary name: `grok`
- This is a community-built project, not affiliated with xAI. No fixed
release artifact is bundled by Fusion, so no checksum is pinned
(`upstream-pending-verification`).
## Contract summary
- Provider ID: `grok-cli`
- Binary probe: `grok --version`
- **Auth model — API key, not OAuth/session.** Grok has no `status`/`whoami`
subcommand. Authentication is derived from key PRESENCE only:
1. `GROK_API_KEY` environment variable, or
2. `~/.grok/user-settings.json` → `{ "apiKey": "..." }`
Base URL defaults to `https://api.x.ai/v1`. A missing/unreadable/malformed
key configuration fails closed to `authenticated: false` with an
actionable reason — never throws.
- Model discovery: `grok models` (plain-text output, with pricing hints per
the upstream README). The exact line shape is
`upstream-pending-verification`, so discovery parses conservatively: the
leading token before a ` - ` label separator, or before the first
multi-space pricing column, is treated as the model id; ids are
deduplicated. Output that happens to be JSON is tolerated defensively even
though the CLI is not known to emit it.
## Enable via Settings → Authentication
1. Install the `grok` CLI and set `GROK_API_KEY` (or populate
`~/.grok/user-settings.json`).
2. Open Settings → Authentication in the Fusion dashboard.
3. The "Grok — via Grok CLI" card shows probe status (binary found, API key
present). Click **Enable** once the binary is available.
4. Discovered Grok models (via `grok models`) then merge into the model
picker under the `grok-cli` provider id.
## Notes
Do not invent a `grok status`/`whoami` JSON auth contract — Grok is
API-key auth. See `AGENTS.md`'s "External-integration evidence" policy for
why the release/checksum fields above stay at
`upstream-pending-verification`.

View File

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

View File

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

View File

@@ -0,0 +1,103 @@
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("node:child_process", () => ({ spawn: vi.fn() }));
import { spawn } from "node:child_process";
import { runGrokCommand } from "../cli-spawn.js";
function mockPlatform(platform: NodeJS.Platform) {
return vi.spyOn(process, "platform", "get").mockReturnValue(platform);
}
function createMockChild() {
const child = new EventEmitter() as EventEmitter & {
stdout: PassThrough;
stderr: PassThrough;
kill: ReturnType<typeof vi.fn>;
};
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.kill = vi.fn();
vi.mocked(spawn).mockReturnValue(child as never);
return child;
}
describe("runGrokCommand", () => {
beforeEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("uses the Windows shell so PATH .cmd and .bat Grok shims can run", async () => {
mockPlatform("win32");
const child = createMockChild();
const resultPromise = runGrokCommand("grok", ["--version"], 1000);
expect(spawn).toHaveBeenCalledWith("grok", ["--version"], {
stdio: ["ignore", "pipe", "pipe"],
shell: true,
});
child.stdout.write("grok 1.0.0\n");
child.stderr.write("diagnostic\n");
child.emit("close", 0);
await expect(resultPromise).resolves.toEqual({
code: 0,
stdout: "grok 1.0.0\n",
stderr: "diagnostic\n",
});
});
it("keeps non-Windows Grok invocations on direct spawn", async () => {
mockPlatform("darwin");
const child = createMockChild();
const resultPromise = runGrokCommand("grok", ["--version"], 1000);
expect(spawn).toHaveBeenCalledWith("grok", ["--version"], {
stdio: ["ignore", "pipe", "pipe"],
shell: false,
});
child.emit("close", 0);
await expect(resultPromise).resolves.toMatchObject({ code: 0 });
});
it("returns spawn errors with diagnostics instead of empty stderr", async () => {
mockPlatform("win32");
const child = createMockChild();
const resultPromise = runGrokCommand("grok", ["--version"], 1000);
child.emit("error", Object.assign(new Error("spawn grok ENOENT"), { code: "ENOENT" }));
const result = await resultPromise;
expect(result.code).toBe(127);
expect(result.stderr).toContain("spawn error: ENOENT: spawn grok ENOENT");
});
it("kills timed-out Grok commands best-effort and resolves once", async () => {
vi.useFakeTimers();
mockPlatform("linux");
const child = createMockChild();
const resultPromise = runGrokCommand("grok", ["models"], 25);
child.stdout.write("partial");
await vi.advanceTimersByTimeAsync(25);
await expect(resultPromise).resolves.toEqual({ code: 124, stdout: "partial", stderr: "" });
expect(child.kill).toHaveBeenCalledWith("SIGKILL");
child.emit("close", 0);
await expect(resultPromise).resolves.toMatchObject({ code: 124 });
});
});

View File

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

View File

@@ -0,0 +1,135 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../cli-spawn.js", () => ({ runGrokCommand: vi.fn() }));
vi.mock("node:fs/promises", () => ({ readFile: vi.fn() }));
import { runGrokCommand } from "../cli-spawn.js";
import { readFile } from "node:fs/promises";
import { probeGrokBinary } from "../probe.js";
const ORIGINAL_ENV = { ...process.env };
describe("probeGrokBinary", () => {
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...ORIGINAL_ENV };
delete process.env.GROK_API_KEY;
});
it("reports authenticated:true when GROK_API_KEY is set", async () => {
process.env.GROK_API_KEY = "xai-test-key";
vi.mocked(runGrokCommand).mockResolvedValueOnce({ code: 0, stdout: "grok 1.0.0", stderr: "" });
const result = await probeGrokBinary({ binaryPath: "/usr/local/bin/grok" });
expect(runGrokCommand).toHaveBeenCalledWith("/usr/local/bin/grok", ["--version"], 3000);
expect(readFile).not.toHaveBeenCalled();
expect(result.available).toBe(true);
expect(result.authenticated).toBe(true);
expect(result.version).toBe("grok 1.0.0");
expect(result.reason).toBeUndefined();
});
it("falls back to ~/.grok/user-settings.json apiKey when GROK_API_KEY is unset", async () => {
vi.mocked(runGrokCommand).mockResolvedValueOnce({ code: 0, stdout: "grok 1.0.0", stderr: "" });
vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify({ apiKey: "xai-from-file" }));
const result = await probeGrokBinary();
expect(result.available).toBe(true);
expect(result.authenticated).toBe(true);
});
it("fails closed to authenticated:false with an actionable reason when no key is configured", async () => {
vi.mocked(runGrokCommand).mockResolvedValueOnce({ code: 0, stdout: "grok 1.0.0", stderr: "" });
vi.mocked(readFile).mockRejectedValueOnce(new Error("ENOENT"));
const result = await probeGrokBinary();
expect(result.available).toBe(true);
expect(result.authenticated).toBe(false);
expect(result.reason).toContain("GROK_API_KEY is not set");
});
it("fails closed to authenticated:false on malformed ~/.grok/user-settings.json", async () => {
vi.mocked(runGrokCommand).mockResolvedValueOnce({ code: 0, stdout: "grok 1.0.0", stderr: "" });
vi.mocked(readFile).mockResolvedValueOnce("not json at all");
const result = await probeGrokBinary();
expect(result.available).toBe(true);
expect(result.authenticated).toBe(false);
expect(result.reason).toContain("malformed JSON");
});
it("fails closed to authenticated:false when the settings file has no non-empty apiKey", async () => {
vi.mocked(runGrokCommand).mockResolvedValueOnce({ code: 0, stdout: "grok 1.0.0", stderr: "" });
vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify({ apiKey: "" }));
const result = await probeGrokBinary();
expect(result.available).toBe(true);
expect(result.authenticated).toBe(false);
expect(result.reason).toContain("no non-empty apiKey field");
});
it("never invents a status/whoami subcommand — only --version is probed", async () => {
process.env.GROK_API_KEY = "xai-test-key";
vi.mocked(runGrokCommand).mockResolvedValueOnce({ code: 0, stdout: "grok 1.0.0", stderr: "" });
await probeGrokBinary({ binaryPath: "grok" });
expect(runGrokCommand).toHaveBeenCalledTimes(1);
expect(runGrokCommand).toHaveBeenCalledWith("grok", ["--version"], 3000);
});
it("reports binary unavailable with actionable diagnostics when the candidate fails", async () => {
vi.mocked(runGrokCommand).mockResolvedValueOnce({ code: 127, stdout: "", stderr: "spawn error: ENOENT: grok" });
const result = await probeGrokBinary();
expect(result.available).toBe(false);
expect(result.authenticated).toBe(false);
expect(result.reason).toContain("not found");
expect(result.reason).toContain("grok: spawn error: ENOENT");
});
it("tries a configured binary path before falling back to PATH", async () => {
vi.mocked(runGrokCommand)
.mockResolvedValueOnce({ code: 127, stdout: "", stderr: "spawn error: ENOENT: /missing/grok" })
.mockResolvedValueOnce({ code: 0, stdout: "grok 1.0.0\n", stderr: "" });
vi.mocked(readFile).mockRejectedValueOnce(new Error("ENOENT"));
const result = await probeGrokBinary({ binaryPath: "/missing/grok" });
expect(runGrokCommand).toHaveBeenNthCalledWith(1, "/missing/grok", ["--version"], 3000);
expect(runGrokCommand).toHaveBeenNthCalledWith(2, "grok", ["--version"], 3000);
expect(result.available).toBe(true);
expect(result.binaryPath).toBe("grok");
expect(result.usingConfiguredBinaryPath).toBe(false);
expect(result.diagnostics?.[0]).toContain("/missing/grok: spawn error: ENOENT");
});
it("dedupes overrides equal to default PATH candidate names", async () => {
process.env.GROK_API_KEY = "xai-test-key";
vi.mocked(runGrokCommand).mockResolvedValueOnce({ code: 0, stdout: "grok 1.0.0\n", stderr: "" });
const result = await probeGrokBinary({ binaryPath: " grok " });
expect(runGrokCommand).toHaveBeenCalledTimes(1);
expect(runGrokCommand).toHaveBeenCalledWith("grok", ["--version"], 3000);
expect(result.binaryPath).toBe("grok");
});
it("tries a Windows path with spaces and .cmd shim before PATH fallback", async () => {
process.env.GROK_API_KEY = "xai-test-key";
vi.mocked(runGrokCommand).mockResolvedValueOnce({ code: 0, stdout: "grok.cmd 1.0.0", stderr: "" });
const binaryPath = "C:\\Users\\A User\\AppData\\Roaming\\npm\\grok.cmd";
const result = await probeGrokBinary({ binaryPath });
expect(runGrokCommand).toHaveBeenNthCalledWith(1, binaryPath, ["--version"], 3000);
expect(result.binaryPath).toBe(binaryPath);
expect(result.usingConfiguredBinaryPath).toBe(true);
});
});

View File

@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../cli-spawn.js", () => ({ runGrokCommand: vi.fn() }));
import { runGrokCommand } from "../cli-spawn.js";
import { discoverGrokModels } from "../process-manager.js";
const DASH_MODELS_OUTPUT = [
"Available models",
"",
"grok-4 - Grok 4 ($5.00/M in, $15.00/M out)",
"grok-4-fast - Grok 4 Fast ($0.20/M in, $0.50/M out)",
"",
"Tip: use --model <id> to switch.",
].join("\n");
const COLUMN_MODELS_OUTPUT = ["grok-4 $5.00/M in", "grok-4-fast $0.20/M in"].join("\n");
describe("discoverGrokModels", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("invokes only `models` and never a --json flag", async () => {
vi.mocked(runGrokCommand).mockResolvedValueOnce({ code: 0, stdout: DASH_MODELS_OUTPUT, stderr: "" });
await discoverGrokModels("grok");
expect(runGrokCommand).toHaveBeenCalledTimes(1);
expect(runGrokCommand).toHaveBeenCalledWith("grok", ["models"], 5000);
expect(runGrokCommand).not.toHaveBeenCalledWith("grok", ["models", "--json"], expect.anything());
});
it("extracts bare ids from `id - Label (pricing)` output, dropping header/tip lines", async () => {
vi.mocked(runGrokCommand).mockResolvedValueOnce({ code: 0, stdout: DASH_MODELS_OUTPUT, stderr: "" });
const result = await discoverGrokModels("grok");
expect(result.models).toEqual(["grok-4", "grok-4-fast"]);
expect(result.source).toBe("models-text");
expect(result.fallbackUsed).toBe(false);
});
it("extracts bare ids from columnar/pricing-separated output", async () => {
vi.mocked(runGrokCommand).mockResolvedValueOnce({ code: 0, stdout: COLUMN_MODELS_OUTPUT, stderr: "" });
const result = await discoverGrokModels("grok");
expect(result.models).toEqual(["grok-4", "grok-4-fast"]);
});
it("dedupes repeated ids", async () => {
vi.mocked(runGrokCommand).mockResolvedValueOnce({ code: 0, stdout: "grok-4 - Grok 4\ngrok-4 - Grok 4", stderr: "" });
const result = await discoverGrokModels("grok");
expect(result.models).toEqual(["grok-4"]);
});
it("returns an empty list with a clear reason for the empty-account state", async () => {
vi.mocked(runGrokCommand).mockResolvedValueOnce({ code: 0, stdout: "No models available for this account.", stderr: "" });
const result = await discoverGrokModels("grok");
expect(result).toEqual({ models: [], source: "models-text", fallbackUsed: false, reason: "no models available for this account" });
});
it("tolerates JSON output defensively even though the real CLI is not known to send it", async () => {
vi.mocked(runGrokCommand).mockResolvedValueOnce({ code: 0, stdout: '[{"id":"grok-4"},{"id":"grok-4-fast"}]', stderr: "" });
const result = await discoverGrokModels("grok");
expect(result.models).toEqual(["grok-4", "grok-4-fast"]);
expect(result.source).toBe("models-json");
});
it("returns empty discovery when the command fails outright", async () => {
vi.mocked(runGrokCommand).mockResolvedValueOnce({ code: 127, stdout: "", stderr: "spawn error: ENOENT" });
const result = await discoverGrokModels("grok", 2500);
expect(runGrokCommand).toHaveBeenCalledWith("grok", ["models"], 2500);
expect(result).toEqual({ models: [], source: "none", fallbackUsed: true, reason: "model discovery command unavailable" });
});
it("returns empty discovery on empty output", async () => {
vi.mocked(runGrokCommand).mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" });
const result = await discoverGrokModels("grok");
expect(result).toEqual({ models: [], source: "none", fallbackUsed: true, reason: "model discovery command returned no output" });
});
it("passes Windows .bat paths with spaces as one binary string", async () => {
vi.mocked(runGrokCommand).mockResolvedValueOnce({ code: 0, stdout: "grok-4 - Grok 4", stderr: "" });
const binary = "C:\\Program Files\\Grok\\grok.bat";
const result = await discoverGrokModels(binary);
expect(runGrokCommand).toHaveBeenCalledWith(binary, ["models"], 5000);
expect(result.models).toEqual(["grok-4"]);
});
});

View File

@@ -0,0 +1,57 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../probe.js", () => ({ probeGrokBinary: vi.fn() }));
vi.mock("../process-manager.js", () => ({ discoverGrokModels: vi.fn() }));
import { discoverGrokModels } from "../process-manager.js";
import { probeGrokBinary } from "../probe.js";
import { discoverGrokProviderModels } from "../provider.js";
describe("discoverGrokProviderModels", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("uses the override-aware probe binary for model discovery", async () => {
vi.mocked(probeGrokBinary).mockResolvedValue({
available: true,
authenticated: true,
binaryName: "/usr/local/bin/grok",
binaryPath: "/usr/local/bin/grok",
configuredBinaryPath: "/usr/local/bin/grok",
usingConfiguredBinaryPath: true,
probeDurationMs: 12,
});
vi.mocked(discoverGrokModels).mockResolvedValue({
models: ["grok-4"],
source: "models-text",
fallbackUsed: false,
});
const result = await discoverGrokProviderModels({ binaryPath: "/usr/local/bin/grok" });
expect(probeGrokBinary).toHaveBeenCalledWith({ binaryPath: "/usr/local/bin/grok" });
expect(discoverGrokModels).toHaveBeenCalledWith("/usr/local/bin/grok");
expect(result.models).toEqual([{ id: "grok-4", label: "grok-4" }]);
});
it("returns probe diagnostics when no effective binary is available", async () => {
vi.mocked(probeGrokBinary).mockResolvedValue({
available: false,
authenticated: false,
configuredBinaryPath: "/missing/grok",
reason: "Configured Grok CLI binary '/missing/grok' failed; PATH fallback grok also failed",
probeDurationMs: 10,
});
const result = await discoverGrokProviderModels({ binaryPath: "/missing/grok" });
expect(discoverGrokModels).not.toHaveBeenCalled();
expect(result).toEqual({
models: [],
source: "probe",
fallbackUsed: true,
reason: "Configured Grok CLI binary '/missing/grok' failed; PATH fallback grok also failed",
});
});
});

View File

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

View File

@@ -0,0 +1,50 @@
import { spawn } from "node:child_process";
function formatSpawnError(error: Error & { code?: unknown }): string {
const code = typeof error.code === "string" ? `${error.code}: ` : "";
return `spawn error: ${code}${error.message}`.trim();
}
export async function runGrokCommand(binary: string, args: string[], timeoutMs: number): Promise<{ code: number | null; stdout: string; stderr: string }> {
return new Promise((resolve) => {
let stdout = "";
let stderr = "";
let settled = false;
let timer: NodeJS.Timeout | undefined;
const finish = (result: { code: number | null; stdout: string; stderr: string }) => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
resolve(result);
};
/*
FNXC:GrokCli 2026-07-08-00:00:
Windows Grok installers/npm-style shims can expose `grok.cmd` or `grok.bat` on PATH, and Node cannot direct-spawn those batch wrappers without the command shell.
Keep Unix/macOS on direct spawn so only the known Grok CLI probe/discovery seam uses shell resolution where Windows requires it. Copied verbatim from the Cursor plugin's cli-spawn seam (FN-7705).
*/
const child = spawn(binary, args, {
stdio: ["ignore", "pipe", "pipe"],
shell: process.platform === "win32",
});
timer = setTimeout(() => {
try { child.kill("SIGKILL"); } catch {
// best effort
}
finish({ 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", (error: Error & { code?: unknown }) => {
const diagnostic = formatSpawnError(error);
stderr = stderr ? `${stderr}\n${diagnostic}` : diagnostic;
finish({ code: 127, stdout, stderr });
});
child.once("close", (code) => {
finish({ code, stdout, stderr });
});
});
}

View File

@@ -0,0 +1,74 @@
import { definePlugin } from "@fusion/plugin-sdk";
import type { FusionPlugin } from "@fusion/plugin-sdk";
import { probeGrokBinary } from "./probe.js";
import { discoverGrokProviderModels } from "./provider.js";
import { GrokRuntimeAdapter } from "./runtime-adapter.js";
/*
FNXC:GrokCli 2026-07-08-00:00:
FN-7705: mirrors the landed Cursor Runtime plugin (FN-7697) end to end, with
one contract difference — Grok is API-key auth (GROK_API_KEY env var or
~/.grok/user-settings.json apiKey), not an OAuth/session CLI, so there is no
`grok status --format json` route; probe/authRoute here surface key-presence
auth state instead (see probe.ts). This shells out to an operator-installed
`grok` binary on PATH — Fusion does not download or bundle it.
*/
const plugin: FusionPlugin = definePlugin({
manifest: {
id: "fusion-plugin-grok-runtime",
name: "Grok Runtime Plugin",
version: "0.1.0",
description: "Grok CLI runtime support for Fusion",
runtime: {
runtimeId: "grok",
name: "Grok Runtime",
version: "0.1.0",
},
},
state: "installed",
hooks: {},
runtime: {
metadata: {
runtimeId: "grok",
name: "Grok Runtime",
version: "0.1.0",
},
factory: async () => new GrokRuntimeAdapter(),
},
cliProviders: [
{
providerId: "grok-cli",
displayName: "Grok CLI",
binaryName: "grok",
providerType: "cli",
statusRoute: "/providers/grok-cli/status",
authRoute: "/auth/grok-cli",
actions: [
{ actionId: "enable", label: "Enable", actionType: "enable", method: "POST", route: "/auth/grok-cli" },
{ actionId: "disable", label: "Disable", actionType: "disable", method: "POST", route: "/auth/grok-cli" },
{ actionId: "test", label: "Test", actionType: "test", method: "GET", route: "/providers/grok-cli/status" }
],
probe: async () => {
const status = await probeGrokBinary();
return {
available: status.available,
authenticated: status.authenticated,
binaryPath: status.binaryPath,
binaryName: status.binaryName,
version: status.version,
reason: status.reason,
};
},
discoverModels: discoverGrokProviderModels,
runtime: {
runtimeId: "grok",
createAdapter: async () => new GrokRuntimeAdapter(),
},
},
],
});
export default plugin;
export { probeGrokBinary } from "./probe.js";
export { discoverGrokProviderModels } from "./provider.js";
export type { GrokBinaryStatus } from "./types.js";

View File

@@ -0,0 +1,107 @@
import { readFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import { runGrokCommand } from "./cli-spawn.js";
import type { GrokBinaryStatus } from "./types.js";
const CANDIDATES = ["grok"] as const;
const MAX_FAILURE_DETAIL_LENGTH = 180;
function buildCandidates(binaryPath?: string): { candidates: string[]; configuredBinaryPath?: string } {
/*
FNXC:GrokCli 2026-07-08-00:00:
Manual operator paths must be tried before PATH candidates without deleting the fallback order, mirroring the Cursor plugin's probe. Deduping keeps a `grok` override from probing the same shim twice while still preserving auto-detection.
*/
const configuredBinaryPath = binaryPath?.trim() || undefined;
const ordered = configuredBinaryPath ? [configuredBinaryPath, ...CANDIDATES] : [...CANDIDATES];
return { candidates: Array.from(new Set(ordered)), configuredBinaryPath };
}
function summarizeFailure(binary: string, stdout: string, stderr: string): string | undefined {
const detail = `${stderr || stdout}`.replace(/\s+/g, " ").trim();
if (!detail) return undefined;
const truncated = detail.length > MAX_FAILURE_DETAIL_LENGTH ? `${detail.slice(0, MAX_FAILURE_DETAIL_LENGTH - 1)}…` : detail;
return `${binary}: ${truncated}`;
}
/*
FNXC:GrokCli 2026-07-08-00:00:
Grok is API-key auth, NOT an OAuth/session CLI like Cursor — there is no
`grok status --format json` (or `whoami`) subcommand to probe. Auth is a Grok
API key supplied via the `GROK_API_KEY` env var OR `~/.grok/user-settings.json`
`{ "apiKey": ... }` (per the upstream README, verified 2026-07-08). We derive
`authenticated` from key PRESENCE only — env var first, then the settings
file — and fail closed to `authenticated: false` with an actionable reason on
a missing key or an unreadable/malformed settings file. Never throw: a
missing/corrupt `~/.grok/user-settings.json` must degrade gracefully, not
crash the probe. Do NOT invent a status subcommand for Grok (AGENTS.md /
PROMPT.md "Do NOT").
*/
async function probeGrokApiKeyPresence(): Promise<{ authenticated: boolean; reason?: string }> {
const envKey = process.env.GROK_API_KEY;
if (typeof envKey === "string" && envKey.trim().length > 0) {
return { authenticated: true };
}
const settingsPath = join(homedir(), ".grok", "user-settings.json");
let raw: string;
try {
raw = await readFile(settingsPath, "utf-8");
} catch {
return { authenticated: false, reason: "GROK_API_KEY is not set and ~/.grok/user-settings.json was not found" };
}
try {
const parsed = JSON.parse(raw) as { apiKey?: unknown };
if (typeof parsed?.apiKey === "string" && parsed.apiKey.trim().length > 0) {
return { authenticated: true };
}
return { authenticated: false, reason: "~/.grok/user-settings.json has no non-empty apiKey field" };
} catch {
return { authenticated: false, reason: "~/.grok/user-settings.json is malformed JSON" };
}
}
export async function probeGrokBinary(options?: { timeoutMs?: number; binaryPath?: string }): Promise<GrokBinaryStatus> {
const startedAt = Date.now();
const timeoutMs = options?.timeoutMs ?? 3000;
const { candidates, configuredBinaryPath } = buildCandidates(options?.binaryPath);
const failureDetails: string[] = [];
for (const binary of candidates) {
const version = await runGrokCommand(binary, ["--version"], timeoutMs);
const failureDetail = summarizeFailure(binary, version.stdout, version.stderr);
if (failureDetail) failureDetails.push(failureDetail);
const common = {
binaryName: binary,
binaryPath: binary,
configuredBinaryPath,
usingConfiguredBinaryPath: configuredBinaryPath === binary,
diagnostics: failureDetails.length > 0 ? [...failureDetails] : undefined,
probeDurationMs: Date.now() - startedAt,
};
if (version.code === 0) {
const auth = await probeGrokApiKeyPresence();
return {
available: true,
authenticated: auth.authenticated,
...common,
version: version.stdout.trim() || undefined,
reason: auth.authenticated ? undefined : auth.reason,
};
}
}
const baseReason = configuredBinaryPath
? `Configured Grok CLI binary '${configuredBinaryPath}' failed; PATH fallback grok also failed`
: "grok not found on PATH";
return {
available: false,
authenticated: false,
configuredBinaryPath,
usingConfiguredBinaryPath: false,
diagnostics: failureDetails.length > 0 ? failureDetails : undefined,
reason: failureDetails.length > 0 ? `${baseReason} (${failureDetails.join("; ")})` : baseReason,
probeDurationMs: Date.now() - startedAt,
};
}

View File

@@ -0,0 +1,86 @@
import { runGrokCommand } from "./cli-spawn.js";
/*
FNXC:GrokCli 2026-07-08-00:00:
FN-7705: the exact `grok models` output line shape is
`upstream-pending-verification` — the upstream README documents the command
lists available Grok models "with pricing hints" but does not pin an exact
column/separator format. We parse CONSERVATIVELY: strip obvious header/tip/
empty-state lines, then take the leading token before a ` - ` label
separator (mirroring the Cursor CLI's `<id> - <Label>` shape) or, absent
that, before the first run of 2+ spaces (a common columnar pricing-hint
layout), falling back to the whole trimmed line as a single-token id.
Defensive JSON-tolerant parsing is attempted first (mirroring Cursor's
process-manager.ts), even though the real CLI is not known to emit JSON.
*/
function parseModelLines(raw: string): string[] {
const ids = raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.filter((line) => !/^available models$/i.test(line))
.filter((line) => !/^models?:?$/i.test(line))
.filter((line) => !/^no models? available/i.test(line))
.filter((line) => !/^tip:/i.test(line))
.filter((line) => !/^usage/i.test(line))
.map((line) => {
const dashIndex = line.indexOf(" - ");
if (dashIndex !== -1) return line.slice(0, dashIndex).trim();
const columnMatch = line.match(/\s{2,}/);
if (columnMatch && typeof columnMatch.index === "number") {
return line.slice(0, columnMatch.index).trim();
}
return line;
})
.filter(Boolean);
return Array.from(new Set(ids));
}
export interface GrokModelDiscoveryResult {
models: string[];
source: string;
fallbackUsed: boolean;
reason?: string;
}
export async function discoverGrokModels(binary: string, timeoutMs = 5000): Promise<GrokModelDiscoveryResult> {
const res = await runGrokCommand(binary, ["models"], timeoutMs);
if (res.code !== 0) {
return { models: [], source: "none", fallbackUsed: true, reason: "model discovery command unavailable" };
}
const output = (res.stdout || "").trim();
if (!output) {
return { models: [], source: "none", fallbackUsed: true, reason: "model discovery command returned no output" };
}
if (/^no models? available/i.test(output)) {
return { models: [], source: "models-text", fallbackUsed: false, reason: "no models available for this account" };
}
// Defensive fallback: tolerate output that happens to be JSON, even though
// the real CLI is not known to support a --json flag today.
try {
const parsed = JSON.parse(output);
if (Array.isArray(parsed)) {
const ids: string[] = [];
for (const entry of parsed) {
const id = typeof entry === "string" ? entry : typeof entry?.id === "string" ? entry.id : undefined;
if (id) ids.push(id);
}
if (ids.length > 0) {
return { models: Array.from(new Set(ids)), source: "models-json", fallbackUsed: false };
}
}
} catch {
// output is not JSON; fall through to line-based parsing
}
const ids = parseModelLines(output);
if (ids.length > 0) {
return { models: ids, source: "models-text", fallbackUsed: false };
}
return { models: [], source: "none", fallbackUsed: true, reason: "model discovery command unavailable" };
}

View File

@@ -0,0 +1,25 @@
import { discoverGrokModels } from "./process-manager.js";
import { probeGrokBinary } from "./probe.js";
function normalizeDiscoveryOptions(options?: unknown): { binaryPath?: string; timeoutMs?: number } {
if (!options || typeof options !== "object") return {};
const record = options as Record<string, unknown>;
return {
binaryPath: typeof record.binaryPath === "string" ? record.binaryPath : undefined,
timeoutMs: typeof record.timeoutMs === "number" ? record.timeoutMs : undefined,
};
}
export async function discoverGrokProviderModels(options?: unknown) {
const probe = await probeGrokBinary(normalizeDiscoveryOptions(options));
if (!probe.available || !probe.binaryName) {
return { models: [], source: "probe", fallbackUsed: true, reason: probe.reason ?? "binary unavailable" };
}
const result = await discoverGrokModels(probe.binaryPath ?? probe.binaryName);
return {
models: result.models.map((id) => ({ id, label: id })),
source: result.source,
fallbackUsed: result.fallbackUsed,
reason: result.reason,
};
}

View File

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

View File

@@ -0,0 +1,12 @@
export interface GrokBinaryStatus {
available: boolean;
authenticated?: boolean;
binaryPath?: string;
binaryName?: string;
configuredBinaryPath?: string;
usingConfiguredBinaryPath?: boolean;
diagnostics?: string[];
version?: string;
reason?: string;
probeDurationMs: number;
}

View File

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

View File

@@ -0,0 +1,22 @@
import { defineConfig } from "vitest/config";
import { fileURLToPath } from "node:url";
import { computeMaxWorkers } from "../../packages/core/src/__test-utils__/vitest-workers";
const maxWorkers = computeMaxWorkers();
export default defineConfig({
resolve: {
alias: {
"@fusion/core": fileURLToPath(new URL("../../packages/core/src/index.ts", import.meta.url)),
"@fusion/plugin-sdk": fileURLToPath(new URL("../../packages/plugin-sdk/src/index.ts", import.meta.url)),
},
},
test: {
include: ["src/**/*.test.ts"],
environment: "node",
setupFiles: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-setup.ts", import.meta.url))],
globalSetup: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-teardown.ts", import.meta.url))],
pool: "threads",
maxWorkers,
},
});

25
pnpm-lock.yaml generated
View File

@@ -246,6 +246,9 @@ importers:
'@fusion-plugin-examples/droid-runtime':
specifier: workspace:*
version: link:../../plugins/fusion-plugin-droid-runtime
'@fusion-plugin-examples/grok-runtime':
specifier: workspace:*
version: link:../../plugins/fusion-plugin-grok-runtime
'@fusion-plugin-examples/hermes-runtime':
specifier: workspace:*
version: link:../../plugins/fusion-plugin-hermes-runtime
@@ -967,6 +970,28 @@ importers:
specifier: ^4.1.0
version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.8)(happy-dom@20.10.1)(jsdom@29.0.1)(vite@6.4.1(@types/node@25.5.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))
plugins/fusion-plugin-grok-runtime:
dependencies:
'@earendil-works/pi-ai':
specifier: '*'
version: 0.80.3(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
'@earendil-works/pi-coding-agent':
specifier: '*'
version: 0.80.3(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
'@fusion/plugin-sdk':
specifier: workspace:*
version: link:../../packages/plugin-sdk
devDependencies:
'@types/node':
specifier: ^25.5.2
version: 25.5.2
typescript:
specifier: ^5.7.0
version: 5.9.3
vitest:
specifier: ^4.1.0
version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.8)(happy-dom@20.10.1)(jsdom@29.0.1)(vite@6.4.1(@types/node@25.5.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))
plugins/fusion-plugin-hermes-runtime:
dependencies:
'@fusion/plugin-sdk':

View File

@@ -22,6 +22,7 @@ packages:
- "plugins/fusion-plugin-droid-runtime"
- "plugins/fusion-plugin-acp-runtime"
- "plugins/fusion-plugin-cursor-runtime"
- "plugins/fusion-plugin-grok-runtime"
- "plugins/fusion-plugin-agent-browser"
- "plugins/fusion-plugin-whatsapp-chat"
- "plugins/fusion-plugin-roadmap"