From b63b1e0259ab12a76cc3d2bbd24963a6ce867100 Mon Sep 17 00:00:00 2001 From: Fenris159 Date: Sat, 22 Aug 2026 13:02:55 -0500 Subject: [PATCH 1/4] fix(grok): populate the skill catalog from grok inspect Grok provider snapshots always shipped an empty skills array, so the composer $ picker showed nothing even when ~/.grok/skills, bundled skills, and plugin skills were on disk. After a successful version probe, run grok inspect --json and map the CLI's resolved catalog onto the snapshot. The / picker already includes enabled skills, so both menus populate from the same list. Asking the CLI is the correct mechanism: a disk scan of ~/.grok/skills misses plugin skills and ignores Grok's own disable/ignore config. Discovery is best-effort. Timeout, a missing inspect command, or malformed JSON yields an empty list and never degrades probe status. The inspect spawn uses ServerConfig.cwd so project-scope skills are included. Parsing strips a UTF-8 BOM and warning preamble so Windows CLI output maps. Fixes #4109 --- .../server/src/provider/Drivers/GrokDriver.ts | 3 +- .../src/provider/Drivers/GrokSkills.test.ts | 228 ++++++++++++++++++ .../server/src/provider/Drivers/GrokSkills.ts | 168 +++++++++++++ .../src/provider/Layers/GrokProvider.test.ts | 160 ++++++++---- .../src/provider/Layers/GrokProvider.ts | 12 +- docs/internals/providers.md | 10 + 6 files changed, 533 insertions(+), 48 deletions(-) create mode 100644 apps/server/src/provider/Drivers/GrokSkills.test.ts create mode 100644 apps/server/src/provider/Drivers/GrokSkills.ts diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index 112f11013161..0b4e957fe1b2 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -88,6 +88,7 @@ export const GrokDriver: ProviderDriver = { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; + const { cwd } = yield* ServerConfig; const eventLoggers = yield* ProviderEventLoggers; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ @@ -113,7 +114,7 @@ export const GrokDriver: ProviderDriver = { }); const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv); - const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv).pipe( + const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv, cwd).pipe( Effect.map(stampIdentity), Effect.provideService(Crypto.Crypto, crypto), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), diff --git a/apps/server/src/provider/Drivers/GrokSkills.test.ts b/apps/server/src/provider/Drivers/GrokSkills.test.ts new file mode 100644 index 000000000000..e4811ec8a9b1 --- /dev/null +++ b/apps/server/src/provider/Drivers/GrokSkills.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { discoverGrokSkills, parseGrokInspectSkills } from "./GrokSkills.ts"; + +const inspectPayload = (skills: ReadonlyArray) => JSON.stringify({ skills }); + +describe("parseGrokInspectSkills", () => { + it("maps inspect entries onto provider skills, sorted by name", () => { + const skills = parseGrokInspectSkills( + inspectPayload([ + { + name: "writing-docs", + description: "Write user docs.", + source: { type: "user", path: "/home/dev/.grok/skills/writing-docs/SKILL.md" }, + userInvocable: true, + }, + { + name: "deploy", + description: "Deploy the app.", + source: { + type: "plugin", + path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md", + }, + userInvocable: true, + }, + ]), + ); + + expect(skills).toEqual([ + { + name: "deploy", + description: "Deploy the app.", + path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md", + scope: "plugin", + enabled: true, + }, + { + name: "writing-docs", + description: "Write user docs.", + path: "/home/dev/.grok/skills/writing-docs/SKILL.md", + scope: "user", + enabled: true, + }, + ]); + }); + + it("keeps Windows SKILL.md paths and Grok's global user scope", () => { + const skills = parseGrokInspectSkills( + inspectPayload([ + { + name: "tdd", + description: "Test-driven development.", + source: { + type: "user", + path: "C:\\Users\\Drew\\.grok\\skills\\tdd\\SKILL.md", + }, + }, + { + name: "create-skill", + source: { + type: "bundled", + path: "C:\\Users\\Drew\\.grok\\bundled\\skills\\create-skill\\SKILL.md", + }, + }, + ]), + ); + + expect(skills).toEqual([ + { + name: "create-skill", + path: "C:\\Users\\Drew\\.grok\\bundled\\skills\\create-skill\\SKILL.md", + scope: "bundled", + enabled: true, + }, + { + name: "tdd", + description: "Test-driven development.", + path: "C:\\Users\\Drew\\.grok\\skills\\tdd\\SKILL.md", + scope: "user", + enabled: true, + }, + ]); + }); + + it("accepts source.kind as an alias for source.type", () => { + const skills = parseGrokInspectSkills( + inspectPayload([ + { + name: "kept", + source: { kind: "project", path: "/repo/.grok/skills/kept/SKILL.md" }, + }, + ]), + ); + + expect(skills).toEqual([ + { + name: "kept", + path: "/repo/.grok/skills/kept/SKILL.md", + scope: "project", + enabled: true, + }, + ]); + }); + + it("disables skills the CLI marks as not user-invocable", () => { + const skills = parseGrokInspectSkills( + inspectPayload([ + { + name: "internal-helper", + source: { type: "bundled", path: "/opt/grok/bundled/skills/internal-helper/SKILL.md" }, + userInvocable: false, + }, + ]), + ); + + expect(skills).toEqual([ + { + name: "internal-helper", + path: "/opt/grok/bundled/skills/internal-helper/SKILL.md", + scope: "bundled", + enabled: false, + }, + ]); + }); + + it("keeps the first entry when inspect repeats a skill name", () => { + const skills = parseGrokInspectSkills( + inspectPayload([ + { + name: "review", + source: { type: "user", path: "/home/dev/.grok/skills/review/SKILL.md" }, + }, + { + name: "review", + source: { type: "bundled", path: "/opt/grok/bundled/skills/review/SKILL.md" }, + }, + ]), + ); + + expect(skills).toEqual([ + { + name: "review", + path: "/home/dev/.grok/skills/review/SKILL.md", + scope: "user", + enabled: true, + }, + ]); + }); + + it("skips entries without a name or a filesystem path", () => { + const skills = parseGrokInspectSkills( + inspectPayload([ + { name: " ", source: { type: "user", path: "/tmp/skills/a/SKILL.md" } }, + { name: "no-path", source: { type: "user" } }, + { name: "no-source" }, + "not-an-object", + { name: "kept", source: { type: "project", path: "/repo/.grok/skills/kept/SKILL.md" } }, + ]), + ); + + expect(skills.map((skill) => skill.name)).toEqual(["kept"]); + }); + + it("parses JSON with a UTF-8 BOM or a warning preamble", () => { + const body = inspectPayload([ + { name: "kept", source: { type: "user", path: "/home/dev/.grok/skills/kept/SKILL.md" } }, + ]); + + expect(parseGrokInspectSkills(`\uFEFF${body}`).map((skill) => skill.name)).toEqual(["kept"]); + expect( + parseGrokInspectSkills(`warn: inspect starting\n${body}`).map((skill) => skill.name), + ).toEqual(["kept"]); + }); + + it("returns an empty list for malformed or unexpected output", () => { + expect(parseGrokInspectSkills("not json")).toEqual([]); + expect(parseGrokInspectSkills("null")).toEqual([]); + expect(parseGrokInspectSkills(JSON.stringify({ skills: "nope" }))).toEqual([]); + expect(parseGrokInspectSkills(JSON.stringify({}))).toEqual([]); + }); +}); + +describe("discoverGrokSkills", () => { + it.effect("spawns the inspect probe in the configured cwd", () => { + const spawnCwds: Array = []; + const spawner = ChildProcessSpawner.make((command) => { + spawnCwds.push(command._tag === "StandardCommand" ? command.options.cwd : undefined); + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.encodeText( + Stream.make( + inspectPayload([ + { + name: "kept", + source: { type: "project", path: "/workspaces/demo/.grok/skills/kept/SKILL.md" }, + }, + ]), + ), + ), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }); + + return Effect.gen(function* () { + const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {}, "/workspaces/demo").pipe( + Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), + ); + + expect(spawnCwds).toEqual(["/workspaces/demo"]); + expect(skills.map((skill) => skill.name)).toEqual(["kept"]); + }); + }); +}); diff --git a/apps/server/src/provider/Drivers/GrokSkills.ts b/apps/server/src/provider/Drivers/GrokSkills.ts new file mode 100644 index 000000000000..f076eba4d18d --- /dev/null +++ b/apps/server/src/provider/Drivers/GrokSkills.ts @@ -0,0 +1,168 @@ +/** + * GrokSkills — skill discovery for the `$` picker via `grok inspect --json`. + * + * Unlike Claude Code, the Grok CLI already resolves its full catalog: + * `grok inspect --json` returns `skills[]` with `name`, `description`, + * `source.type` (`user` / `project` / `bundled` / `plugin` / `config` / + * `server`), `source.path` (the absolute `SKILL.md` path), and + * `userInvocable`. Asking the CLI beats scanning the filesystem because + * the catalog honors Grok's own ignore/disable config and includes plugin + * skills, which live several levels deep under `~/.grok/installed-plugins/` + * where a flat scan cannot see them. This mirrors how the Codex app-server + * reports skills over `skills/list`. + * + * Discovery is best-effort: an older CLI without `inspect`, a timeout, a + * non-zero exit, or malformed output yields an empty list and never + * degrades the provider snapshot. + * + * @module provider/Drivers/GrokSkills + */ +import type { GrokSettings, ServerProviderSkill } from "@t3tools/contracts"; +import { errorTag } from "@t3tools/shared/observability"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { spawnAndCollect } from "../providerSnapshot.ts"; + +const GROK_SKILLS_PROBE_TIMEOUT_MS = 4_000; + +function parseInspectDocument(stdout: string): unknown { + const trimmed = stdout.replace(/^\uFEFF/, "").trim(); + if (trimmed.length === 0) { + return undefined; + } + + try { + return JSON.parse(trimmed); + } catch { + // Grok may print a warning line before the JSON object, especially on + // Windows. Take the outermost object if it is valid JSON. + const start = trimmed.indexOf("{"); + const end = trimmed.lastIndexOf("}"); + if (start < 0 || end <= start) { + return undefined; + } + try { + return JSON.parse(trimmed.slice(start, end + 1)); + } catch { + return undefined; + } + } +} + +function asRecord(value: unknown): Record | undefined { + if (typeof value !== "object" || value === null) { + return undefined; + } + return value as Record; +} + +function inspectSourceScope(source: Record | undefined): string { + const raw = source?.type ?? source?.kind; + return typeof raw === "string" ? raw.trim() : ""; +} + +function inspectSourcePath( + record: Record, + source: Record | undefined, +): string { + const fromSource = typeof source?.path === "string" ? source.path.trim() : ""; + if (fromSource.length > 0) { + return fromSource; + } + return typeof record.path === "string" ? record.path.trim() : ""; +} + +/** + * Map `grok inspect --json` output onto provider skills. Entries without a + * name or a filesystem path are skipped; `userInvocable: false` skills are + * kept but disabled so pickers that filter on `enabled` hide them. Grok + * already deduplicates by name with its own precedence; if a payload still + * repeats a name, the first entry wins. + */ +export function parseGrokInspectSkills(stdout: string): ReadonlyArray { + const parsed = parseInspectDocument(stdout); + const root = asRecord(parsed); + const entries = root?.skills; + if (!Array.isArray(entries)) { + return []; + } + + const skillsByName = new Map(); + for (const entry of entries) { + const record = asRecord(entry); + if (!record) { + continue; + } + const name = typeof record.name === "string" ? record.name.trim() : ""; + const source = asRecord(record.source); + const path = inspectSourcePath(record, source); + if (!name || !path || skillsByName.has(name)) { + continue; + } + const scope = inspectSourceScope(source); + const description = typeof record.description === "string" ? record.description.trim() : ""; + skillsByName.set(name, { + name, + path, + enabled: record.userInvocable !== false, + ...(scope ? { scope } : {}), + ...(description ? { description } : {}), + }); + } + + return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)); +} + +/** + * Run `grok inspect --json` and map the reported catalog onto provider + * skills. Never fails: any spawn error, non-zero exit, or timeout resolves + * to an empty list. + */ +export const discoverGrokSkills = Effect.fn("discoverGrokSkills")(function* ( + grokSettings: Pick, + environment: NodeJS.ProcessEnv = process.env, + cwd?: string, +): Effect.fn.Return< + ReadonlyArray, + never, + ChildProcessSpawner.ChildProcessSpawner +> { + const command = grokSettings.binaryPath || "grok"; + const inspectResult = yield* Effect.gen(function* () { + const spawnCommand = yield* resolveSpawnCommand(command, ["inspect", "--json"], { + env: environment, + }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + ...(cwd ? { cwd } : {}), + env: environment, + shell: spawnCommand.shell, + }), + ); + }).pipe(Effect.timeoutOption(GROK_SKILLS_PROBE_TIMEOUT_MS), Effect.result); + + if (Result.isFailure(inspectResult)) { + yield* Effect.logDebug("Grok skill discovery failed; continuing without skills.", { + errorTag: errorTag(inspectResult.failure), + }); + return []; + } + if (Option.isNone(inspectResult.success)) { + yield* Effect.logDebug("Grok skill discovery timed out; continuing without skills."); + return []; + } + + const output = inspectResult.success.value; + if (output.code !== 0) { + yield* Effect.logDebug("Grok skill discovery exited non-zero; continuing without skills.", { + exitCode: output.code, + }); + return []; + } + return parseGrokInspectSkills(output.stdout); +}); diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 1c9bf1f26de7..366a305f4d01 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -1,9 +1,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; +import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { GrokSettings } from "@t3tools/contracts"; import { buildInitialGrokProviderSnapshot, checkGrokProviderStatus } from "./GrokProvider.ts"; @@ -46,6 +48,28 @@ describe("buildInitialGrokProviderSnapshot", () => { ); }); +const makeSpawnHandle = (input: { + readonly stdout?: string; + readonly stderr?: string; + readonly exitCode?: number; +}) => + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode ?? 0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.encodeText(Stream.make(input.stdout ?? "")), + stderr: Stream.encodeText(input.stderr ? Stream.make(input.stderr) : Stream.empty), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + +const spawnArgs = (command: { readonly _tag: string; readonly args?: ReadonlyArray }) => + command._tag === "StandardCommand" ? (command.args ?? []) : []; + it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { it.effect("reports the binary as missing when the binary path does not resolve", () => Effect.gen(function* () { @@ -62,59 +86,105 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { }), ); - it.effect("reports an installed CLI as unhealthy when --version exits non-zero", () => - Effect.gen(function* () { - const secretStderr = "broken grok install: secret-token-value"; - const snapshot = yield* Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-version-" }); - const grokPath = path.join(dir, "grok"); - yield* fs.writeFileString( - grokPath, - ["#!/bin/sh", `printf "%s\\n" "${secretStderr}" >&2`, "exit 2", ""].join("\n"), - ); - yield* fs.chmod(grokPath, 0o755); - - return yield* checkGrokProviderStatus( - decodeGrokSettings({ enabled: true, binaryPath: grokPath }), - ); - }), - ); + it.effect("reports an installed CLI as unhealthy when --version exits non-zero", () => { + const secretStderr = "broken grok install: secret-token-value"; + const spawner = ChildProcessSpawner.make((command) => { + if (spawnArgs(command).includes("--version")) { + return Effect.succeed(makeSpawnHandle({ stderr: `${secretStderr}\n`, exitCode: 2 })); + } + return Effect.succeed(makeSpawnHandle({ stderr: "unexpected grok spawn\n", exitCode: 1 })); + }); + + return Effect.gen(function* () { + const snapshot = yield* checkGrokProviderStatus( + decodeGrokSettings({ enabled: true, binaryPath: "grok" }), + ).pipe(Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner))); expect(snapshot.enabled).toBe(true); expect(snapshot.installed).toBe(true); expect(snapshot.status).toBe("error"); expect(snapshot.message).toBe("Grok CLI is installed but failed to run."); expect(snapshot.message).not.toContain(secretStderr); - }), - ); + }); + }); - it.effect("reports an error when ACP model discovery is unavailable", () => - Effect.gen(function* () { - const snapshot = yield* Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-success-" }); - const grokPath = path.join(dir, "grok"); - yield* fs.writeFileString( - grokPath, - ["#!/bin/sh", 'printf "grok-cli 0.0.99\\n"', "exit 0", ""].join("\n"), - ); - yield* fs.chmod(grokPath, 0o755); - - return yield* checkGrokProviderStatus( - decodeGrokSettings({ enabled: true, binaryPath: grokPath }), - ); - }), - ); + it.effect("reports an error when ACP model discovery is unavailable", () => { + const spawner = ChildProcessSpawner.make((command) => { + const args = spawnArgs(command); + if (args.includes("--version")) { + return Effect.succeed(makeSpawnHandle({ stdout: "grok-cli 0.0.99\n" })); + } + if (args.includes("inspect")) { + return Effect.succeed(makeSpawnHandle({ stdout: JSON.stringify({ skills: [] }) })); + } + return Effect.succeed(makeSpawnHandle({ stderr: "ACP probe unavailable\n", exitCode: 1 })); + }); + + return Effect.gen(function* () { + const snapshot = yield* checkGrokProviderStatus( + decodeGrokSettings({ enabled: true, binaryPath: "grok" }), + ).pipe(Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner))); expect(snapshot.status).toBe("error"); expect(snapshot.installed).toBe(true); expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-build"]); expect(snapshot.message).toContain("ACP startup failed"); - }), - ); + expect(snapshot.skills).toEqual([]); + }); + }); + + it.effect("attaches inspect skills even when ACP model discovery fails", () => { + const inspectCwds: Array = []; + const spawner = ChildProcessSpawner.make((command) => { + const args = spawnArgs(command); + if (args.includes("inspect")) { + inspectCwds.push(command._tag === "StandardCommand" ? command.options.cwd : undefined); + return Effect.succeed( + makeSpawnHandle({ + stdout: JSON.stringify({ + skills: [ + { + name: "tdd", + description: "Test-driven development.", + source: { + type: "user", + path: "C:\\Users\\Drew\\.grok\\skills\\tdd\\SKILL.md", + }, + userInvocable: true, + }, + ], + }), + }), + ); + } + if (args.includes("--version")) { + return Effect.succeed(makeSpawnHandle({ stdout: "grok 1.0.5\n" })); + } + return Effect.succeed( + makeSpawnHandle({ stderr: "ACP probe should not block skill discovery\n", exitCode: 1 }), + ); + }); + + return Effect.gen(function* () { + const snapshot = yield* checkGrokProviderStatus( + decodeGrokSettings({ enabled: true, binaryPath: "grok" }), + {}, + "C:\\workspaces\\demo", + ).pipe(Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner))); + + expect(inspectCwds).toEqual(["C:\\workspaces\\demo"]); + expect(snapshot.skills).toEqual([ + { + name: "tdd", + description: "Test-driven development.", + path: "C:\\Users\\Drew\\.grok\\skills\\tdd\\SKILL.md", + scope: "user", + enabled: true, + }, + ]); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toContain("ACP startup failed"); + }); + }); }); diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 934eecdb5ae6..2cb5e3753e2a 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -30,6 +30,7 @@ import { type ProviderMaintenanceCapabilities, } from "../providerMaintenance.ts"; import { makeGrokAcpRuntime, resolveGrokAcpBaseModelId } from "../acp/GrokAcpSupport.ts"; +import { discoverGrokSkills } from "../Drivers/GrokSkills.ts"; const GROK_PRESENTATION = { displayName: "Grok", @@ -126,6 +127,7 @@ function buildGrokDiscoveredModelsFromSessionModelState( const discoverGrokModelsViaAcp = ( grokSettings: GrokSettings, environment: NodeJS.ProcessEnv = process.env, + cwd?: string, ) => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -133,7 +135,7 @@ const discoverGrokModelsViaAcp = ( grokSettings, environment, childProcessSpawner, - cwd: process.cwd(), + cwd: cwd && cwd.length > 0 ? cwd : process.cwd(), clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, }); const started = yield* acp.start(); @@ -161,6 +163,7 @@ const runGrokVersionCommand = ( export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* ( grokSettings: GrokSettings, environment: NodeJS.ProcessEnv = process.env, + cwd?: string, ): Effect.fn.Return< ServerProviderDraft, never, @@ -251,7 +254,9 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func }); } - const discoveryExit = yield* discoverGrokModelsViaAcp(grokSettings, environment).pipe( + const skills = yield* discoverGrokSkills(grokSettings, environment, cwd); + + const discoveryExit = yield* discoverGrokModelsViaAcp(grokSettings, environment, cwd).pipe( Effect.timeoutOption(GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS), Effect.exit, ); @@ -264,6 +269,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func enabled: grokSettings.enabled, checkedAt, models: fallbackModels, + skills, probe: { installed: true, version, @@ -282,6 +288,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func enabled: grokSettings.enabled, checkedAt, models: fallbackModels, + skills, probe: { installed: true, version, @@ -302,6 +309,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func enabled: grokSettings.enabled, checkedAt, models, + skills, probe: { installed: true, version, diff --git a/docs/internals/providers.md b/docs/internals/providers.md index a309d70f03de..4ff88ea19e60 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -23,6 +23,16 @@ adapter in a child scope. Adapter implementations live beside them in [`ProviderAdapter.ts`][adapter]. Read the driver plus its adapter to see how a specific agent's transport, config, and event shapes are mapped. +### Grok skill catalog + +The composer `$` picker (and the skill rows in `/`) read `ServerProvider.skills` from the Grok +snapshot. Claude scans disk because its handshake omits paths. Grok already resolves user, project, +bundled, plugin, and config skills, so the Grok probe runs `grok inspect --json` after a successful +`grok --version` and maps `skills[]` onto that snapshot. Discovery is best-effort: timeout, missing +`inspect`, or malformed JSON yields `[]` and does not change probe status. The inspect spawn uses +`ServerConfig.cwd` so project-scope skills under `.grok/skills` are included. Do not flatten +`~/.grok/skills` in T3 — that misses plugin skills and ignores Grok's own disable/ignore config. + ## Registry and routing Two registries separate configuration from live processes: From 7a484c5bf3e78d04e5229e26eac2a8f61419ec5d Mon Sep 17 00:00:00 2001 From: Fenris159 Date: Sat, 22 Aug 2026 13:45:48 -0500 Subject: [PATCH 2/4] fix(grok): validate inspect output with schema --- .../src/provider/Drivers/GrokSkills.test.ts | 8 +- .../server/src/provider/Drivers/GrokSkills.ts | 92 +++++++++---------- 2 files changed, 51 insertions(+), 49 deletions(-) diff --git a/apps/server/src/provider/Drivers/GrokSkills.test.ts b/apps/server/src/provider/Drivers/GrokSkills.test.ts index e4811ec8a9b1..6f8344bd7a19 100644 --- a/apps/server/src/provider/Drivers/GrokSkills.test.ts +++ b/apps/server/src/provider/Drivers/GrokSkills.test.ts @@ -158,8 +158,14 @@ describe("parseGrokInspectSkills", () => { { name: " ", source: { type: "user", path: "/tmp/skills/a/SKILL.md" } }, { name: "no-path", source: { type: "user" } }, { name: "no-source" }, + { name: 42, source: { type: "user", path: "/tmp/skills/wrong-name/SKILL.md" } }, + { name: "wrong-source", source: "user" }, "not-an-object", - { name: "kept", source: { type: "project", path: "/repo/.grok/skills/kept/SKILL.md" } }, + { + name: "kept", + source: { type: "project", path: "/repo/.grok/skills/kept/SKILL.md" }, + ignoredByT3: true, + }, ]), ); diff --git a/apps/server/src/provider/Drivers/GrokSkills.ts b/apps/server/src/provider/Drivers/GrokSkills.ts index f076eba4d18d..d79cf66264f3 100644 --- a/apps/server/src/provider/Drivers/GrokSkills.ts +++ b/apps/server/src/provider/Drivers/GrokSkills.ts @@ -23,57 +23,55 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { spawnAndCollect } from "../providerSnapshot.ts"; const GROK_SKILLS_PROBE_TIMEOUT_MS = 4_000; -function parseInspectDocument(stdout: string): unknown { +const GrokInspectSource = Schema.Struct({ + type: Schema.optional(Schema.String), + kind: Schema.optional(Schema.String), + path: Schema.optional(Schema.String), +}); + +const GrokInspectSkill = Schema.Struct({ + name: Schema.String, + description: Schema.optional(Schema.String), + source: Schema.optional(GrokInspectSource), + path: Schema.optional(Schema.String), + userInvocable: Schema.optional(Schema.Boolean), +}); + +const GrokInspectDocument = Schema.Struct({ + skills: Schema.Array(Schema.Unknown), +}); + +const decodeGrokInspectDocument = Schema.decodeUnknownOption( + Schema.fromJsonString(GrokInspectDocument), +); +const decodeGrokInspectSkill = Schema.decodeUnknownOption(GrokInspectSkill); + +function decodeInspectDocument(stdout: string) { const trimmed = stdout.replace(/^\uFEFF/, "").trim(); if (trimmed.length === 0) { return undefined; } - try { - return JSON.parse(trimmed); - } catch { - // Grok may print a warning line before the JSON object, especially on - // Windows. Take the outermost object if it is valid JSON. - const start = trimmed.indexOf("{"); - const end = trimmed.lastIndexOf("}"); - if (start < 0 || end <= start) { - return undefined; - } - try { - return JSON.parse(trimmed.slice(start, end + 1)); - } catch { - return undefined; - } + const decoded = decodeGrokInspectDocument(trimmed); + if (Option.isSome(decoded)) { + return decoded.value; } -} -function asRecord(value: unknown): Record | undefined { - if (typeof value !== "object" || value === null) { + // Grok may print a warning line before the JSON object, especially on + // Windows. Take the outermost object if it is valid inspect JSON. + const start = trimmed.indexOf("{"); + const end = trimmed.lastIndexOf("}"); + if (start < 0 || end <= start) { return undefined; } - return value as Record; -} - -function inspectSourceScope(source: Record | undefined): string { - const raw = source?.type ?? source?.kind; - return typeof raw === "string" ? raw.trim() : ""; -} - -function inspectSourcePath( - record: Record, - source: Record | undefined, -): string { - const fromSource = typeof source?.path === "string" ? source.path.trim() : ""; - if (fromSource.length > 0) { - return fromSource; - } - return typeof record.path === "string" ? record.path.trim() : ""; + return Option.getOrUndefined(decodeGrokInspectDocument(trimmed.slice(start, end + 1))); } /** @@ -84,27 +82,25 @@ function inspectSourcePath( * repeats a name, the first entry wins. */ export function parseGrokInspectSkills(stdout: string): ReadonlyArray { - const parsed = parseInspectDocument(stdout); - const root = asRecord(parsed); - const entries = root?.skills; - if (!Array.isArray(entries)) { + const document = decodeInspectDocument(stdout); + if (!document) { return []; } const skillsByName = new Map(); - for (const entry of entries) { - const record = asRecord(entry); - if (!record) { + for (const entry of document.skills) { + const decoded = decodeGrokInspectSkill(entry); + if (Option.isNone(decoded)) { continue; } - const name = typeof record.name === "string" ? record.name.trim() : ""; - const source = asRecord(record.source); - const path = inspectSourcePath(record, source); + const record = decoded.value; + const name = record.name.trim(); + const path = (record.source?.path ?? record.path ?? "").trim(); if (!name || !path || skillsByName.has(name)) { continue; } - const scope = inspectSourceScope(source); - const description = typeof record.description === "string" ? record.description.trim() : ""; + const scope = (record.source?.type ?? record.source?.kind ?? "").trim(); + const description = record.description?.trim() ?? ""; skillsByName.set(name, { name, path, From 33dca58f43bbc3d5d12657689184115cab9159fc Mon Sep 17 00:00:00 2001 From: Fenris159 Date: Sat, 22 Aug 2026 13:56:56 -0500 Subject: [PATCH 3/4] fix(grok): skip braces in inspect warnings Try each possible JSON object start so warning text containing braces cannot hide the schema-valid inspect envelope. --- .../server/src/provider/Drivers/GrokSkills.test.ts | 3 +++ apps/server/src/provider/Drivers/GrokSkills.ts | 14 +++++++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/Drivers/GrokSkills.test.ts b/apps/server/src/provider/Drivers/GrokSkills.test.ts index 6f8344bd7a19..2bfe419ec123 100644 --- a/apps/server/src/provider/Drivers/GrokSkills.test.ts +++ b/apps/server/src/provider/Drivers/GrokSkills.test.ts @@ -181,6 +181,9 @@ describe("parseGrokInspectSkills", () => { expect( parseGrokInspectSkills(`warn: inspect starting\n${body}`).map((skill) => skill.name), ).toEqual(["kept"]); + expect( + parseGrokInspectSkills(`warn: config contains {braces}\n${body}`).map((skill) => skill.name), + ).toEqual(["kept"]); }); it("returns an empty list for malformed or unexpected output", () => { diff --git a/apps/server/src/provider/Drivers/GrokSkills.ts b/apps/server/src/provider/Drivers/GrokSkills.ts index d79cf66264f3..120641fc8edf 100644 --- a/apps/server/src/provider/Drivers/GrokSkills.ts +++ b/apps/server/src/provider/Drivers/GrokSkills.ts @@ -65,13 +65,17 @@ function decodeInspectDocument(stdout: string) { } // Grok may print a warning line before the JSON object, especially on - // Windows. Take the outermost object if it is valid inspect JSON. - const start = trimmed.indexOf("{"); + // Windows. Try each object start because the warning itself may contain + // braces before the inspect document. const end = trimmed.lastIndexOf("}"); - if (start < 0 || end <= start) { - return undefined; + for (let start = trimmed.indexOf("{"); start >= 0 && start < end; ) { + const candidate = decodeGrokInspectDocument(trimmed.slice(start, end + 1)); + if (Option.isSome(candidate)) { + return candidate.value; + } + start = trimmed.indexOf("{", start + 1); } - return Option.getOrUndefined(decodeGrokInspectDocument(trimmed.slice(start, end + 1))); + return undefined; } /** From 60e2f07654f785a0d82aab58210295c45616260d Mon Sep 17 00:00:00 2001 From: Fenris159 Date: Sat, 22 Aug 2026 14:33:55 -0500 Subject: [PATCH 4/4] test(grok): cover inspect failure fallbacks --- .../src/provider/Drivers/GrokSkills.test.ts | 116 ++++++++++++++---- 1 file changed, 95 insertions(+), 21 deletions(-) diff --git a/apps/server/src/provider/Drivers/GrokSkills.test.ts b/apps/server/src/provider/Drivers/GrokSkills.test.ts index 2bfe419ec123..cabc4ea004ce 100644 --- a/apps/server/src/provider/Drivers/GrokSkills.test.ts +++ b/apps/server/src/provider/Drivers/GrokSkills.test.ts @@ -1,14 +1,51 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/unstable/process"; import { discoverGrokSkills, parseGrokInspectSkills } from "./GrokSkills.ts"; const inspectPayload = (skills: ReadonlyArray) => JSON.stringify({ skills }); +const makeSpawnHandle = (input: { + readonly stdout?: string; + readonly stderr?: string; + readonly exitCode?: number; +}) => + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode ?? 0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.encodeText(input.stdout ? Stream.make(input.stdout) : Stream.empty), + stderr: Stream.encodeText(input.stderr ? Stream.make(input.stderr) : Stream.empty), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + +const makeNeverFinishingSpawnHandle = () => + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.never, + isRunning: Effect.succeed(true), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + describe("parseGrokInspectSkills", () => { it("maps inspect entries onto provider skills, sorted by name", () => { const skills = parseGrokInspectSkills( @@ -200,27 +237,13 @@ describe("discoverGrokSkills", () => { const spawner = ChildProcessSpawner.make((command) => { spawnCwds.push(command._tag === "StandardCommand" ? command.options.cwd : undefined); return Effect.succeed( - ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), - isRunning: Effect.succeed(false), - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - stdin: Sink.drain, - stdout: Stream.encodeText( - Stream.make( - inspectPayload([ - { - name: "kept", - source: { type: "project", path: "/workspaces/demo/.grok/skills/kept/SKILL.md" }, - }, - ]), - ), - ), - stderr: Stream.empty, - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, + makeSpawnHandle({ + stdout: inspectPayload([ + { + name: "kept", + source: { type: "project", path: "/workspaces/demo/.grok/skills/kept/SKILL.md" }, + }, + ]), }), ); }); @@ -234,4 +257,55 @@ describe("discoverGrokSkills", () => { expect(skills.map((skill) => skill.name)).toEqual(["kept"]); }); }); + + it.effect("fails open when the inspect process cannot spawn", () => { + const spawnError = PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + cause: new Error("grok executable unavailable"), + }); + const spawner = ChildProcessSpawner.make(() => Effect.fail(spawnError)); + + return Effect.gen(function* () { + const exit = yield* discoverGrokSkills({ binaryPath: "grok" }).pipe( + Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), + Effect.exit, + ); + + expect(exit).toMatchObject({ _tag: "Success", value: [] }); + }); + }); + + it.effect("fails open when the inspect process exits non-zero", () => { + const spawner = ChildProcessSpawner.make(() => + Effect.succeed(makeSpawnHandle({ stderr: "inspect failed", exitCode: 7 })), + ); + + return Effect.gen(function* () { + const exit = yield* discoverGrokSkills({ binaryPath: "grok" }).pipe( + Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), + Effect.exit, + ); + + expect(exit).toMatchObject({ _tag: "Success", value: [] }); + }); + }); + + it.effect("fails open when the inspect process times out", () => { + const spawner = ChildProcessSpawner.make(() => Effect.succeed(makeNeverFinishingSpawnHandle())); + + return Effect.gen(function* () { + const exitFiber = yield* discoverGrokSkills({ binaryPath: "grok" }).pipe( + Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), + Effect.exit, + Effect.forkScoped, + ); + yield* Effect.yieldNow; + yield* TestClock.adjust("4 seconds"); + const exit = yield* Fiber.join(exitFiber); + + expect(exit).toMatchObject({ _tag: "Success", value: [] }); + }); + }); });