1/**
2 * ClaudeTextGeneration – Text generation layer using the Claude CLI.
3 *
4 * Implements the same TextGeneration service contract as CodexTextGeneration but
5 * delegates to the `claude` CLI (`claude -p`) with structured JSON output
6 * instead of the `codex exec` CLI.
7 *
8 * @module ClaudeTextGeneration
9 */
10import * as Effect from "effect/Effect";
11import * as FileSystem from "effect/FileSystem";
12import * as Option from "effect/Option";
13import * as Schema from "effect/Schema";
14import * as Stream from "effect/Stream";
15import { ChildProcess, ChildProcessSpawner } from "effect/process";
16
17import { type ClaudeSettings, TextGenerationError } from "@t3tools/contracts";
18import { resolveSpawnCommand } from "@t3tools/shared/shell";
19
20import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations";
21import {
22 normalizeCliError,
23 toJsonSchemaObject,
24} from "@t3tools/provider-core/server/textGenerationUtils";
25import {
26 getModelSelectionStringOptionValue,
27 getProviderOptionDescriptors,
28} from "@t3tools/shared/model";
29import {
30 BUNDLED_CLAUDE_MODEL_CATALOG,
31 type ClaudeModelCatalog,
32 getClaudeCatalogModelCapabilities,
33 isClaudeCatalogUltracodeEffort,
34 normalizeClaudeCatalogEffort,
35 resolveClaudeCatalogApiModelId,
36 resolveClaudeCatalogEffort,
37 resolveClaudeModelSlug,
38 scopeClaudeModelCatalog,
39} from "../provider/ClaudeModelCatalog.ts";
40import {
41 hasClaudeManagedMcpConfig,
42 makeClaudeEnvironment,
43} from "../provider/Drivers/ClaudeHome.ts";
44
45const CLAUDE_TIMEOUT_MS = 180_000;
46/** The tools an investigation can see. `--tools` takes bare names; patterns go in the allow list. */
47const CLAUDE_INVESTIGATION_TOOLS = "Read,Grep,Glob,Bash";
48/** What it may run without asking. In dontAsk mode every other call, Bash included, is refused. */
49const CLAUDE_INVESTIGATION_ALLOWED = [
50 "Read",
51 "Grep",
52 "Glob",
53 "Bash(git diff:*)",
54 "Bash(git log:*)",
55 "Bash(git show:*)",
56].join(",");
57/**
58 * The allowed git commands accept any trailing arguments, and `--output` makes each of them write
59 * a file anywhere. Deny rules win over allow rules, so this keeps the investigation read-only.
60 */
61const CLAUDE_INVESTIGATION_DENIED = ["Bash(git * --output*)", "Bash(git * -o *)"].join(",");
62
63/**
64 * Schema for the wrapper JSON returned by `claude -p --output-format json`.
65 * Verbose mode wraps the result in an array of conversation messages.
66 */
67const ClaudeOutputEnvelope = Schema.Struct({
68 structured_output: Schema.Unknown,
69});
70const ClaudeOutputMessage = Schema.Struct({
71 type: Schema.String,
72 structured_output: Schema.optionalKey(Schema.Unknown),
73});
74const isClaudeOutputEnvelope = Schema.is(ClaudeOutputEnvelope);
75
76const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
77const decodeClaudeOutput = Schema.decodeEffect(
78 Schema.fromJsonString(Schema.Union([ClaudeOutputEnvelope, Schema.Array(ClaudeOutputMessage)])),
79);
80
81const ClaudeErrorResult = Schema.Struct({ is_error: Schema.Literal(true), result: Schema.String });
82const isClaudeErrorResult = Schema.is(ClaudeErrorResult);
83const decodeJsonOption = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown));
84
85/**
86 * The error a failed `claude -p --output-format json` run reports on stdout. Stderr may only
87 * hold notices from a wrapper around the binary, so the caller prefers this when present.
88 */
89function claudeErrorResult(stdout: string): string | undefined {
90 const output = Option.getOrUndefined(decodeJsonOption(stdout));
91 const messages: ReadonlyArray<unknown> = Array.isArray(output) ? output : [output];
92 return messages
93 .filter(isClaudeErrorResult)
94 .map((message) => message.result.trim())
95 .findLast((result) => result.length > 0);
96}
97
98export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* (
99 claudeSettings: ClaudeSettings,
100 environment?: NodeJS.ProcessEnv,
101 modelCatalog: Effect.Effect<ClaudeModelCatalog> = Effect.succeed(BUNDLED_CLAUDE_MODEL_CATALOG),
102) {
103 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
104 const fileSystem = yield* FileSystem.FileSystem;
105 const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment);
106 const scopedModelCatalog = modelCatalog.pipe(
107 Effect.map((catalog) => scopeClaudeModelCatalog(catalog, claudeSettings.customModels)),
108 );
109
110 const readStreamAsString = <E>(
111 operation: string,
112 stream: Stream.Stream<Uint8Array, E>,
113 ): Effect.Effect<string, TextGenerationError> =>
114 stream.pipe(
115 Stream.decodeText(),
116 Stream.runFold(
117 () => "",
118 (acc, chunk) => acc + chunk,
119 ),
120 Effect.mapError((cause) =>
121 normalizeCliError("claude", operation, cause, "Failed to collect process output"),
122 ),
123 );
124
125 const encodeJsonForOperation = (
126 operation: TextGenerationOperations.Operation,
127 value: unknown,
128 detail: string,
129 ): Effect.Effect<string, TextGenerationError> =>
130 encodeJsonString(value).pipe(
131 Effect.mapError(
132 (cause) =>
133 new TextGenerationError({
134 operation,
135 detail,
136 cause,
137 }),
138 ),
139 );
140
141 /**
142 * Spawn the Claude CLI with structured JSON output and return the parsed,
143 * schema-validated result.
144 */
145 const runClaudeJson = Effect.fn("runClaudeJson")(function* <S extends Schema.Top>({
146 operation,
147 cwd,
148 prompt,
149 outputSchema: outputSchemaJson,
150 modelSelection,
151 investigate,
152 }: TextGenerationOperations.Request<S>): Effect.fn.Return<
153 S["Type"],
154 TextGenerationError,
155 S["DecodingServices"]
156 > {
157 const catalog = yield* scopedModelCatalog;
158 const resolvedModelSelection = {
159 ...modelSelection,
160 model: resolveClaudeModelSlug(catalog, modelSelection.model),
161 };
162 const jsonSchemaStr = yield* encodeJsonForOperation(
163 operation,
164 toJsonSchemaObject(outputSchemaJson),
165 "Failed to encode structured output schema.",
166 );
167 const caps = getClaudeCatalogModelCapabilities(catalog, resolvedModelSelection.model);
168 const descriptors = getProviderOptionDescriptors({
169 caps,
170 selections: resolvedModelSelection.options,
171 });
172 const findDescriptor = (id: string) => descriptors.find((descriptor) => descriptor.id === id);
173 const rawEffortSelection = getModelSelectionStringOptionValue(resolvedModelSelection, "effort");
174 const resolvedEffort = resolveClaudeCatalogEffort(
175 catalog,
176 resolvedModelSelection.model,
177 rawEffortSelection,
178 );
179 const cliEffort = normalizeClaudeCatalogEffort(
180 catalog,
181 resolvedEffort,
182 resolvedModelSelection.model,
183 );
184 const ultracode = isClaudeCatalogUltracodeEffort(resolvedEffort);
185 const thinkingDescriptor = findDescriptor("thinking");
186 const fastModeDescriptor = findDescriptor("fastMode");
187 const thinking =
188 thinkingDescriptor?.type === "boolean" ? thinkingDescriptor.currentValue : undefined;
189 const fastMode =
190 fastModeDescriptor?.type === "boolean" ? fastModeDescriptor.currentValue : undefined;
191 const settings = {
192 disableAllHooks: true,
193 ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}),
194 ...(fastMode ? { fastMode: true } : {}),
195 ...(ultracode ? { ultracode: true } : {}),
196 };
197 const settingsJson = yield* encodeJsonForOperation(
198 operation,
199 settings,
200 "Failed to encode Claude CLI settings.",
201 );
202
203 const runClaudeCommand = Effect.fn("runClaudeJson.runClaudeCommand")(function* () {
204 // Titles need only the supplied prompt, not configuration from the checkout.
205 const workingDirectory =
206 operation === "generateThreadTitle"
207 ? yield* fileSystem
208 .makeTempDirectoryScoped({ prefix: "t3code-claude-title-" })
209 .pipe(
210 Effect.mapError((cause) =>
211 normalizeCliError("claude", operation, cause, "Failed to create title directory"),
212 ),
213 )
214 : cwd;
215 const managedMcpConfig = yield* hasClaudeManagedMcpConfig();
216 const spawnCommand = yield* resolveSpawnCommand(
217 claudeSettings.binaryPath || "claude",
218 [
219 "-p",
220 "--output-format",
221 "json",
222 "--json-schema",
223 jsonSchemaStr,
224 "--model",
225 resolveClaudeCatalogApiModelId(catalog, resolvedModelSelection),
226 ...(cliEffort ? ["--effort", cliEffort] : []),
227 "--settings",
228 settingsJson,
229 // Metadata prompts need no executable capabilities, even when they contain a skill name.
230 // An investigation gets the read-only file tools and Git's read commands, and nothing
231 // that writes: in dontAsk mode anything not allowed here is refused.
232 ...(investigate
233 ? [
234 "--tools",
235 CLAUDE_INVESTIGATION_TOOLS,
236 "--allowedTools",
237 CLAUDE_INVESTIGATION_ALLOWED,
238 "--disallowedTools",
239 CLAUDE_INVESTIGATION_DENIED,
240 ]
241 : ["--tools", ""]),
242 "--disable-slash-commands",
243 // Claude refuses this flag next to a managed-mcp.json, which already keeps the
244 // user's own MCP servers out.
245 ...(managedMcpConfig ? [] : ["--strict-mcp-config"]),
246 "--permission-mode",
247 "dontAsk",
248 ],
249 { env: claudeEnvironment },
250 );
251 const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, {
252 env: claudeEnvironment,
253 cwd: workingDirectory,
254 shell: spawnCommand.shell,
255 stdin: {
256 stream: Stream.encodeText(Stream.make(prompt)),
257 },
258 });
259
260 const child = yield* commandSpawner
261 .spawn(command)
262 .pipe(
263 Effect.mapError((cause) =>
264 normalizeCliError("claude", operation, cause, "Failed to spawn Claude CLI process"),
265 ),
266 );
267
268 const [stdout, stderr, exitCode] = yield* Effect.all(
269 [
270 readStreamAsString(operation, child.stdout),
271 readStreamAsString(operation, child.stderr),
272 child.exitCode.pipe(
273 Effect.mapError((cause) =>
274 normalizeCliError("claude", operation, cause, "Failed to read Claude CLI exit code"),
275 ),
276 ),
277 ],
278 { concurrency: "unbounded" },
279 );
280
281 if (exitCode !== 0) {
282 const stderrDetail = stderr.trim();
283 const stdoutDetail = stdout.trim();
284 const detail =
285 claudeErrorResult(stdoutDetail) ??
286 (stderrDetail.length > 0 ? stderrDetail : stdoutDetail);
287 return yield* new TextGenerationError({
288 operation,
289 detail:
290 detail.length > 0
291 ? `Claude CLI command failed: ${detail}`
292 : `Claude CLI command failed with code ${exitCode}.`,
293 });
294 }
295
296 return stdout;
297 });
298
299 const rawStdout = yield* runClaudeCommand().pipe(
300 Effect.scoped,
301 Effect.timeoutOption(investigate?.timeoutMs ?? CLAUDE_TIMEOUT_MS),
302 Effect.flatMap(
303 Option.match({
304 onNone: () =>
305 Effect.fail(
306 new TextGenerationError({ operation, detail: "Claude CLI request timed out." }),
307 ),
308 onSome: (value) => Effect.succeed(value),
309 }),
310 ),
311 );
312
313 const output = yield* decodeClaudeOutput(rawStdout).pipe(
314 Effect.catchTags({
315 SchemaError: (cause) =>
316 Effect.fail(
317 new TextGenerationError({
318 operation,
319 detail: "Claude CLI returned unexpected output format.",
320 cause,
321 }),
322 ),
323 }),
324 );
325 const envelope = isClaudeOutputEnvelope(output)
326 ? output
327 : output.findLast((message) => message.type === "result");
328
329 const decodeOutput = Schema.decodeEffect(outputSchemaJson);
330 return yield* decodeOutput(envelope?.structured_output).pipe(
331 Effect.catchTags({
332 SchemaError: (cause) =>
333 Effect.fail(
334 new TextGenerationError({
335 operation,
336 detail: "Claude returned invalid structured output.",
337 cause,
338 }),
339 ),
340 }),
341 );
342 });
343
344 return TextGenerationOperations.fromRunner("ClaudeTextGeneration", runClaudeJson);
345});