From 7afb9d1c317135ef6b6709e4ba700bccd0b08fd8 Mon Sep 17 00:00:00 2001 From: Michael Brown Date: Thu, 13 Aug 2026 15:40:54 -0400 Subject: [PATCH 1/2] feat(server): list Grok CLI skills in the $ picker Grok already knows its skills via `grok inspect --json`. The provider snapshot now copies that catalog onto ServerProvider.skills so the composer $ menu matches Claude/Codex. Inspect failure stays empty. --- .../server/src/provider/Drivers/GrokDriver.ts | 3 +- .../src/provider/Drivers/GrokSkills.test.ts | 96 ++++++++++++++ .../server/src/provider/Drivers/GrokSkills.ts | 117 ++++++++++++++++++ .../src/provider/Layers/GrokProvider.test.ts | 20 ++- .../src/provider/Layers/GrokProvider.ts | 17 ++- 5 files changed, 248 insertions(+), 5 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..17c4460755c5 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -106,6 +106,7 @@ export const GrokDriver: ProviderDriver = { env: processEnv, }); + const { cwd } = yield* ServerConfig; const adapter = yield* makeGrokAdapter(effectiveConfig, { environment: processEnv, ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), @@ -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..d39cf27f76f5 --- /dev/null +++ b/apps/server/src/provider/Drivers/GrokSkills.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { parseGrokInspectSkills } from "./GrokSkills.ts"; + +describe("parseGrokInspectSkills", () => { + it("maps inspect JSON onto ServerProviderSkill rows", () => { + expect( + parseGrokInspectSkills({ + skills: [ + { + name: "handoff-session", + description: "Write a session handoff.", + userInvocable: true, + source: { type: "user", path: "/tmp/.grok/skills/handoff-session/SKILL.md" }, + }, + { + name: "ce-plan", + description: "Create structured plans.", + userInvocable: true, + source: { + type: "plugin", + plugin_name: "compound-engineering", + path: "/tmp/plugins/ce-plan/SKILL.md", + }, + }, + ], + }), + ).toEqual([ + { + name: "handoff-session", + description: "Write a session handoff.", + path: "/tmp/.grok/skills/handoff-session/SKILL.md", + scope: "user", + enabled: true, + }, + { + name: "ce-plan", + description: "Create structured plans.", + path: "/tmp/plugins/ce-plan/SKILL.md", + scope: "plugin:compound-engineering", + enabled: true, + }, + ]); + }); + + it("skips malformed rows and keeps the first name on collisions", () => { + expect( + parseGrokInspectSkills({ + skills: [ + { + name: "review", + source: { type: "project", path: "/repo/.grok/skills/review/SKILL.md" }, + }, + { name: "review", source: { type: "user", path: "/home/.grok/skills/review/SKILL.md" } }, + { name: "broken" }, + null, + "nope", + ], + }), + ).toEqual([ + { + name: "review", + path: "/repo/.grok/skills/review/SKILL.md", + scope: "project", + enabled: true, + }, + ]); + }); + + it("marks non-invocable or disabled skills as disabled", () => { + const skills = parseGrokInspectSkills({ + skills: [ + { + name: "hidden", + userInvocable: false, + source: { type: "bundled", path: "/bundled/hidden/SKILL.md" }, + }, + { + name: "off", + disabled: true, + source: { type: "user", path: "/home/.grok/skills/off/SKILL.md" }, + }, + ], + }); + expect(skills.map((skill) => ({ name: skill.name, enabled: skill.enabled }))).toEqual([ + { name: "hidden", enabled: false }, + { name: "off", enabled: false }, + ]); + }); + + it("returns an empty list for junk input", () => { + expect(parseGrokInspectSkills(null)).toEqual([]); + expect(parseGrokInspectSkills({})).toEqual([]); + expect(parseGrokInspectSkills({ skills: "nope" })).toEqual([]); + }); +}); diff --git a/apps/server/src/provider/Drivers/GrokSkills.ts b/apps/server/src/provider/Drivers/GrokSkills.ts new file mode 100644 index 000000000000..7dbd7ffbd133 --- /dev/null +++ b/apps/server/src/provider/Drivers/GrokSkills.ts @@ -0,0 +1,117 @@ +/** + * GrokSkills — surface Grok Build skills on the provider snapshot for the `$` + * picker. The CLI already discovers skills (`grok inspect --json`); T3 does + * not walk the filesystem a second time. + * + * @module provider/Drivers/GrokSkills + */ +import type { GrokSettings, ServerProviderSkill } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +import { spawnAndCollect } from "../providerSnapshot.ts"; + +const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown); + +type GrokInspectSkillSource = { + readonly type?: unknown; + readonly path?: unknown; + readonly plugin_name?: unknown; +}; + +type GrokInspectSkill = { + readonly name?: unknown; + readonly description?: unknown; + readonly userInvocable?: unknown; + readonly disabled?: unknown; + readonly source?: GrokInspectSkillSource; +}; + +function asTrimmedString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function inspectScope(source: GrokInspectSkillSource | undefined): string | undefined { + const type = asTrimmedString(source?.type); + if (!type) return undefined; + const pluginName = asTrimmedString(source?.plugin_name); + return type === "plugin" && pluginName ? `plugin:${pluginName}` : type; +} + +export function parseGrokInspectSkills(input: unknown): ReadonlyArray { + if (typeof input !== "object" || input === null) { + return []; + } + const rawSkills = (input as { readonly skills?: unknown }).skills; + if (!Array.isArray(rawSkills)) { + return []; + } + + const skills: ServerProviderSkill[] = []; + const seen = new Set(); + for (const entry of rawSkills) { + if (typeof entry !== "object" || entry === null) { + continue; + } + const skill = entry as GrokInspectSkill; + const name = asTrimmedString(skill.name); + const path = asTrimmedString(skill.source?.path); + if (!name || !path || seen.has(name)) { + continue; + } + seen.add(name); + const description = asTrimmedString(skill.description); + const scope = inspectScope(skill.source); + const enabled = skill.userInvocable !== false && skill.disabled !== true; + skills.push({ + name, + path, + enabled, + ...(description ? { description } : {}), + ...(scope ? { scope } : {}), + }); + } + return skills; +} + +/** + * Ask the Grok CLI what skills it would load in this cwd. Failure is empty: + * a broken inspect must not take the provider snapshot down. + */ +export const discoverGrokSkills = Effect.fn("discoverGrokSkills")(function* ( + grokSettings: Pick, + environment: NodeJS.ProcessEnv = process.env, + cwd?: string, +) { + const command = grokSettings.binaryPath || "grok"; + const spawnCommand = yield* resolveSpawnCommand(command, ["inspect", "--json"], { + env: environment, + }).pipe(Effect.orElseSucceed(() => null)); + if (!spawnCommand) { + return []; + } + + const result = yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + ...(cwd ? { cwd } : {}), + }), + ).pipe(Effect.orElseSucceed(() => null)); + if (!result || result.code !== 0) { + return []; + } + + const parsed = yield* Schema.decodeUnknownEffect(UnknownFromJsonString)(result.stdout).pipe( + Effect.orElseSucceed(() => null), + ); + if (parsed === null) { + return []; + } + return parseGrokInspectSkills(parsed); +}); diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 000243869c9e..2c6530a679bb 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -91,7 +91,16 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { const grokPath = path.join(dir, "grok"); yield* fs.writeFileString( grokPath, - ["#!/bin/sh", 'printf "grok-cli 0.0.99\\n"', "exit 0", ""].join("\n"), + [ + "#!/bin/sh", + 'if [ "$1" = "inspect" ]; then', + ' printf \'%s\\n\' \'{"skills":[{"name":"handoff-session","description":"Write a handoff.","userInvocable":true,"source":{"type":"user","path":"/tmp/handoff-session/SKILL.md"}}]}\'', + " exit 0", + "fi", + 'printf "grok-cli 0.0.99\\n"', + "exit 0", + "", + ].join("\n"), ); yield* fs.chmod(grokPath, 0o755); @@ -105,6 +114,15 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { 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([ + { + name: "handoff-session", + description: "Write a handoff.", + path: "/tmp/handoff-session/SKILL.md", + scope: "user", + enabled: true, + }, + ]); }), ); }); diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 934eecdb5ae6..be0eb3bd8da6 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", @@ -161,6 +162,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,9 +253,15 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func }); } - const discoveryExit = yield* discoverGrokModelsViaAcp(grokSettings, environment).pipe( - Effect.timeoutOption(GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS), - Effect.exit, + const [skills, discoveryExit] = yield* Effect.all( + [ + discoverGrokSkills(grokSettings, environment, cwd), + discoverGrokModelsViaAcp(grokSettings, environment).pipe( + Effect.timeoutOption(GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS), + Effect.exit, + ), + ], + { concurrency: "unbounded" }, ); if (Exit.isFailure(discoveryExit)) { yield* Effect.logWarning("Grok ACP model discovery failed", { @@ -264,6 +272,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func enabled: grokSettings.enabled, checkedAt, models: fallbackModels, + skills, probe: { installed: true, version, @@ -282,6 +291,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func enabled: grokSettings.enabled, checkedAt, models: fallbackModels, + skills, probe: { installed: true, version, @@ -302,6 +312,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func enabled: grokSettings.enabled, checkedAt, models, + skills, probe: { installed: true, version, From 9ff7976e6e4bf2f8c2259c407e581bfe62762f65 Mon Sep 17 00:00:00 2001 From: Michael Brown Date: Mon, 17 Aug 2026 21:16:39 -0400 Subject: [PATCH 2/2] fix(server): timeout hung Grok inspect skill discovery A started grok inspect that never exits blocked checkGrokProviderStatus. Bound the spawn to the existing provider probe timeout so the snapshot can still degrade to an empty skill list. Co-authored-by: Cursor --- .../src/provider/Drivers/GrokSkills.test.ts | 112 +++++++++++++++++- .../server/src/provider/Drivers/GrokSkills.ts | 9 +- 2 files changed, 118 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Drivers/GrokSkills.test.ts b/apps/server/src/provider/Drivers/GrokSkills.test.ts index d39cf27f76f5..7c121e5f7ae6 100644 --- a/apps/server/src/provider/Drivers/GrokSkills.test.ts +++ b/apps/server/src/provider/Drivers/GrokSkills.test.ts @@ -1,6 +1,52 @@ import { describe, expect, it } from "@effect/vitest"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { TestClock } from "effect/testing"; +import { ChildProcessSpawner } from "effect/unstable/process"; -import { parseGrokInspectSkills } from "./GrokSkills.ts"; +import { DEFAULT_TIMEOUT_MS } from "../providerSnapshot.ts"; +import { discoverGrokSkills, parseGrokInspectSkills } from "./GrokSkills.ts"; + +const hangingInspectSpawner = ChildProcessSpawner.make(() => + Effect.succeed( + 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, + }), + ), +); + +const inspectSpawner = (stdout: string, code: number) => + ChildProcessSpawner.make(() => + Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(code)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.encodeText(Stream.make(stdout)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ), + ); describe("parseGrokInspectSkills", () => { it("maps inspect JSON onto ServerProviderSkill rows", () => { @@ -94,3 +140,67 @@ describe("parseGrokInspectSkills", () => { expect(parseGrokInspectSkills({ skills: "nope" })).toEqual([]); }); }); + +describe("discoverGrokSkills", () => { + it.effect("returns [] when inspect starts but never exits", () => + Effect.gen(function* () { + const fiber = yield* discoverGrokSkills({ binaryPath: process.execPath }).pipe( + Effect.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, hangingInspectSpawner), + ), + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.millis(DEFAULT_TIMEOUT_MS)); + expect(yield* Fiber.join(fiber)).toEqual([]); + }), + ); + + it.effect("keeps a successful inspect catalog", () => + Effect.gen(function* () { + const skills = yield* discoverGrokSkills({ binaryPath: process.execPath }).pipe( + Effect.provide( + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + inspectSpawner( + JSON.stringify({ + skills: [ + { + name: "handoff-session", + description: "Write a session handoff.", + userInvocable: true, + source: { type: "user", path: "/tmp/.grok/skills/handoff-session/SKILL.md" }, + }, + ], + }), + 0, + ), + ), + ), + ); + expect(skills).toEqual([ + { + name: "handoff-session", + description: "Write a session handoff.", + path: "/tmp/.grok/skills/handoff-session/SKILL.md", + scope: "user", + enabled: true, + }, + ]); + }), + ); + + it.effect("returns [] when inspect exits non-zero", () => + Effect.gen(function* () { + const skills = yield* discoverGrokSkills({ binaryPath: process.execPath }).pipe( + Effect.provide( + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + inspectSpawner("inspect failed: secret-token-value", 2), + ), + ), + ); + expect(skills).toEqual([]); + }), + ); +}); diff --git a/apps/server/src/provider/Drivers/GrokSkills.ts b/apps/server/src/provider/Drivers/GrokSkills.ts index 7dbd7ffbd133..761cce8bbff0 100644 --- a/apps/server/src/provider/Drivers/GrokSkills.ts +++ b/apps/server/src/provider/Drivers/GrokSkills.ts @@ -7,11 +7,12 @@ */ import type { GrokSettings, ServerProviderSkill } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; -import { spawnAndCollect } from "../providerSnapshot.ts"; +import { DEFAULT_TIMEOUT_MS, spawnAndCollect } from "../providerSnapshot.ts"; const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown); @@ -102,7 +103,11 @@ export const discoverGrokSkills = Effect.fn("discoverGrokSkills")(function* ( shell: spawnCommand.shell, ...(cwd ? { cwd } : {}), }), - ).pipe(Effect.orElseSucceed(() => null)); + ).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.map((inspect) => (Option.isSome(inspect) ? inspect.value : null)), + Effect.orElseSucceed(() => null), + ); if (!result || result.code !== 0) { return []; }