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:
7
.changeset/fn-7705-grok-cli-runtime.md
Normal file
7
.changeset/fn-7705-grok-cli-runtime.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
34
packages/core/src/__tests__/grok-cli-settings.test.ts
Normal file
34
packages/core/src/__tests__/grok-cli-settings.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
65
packages/dashboard/app/components/GrokCliProviderCard.css
Normal file
65
packages/dashboard/app/components/GrokCliProviderCard.css
Normal 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);
|
||||
}
|
||||
}
|
||||
204
packages/dashboard/app/components/GrokCliProviderCard.tsx
Normal file
204
packages/dashboard/app/components/GrokCliProviderCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
}}/>);
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
152
packages/dashboard/src/__tests__/grok-model-cache.test.ts
Normal file
152
packages/dashboard/src/__tests__/grok-model-cache.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
166
packages/dashboard/src/grok-model-cache.ts
Normal file
166
packages/dashboard/src/grok-model-cache.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
]);
|
||||
|
||||
7
plugins/fusion-plugin-grok-runtime/CHANGELOG.md
Normal file
7
plugins/fusion-plugin-grok-runtime/CHANGELOG.md
Normal 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`.
|
||||
54
plugins/fusion-plugin-grok-runtime/README.md
Normal file
54
plugins/fusion-plugin-grok-runtime/README.md
Normal 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`.
|
||||
6
plugins/fusion-plugin-grok-runtime/manifest.json
Normal file
6
plugins/fusion-plugin-grok-runtime/manifest.json
Normal 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"
|
||||
}
|
||||
40
plugins/fusion-plugin-grok-runtime/package.json
Normal file
40
plugins/fusion-plugin-grok-runtime/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
135
plugins/fusion-plugin-grok-runtime/src/__tests__/probe.test.ts
Normal file
135
plugins/fusion-plugin-grok-runtime/src/__tests__/probe.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
50
plugins/fusion-plugin-grok-runtime/src/cli-spawn.ts
Normal file
50
plugins/fusion-plugin-grok-runtime/src/cli-spawn.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
}
|
||||
74
plugins/fusion-plugin-grok-runtime/src/index.ts
Normal file
74
plugins/fusion-plugin-grok-runtime/src/index.ts
Normal 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";
|
||||
107
plugins/fusion-plugin-grok-runtime/src/probe.ts
Normal file
107
plugins/fusion-plugin-grok-runtime/src/probe.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
86
plugins/fusion-plugin-grok-runtime/src/process-manager.ts
Normal file
86
plugins/fusion-plugin-grok-runtime/src/process-manager.ts
Normal 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" };
|
||||
}
|
||||
25
plugins/fusion-plugin-grok-runtime/src/provider.ts
Normal file
25
plugins/fusion-plugin-grok-runtime/src/provider.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
25
plugins/fusion-plugin-grok-runtime/src/runtime-adapter.ts
Normal file
25
plugins/fusion-plugin-grok-runtime/src/runtime-adapter.ts
Normal 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"}`;
|
||||
}
|
||||
}
|
||||
12
plugins/fusion-plugin-grok-runtime/src/types.ts
Normal file
12
plugins/fusion-plugin-grok-runtime/src/types.ts
Normal 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;
|
||||
}
|
||||
10
plugins/fusion-plugin-grok-runtime/tsconfig.json
Normal file
10
plugins/fusion-plugin-grok-runtime/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"declarationMap": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
22
plugins/fusion-plugin-grok-runtime/vitest.config.ts
Normal file
22
plugins/fusion-plugin-grok-runtime/vitest.config.ts
Normal 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
25
pnpm-lock.yaml
generated
@@ -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':
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user