/** * ClaudeTextGeneration – Text generation layer using the Claude CLI. * * Implements the same TextGenerationShape contract as CodexTextGeneration but * delegates to the `claude` CLI (`claude -p`) with structured JSON output * instead of the `codex exec` CLI. * * @module ClaudeTextGeneration */ import { Effect, Layer, Option, Schema, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { ClaudeModelSelection } from "@t3tools/contracts"; import { resolveApiModelId } from "@t3tools/shared/model"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { TextGenerationError } from "@t3tools/contracts"; import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; import { buildBranchNamePrompt, buildCommitMessagePrompt, buildPrContentPrompt, buildThreadTitlePrompt, } from "../Prompts.ts"; import { normalizeCliError, sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle, toJsonSchemaObject, } from "../Utils.ts"; import { normalizeClaudeModelOptionsWithCapabilities } from "@t3tools/shared/model"; import { ServerSettingsService } from "../../serverSettings.ts"; import { getClaudeModelCapabilities } from "../../provider/Layers/ClaudeProvider.ts"; const CLAUDE_TIMEOUT_MS = 180_000; /** * Schema for the wrapper JSON returned by `claude -p --output-format json`. * We only care about `structured_output`. */ const ClaudeOutputEnvelope = Schema.Struct({ structured_output: Schema.Unknown, }); const makeClaudeTextGeneration = Effect.gen(function* () { const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serverSettingsService = yield* Effect.service(ServerSettingsService); const readStreamAsString = ( operation: string, stream: Stream.Stream, ): Effect.Effect => stream.pipe( Stream.decodeText(), Stream.runFold( () => "", (acc, chunk) => acc + chunk, ), Effect.mapError((cause) => normalizeCliError("claude", operation, cause, "Failed to collect process output"), ), ); /** * Spawn the Claude CLI with structured JSON output and return the parsed, * schema-validated result. */ const runClaudeJson = Effect.fn("runClaudeJson")(function* ({ operation, cwd, prompt, outputSchemaJson, modelSelection, }: { operation: | "generateCommitMessage" | "generatePrContent" | "generateBranchName" | "generateThreadTitle"; cwd: string; prompt: string; outputSchemaJson: S; modelSelection: ClaudeModelSelection; }): Effect.fn.Return { const jsonSchemaStr = JSON.stringify(toJsonSchemaObject(outputSchemaJson)); const normalizedOptions = normalizeClaudeModelOptionsWithCapabilities( getClaudeModelCapabilities(modelSelection.model), modelSelection.options, ); const settings = { ...(typeof normalizedOptions?.thinking === "boolean" ? { alwaysThinkingEnabled: normalizedOptions.thinking } : {}), ...(normalizedOptions?.fastMode ? { fastMode: true } : {}), }; const claudeSettings = yield* Effect.map( serverSettingsService.getSettings, (settings) => settings.providers.claudeAgent, ).pipe(Effect.catch(() => Effect.undefined)); const runClaudeCommand = Effect.fn("runClaudeJson.runClaudeCommand")(function* () { const command = ChildProcess.make( claudeSettings?.binaryPath || "claude", [ "-p", "--output-format", "json", "--json-schema", jsonSchemaStr, "--model", resolveApiModelId(modelSelection), ...(normalizedOptions?.effort ? ["--effort", normalizedOptions.effort] : []), ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []), "--dangerously-skip-permissions", ], { cwd, shell: process.platform === "win32", stdin: { stream: Stream.encodeText(Stream.make(prompt)), }, }, ); const child = yield* commandSpawner .spawn(command) .pipe( Effect.mapError((cause) => normalizeCliError("claude", operation, cause, "Failed to spawn Claude CLI process"), ), ); const [stdout, stderr, exitCode] = yield* Effect.all( [ readStreamAsString(operation, child.stdout), readStreamAsString(operation, child.stderr), child.exitCode.pipe( Effect.mapError((cause) => normalizeCliError("claude", operation, cause, "Failed to read Claude CLI exit code"), ), ), ], { concurrency: "unbounded" }, ); if (exitCode !== 0) { const stderrDetail = stderr.trim(); const stdoutDetail = stdout.trim(); const detail = stderrDetail.length > 0 ? stderrDetail : stdoutDetail; return yield* new TextGenerationError({ operation, detail: detail.length > 0 ? `Claude CLI command failed: ${detail}` : `Claude CLI command failed with code ${exitCode}.`, }); } return stdout; }); const rawStdout = yield* runClaudeCommand().pipe( Effect.scoped, Effect.timeoutOption(CLAUDE_TIMEOUT_MS), Effect.flatMap( Option.match({ onNone: () => Effect.fail( new TextGenerationError({ operation, detail: "Claude CLI request timed out." }), ), onSome: (value) => Effect.succeed(value), }), ), ); const envelope = yield* Schema.decodeEffect(Schema.fromJsonString(ClaudeOutputEnvelope))( rawStdout, ).pipe( Effect.catchTag("SchemaError", (cause) => Effect.fail( new TextGenerationError({ operation, detail: "Claude CLI returned unexpected output format.", cause, }), ), ), ); return yield* Schema.decodeEffect(outputSchemaJson)(envelope.structured_output).pipe( Effect.catchTag("SchemaError", (cause) => Effect.fail( new TextGenerationError({ operation, detail: "Claude returned invalid structured output.", cause, }), ), ), ); }); // --------------------------------------------------------------------------- // TextGenerationShape methods // --------------------------------------------------------------------------- const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( "ClaudeTextGeneration.generateCommitMessage", )(function* (input) { const { prompt, outputSchema } = buildCommitMessagePrompt({ branch: input.branch, stagedSummary: input.stagedSummary, stagedPatch: input.stagedPatch, includeBranch: input.includeBranch === true, }); if (input.modelSelection.provider !== "claudeAgent") { return yield* new TextGenerationError({ operation: "generateCommitMessage", detail: "Invalid model selection.", }); } const generated = yield* runClaudeJson({ operation: "generateCommitMessage", cwd: input.cwd, prompt, outputSchemaJson: outputSchema, modelSelection: input.modelSelection, }); return { subject: sanitizeCommitSubject(generated.subject), body: generated.body.trim(), ...("branch" in generated && typeof generated.branch === "string" ? { branch: sanitizeFeatureBranchName(generated.branch) } : {}), }; }); const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( "ClaudeTextGeneration.generatePrContent", )(function* (input) { const { prompt, outputSchema } = buildPrContentPrompt({ baseBranch: input.baseBranch, headBranch: input.headBranch, commitSummary: input.commitSummary, diffSummary: input.diffSummary, diffPatch: input.diffPatch, }); if (input.modelSelection.provider !== "claudeAgent") { return yield* new TextGenerationError({ operation: "generatePrContent", detail: "Invalid model selection.", }); } const generated = yield* runClaudeJson({ operation: "generatePrContent", cwd: input.cwd, prompt, outputSchemaJson: outputSchema, modelSelection: input.modelSelection, }); return { title: sanitizePrTitle(generated.title), body: generated.body.trim(), }; }); const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( "ClaudeTextGeneration.generateBranchName", )(function* (input) { const { prompt, outputSchema } = buildBranchNamePrompt({ message: input.message, attachments: input.attachments, }); if (input.modelSelection.provider !== "claudeAgent") { return yield* new TextGenerationError({ operation: "generateBranchName", detail: "Invalid model selection.", }); } const generated = yield* runClaudeJson({ operation: "generateBranchName", cwd: input.cwd, prompt, outputSchemaJson: outputSchema, modelSelection: input.modelSelection, }); return { branch: sanitizeBranchFragment(generated.branch), }; }); const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( "ClaudeTextGeneration.generateThreadTitle", )(function* (input) { const { prompt, outputSchema } = buildThreadTitlePrompt({ message: input.message, attachments: input.attachments, }); if (input.modelSelection.provider !== "claudeAgent") { return yield* new TextGenerationError({ operation: "generateThreadTitle", detail: "Invalid model selection.", }); } const generated = yield* runClaudeJson({ operation: "generateThreadTitle", cwd: input.cwd, prompt, outputSchemaJson: outputSchema, modelSelection: input.modelSelection, }); return { title: sanitizeThreadTitle(generated.title), }; }); return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, } satisfies TextGenerationShape; }); export const ClaudeTextGenerationLive = Layer.effect(TextGeneration, makeClaudeTextGeneration);