1/**
2 * GrokSkills — skill discovery for the `$` picker via `grok inspect --json`.
3 *
4 * Unlike Claude Code, the Grok CLI reports its full skill catalog itself:
5 * `grok inspect --json` returns `skills[]` with `name`, `description`,
6 * `source.type` (`user` / `project` / `bundled` / `plugin`), `source.path`
7 * (the absolute `SKILL.md` path), and `userInvocable`. Asking the CLI beats
8 * scanning the filesystem because the catalog honors Grok's own skill config
9 * (ignore lists, disabled skills) and includes plugin skills, which live
10 * three levels deep under `~/.grok/installed-plugins/` where a flat scan
11 * cannot see them. This mirrors how the Codex app-server reports skills over
12 * `skills/list`. Probe failures stay typed so workspace snapshots do not
13 * cache an empty catalog; machine-level discovery recovers them to an empty
14 * list without degrading the provider.
15 *
16 * @module provider/Drivers/GrokSkills
17 */
18import type { ServerProviderSkill } from "@t3tools/contracts";
19import type { GrokSettings } from "../settings.ts";
20import * as Effect from "effect/Effect";
21import * as Option from "effect/Option";
22import * as Schema from "effect/Schema";
23import * as ChildProcess from "effect/process/ChildProcess";
24import { resolveSpawnCommand } from "@t3tools/shared/shell";
25
26import { spawnAndCollect } from "@t3tools/provider-core/server/snapshotProbe";
27
28const GROK_SKILLS_PROBE_TIMEOUT_MS = 4_000;
29
30class GrokSkillsProbeError extends Schema.TaggedError<GrokSkillsProbeError>()(
31 "GrokSkillsProbeError",
32 {
33 stage: Schema.Literals(["spawn", "timeout", "exit", "decode"]),
34 cwd: Schema.optional(Schema.String),
35 exitCode: Schema.optional(Schema.Number),
36 cause: Schema.optional(Schema.Defect()),
37 },
38) {
39 override get message(): string {
40 const location = this.cwd === undefined ? "" : ` for '${this.cwd}'`;
41 const exitCode = this.exitCode === undefined ? "" : ` with exit code ${this.exitCode}`;
42 return `\`grok inspect --json\` failed during ${this.stage}${location}${exitCode}.`;
43 }
44}
45
46/**
47 * Map `grok inspect --json` output onto provider skills. Entries without a
48 * name or a filesystem path are skipped; `userInvocable: false` skills are
49 * kept but disabled so pickers that filter on `enabled` hide them.
50 */
51function decodeGrokInspectSkills(stdout: string): ReadonlyArray<ServerProviderSkill> | undefined {
52 let parsed: unknown;
53 try {
54 parsed = JSON.parse(stdout);
55 } catch {
56 return undefined;
57 }
58 if (typeof parsed !== "object" || parsed === null) {
59 return undefined;
60 }
61 const entries = (parsed as Record<string, unknown>).skills;
62 if (!Array.isArray(entries)) {
63 return undefined;
64 }
65
66 const skillsByName = new Map<string, ServerProviderSkill>();
67 for (const entry of entries) {
68 if (typeof entry !== "object" || entry === null) {
69 continue;
70 }
71 const record = entry as Record<string, unknown>;
72 const name = typeof record.name === "string" ? record.name.trim() : "";
73 const source =
74 typeof record.source === "object" && record.source !== null
75 ? (record.source as Record<string, unknown>)
76 : undefined;
77 const path = typeof source?.path === "string" ? source.path.trim() : "";
78 if (!name || !path) {
79 continue;
80 }
81 const scope = typeof source?.type === "string" ? source.type.trim() : "";
82 const description = typeof record.description === "string" ? record.description.trim() : "";
83 skillsByName.set(name, {
84 name,
85 path,
86 enabled: record.userInvocable !== false,
87 ...(scope ? { scope } : {}),
88 ...(description ? { description } : {}),
89 });
90 }
91
92 return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name));
93}
94
95/**
96 * Run `grok inspect --json` and map the reported catalog onto provider
97 * skills. Callers that need best-effort discovery can recover this effect to
98 * an empty list; workspace callers leave failures typed so they are not cached.
99 */
100export const discoverGrokSkills = Effect.fn("discoverGrokSkills")(function* (
101 grokSettings: Pick<GrokSettings, "binaryPath">,
102 environment: NodeJS.ProcessEnv = process.env,
103 cwd?: string,
104) {
105 const command = grokSettings.binaryPath || "grok";
106 const inspectResult = yield* Effect.gen(function* () {
107 const spawnCommand = yield* resolveSpawnCommand(command, ["inspect", "--json"], {
108 env: environment,
109 });
110 return yield* spawnAndCollect(
111 command,
112 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
113 ...(cwd ? { cwd } : {}),
114 env: environment,
115 shell: spawnCommand.shell,
116 }),
117 );
118 }).pipe(
119 Effect.mapError(
120 (cause) =>
121 new GrokSkillsProbeError({
122 stage: "spawn",
123 ...(cwd ? { cwd } : {}),
124 cause,
125 }),
126 ),
127 Effect.timeoutOption(GROK_SKILLS_PROBE_TIMEOUT_MS),
128 );
129
130 if (Option.isNone(inspectResult)) {
131 return yield* new GrokSkillsProbeError({
132 stage: "timeout",
133 ...(cwd ? { cwd } : {}),
134 });
135 }
136 const output = inspectResult.value;
137 if (output.code !== 0) {
138 return yield* new GrokSkillsProbeError({
139 stage: "exit",
140 ...(cwd ? { cwd } : {}),
141 exitCode: output.code,
142 });
143 }
144 const skills = decodeGrokInspectSkills(output.stdout);
145 if (!skills) {
146 return yield* new GrokSkillsProbeError({
147 stage: "decode",
148 ...(cwd ? { cwd } : {}),
149 });
150 }
151 return skills;
152});