From 081dae0e0fa9acf72f3d6207c5af244613b252d5 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 8 Jul 2026 20:13:41 -0700 Subject: [PATCH] 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) --- .changeset/fn-7705-grok-cli-runtime.md | 7 + docs/PLUGIN_AUTHORING.md | 2 +- docs/settings-reference.md | 4 + .../cli/src/plugins/bundled-plugin-install.ts | 8 + .../src/plugins/staged-bundled-plugin-ids.ts | 1 + packages/cli/vitest.config.ts | 12 + .../src/__tests__/grok-cli-settings.test.ts | 34 +++ packages/core/src/index.ts | 1 + .../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 ++++++++++++++ .../dashboard/app/components/ProviderIcon.tsx | 5 + .../__tests__/GrokCliProviderCard.test.tsx | 105 +++++++ .../__tests__/ProviderIcon.test.tsx | 8 + .../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 +++++++++++++++ .../src/__tests__/routes-auth.test.ts | 258 +++++++++++++++++- packages/dashboard/src/grok-model-cache.ts | 166 +++++++++++ packages/dashboard/src/routes.ts | 1 + .../src/routes/register-auth-routes.ts | 134 ++++++++- .../src/routes/register-model-routes.ts | 51 ++++ .../dashboard/src/runtime-provider-probes.ts | 43 +++ packages/dashboard/vitest.config.ts | 12 + packages/desktop/scripts/workspace-tools.ts | 3 +- .../fusion-plugin-grok-runtime/CHANGELOG.md | 7 + plugins/fusion-plugin-grok-runtime/README.md | 54 ++++ .../fusion-plugin-grok-runtime/manifest.json | 6 + .../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 ++ .../src/cli-spawn.ts | 50 ++++ .../fusion-plugin-grok-runtime/src/index.ts | 74 +++++ .../fusion-plugin-grok-runtime/src/probe.ts | 107 ++++++++ .../src/process-manager.ts | 86 ++++++ .../src/provider.ts | 25 ++ .../src/runtime-adapter.ts | 25 ++ .../fusion-plugin-grok-runtime/src/types.ts | 12 + .../fusion-plugin-grok-runtime/tsconfig.json | 10 + .../vitest.config.ts | 22 ++ pnpm-lock.yaml | 25 ++ pnpm-workspace.yaml | 1 + 50 files changed, 2525 insertions(+), 7 deletions(-) create mode 100644 .changeset/fn-7705-grok-cli-runtime.md create mode 100644 packages/core/src/__tests__/grok-cli-settings.test.ts create mode 100644 packages/dashboard/app/components/GrokCliProviderCard.css create mode 100644 packages/dashboard/app/components/GrokCliProviderCard.tsx create mode 100644 packages/dashboard/app/components/__tests__/GrokCliProviderCard.test.tsx create mode 100644 packages/dashboard/src/__tests__/grok-model-cache.test.ts create mode 100644 packages/dashboard/src/__tests__/register-model-routes-grok-cli.test.ts create mode 100644 packages/dashboard/src/grok-model-cache.ts create mode 100644 plugins/fusion-plugin-grok-runtime/CHANGELOG.md create mode 100644 plugins/fusion-plugin-grok-runtime/README.md create mode 100644 plugins/fusion-plugin-grok-runtime/manifest.json create mode 100644 plugins/fusion-plugin-grok-runtime/package.json create mode 100644 plugins/fusion-plugin-grok-runtime/src/__tests__/cli-spawn.test.ts create mode 100644 plugins/fusion-plugin-grok-runtime/src/__tests__/index.test.ts create mode 100644 plugins/fusion-plugin-grok-runtime/src/__tests__/probe.test.ts create mode 100644 plugins/fusion-plugin-grok-runtime/src/__tests__/process-manager.test.ts create mode 100644 plugins/fusion-plugin-grok-runtime/src/__tests__/provider.test.ts create mode 100644 plugins/fusion-plugin-grok-runtime/src/__tests__/runtime-adapter.test.ts create mode 100644 plugins/fusion-plugin-grok-runtime/src/cli-spawn.ts create mode 100644 plugins/fusion-plugin-grok-runtime/src/index.ts create mode 100644 plugins/fusion-plugin-grok-runtime/src/probe.ts create mode 100644 plugins/fusion-plugin-grok-runtime/src/process-manager.ts create mode 100644 plugins/fusion-plugin-grok-runtime/src/provider.ts create mode 100644 plugins/fusion-plugin-grok-runtime/src/runtime-adapter.ts create mode 100644 plugins/fusion-plugin-grok-runtime/src/types.ts create mode 100644 plugins/fusion-plugin-grok-runtime/tsconfig.json create mode 100644 plugins/fusion-plugin-grok-runtime/vitest.config.ts diff --git a/.changeset/fn-7705-grok-cli-runtime.md b/.changeset/fn-7705-grok-cli-runtime.md new file mode 100644 index 0000000000..42964a0c69 --- /dev/null +++ b/.changeset/fn-7705-grok-cli-runtime.md @@ -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. diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md index f2780b074b..dda4fcaea7 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -779,7 +779,7 @@ This catches stale `dist/` drift: `resolvePluginEntryPath` prefers `bundled.js` -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 `/dist/plugins//` (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-`) to its staged `@fusion-plugin-examples/` 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. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 4becc9470f..64e85dfb23 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -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` diff --git a/packages/cli/src/plugins/bundled-plugin-install.ts b/packages/cli/src/plugins/bundled-plugin-install.ts index 6cb4b18a8f..c4fb940ccd 100644 --- a/packages/cli/src/plugins/bundled-plugin-install.ts +++ b/packages/cli/src/plugins/bundled-plugin-install.ts @@ -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 { return coreEnsureBundledCursorRuntimePluginInstalled(pluginStore, pluginLoader, getCandidatePluginDirs); } + +export async function ensureBundledGrokRuntimePluginInstalled( + pluginStore: PluginStore, + pluginLoader: PluginLoader, +): Promise { + return coreEnsureBundledGrokRuntimePluginInstalled(pluginStore, pluginLoader, getCandidatePluginDirs); +} diff --git a/packages/cli/src/plugins/staged-bundled-plugin-ids.ts b/packages/cli/src/plugins/staged-bundled-plugin-ids.ts index f490560b1e..e9c99b29d9 100644 --- a/packages/cli/src/plugins/staged-bundled-plugin-ids.ts +++ b/packages/cli/src/plugins/staged-bundled-plugin-ids.ts @@ -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; diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index 2201e62314..c3b223863b 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -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. */ diff --git a/packages/core/src/__tests__/grok-cli-settings.test.ts b/packages/core/src/__tests__/grok-cli-settings.test.ts new file mode 100644 index 0000000000..6c74195cca --- /dev/null +++ b/packages/core/src/__tests__/grok-cli-settings.test.ts @@ -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"); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a6603cc98f..b9f245e52c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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"; diff --git a/packages/core/src/plugins/bundled-plugin-install.ts b/packages/core/src/plugins/bundled-plugin-install.ts index 1c72d6a573..5e7c9efbb9 100644 --- a/packages/core/src/plugins/bundled-plugin-install.ts +++ b/packages/core/src/plugins/bundled-plugin-install.ts @@ -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 { return ensureBundledPluginInstalled(pluginStore, pluginLoader, CURSOR_RUNTIME_PLUGIN_ID, getCandidatePluginDirs); } + +export async function ensureBundledGrokRuntimePluginInstalled( + pluginStore: PluginStore, + pluginLoader: PluginLoader, + getCandidatePluginDirs: BundledPluginDirResolver, +): Promise { + return ensureBundledPluginInstalled(pluginStore, pluginLoader, GROK_RUNTIME_PLUGIN_ID, getCandidatePluginDirs); +} diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 542104a92e..1162086f5c 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -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, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 15a1021312..9e1ca6209b 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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 diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 18c1ab4722..99fa327b7c 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -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 { return api("/providers/cursor-cli/status"); } +export function fetchGrokCliStatus(): Promise { + return api("/providers/grok-cli/status"); +} + /** Probe llama.cpp server + setting + extension state. */ export function fetchLlamaCppStatus(): Promise { return api("/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, diff --git a/packages/dashboard/app/components/GrokCliProviderCard.css b/packages/dashboard/app/components/GrokCliProviderCard.css new file mode 100644 index 0000000000..a2a809b5ca --- /dev/null +++ b/packages/dashboard/app/components/GrokCliProviderCard.css @@ -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); + } +} diff --git a/packages/dashboard/app/components/GrokCliProviderCard.tsx b/packages/dashboard/app/components/GrokCliProviderCard.tsx new file mode 100644 index 0000000000..a08a2acd0b --- /dev/null +++ b/packages/dashboard/app/components/GrokCliProviderCard.tsx @@ -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(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 ? ( +
+ +
+ handleBinaryPathChange(event.target.value)} + placeholder={t("setup.grokCli.binaryPathPlaceholder", "/usr/local/bin/grok")} + disabled={busy !== null} + /> + +
+ {t("setup.grokCli.binaryPathHelp", "Leave blank to use PATH auto-detection (`grok`).")} + {pathMessage ? {pathMessage.text} : null} +
+ ) : null; + + const actions = ( + <> + + {currentlyEnabled ? ( + + ) : ( + + )} + + ); + + /* + 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 ( +
+
+
+ + {t("setup.grokCli.providerName", "Grok — via Grok CLI")} + {currentlyEnabled ? t("setup.grokCli.active", "✓ Active") : t("setup.grokCli.notConnected", "✗ Not connected")} +
+
{actions}
+
+
+ {statusText} + {binaryPathControl} +
+
+ ); + } + + return ( +
+
+ +
+
+ {t("setup.grokCli.providerName", "Grok — via Grok CLI")} + {t("setup.grokCli.description", "Route AI calls through your local Grok CLI runtime.")} + {statusText} +
+
{actions}
+
+ ); +} diff --git a/packages/dashboard/app/components/ProviderIcon.tsx b/packages/dashboard/app/components/ProviderIcon.tsx index d62c6fedec..b374577ddc 100644 --- a/packages/dashboard/app/components/ProviderIcon.tsx +++ b/packages/dashboard/app/components/ProviderIcon.tsx @@ -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" }, diff --git a/packages/dashboard/app/components/__tests__/GrokCliProviderCard.test.tsx b/packages/dashboard/app/components/__tests__/GrokCliProviderCard.test.tsx new file mode 100644 index 0000000000..0e53627a12 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/GrokCliProviderCard.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + 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(); + + 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(); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/ProviderIcon.test.tsx b/packages/dashboard/app/components/__tests__/ProviderIcon.test.tsx index 1e3d8595a7..d06deebba1 100644 --- a/packages/dashboard/app/components/__tests__/ProviderIcon.test.tsx +++ b/packages/dashboard/app/components/__tests__/ProviderIcon.test.tsx @@ -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(); + 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(); const svg = screen.getByTestId("claude-cli-icon"); diff --git a/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx b/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx index 08e01136d4..79499a7d51 100644 --- a/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx +++ b/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx @@ -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 ( { + void loadAuthStatus(); + }}/>); + } return ( { void loadAuthStatus(); }}/>); diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index 21e8961383..d0e123a5c8 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -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:*", diff --git a/packages/dashboard/src/__tests__/grok-model-cache.test.ts b/packages/dashboard/src/__tests__/grok-model-cache.test.ts new file mode 100644 index 0000000000..73bdfe1d7b --- /dev/null +++ b/packages/dashboard/src/__tests__/grok-model-cache.test.ts @@ -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((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); + }); +}); diff --git a/packages/dashboard/src/__tests__/register-model-routes-grok-cli.test.ts b/packages/dashboard/src/__tests__/register-model-routes-grok-cli.test.ts new file mode 100644 index 0000000000..253dd147c9 --- /dev/null +++ b/packages/dashboard/src/__tests__/register-model-routes-grok-cli.test.ts @@ -0,0 +1,214 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + 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 void }) => Promise>(); + const router = { + get: vi.fn((path: string, handler: (req: unknown, res: { json: (body: unknown) => void }) => Promise) => { + 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) { + 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); + }); +}); diff --git a/packages/dashboard/src/__tests__/routes-auth.test.ts b/packages/dashboard/src/__tests__/routes-auth.test.ts index f4a5bd4746..c5af40f4d6 100644 --- a/packages/dashboard/src/__tests__/routes-auth.test.ts +++ b/packages/dashboard/src/__tests__/routes-auth.test.ts @@ -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 }); diff --git a/packages/dashboard/src/grok-model-cache.ts b/packages/dashboard/src/grok-model-cache.ts new file mode 100644 index 0000000000..9955a83adc --- /dev/null +++ b/packages/dashboard/src/grok-model-cache.ts @@ -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(); + 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(); + +/** Per-binaryPath in-flight fetch promise, for single-flight de-duplication. */ +const inFlight = new Map>(); + +/** + * 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 { + 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 => { + 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); + } +} diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 5bd35a15bb..1d0f4b4620 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -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", ]); diff --git a/packages/dashboard/src/routes/register-auth-routes.ts b/packages/dashboard/src/routes/register-auth-routes.ts index ec2d480d87..8e36d6f881 100644 --- a/packages/dashboard/src/routes/register-auth-routes.ts +++ b/packages/dashboard/src/routes/register-auth-routes.ts @@ -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 { + if (!store) return undefined; + const globalSettings = await store.getGlobalSettingsStore().getSettings(); + return normalizeGrokCliBinaryPath((globalSettings as Record).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).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).useGrokCli === true; + const currentBinaryPath = normalizeGrokCliBinaryPath((currentSettings as Record).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 = {}; + if (hasEnabledPatch) { + patch.useGrokCli = enabled; + } + if (hasBinaryPathPatch) { + patch.grokCliBinaryPath = nextBinaryPath ?? null; + } + const settings = await store.updateGlobalSettings(patch); + invalidateAllGlobalSettingsCaches(); + res.json({ + enabled: (settings as Record).useGrokCli === true, + binaryPath: normalizeGrokCliBinaryPath((settings as Record).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).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) { diff --git a/packages/dashboard/src/routes/register-model-routes.ts b/packages/dashboard/src/routes/register-model-routes.ts index 514479d6bd..cefc783237 100644 --- a/packages/dashboard/src/routes/register-model-routes.ts +++ b/packages/dashboard/src/routes/register-model-routes.ts @@ -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).cursorCliBinaryPath; cursorCliBinaryPath = typeof rawCursorCliBinaryPath === "string" ? rawCursorCliBinaryPath.trim() || undefined : undefined; + useGrokCli = (globalSettings as Record).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).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 diff --git a/packages/dashboard/src/runtime-provider-probes.ts b/packages/dashboard/src/runtime-provider-probes.ts index 797db6eb88..83e55f6fa5 100644 --- a/packages/dashboard/src/runtime-provider-probes.ts +++ b/packages/dashboard/src/runtime-provider-probes.ts @@ -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 { + 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; } +/** + * 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 { + return discoverGrokProviderModels(opts) as Promise; +} + /** * Probe the local Hermes binary. * diff --git a/packages/dashboard/vitest.config.ts b/packages/dashboard/vitest.config.ts index 0192bc8ce8..e8d7e596d5 100644 --- a/packages/dashboard/vitest.config.ts +++ b/packages/dashboard/vitest.config.ts @@ -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", diff --git a/packages/desktop/scripts/workspace-tools.ts b/packages/desktop/scripts/workspace-tools.ts index 6cf01bb279..19ba8f8228 100644 --- a/packages/desktop/scripts/workspace-tools.ts +++ b/packages/desktop/scripts/workspace-tools.ts @@ -70,7 +70,7 @@ async function buildPackage(relativePath: string): Promise { // 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 { await buildPackage("packages/plugin-sdk"); @@ -80,6 +80,7 @@ export async function buildDashboardRuntimePlugins(): Promise { 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"), ]); diff --git a/plugins/fusion-plugin-grok-runtime/CHANGELOG.md b/plugins/fusion-plugin-grok-runtime/CHANGELOG.md new file mode 100644 index 0000000000..3ef6c3bc14 --- /dev/null +++ b/plugins/fusion-plugin-grok-runtime/CHANGELOG.md @@ -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`. diff --git a/plugins/fusion-plugin-grok-runtime/README.md b/plugins/fusion-plugin-grok-runtime/README.md new file mode 100644 index 0000000000..71b830e429 --- /dev/null +++ b/plugins/fusion-plugin-grok-runtime/README.md @@ -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`. diff --git a/plugins/fusion-plugin-grok-runtime/manifest.json b/plugins/fusion-plugin-grok-runtime/manifest.json new file mode 100644 index 0000000000..b09f082ca8 --- /dev/null +++ b/plugins/fusion-plugin-grok-runtime/manifest.json @@ -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" +} diff --git a/plugins/fusion-plugin-grok-runtime/package.json b/plugins/fusion-plugin-grok-runtime/package.json new file mode 100644 index 0000000000..79e6c0ca2b --- /dev/null +++ b/plugins/fusion-plugin-grok-runtime/package.json @@ -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" + } +} diff --git a/plugins/fusion-plugin-grok-runtime/src/__tests__/cli-spawn.test.ts b/plugins/fusion-plugin-grok-runtime/src/__tests__/cli-spawn.test.ts new file mode 100644 index 0000000000..a385b64909 --- /dev/null +++ b/plugins/fusion-plugin-grok-runtime/src/__tests__/cli-spawn.test.ts @@ -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; + }; + 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 }); + }); +}); diff --git a/plugins/fusion-plugin-grok-runtime/src/__tests__/index.test.ts b/plugins/fusion-plugin-grok-runtime/src/__tests__/index.test.ts new file mode 100644 index 0000000000..4fadc46840 --- /dev/null +++ b/plugins/fusion-plugin-grok-runtime/src/__tests__/index.test.ts @@ -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"); + }); +}); diff --git a/plugins/fusion-plugin-grok-runtime/src/__tests__/probe.test.ts b/plugins/fusion-plugin-grok-runtime/src/__tests__/probe.test.ts new file mode 100644 index 0000000000..289d5bb9b0 --- /dev/null +++ b/plugins/fusion-plugin-grok-runtime/src/__tests__/probe.test.ts @@ -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); + }); +}); diff --git a/plugins/fusion-plugin-grok-runtime/src/__tests__/process-manager.test.ts b/plugins/fusion-plugin-grok-runtime/src/__tests__/process-manager.test.ts new file mode 100644 index 0000000000..4ac8c7298e --- /dev/null +++ b/plugins/fusion-plugin-grok-runtime/src/__tests__/process-manager.test.ts @@ -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 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"]); + }); +}); diff --git a/plugins/fusion-plugin-grok-runtime/src/__tests__/provider.test.ts b/plugins/fusion-plugin-grok-runtime/src/__tests__/provider.test.ts new file mode 100644 index 0000000000..ffa1c17690 --- /dev/null +++ b/plugins/fusion-plugin-grok-runtime/src/__tests__/provider.test.ts @@ -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", + }); + }); +}); diff --git a/plugins/fusion-plugin-grok-runtime/src/__tests__/runtime-adapter.test.ts b/plugins/fusion-plugin-grok-runtime/src/__tests__/runtime-adapter.test.ts new file mode 100644 index 0000000000..e6f12b4c9e --- /dev/null +++ b/plugins/fusion-plugin-grok-runtime/src/__tests__/runtime-adapter.test.ts @@ -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"); + }); +}); diff --git a/plugins/fusion-plugin-grok-runtime/src/cli-spawn.ts b/plugins/fusion-plugin-grok-runtime/src/cli-spawn.ts new file mode 100644 index 0000000000..06510b5bd3 --- /dev/null +++ b/plugins/fusion-plugin-grok-runtime/src/cli-spawn.ts @@ -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 }); + }); + }); +} diff --git a/plugins/fusion-plugin-grok-runtime/src/index.ts b/plugins/fusion-plugin-grok-runtime/src/index.ts new file mode 100644 index 0000000000..328683ca63 --- /dev/null +++ b/plugins/fusion-plugin-grok-runtime/src/index.ts @@ -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"; diff --git a/plugins/fusion-plugin-grok-runtime/src/probe.ts b/plugins/fusion-plugin-grok-runtime/src/probe.ts new file mode 100644 index 0000000000..1f64f70824 --- /dev/null +++ b/plugins/fusion-plugin-grok-runtime/src/probe.ts @@ -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 { + 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, + }; +} diff --git a/plugins/fusion-plugin-grok-runtime/src/process-manager.ts b/plugins/fusion-plugin-grok-runtime/src/process-manager.ts new file mode 100644 index 0000000000..fe6b681552 --- /dev/null +++ b/plugins/fusion-plugin-grok-runtime/src/process-manager.ts @@ -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 ` -