Skip to content
Source
Sign inSign up

ClaudeProvider.ts

900 lines31.4 KB

1import { resolveProviderProbeTimeoutMs } from "@t3tools/provider-core/server/snapshotProbe";
2import {
3 type ClaudeSettings,
4 type ModelCapabilities,
5 type ServerProvider,
6 type ServerProviderModel,
7 type ServerProviderSlashCommand,
8 type ServerProviderResetCredits,
9} from "@t3tools/contracts";
10import * as Cause from "effect/Cause";
11import * as DateTime from "effect/DateTime";
12import * as Effect from "effect/Effect";
13import * as FileSystem from "effect/FileSystem";
14import * as Option from "effect/Option";
15import * as Path from "effect/Path";
16import * as Ref from "effect/Ref";
17import * as Result from "effect/Result";
18import * as Schema from "effect/Schema";
19import { ChildProcess, ChildProcessSpawner } from "effect/process";
20import { createModelCapabilities } from "@t3tools/shared/model";
21import { resolveSpawnCommand } from "@t3tools/shared/shell";
22import {
23 query as claudeQuery,
24 type ModelInfo as ClaudeModelInfo,
25 type Options as ClaudeQueryOptions,
26 type SlashCommand as ClaudeSlashCommand,
27 type SDKControlGetUsageResponse,
28 type SDKUserMessage,
29 type SettingSource,
30} from "@anthropic-ai/claude-agent-sdk";
31​
32import {
33 buildServerProvider,
34 COMPACT_SLASH_COMMAND,
35 DEFAULT_TIMEOUT_MS,
36 isCommandMissingCause,
37 parseCliVersionResult,
38 providerModelsFromSettings,
39 providerProbeEnvironment,
40 spawnAndCollect,
41 type ServerProviderDraft,
42} from "@t3tools/provider-core/server/snapshotProbe";
43import { resolveClaudeSdkExecutablePath } from "./Drivers/ClaudeExecutable.ts";
44import {
45 claudeSignedOutMessage,
46 hasClaudeManagedMcpConfig,
47 makeClaudeEnvironment,
48} from "./Drivers/ClaudeHome.ts";
49import {
50 customClaudeApiEndpointHost,
51 discoverClaudeSkills,
52 readClaudeSettingsEnv,
53} from "./Drivers/ClaudeSkills.ts";
54import type { ProviderWorkspaceSnapshot } from "@t3tools/provider-core/server/driver";
55import { makeUnavailableUsageLimits } from "@t3tools/provider-core/server/usageLimits";
56import {
57 type ClaudeScopedLimitNames,
58 claudeUsageResponseToLimits,
59 recordClaudeUsageResponse,
60} from "./claudeUsageLimits.ts";
61import {
62 BUNDLED_CLAUDE_MODEL_CATALOG,
63 CLAUDE_EFFORT_LEVELS,
64 capClaudeCatalogEffort,
65 type ClaudeEffortCapSource,
66 type ClaudeEffortLevel,
67 type ClaudeModelCatalog,
68 claudeReportedModels,
69 formatClaudeVersionUpgradeMessage,
70 resolveClaudeModelsForVersion,
71 resolveClaudeUpdateRequiredModels,
72 withUltracodeToggle,
73} from "./ClaudeModelCatalog.ts";
74​
75const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({
76 optionDescriptors: [],
77});
78​
79function claudeProviderModels(
80 builtInModels: ReadonlyArray<ServerProviderModel>,
81 customModels: ClaudeSettings["customModels"],
82): ReadonlyArray<ServerProviderModel> {
83 return providerModelsFromSettings(
84 builtInModels,
85 customModels,
86 DEFAULT_CLAUDE_MODEL_CAPABILITIES,
87 ).map((model) =>
88 model.isCustom ? { ...model, capabilities: withUltracodeToggle(model.capabilities) } : model,
89 );
91​
92const CLAUDE_PRESENTATION = {
93 displayName: "Claude",
94 showInteractionModeToggle: true,
95 reportsContextWindow: true,
96} as const;
97function toTitleCaseWords(value: string): string {
98 const parts: Array<string> = [];
99 for (const part of value.split(/[\s_-]+/g)) {
100 if (part.length > 0) {
101 parts.push(part[0]!.toUpperCase() + part.slice(1).toLowerCase());
102 }
103 }
104 return parts.join(" ");
106​
107function claudeSubscriptionLabel(subscriptionType: string | undefined): string | undefined {
108 const normalized = subscriptionType?.toLowerCase().replace(/[\s_-]+/g, "");
109 if (!normalized) return undefined;
110​
111 switch (normalized) {
112 case "claudemaxsubscription":
113 case "claudemax5xsubscription":
114 return "Max";
115 case "claudemax20xsubscription":
116 return "Ultra";
117 case "claudeenterprisesubscription":
118 return "Enterprise";
119 case "claudeteamsubscription":
120 return "Team";
121 case "claudeprosubscription":
122 return "Pro";
123 case "claudefreesubscription":
124 return "Free";
125 case "max":
126 case "maxplan":
127 case "max5":
128 return "Max";
129 case "max20":
130 return "Ultra";
131 case "enterprise":
132 return "Enterprise";
133 case "team":
134 return "Team";
135 case "pro":
136 return "Pro";
137 case "free":
138 return "Free";
139 default:
140 return toTitleCaseWords(subscriptionType!);
141 }
143​
144function normalizeClaudeAuthMethod(authMethod: string | undefined): string | undefined {
145 const normalized = authMethod?.toLowerCase().replace(/[\s_-]+/g, "");
146 if (!normalized) return undefined;
147 if (
148 normalized === "apikey" ||
149 normalized === "anthropicapikey" ||
150 normalized === "anthropicauthtoken"
151 ) {
152 return "apiKey";
153 }
154 return undefined;
156​
157function formatClaudeSubscriptionAuthLabel(subscriptionType: string): string {
158 const subscriptionLabel =
159 claudeSubscriptionLabel(subscriptionType) ?? toTitleCaseWords(subscriptionType);
160 const normalized = subscriptionLabel.toLowerCase().replace(/[\s_-]+/g, "");
161​
162 if (normalized.startsWith("claude") && normalized.endsWith("subscription")) {
163 return subscriptionLabel;
164 }
165 if (normalized.startsWith("claude")) {
166 return `${subscriptionLabel} Subscription`;
167 }
168 if (normalized.endsWith("subscription")) {
169 return `Claude ${subscriptionLabel}`;
170 }
171 return `Claude ${subscriptionLabel} Subscription`;
173​
174function claudeAuthMetadata(input: {
175 readonly subscriptionType: string | undefined;
176 readonly authMethod: string | undefined;
177}): { readonly type: string; readonly label: string } | undefined {
178 if (normalizeClaudeAuthMethod(input.authMethod) === "apiKey") {
179 return {
180 type: "apiKey",
181 label: "Claude API Key",
182 };
183 }
184​
185 if (input.subscriptionType) {
186 return {
187 type: input.subscriptionType,
188 label: formatClaudeSubscriptionAuthLabel(input.subscriptionType),
189 };
190 }
191​
192 return undefined;
194​
195function apiProviderAuthMetadata(
196 apiProvider: string | undefined,
197): { readonly type: string; readonly label: string } | undefined {
198 return apiProvider === "bedrock" ? { type: "bedrock", label: "Amazon Bedrock" } : undefined;
200​
201/** Probe completion only proves Claude started. Only tokenSource: "none" disproves auth. */
202function claudeAuthStatus(
203 capabilities: Pick<
204 ClaudeCapabilitiesProbe,
205 "email" | "subscriptionType" | "tokenSource" | "apiKeySource" | "apiProvider"
206 >,
207): "authenticated" | "unauthenticated" {
208 if (capabilities.apiProvider !== undefined && capabilities.apiProvider !== "firstParty") {
209 return "authenticated";
210 }
211 if (capabilities.tokenSource !== "none") return "authenticated";
212 // API keys can authenticate with tokenSource: none; also check key and account fields.
213 const hasApiKey = Boolean(capabilities.apiKeySource) && capabilities.apiKeySource !== "none";
214 return hasApiKey || capabilities.email || capabilities.subscriptionType
215 ? "authenticated"
216 : "unauthenticated";
218​
219// Bedrock initialization includes backend startup and awsAuthRefresh, taking longer than first-party auth.
220const CAPABILITIES_PROBE_TIMEOUT_MS = 25_000;
221// Native CLI startup on Windows can exceed the shared probe deadline.
222const CLAUDE_VERSION_CHECK_TIMEOUT_MS = 20_000;
223​
224const CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES = [
225 "user",
226 "project",
227 "local",
228] as const satisfies ReadonlyArray<SettingSource>;
229​
230export function buildClaudeCapabilitiesProbeQueryOptions(input: {
231 readonly executablePath: string;
232 readonly abortController: AbortController;
233 readonly environment: NodeJS.ProcessEnv;
234 readonly cwd: string | undefined;
235 readonly managedMcpConfig: boolean;
236}): ClaudeQueryOptions {
237 return {
238 persistSession: false,
239 pathToClaudeCodeExecutable: input.executablePath,
240 abortController: input.abortController,
241 settingSources: [...CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES],
242 // Keep settings for slash-command discovery, but disable hooks so health checks do not run SessionStart repeatedly.
243 settings: { disableAllHooks: true },
244 allowedTools: [],
245 // Exclude filesystem MCP servers; omit strict mode when managed-mcp.json already excludes them and forbids the flag.
246 mcpServers: {},
247 ...(input.managedMcpConfig ? {} : { strictMcpConfig: true }),
248 env: {
249 ...providerProbeEnvironment(input.environment),
250 // Disable claude.ai MCP separately; it is discovered outside filesystem config.
251 ENABLE_CLAUDEAI_MCP_SERVERS: "false",
252 // Skipping this avoids a Windows tasklist | findstr process tree on every refresh.
253 FORCE_CODE_TERMINAL: undefined,
254 CLAUDE_CODE_AUTO_CONNECT_IDE: "0",
255 CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL: "1",
256 },
257 ...(input.cwd ? { cwd: input.cwd } : {}),
258 stderr: () => {},
259 };
261​
262function nonEmptyProbeString(value: string): string | undefined {
263 const candidate = value.trim();
264 return candidate ? candidate : undefined;
266​
267export type ClaudeCapabilitiesProbe = {
268 readonly email: string | undefined;
269 readonly workspaceId?: string | undefined;
270 readonly subscriptionType: string | undefined;
271 readonly tokenSource: string | undefined;
272 readonly apiKeySource: string | undefined;
273 /** OAuth applies only to firstParty; Bedrock uses external AWS credentials and omits subscription/token fields. */
274 readonly apiProvider: string | undefined;
275 readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>;
276 readonly models?: ReadonlyArray<ClaudeModelInfo>;
277 /** Undefined means get_usage failed; absent windows on success mean no subscription windows, as with API keys. */
278 readonly usage?: Pick<SDKControlGetUsageResponse, "rate_limits_available" | "rate_limits">;
279 readonly usageCheckedAt?: string;
280 /** Each settings source's `maxEffortLevel` caps; Claude Code clamps every higher effort. */
281 readonly effortCapSources?: ReadonlyArray<ClaudeEffortCapSource>;
282};
283​
284// `getSettings` exists at runtime but is missing from the SDK's `Query` type, and older CLIs
285// reject the control request.
286type ClaudeSettingsQuery = {
287 readonly getSettings?: () => Promise<{
288 readonly effective?: unknown;
289 readonly sources?: unknown;
290 }>;
291};
292​
293function parseClaudeMaxEffortLevel(value: unknown): ClaudeEffortLevel | undefined {
294 return CLAUDE_EFFORT_LEVELS.find((level) => level === value);
296​
297function parseClaudeModelMaxEffortLevels(
298 modelSettings: unknown,
299): ReadonlyMap<string, ClaudeEffortLevel> | undefined {
300 if (!modelSettings || typeof modelSettings !== "object") return undefined;
301 const levels = new Map<string, ClaudeEffortLevel>();
302 for (const [model, settings] of Object.entries(modelSettings)) {
303 const level = parseClaudeMaxEffortLevel(
304 settings && typeof settings === "object"
305 ? (settings as { readonly maxEffortLevel?: unknown }).maxEffortLevel
306 : undefined,
307 );
308 if (level) levels.set(model, level);
309 }
310 return levels.size > 0 ? levels : undefined;
312​
313function parseClaudeEffortCapSource(settings: unknown): ClaudeEffortCapSource | undefined {
314 if (!settings || typeof settings !== "object") return undefined;
315 const { maxEffortLevel, modelSettings } = settings as {
316 readonly maxEffortLevel?: unknown;
317 readonly modelSettings?: unknown;
318 };
319 const level = parseClaudeMaxEffortLevel(maxEffortLevel);
320 const modelMaxEffortLevels = parseClaudeModelMaxEffortLevels(modelSettings);
321 return level || modelMaxEffortLevels
322 ? {
323 ...(level ? { maxEffortLevel: level } : {}),
324 ...(modelMaxEffortLevels ? { modelMaxEffortLevels } : {}),
325 }
326 : undefined;
328​
329// `sources` holds each settings file's raw settings. CLIs that report only the merged `effective`
330// settings are read as a single source.
331function parseClaudeEffortCapSources(
332 settings: { readonly effective?: unknown; readonly sources?: unknown } | undefined,
333): ReadonlyArray<ClaudeEffortCapSource> | undefined {
334 const sources = Array.isArray(settings?.sources)
335 ? settings.sources.flatMap((source) => {
336 const parsed = parseClaudeEffortCapSource(
337 source && typeof source === "object"
338 ? (source as { readonly settings?: unknown }).settings
339 : undefined,
340 );
341 return parsed ? [parsed] : [];
342 })
343 : [parseClaudeEffortCapSource(settings?.effective)].filter((source) => source !== undefined);
344 return sources.length > 0 ? sources : undefined;
346​
347function parseClaudeInitializationCommands(
348 commands: ReadonlyArray<ClaudeSlashCommand> | undefined,
349): ReadonlyArray<ServerProviderSlashCommand> {
350 return dedupeSlashCommands(
351 (commands ?? []).flatMap((command) => {
352 const name = nonEmptyProbeString(command.name);
353 if (!name) {
354 return [];
355 }
356​
357 const description = nonEmptyProbeString(command.description);
358 const argumentHint = nonEmptyProbeString(command.argumentHint);
359​
360 return [
361 {
362 name,
363 ...(description ? { description } : {}),
364 ...(argumentHint ? { input: { hint: argumentHint } } : {}),
365 } satisfies ServerProviderSlashCommand,
366 ];
367 }),
368 );
370​
371function dedupeSlashCommands(
372 commands: ReadonlyArray<ServerProviderSlashCommand>,
373): ReadonlyArray<ServerProviderSlashCommand> {
374 const commandsByName = new Map<string, ServerProviderSlashCommand>();
375​
376 for (const command of commands) {
377 const name = nonEmptyProbeString(command.name);
378 if (!name) {
379 continue;
380 }
381​
382 const key = name.toLowerCase();
383 const existing = commandsByName.get(key);
384 if (!existing) {
385 commandsByName.set(key, {
386 ...command,
387 name,
388 });
389 continue;
390 }
391​
392 commandsByName.set(key, {
393 ...existing,
394 ...(existing.description
395 ? {}
396 : command.description
397 ? { description: command.description }
398 : {}),
399 ...(existing.input?.hint
400 ? {}
401 : command.input?.hint
402 ? { input: { hint: command.input.hint } }
403 : {}),
404 });
405 }
406​
407 return [...commandsByName.values()];
409​
410function waitForAbortSignal(signal: AbortSignal): Promise<void> {
411 if (signal.aborted) {
412 return Promise.resolve();
413 }
414 return new Promise((resolve) => {
415 signal.addEventListener("abort", () => resolve(), { once: true });
416 });
418​
419/**
420 * With gateway model discovery on, Claude Code refreshes its model cache in the background after
421 * initialization, so the probe subprocess outlives init by Claude Code's discovery timeout.
422 */
423function claudeGatewayDiscoveryGraceMs(environment: Readonly<Record<string, unknown>>): number {
424 const enabled = String(environment.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY ?? "")
425 .trim()
426 .toLowerCase();
427 if (!enabled || enabled === "0" || enabled === "false") return 0;
428 if (!String(environment.ANTHROPIC_BASE_URL ?? "").trim()) return 0;
429 const configured = Number(environment.CLAUDE_CODE_GATEWAY_MODEL_DISCOVERY_TIMEOUT_MS);
430 return Number.isFinite(configured) && configured > 0 ? configured : 3_000;
432​
433class ClaudeCapabilitiesProbeSdkError extends Schema.TaggedError<ClaudeCapabilitiesProbeSdkError>()(
434 "ClaudeCapabilitiesProbeSdkError",
435 { operation: Schema.Literals(["initialize", "usage", "settings"]), cause: Schema.Defect() },
436) {
437 override get message(): string {
438 return `Claude capability probe ${this.operation} failed.`;
439 }
441​
442/** A never-yielding prompt allows local initialization IPC without sending an Anthropic request. */
443const probeClaudeCapabilities = (
444 claudeSettings: ClaudeSettings,
445 environment?: NodeJS.ProcessEnv,
446 cwd?: string,
447 includeStatus = true,
448) => {
449 const abort = new AbortController();
450 const abortProbe = Effect.sync(() => {
451 if (!abort.signal.aborted) abort.abort();
452 });
453 return Effect.gen(function* () {
454 const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment);
455 const executablePath = yield* resolveClaudeSdkExecutablePath(
456 claudeSettings.binaryPath,
457 claudeEnvironment,
458 );
459 const managedMcpConfig = yield* hasClaudeManagedMcpConfig();
460 // Workspace probes (no usage) discard models; the machine probe refreshes the shared cache.
461 const graceMs = includeStatus
462 ? claudeGatewayDiscoveryGraceMs({
463 ...claudeEnvironment,
464 ...(yield* readClaudeSettingsEnv(claudeSettings, cwd, claudeEnvironment)),
465 })
466 : 0;
467 return yield* Effect.tryPromise({
468 try: async () => {
469 const q = claudeQuery({
470 // oxlint-disable-next-line require-yield -- never yields, so no prompt reaches the API
471 prompt: (async function* (): AsyncGenerator<SDKUserMessage> {
472 await waitForAbortSignal(abort.signal);
473 })(),
474 options: buildClaudeCapabilitiesProbeQueryOptions({
475 executablePath,
476 abortController: abort,
477 environment: claudeEnvironment,
478 cwd,
479 managedMcpConfig,
480 }),
481 });
482 const init = await q.initializationResult();
483 return { q, init, graceMs };
484 },
485 catch: (cause) => new ClaudeCapabilitiesProbeSdkError({ operation: "initialize", cause }),
486 });
487 }).pipe(
488 Effect.timeout(
489 resolveProviderProbeTimeoutMs("claude", CAPABILITIES_PROBE_TIMEOUT_MS, environment),
490 ),
491 Effect.flatMap(({ q, init, graceMs }) =>
492 Effect.gen(function* () {
493 // Usage has its own deadline so a slow optional request cannot discard initialization.
494 // Only the rate limits are read, so skip the local transcript scan that fills
495 // `behaviors`: with a few GB of transcripts it outlasts the deadline.
496 // The effort caps are read in parallel under the same deadline; workspace probes skip both.
497 const usageCheckedAt = DateTime.formatIso(yield* DateTime.now);
498 const { getSettings } = q as ClaudeSettingsQuery;
499 const [usageResult, settingsResult] = yield* Effect.all(
500 [
501 includeStatus
502 ? Effect.tryPromise({
503 try: () =>
504 q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET({
505 skipBehaviors: true,
506 }),
507 catch: (cause) =>
508 new ClaudeCapabilitiesProbeSdkError({ operation: "usage", cause }),
509 }).pipe(Effect.timeout(DEFAULT_TIMEOUT_MS), Effect.result)
510 : Effect.undefined,
511 includeStatus && getSettings
512 ? Effect.tryPromise({
513 try: () => getSettings.call(q),
514 catch: (cause) =>
515 new ClaudeCapabilitiesProbeSdkError({ operation: "settings", cause }),
516 }).pipe(Effect.timeout(DEFAULT_TIMEOUT_MS), Effect.result)
517 : Effect.undefined,
518 ],
519 { concurrency: "unbounded" },
520 );
521 const usage =
522 usageResult && Result.isSuccess(usageResult)
523 ? {
524 rate_limits_available: usageResult.success.rate_limits_available,
525 rate_limits: usageResult.success.rate_limits,
526 }
527 : undefined;
528 const effortCapSources = parseClaudeEffortCapSources(
529 settingsResult && Result.isSuccess(settingsResult) ? settingsResult.success : undefined,
530 );
531 const account = init.account as
532 | {
533 readonly email?: string;
534 readonly subscriptionType?: string;
535 readonly tokenSource?: string;
536 readonly apiKeySource?: string;
537 readonly apiProvider?: string;
538 }
539 | undefined;
540 return {
541 email: account?.email,
542 subscriptionType: account?.subscriptionType,
543 tokenSource: account?.tokenSource,
544 apiKeySource: account?.apiKeySource,
545 apiProvider: account?.apiProvider,
546 slashCommands: parseClaudeInitializationCommands(init.commands),
547 ...(init.models?.length ? { models: init.models } : {}),
548 ...(usage ? { usage, usageCheckedAt } : {}),
549 ...(effortCapSources ? { effortCapSources } : {}),
550 } satisfies ClaudeCapabilitiesProbe;
551 }).pipe(
552 Effect.tap(() =>
553 graceMs > 0
554 ? Effect.sleep(graceMs).pipe(Effect.andThen(abortProbe), Effect.forkDetach)
555 : abortProbe,
556 ),
557 ),
558 ),
559 Effect.onError(() => abortProbe),
560 Effect.tapError((error) =>
561 Effect.logWarning("Claude capability probe failed.", { errorTag: error._tag }),
562 ),
563 );
564};
565​
566type ClaudeCapabilitiesProbeError = Effect.Error<ReturnType<typeof probeClaudeCapabilities>>;
567​
568const runClaudeCommand = Effect.fn("runClaudeCommand")(function* (
569 claudeSettings: ClaudeSettings,
570 args: ReadonlyArray<string>,
571 environment?: NodeJS.ProcessEnv,
572 cwd?: string,
573) {
574 const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment);
575 const spawnCommand = yield* resolveSpawnCommand(claudeSettings.binaryPath, args, {
576 env: claudeEnvironment,
577 });
578 const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, {
579 env: claudeEnvironment,
580 shell: spawnCommand.shell,
581 ...(cwd ? { cwd } : {}),
582 });
583 return yield* spawnAndCollect(claudeSettings.binaryPath, command);
584});
585​
586export const probeClaudeWorkspaceSnapshot = Effect.fn("probeClaudeWorkspaceSnapshot")(function* (
587 claudeSettings: ClaudeSettings,
588 machineSnapshot: ServerProvider,
589 cwd: string,
590 environment?: NodeJS.ProcessEnv,
591): Effect.fn.Return<ProviderWorkspaceSnapshot, never, FileSystem.FileSystem | Path.Path> {
592 if (!claudeSettings.enabled) return machineSnapshot;
593 const skills = yield* discoverClaudeSkills(claudeSettings, cwd, environment);
594 const fileSystem = yield* FileSystem.FileSystem;
595 // A deleted worktree can never answer the probe. Pending would make clients rescan it forever.
596 const cwdExists = yield* fileSystem.exists(cwd).pipe(Effect.orElseSucceed(() => true));
597 const probeResult = cwdExists
598 ? yield* probeClaudeCapabilities(claudeSettings, environment, cwd, false).pipe(Effect.result)
599 : undefined;
600 const capabilities =
601 probeResult && Result.isSuccess(probeResult) ? probeResult.success : undefined;
602 return {
603 ...machineSnapshot,
604 skills,
605 slashCommands: dedupeSlashCommands([
606 COMPACT_SLASH_COMMAND,
607 ...(capabilities?.slashCommands ?? []),
608 ]),
609 slashCommandsPending: cwdExists && !capabilities,
610 };
611});
612​
613export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(function* (
614 claudeSettings: ClaudeSettings,
615 resolveCapabilities?: (
616 claudeSettings: ClaudeSettings,
617 ) => Effect.Effect<ClaudeCapabilitiesProbe | undefined, ClaudeCapabilitiesProbeError>,
618 environment?: NodeJS.ProcessEnv,
619 cwd?: string,
620 modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG,
621 /** Shared with the adapter so turn events reuse the scoped-bucket names this probe saw. */
622 scopedLimitNames?: Ref.Ref<ClaudeScopedLimitNames>,
623 resolveResetCredits?: (version: string) => Effect.Effect<ServerProviderResetCredits | undefined>,
624): Effect.fn.Return<
625 ServerProviderDraft,
626 never,
627 ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path
628> {
629 const resolvedEnvironment = environment ?? process.env;
630 const checkedAt = DateTime.formatIso(yield* DateTime.now);
631 const allModels = claudeProviderModels(
632 modelCatalog.models.map((entry) => entry.model),
633 claudeSettings.customModels,
634 );
635​
636 if (!claudeSettings.enabled) {
637 return buildServerProvider({
638 presentation: CLAUDE_PRESENTATION,
639 enabled: false,
640 checkedAt,
641 models: allModels,
642 probe: {
643 installed: false,
644 version: null,
645 status: "warning",
646 auth: { status: "unknown" },
647 message: "Claude is disabled in T3 Code settings.",
648 },
649 });
650 }
651​
652 const versionProbe = yield* runClaudeCommand(
653 claudeSettings,
654 ["--version"],
655 providerProbeEnvironment(resolvedEnvironment),
656 cwd,
657 ).pipe(
658 Effect.timeoutOption(
659 resolveProviderProbeTimeoutMs("claude", CLAUDE_VERSION_CHECK_TIMEOUT_MS, resolvedEnvironment),
660 ),
661 Effect.result,
662 );
663​
664 if (Result.isFailure(versionProbe)) {
665 const error = versionProbe.failure;
666 yield* Effect.logWarning("Claude Agent CLI health check failed.", {
667 errorTag: error._tag,
668 });
669 return buildServerProvider({
670 presentation: CLAUDE_PRESENTATION,
671 enabled: claudeSettings.enabled,
672 checkedAt,
673 models: allModels,
674 probe: {
675 installed: !isCommandMissingCause(error),
676 version: null,
677 status: "error",
678 auth: { status: "unknown" },
679 message: isCommandMissingCause(error)
680 ? "Claude Agent CLI (`claude`) was not found on PATH."
681 : "Failed to execute Claude Agent CLI health check.",
682 },
683 });
684 }
685​
686 if (Option.isNone(versionProbe.success)) {
687 return buildServerProvider({
688 presentation: CLAUDE_PRESENTATION,
689 enabled: claudeSettings.enabled,
690 checkedAt,
691 models: allModels,
692 probe: {
693 installed: true,
694 version: null,
695 status: "error",
696 auth: { status: "unknown" },
697 message:
698 "Claude Agent CLI is installed but failed to run. Timed out while running command.",
699 },
700 });
701 }
702​
703 const version = versionProbe.success.value;
704 const parsedVersion = parseCliVersionResult(version);
705 if (version.code !== 0) {
706 yield* Effect.logWarning("Claude Agent CLI version probe exited with a non-zero status.", {
707 exitCode: version.code,
708 stdoutLength: version.stdout.length,
709 stderrLength: version.stderr.length,
710 });
711 return buildServerProvider({
712 presentation: CLAUDE_PRESENTATION,
713 enabled: claudeSettings.enabled,
714 checkedAt,
715 models: allModels,
716 probe: {
717 installed: true,
718 version: parsedVersion,
719 status: "error",
720 auth: { status: "unknown" },
721 message: "Claude Agent CLI is installed but failed to run.",
722 },
723 });
724 }
725​
726 const updateRequiredModels = resolveClaudeUpdateRequiredModels(modelCatalog, parsedVersion);
727 const versionUpgradeMessage = formatClaudeVersionUpgradeMessage(modelCatalog, parsedVersion);
728​
729 const probeResult = resolveCapabilities
730 ? yield* resolveCapabilities(claudeSettings).pipe(Effect.result)
731 : undefined;
732 const capabilities =
733 probeResult && Result.isSuccess(probeResult) ? probeResult.success : undefined;
734 const probeFailure =
735 probeResult && Result.isFailure(probeResult) ? probeResult.failure : undefined;
736 const models = capClaudeCatalogEffort(
737 modelCatalog,
738 claudeProviderModels(
739 [
740 ...resolveClaudeModelsForVersion(modelCatalog, parsedVersion),
741 ...claudeReportedModels(modelCatalog, claudeSettings.customModels, capabilities?.models),
742 ],
743 claudeSettings.customModels,
744 ),
745 capabilities?.effortCapSources ?? [],
746 );
747 const skills = yield* discoverClaudeSkills(claudeSettings, cwd, resolvedEnvironment);
748 const slashCommands = [COMPACT_SLASH_COMMAND, ...(capabilities?.slashCommands ?? [])];
749 const dedupedSlashCommands = dedupeSlashCommands(slashCommands);
750​
751 if (!capabilities) {
752 return buildServerProvider({
753 presentation: CLAUDE_PRESENTATION,
754 enabled: claudeSettings.enabled,
755 checkedAt,
756 models,
757 updateRequiredModels,
758 slashCommands: dedupedSlashCommands,
759 skills,
760 probe: {
761 installed: true,
762 version: parsedVersion,
763 status: "warning",
764 auth: { status: "unknown" },
765 message: probeFailure
766 ? Cause.isTimeoutError(probeFailure)
767 ? `Timed out after ${resolveProviderProbeTimeoutMs("claude", CAPABILITIES_PROBE_TIMEOUT_MS, resolvedEnvironment) / 1_000}s while checking Claude account status.`
768 : "Claude Agent CLI failed while checking account status."
769 : "Could not verify Claude authentication status from initialization result.",
770 },
771 });
772 }
773​
774 // First-party account fields cannot verify custom endpoint credentials.
775 const baseUrl =
776 (yield* readClaudeSettingsEnv(claudeSettings, cwd, resolvedEnvironment)).ANTHROPIC_BASE_URL ??
777 resolvedEnvironment.ANTHROPIC_BASE_URL;
778 const customApiEndpoint = customClaudeApiEndpointHost(baseUrl) !== undefined;
779 if (!customApiEndpoint && claudeAuthStatus(capabilities) === "unauthenticated") {
780 const path = yield* Path.Path;
781 const configDir = (yield* makeClaudeEnvironment(claudeSettings, resolvedEnvironment))
782 .CLAUDE_CONFIG_DIR;
783 return buildServerProvider({
784 presentation: CLAUDE_PRESENTATION,
785 enabled: claudeSettings.enabled,
786 checkedAt,
787 models,
788 updateRequiredModels,
789 slashCommands: dedupedSlashCommands,
790 skills,
791 probe: {
792 installed: true,
793 version: parsedVersion,
794 status: "error",
795 auth: { status: "unauthenticated" },
796 message:
797 configDir === undefined
798 ? "Claude Code is not authenticated. Run `claude auth login` and try again."
799 : claudeSignedOutMessage({ configDir, cwd: path.resolve(cwd ?? ".") }),
800 },
801 });
802 }
803​
804 // A proxy's ANTHROPIC_AUTH_TOKEN reads as an API key; the endpoint is what the token is for.
805 const authMetadata =
806 apiProviderAuthMetadata(capabilities.apiProvider) ??
807 (customApiEndpoint ? { type: "customEndpoint", label: "Custom API endpoint" } : undefined) ??
808 claudeAuthMetadata({
809 subscriptionType: capabilities.subscriptionType,
810 authMethod: capabilities.tokenSource,
811 });
812 const usageCheckedAt = capabilities.usageCheckedAt ?? checkedAt;
813 const usageLimits = !capabilities.usage
814 ? makeUnavailableUsageLimits({ checkedAt, reason: "probeFailed" })
815 : scopedLimitNames
816 ? yield* recordClaudeUsageResponse(scopedLimitNames, {
817 response: capabilities.usage,
818 checkedAt: usageCheckedAt,
819 })
820 : claudeUsageResponseToLimits({
821 response: capabilities.usage,
822 checkedAt: usageCheckedAt,
823 }).limits;
824 const resetCredits =
825 resolveResetCredits &&
826 capabilities.subscriptionType &&
827 !usageLimits.unavailable &&
828 parsedVersion
829 ? yield* resolveResetCredits(parsedVersion)
830 : undefined;
831 return buildServerProvider({
832 presentation: CLAUDE_PRESENTATION,
833 enabled: claudeSettings.enabled,
834 checkedAt,
835 models,
836 updateRequiredModels,
837 slashCommands: dedupedSlashCommands,
838 skills,
839 probe: {
840 installed: true,
841 version: parsedVersion,
842 status: "ready",
843 auth: {
844 status: "authenticated",
845 ...(capabilities.email ? { email: capabilities.email } : {}),
846 ...(capabilities.workspaceId ? { workspaceId: capabilities.workspaceId } : {}),
847 ...(authMetadata ? authMetadata : {}),
848 },
849 ...(versionUpgradeMessage ? { message: versionUpgradeMessage } : {}),
850 usageLimits: resetCredits ? { ...usageLimits, resetCredits } : usageLimits,
851 },
852 });
853});
854​
855const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
856​
857export const makePendingClaudeProvider = Effect.fn("ClaudeProvider.makePendingClaudeProvider")(
858 function* (
859 claudeSettings: ClaudeSettings,
860 modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG,
861 ): Effect.fn.Return<ServerProviderDraft> {
862 const checkedAt = yield* nowIso;
863 const models = claudeProviderModels(
864 modelCatalog.models.map((entry) => entry.model),
865 claudeSettings.customModels,
866 );
867​
868 if (!claudeSettings.enabled) {
869 return buildServerProvider({
870 presentation: CLAUDE_PRESENTATION,
871 enabled: false,
872 checkedAt,
873 models,
874 probe: {
875 installed: false,
876 version: null,
877 status: "warning",
878 auth: { status: "unknown" },
879 message: "Claude is disabled in T3 Code settings.",
880 },
881 });
882 }
883​
884 return buildServerProvider({
885 presentation: CLAUDE_PRESENTATION,
886 enabled: true,
887 checkedAt,
888 models,
889 probe: {
890 installed: false,
891 version: null,
892 status: "warning",
893 auth: { status: "unknown" },
894 message: "Claude provider status has not been checked in this session yet.",
895 },
896 });
897 },
898);
899​
900export { probeClaudeCapabilities };