1import {
2 type CustomModelSetting,
3 type ModelCapabilities,
4 type ServerProvider,
5 type ServerProviderAuth,
6 type ServerProviderModel,
7 type ServerProviderSlashCommand,
8} from "@t3tools/contracts";
9import type { GrokSettings } from "../settings.ts";
10import type * as EffectAcpSchema from "effect-acp/compat";
11import { causeErrorTag } from "@t3tools/shared/observability";
12import * as Crypto from "effect/Crypto";
13import * as DateTime from "effect/DateTime";
14import * as Effect from "effect/Effect";
15import * as Exit from "effect/Exit";
16import * as Option from "effect/Option";
17import * as Result from "effect/Result";
18import * as Schema from "effect/Schema";
19import * as HttpClient from "effect/http/HttpClient";
20import * as ChildProcess from "effect/process/ChildProcess";
21import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
22import { createModelCapabilities } from "@t3tools/shared/model";
23import { resolveSpawnCommand } from "@t3tools/shared/shell";
24
25import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions";
26import {
27 AUTH_PROBE_TIMEOUT_MS,
28 buildServerProvider,
29 COMPACT_SLASH_COMMAND,
30 isCommandMissingCause,
31 parseGenericCliVersion,
32 providerModelsFromSettings,
33 spawnAndCollect,
34 type ServerProviderDraft,
35} from "@t3tools/provider-core/server/snapshotProbe";
36import {
37 enrichProviderSnapshotWithVersionAdvisory,
38 type ProviderMaintenanceCapabilities,
39} from "@t3tools/provider-core/server/maintenanceResolver";
40import {
41 GROK_DEFAULT_MODEL_SLUG,
42 GROK_SUPPORTED_RUNTIME_MODES,
43 isValidGrokReasoningEffortToken,
44 makeGrokAcpRuntime,
45 resolveGrokAcpBaseModelId,
46} from "./acpSupport.ts";
47import { sessionModelStateFromInitialize } from "@t3tools/provider-acp/server/runtimeModel";
48import { discoverGrokSkills } from "./skills.ts";
49
50const GROK_PRESENTATION = {
51 displayName: "Grok",
52 supportsConversationRollback: false,
53 showInteractionModeToggle: false,
54 supportedRuntimeModes: GROK_SUPPORTED_RUNTIME_MODES,
55} as const;
56const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({
57 optionDescriptors: [],
58});
59
60const VERSION_PROBE_TIMEOUT_MS = 4_000;
61// `initialize` is a single local round trip, so this is generous even on slow machines.
62const GROK_ACP_INITIALIZE_TIMEOUT_MS = 8_000;
63const GROK_API_KEY_ENV = "XAI_API_KEY";
64
65const GROK_BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
66 {
67 slug: GROK_DEFAULT_MODEL_SLUG,
68 name: "Grok Build",
69 isCustom: false,
70 capabilities: EMPTY_CAPABILITIES,
71 },
72];
73
74export function buildInitialGrokProviderSnapshot(
75 grokSettings: GrokSettings,
76): Effect.Effect<ServerProviderDraft> {
77 return Effect.gen(function* () {
78 const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso);
79 const models = grokModelsFromSettings(grokSettings.customModels);
80
81 if (!grokSettings.enabled) {
82 return buildServerProvider({
83 presentation: GROK_PRESENTATION,
84 enabled: false,
85 checkedAt,
86 models,
87 probe: {
88 installed: false,
89 version: null,
90 status: "warning",
91 auth: { status: "unknown" },
92 message: "Grok is disabled in T3 Code settings.",
93 },
94 });
95 }
96
97 return buildServerProvider({
98 presentation: GROK_PRESENTATION,
99 enabled: true,
100 checkedAt,
101 models,
102 probe: {
103 installed: true,
104 version: null,
105 status: "warning",
106 auth: { status: "unknown" },
107 message: "Checking Grok CLI availability...",
108 },
109 });
110 });
111}
112
113function grokModelsFromSettings(
114 customModels: ReadonlyArray<CustomModelSetting> | undefined,
115 builtInModels: ReadonlyArray<ServerProviderModel> = GROK_BUILT_IN_MODELS,
116): ReadonlyArray<ServerProviderModel> {
117 return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES);
118}
119
120function nonEmptyString(value: unknown): string | undefined {
121 return typeof value === "string" ? value.trim() || undefined : undefined;
122}
123
124function isRecord(value: unknown): value is Record<string, unknown> {
125 return typeof value === "object" && value !== null && !Array.isArray(value);
126}
127
128function grokReasoningOptionsFromModel(model: EffectAcpSchema.ModelInfo): {
129 readonly options: ReadonlyArray<{
130 value: string;
131 label: string;
132 description?: string;
133 isDefault?: boolean;
134 }>;
135 readonly currentValue: string | undefined;
136} {
137 const meta = model._meta;
138 if (!meta || meta.supportsReasoningEffort === false) {
139 return { options: [], currentValue: undefined };
140 }
141
142 const currentEffort = nonEmptyString(meta.reasoningEffort);
143 const advertisedOptions = Array.isArray(meta.reasoningEfforts) ? meta.reasoningEfforts : [];
144 const seen = new Set<string>();
145 const options: Array<{
146 value: string;
147 label: string;
148 description?: string;
149 advertisedDefault: boolean;
150 }> = [];
151
152 for (const entry of advertisedOptions) {
153 if (!isRecord(entry)) {
154 continue;
155 }
156 const rawValue = nonEmptyString(entry.value);
157 const rawId = nonEmptyString(entry.id);
158 const value =
159 rawValue && isValidGrokReasoningEffortToken(rawValue)
160 ? rawValue
161 : rawId && isValidGrokReasoningEffortToken(rawId)
162 ? rawId
163 : undefined;
164 if (value === undefined || seen.has(value)) {
165 continue;
166 }
167 seen.add(value);
168 const description = nonEmptyString(entry.description);
169 options.push({
170 value,
171 label: nonEmptyString(entry.label) ?? value,
172 ...(description ? { description } : {}),
173 advertisedDefault: entry.default === true || entry.isDefault === true,
174 });
175 }
176
177 const currentValue =
178 currentEffort && options.some((option) => option.value === currentEffort)
179 ? currentEffort
180 : undefined;
181 const advertisedDefaults = options.filter((option) => option.advertisedDefault);
182 const selectedDefault =
183 advertisedDefaults.find((option) => option.value === currentValue)?.value ??
184 advertisedDefaults[0]?.value;
185 return {
186 options: options.map(({ value, label, description }) => ({
187 value,
188 label,
189 ...(description ? { description } : {}),
190 ...(value === selectedDefault ? { isDefault: true } : {}),
191 })),
192 currentValue: currentValue ?? selectedDefault,
193 };
194}
195
196export function buildGrokModelCapabilities(model: EffectAcpSchema.ModelInfo): ModelCapabilities {
197 const reasoning = grokReasoningOptionsFromModel(model);
198 return reasoning.options.length > 0
199 ? createModelCapabilities({
200 optionDescriptors: [
201 {
202 id: "reasoningEffort",
203 label: "Reasoning",
204 type: "select",
205 options: reasoning.options.map((option) => ({
206 id: option.value,
207 label: option.label,
208 ...(option.description ? { description: option.description } : {}),
209 ...(option.isDefault ? { isDefault: true } : {}),
210 })),
211 ...(reasoning.currentValue ? { currentValue: reasoning.currentValue } : {}),
212 },
213 ],
214 })
215 : EMPTY_CAPABILITIES;
216}
217
218/** Models advertised by the ACP agent, with the session's current model marked as default. */
219export function buildGrokModelsFromSessionModelState(
220 modelState: EffectAcpSchema.SessionModelState | null | undefined,
221): ReadonlyArray<ServerProviderModel> {
222 if (!modelState || modelState.availableModels.length === 0) {
223 return [];
224 }
225 const currentModelId = modelState.currentModelId.trim();
226 const seen = new Set<string>();
227 return modelState.availableModels.flatMap((model): ServerProviderModel[] => {
228 const slug = resolveGrokAcpBaseModelId(model.modelId);
229 if (!slug || seen.has(slug)) {
230 return [];
231 }
232 seen.add(slug);
233 return [
234 {
235 slug,
236 name: model.name.trim() || slug,
237 isCustom: false,
238 ...(model.modelId.trim() === currentModelId ? { isDefault: true } : {}),
239 capabilities: buildGrokModelCapabilities(model),
240 },
241 ];
242 });
243}
244
245export interface GrokModelsCliOutput {
246 /** True or false when the CLI printed a login line, null when it printed neither. */
247 readonly authenticated: boolean | null;
248 readonly models: ReadonlyArray<ServerProviderModel>;
249}
250
251/**
252 * Parses `grok models`. The command exits 0 whether or not the user is logged in, so the
253 * text is the only signal. Current output looks like:
254 *
255 * You are logged in with grok.com.
256 * Default model: grok-4.6
257 * Available models:
258 * * grok-4.6 (default)
259 * - grok-4.5
260 */
261export function parseGrokModelsCliOutput(output: string): GrokModelsCliOutput {
262 const authenticated = /you are logged in/i.test(output)
263 ? true
264 : /not authenticated|not logged in/i.test(output)
265 ? false
266 : null;
267
268 const seen = new Set<string>();
269 const models: ServerProviderModel[] = [];
270 for (const line of output.split(/\r?\n/)) {
271 const bullet = line.match(/^\s*[*-]\s+(\S+)(.*)$/);
272 if (!bullet?.[1]) {
273 continue;
274 }
275 const slug = resolveGrokAcpBaseModelId(bullet[1]);
276 if (seen.has(slug)) {
277 continue;
278 }
279 seen.add(slug);
280 models.push({
281 slug,
282 name: displayNameFromGrokModelSlug(slug),
283 isCustom: false,
284 ...(/\(default\)/i.test(bullet[2] ?? "") ? { isDefault: true } : {}),
285 capabilities: EMPTY_CAPABILITIES,
286 });
287 }
288 return { authenticated, models };
289}
290
291function displayNameFromGrokModelSlug(slug: string): string {
292 return slug
293 .split(/[-_]/g)
294 .map((part) => (part.toLowerCase() === "grok" ? "Grok" : part))
295 .join(" ");
296}
297
298const runGrokCliCommand = (
299 grokSettings: GrokSettings,
300 args: ReadonlyArray<string>,
301 environment: NodeJS.ProcessEnv,
302) =>
303 Effect.gen(function* () {
304 const command = grokSettings.binaryPath || "grok";
305 const spawnCommand = yield* resolveSpawnCommand(command, args, { env: environment });
306 return yield* spawnAndCollect(
307 command,
308 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
309 env: environment,
310 shell: spawnCommand.shell,
311 }),
312 );
313 });
314
315const decodeAvailableCommands = Schema.decodeUnknownOption(Schema.Array(Schema.Unknown));
316const decodeAvailableCommand = Schema.decodeUnknownOption(
317 Schema.Struct({
318 name: Schema.String,
319 description: Schema.String,
320 input: Schema.optional(Schema.NullOr(Schema.Struct({ hint: Schema.String }))),
321 }),
322);
323
324export function grokSlashCommandsFromInitialize(
325 initialized: EffectAcpSchema.InitializeResponse,
326): ReadonlyArray<ServerProviderSlashCommand> {
327 const commands = decodeAvailableCommands(initialized._meta?.availableCommands);
328 const byName = new Map<string, ServerProviderSlashCommand>([
329 [COMPACT_SLASH_COMMAND.name, COMPACT_SLASH_COMMAND],
330 ]);
331 for (const entry of Option.getOrElse(commands, () => [])) {
332 const decoded = decodeAvailableCommand(entry);
333 if (Option.isNone(decoded)) continue;
334 const command = decoded.value;
335 const name = command.name.trim();
336 // Permission changes must go through T3 so the client and provider agree.
337 if (!name || name.toLowerCase() === "always-approve") continue;
338 // Grok advertises /context, but its ACP handler completes without emitting output.
339 if (name.toLowerCase() === "context") continue;
340 const description = command.description.trim();
341 const hint = command.input?.hint.trim();
342 byName.set(name, {
343 name,
344 ...(description ? { description } : {}),
345 ...(hint ? { input: { hint } } : {}),
346 });
347 }
348 return [...byName.values()];
349}
350
351/**
352 * Reads model and command metadata from `initialize._meta`. This never calls `authenticate`
353 * or `session/new`, so it cannot open a browser login or boot the workspace's MCP servers.
354 */
355const discoverGrokMetadataViaAcpInitialize = (
356 grokSettings: GrokSettings,
357 environment: NodeJS.ProcessEnv,
358) =>
359 Effect.gen(function* () {
360 const acp = yield* makeGrokAcpRuntime({
361 grokSettings,
362 environment,
363 cwd: process.cwd(),
364 clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" },
365 });
366 const initialized = yield* acp.initialize();
367 return {
368 models: buildGrokModelsFromSessionModelState(sessionModelStateFromInitialize(initialized)),
369 slashCommands: grokSlashCommandsFromInitialize(initialized),
370 };
371 }).pipe(Effect.scoped);
372
373export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* (
374 grokSettings: GrokSettings,
375 environment: NodeJS.ProcessEnv = process.env,
376 cwd?: string,
377): Effect.fn.Return<
378 ServerProviderDraft,
379 never,
380 ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto
381> {
382 const checkedAt = DateTime.formatIso(yield* DateTime.now);
383 const fallbackModels = grokModelsFromSettings(grokSettings.customModels);
384
385 if (!grokSettings.enabled) {
386 return buildServerProvider({
387 presentation: GROK_PRESENTATION,
388 enabled: false,
389 checkedAt,
390 models: fallbackModels,
391 probe: {
392 installed: false,
393 version: null,
394 status: "warning",
395 auth: { status: "unknown" },
396 message: "Grok is disabled in T3 Code settings.",
397 },
398 });
399 }
400
401 const versionResult = yield* runGrokCliCommand(grokSettings, ["--version"], environment).pipe(
402 Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS),
403 Effect.result,
404 );
405
406 if (Result.isFailure(versionResult)) {
407 const error = versionResult.failure;
408 yield* Effect.logWarning("Grok CLI health check failed.", {
409 errorTag: error._tag,
410 });
411 return buildServerProvider({
412 presentation: GROK_PRESENTATION,
413 enabled: grokSettings.enabled,
414 checkedAt,
415 models: fallbackModels,
416 probe: {
417 installed: !isCommandMissingCause(error),
418 version: null,
419 status: "error",
420 auth: { status: "unknown" },
421 message: isCommandMissingCause(error)
422 ? "Grok CLI (`grok`) is not installed or not on PATH."
423 : "Failed to execute Grok CLI health check.",
424 },
425 });
426 }
427
428 if (Option.isNone(versionResult.success)) {
429 return buildServerProvider({
430 presentation: GROK_PRESENTATION,
431 enabled: grokSettings.enabled,
432 checkedAt,
433 models: fallbackModels,
434 probe: {
435 installed: true,
436 version: null,
437 status: "error",
438 auth: { status: "unknown" },
439 message: "Grok CLI is installed but timed out while running `grok --version`.",
440 },
441 });
442 }
443
444 const versionOutput = versionResult.success.value;
445 const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`);
446 if (versionOutput.code !== 0) {
447 yield* Effect.logWarning("Grok CLI version probe exited with a non-zero status.", {
448 exitCode: versionOutput.code,
449 stdoutLength: versionOutput.stdout.length,
450 stderrLength: versionOutput.stderr.length,
451 });
452 return buildServerProvider({
453 presentation: GROK_PRESENTATION,
454 enabled: grokSettings.enabled,
455 checkedAt,
456 models: fallbackModels,
457 probe: {
458 installed: true,
459 version,
460 status: "error",
461 auth: { status: "unknown" },
462 message: "Grok CLI is installed but failed to run.",
463 },
464 });
465 }
466
467 // `grok models` reports login state and model slugs without starting the agent.
468 const modelsResult = yield* runGrokCliCommand(grokSettings, ["models"], environment).pipe(
469 Effect.timeoutOption(AUTH_PROBE_TIMEOUT_MS),
470 Effect.result,
471 );
472 // Only a clean exit is parsed. Failed invocations print help or error text that
473 // must not be read as model slugs or as a login verdict.
474 const modelsOutput =
475 Result.isSuccess(modelsResult) &&
476 Option.isSome(modelsResult.success) &&
477 modelsResult.success.value.code === 0
478 ? modelsResult.success.value
479 : undefined;
480 const cliModels: GrokModelsCliOutput = modelsOutput
481 ? parseGrokModelsCliOutput(`${modelsOutput.stdout}\n${modelsOutput.stderr}`)
482 : { authenticated: null, models: [] };
483 if (!modelsOutput) {
484 yield* Effect.logWarning("Grok CLI model listing failed or timed out.", {
485 errorTag: Result.isFailure(modelsResult)
486 ? modelsResult.failure._tag
487 : Option.isNone(modelsResult.success)
488 ? "Timeout"
489 : `ExitCode${modelsResult.success.value.code}`,
490 });
491 }
492
493 const auth: ServerProviderAuth = environment[GROK_API_KEY_ENV]?.trim()
494 ? { status: "authenticated", type: "api_key", label: "xAI API key" }
495 : cliModels.authenticated === true
496 ? { status: "authenticated", type: "cached_token", label: "Grok account" }
497 : cliModels.authenticated === false
498 ? { status: "unauthenticated" }
499 : { status: "unknown" };
500
501 const skills = yield* discoverGrokSkills(grokSettings, environment, cwd).pipe(
502 Effect.tapError((cause) => Effect.logDebug("Grok skill discovery failed.", { cause })),
503 Effect.orElseSucceed(() => []),
504 );
505
506 const acpExit = yield* discoverGrokMetadataViaAcpInitialize(grokSettings, environment).pipe(
507 Effect.timeoutOption(GROK_ACP_INITIALIZE_TIMEOUT_MS),
508 Effect.exit,
509 );
510 const acpMetadata = Exit.isSuccess(acpExit) ? Option.getOrUndefined(acpExit.value) : undefined;
511 const acpModels = acpMetadata?.models ?? [];
512 const acpFailed = Exit.isFailure(acpExit) || Option.isNone(acpExit.value);
513 if (acpFailed) {
514 yield* Effect.logWarning("Grok ACP initialize probe failed or timed out.", {
515 errorTag: Exit.isFailure(acpExit) ? causeErrorTag(acpExit.cause) : "Timeout",
516 });
517 }
518
519 const discoveredModels = acpModels.length > 0 ? acpModels : cliModels.models;
520 const models =
521 discoveredModels.length > 0
522 ? grokModelsFromSettings(grokSettings.customModels, discoveredModels)
523 : fallbackModels;
524
525 if (auth.status === "unauthenticated") {
526 return buildServerProvider({
527 presentation: GROK_PRESENTATION,
528 enabled: grokSettings.enabled,
529 checkedAt,
530 models,
531 skills,
532 probe: {
533 installed: true,
534 version,
535 status: "error",
536 auth,
537 message: "Grok CLI is installed but not logged in. Run `grok login`.",
538 },
539 });
540 }
541
542 return buildServerProvider({
543 presentation: GROK_PRESENTATION,
544 enabled: grokSettings.enabled,
545 checkedAt,
546 models,
547 skills,
548 slashCommands: acpMetadata?.slashCommands ?? [COMPACT_SLASH_COMMAND],
549 probe: {
550 installed: true,
551 version,
552 // A failed metadata probe degrades the model picker, it does not make chats fail.
553 status: acpFailed ? "warning" : "ready",
554 auth,
555 ...(acpFailed
556 ? {
557 message:
558 "Grok CLI is installed but ACP initialize failed. Model options may be incomplete.",
559 }
560 : {}),
561 },
562 });
563});
564
565export const enrichGrokSnapshot = (input: {
566 readonly snapshot: ServerProvider;
567 readonly maintenanceCapabilities: ProviderMaintenanceCapabilities;
568 readonly enableProviderUpdateChecks?: boolean;
569 readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect<void>;
570}): Effect.Effect<
571 void,
572 never,
573 HttpClient.HttpClient | ProviderLatestVersions.ProviderLatestVersions
574> => {
575 const { snapshot, publishSnapshot } = input;
576
577 return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, {
578 enableProviderUpdateChecks: input.enableProviderUpdateChecks,
579 }).pipe(
580 Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
581 Effect.catchCause((cause) =>
582 Effect.logWarning("Grok version advisory enrichment failed", {
583 errorTag: causeErrorTag(cause),
584 }),
585 ),
586 Effect.asVoid,
587 );
588};