| 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 | */ |
| 10 | import * as Effect from "effect/Effect"; |
| 11 | import * as FileSystem from "effect/FileSystem"; |
| 12 | import * as Option from "effect/Option"; |
| 13 | import * as Schema from "effect/Schema"; |
| 14 | import * as Stream from "effect/Stream"; |
| 15 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; |
| 16 | |
| 17 | import { type ClaudeSettings, TextGenerationError } from "@t3tools/contracts"; |
| 18 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 19 | |
| 20 | import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; |
| 21 | import { |
| 22 | normalizeCliError, |
| 23 | toJsonSchemaObject, |
| 24 | } from "@t3tools/provider-core/server/textGenerationUtils"; |
| 25 | import { |
| 26 | getModelSelectionStringOptionValue, |
| 27 | getProviderOptionDescriptors, |
| 28 | } from "@t3tools/shared/model"; |
| 29 | import { |
| 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"; |
| 40 | import { |
| 41 | hasClaudeManagedMcpConfig, |
| 42 | makeClaudeEnvironment, |
| 43 | } from "../provider/Drivers/ClaudeHome.ts"; |
| 44 | |
| 45 | const CLAUDE_TIMEOUT_MS = 180_000; |
| 46 | /** The tools an investigation can see. `--tools` takes bare names; patterns go in the allow list. */ |
| 47 | const CLAUDE_INVESTIGATION_TOOLS = "Read,Grep,Glob,Bash"; |
| 48 | /** What it may run without asking. In dontAsk mode every other call, Bash included, is refused. */ |
| 49 | const 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 | */ |
| 61 | const 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 | */ |
| 67 | const ClaudeOutputEnvelope = Schema.Struct({ |
| 68 | structured_output: Schema.Unknown, |
| 69 | }); |
| 70 | const ClaudeOutputMessage = Schema.Struct({ |
| 71 | type: Schema.String, |
| 72 | structured_output: Schema.optionalKey(Schema.Unknown), |
| 73 | }); |
| 74 | const isClaudeOutputEnvelope = Schema.is(ClaudeOutputEnvelope); |
| 75 | |
| 76 | const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); |
| 77 | const decodeClaudeOutput = Schema.decodeEffect( |
| 78 | Schema.fromJsonString(Schema.Union([ClaudeOutputEnvelope, Schema.Array(ClaudeOutputMessage)])), |
| 79 | ); |
| 80 | |
| 81 | const ClaudeErrorResult = Schema.Struct({ is_error: Schema.Literal(true), result: Schema.String }); |
| 82 | const isClaudeErrorResult = Schema.is(ClaudeErrorResult); |
| 83 | const 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 | */ |
| 89 | function 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 | |
| 98 | export 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 | }); |