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..cabc4ea004ce --- /dev/null +++ b/apps/server/src/provider/Drivers/GrokSkills.test.ts @@ -0,0 +1,311 @@ +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( + 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" }, + { 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" }, + ignoredByT3: true, + }, + ]), + ); + + 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"]); + expect( + parseGrokInspectSkills(`warn: config contains {braces}\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( + makeSpawnHandle({ + stdout: inspectPayload([ + { + name: "kept", + source: { type: "project", path: "/workspaces/demo/.grok/skills/kept/SKILL.md" }, + }, + ]), + }), + ); + }); + + 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"]); + }); + }); + + 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: [] }); + }); + }); +}); diff --git a/apps/server/src/provider/Drivers/GrokSkills.ts b/apps/server/src/provider/Drivers/GrokSkills.ts new file mode 100644 index 000000000000..120641fc8edf --- /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 * 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; + +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; + } + + const decoded = decodeGrokInspectDocument(trimmed); + if (Option.isSome(decoded)) { + return decoded.value; + } + + // Grok may print a warning line before the JSON object, especially on + // Windows. Try each object start because the warning itself may contain + // braces before the inspect document. + const end = trimmed.lastIndexOf("}"); + 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 undefined; +} + +/** + * 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 document = decodeInspectDocument(stdout); + if (!document) { + return []; + } + + const skillsByName = new Map(); + for (const entry of document.skills) { + const decoded = decodeGrokInspectSkill(entry); + if (Option.isNone(decoded)) { + continue; + } + 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 = (record.source?.type ?? record.source?.kind ?? "").trim(); + const description = 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: