-
Notifications
You must be signed in to change notification settings - Fork 1k
feat(git): Add Claude as a TextGenerationProvider #1323
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
d904aba
cfc410f
26aa34c
90a1a46
f86c66a
c292aed
5e9d1ea
b7cb4e4
bc609db
2613e22
f4e3a64
e30f4b6
20a38c2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,272 @@ | ||
| /** | ||
| * 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 { DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER } from "@t3tools/contracts"; | ||
| import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | ||
|
|
||
| import { TextGenerationError } from "../Errors.ts"; | ||
| import { | ||
| type BranchNameGenerationResult, | ||
| type CommitMessageGenerationResult, | ||
| type PrContentGenerationResult, | ||
| type TextGenerationShape, | ||
| TextGeneration, | ||
| } from "../Services/TextGeneration.ts"; | ||
| import { | ||
| buildBranchNamePrompt, | ||
| buildCommitMessagePrompt, | ||
| buildPrContentPrompt, | ||
| } from "./textGenerationPrompts.ts"; | ||
| import { normalizeCliError, sanitizeCommitSubject, sanitizePrTitle, toJsonSchemaObject } from "./textGenerationUtils.ts"; | ||
|
|
||
| const CLAUDE_REASONING_EFFORT = "low"; | ||
| 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 readStreamAsString = <E>( | ||
| operation: string, | ||
| stream: Stream.Stream<Uint8Array, E>, | ||
| ): Effect.Effect<string, TextGenerationError> => | ||
| Effect.gen(function* () { | ||
| let text = ""; | ||
| yield* Stream.runForEach(stream, (chunk) => | ||
| Effect.sync(() => { | ||
| text += Buffer.from(chunk).toString("utf8"); | ||
| }), | ||
| ).pipe( | ||
| Effect.mapError((cause) => | ||
| normalizeCliError("claude", operation, cause, "Failed to collect process output"), | ||
| ), | ||
| ); | ||
| return text; | ||
| }); | ||
|
|
||
| /** | ||
| * Spawn the Claude CLI with structured JSON output and return the parsed, | ||
| * schema-validated result. | ||
| */ | ||
| const runClaudeJson = <S extends Schema.Top>({ | ||
| operation, | ||
| cwd, | ||
| prompt, | ||
| outputSchemaJson, | ||
| model, | ||
| }: { | ||
| operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName"; | ||
| cwd: string; | ||
| prompt: string; | ||
| outputSchemaJson: S; | ||
| model?: string; | ||
| }): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> => | ||
| Effect.gen(function* () { | ||
| const jsonSchemaStr = JSON.stringify(toJsonSchemaObject(outputSchemaJson)); | ||
|
|
||
| const runClaudeCommand = Effect.gen(function* () { | ||
| const command = ChildProcess.make( | ||
| "claude", | ||
| [ | ||
| "-p", | ||
| "--output-format", | ||
| "json", | ||
| "--json-schema", | ||
| jsonSchemaStr, | ||
| "--model", | ||
| model ?? DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.claudeAgent, | ||
| "--effort", | ||
| CLAUDE_REASONING_EFFORT, | ||
| "--dangerously-skip-permissions", | ||
| ], | ||
| { | ||
| cwd, | ||
| shell: process.platform === "win32", | ||
| stdin: { | ||
| stream: Stream.make(new TextEncoder().encode(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.map((value) => Number(value)), | ||
| 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"] = (input) => { | ||
| const { prompt, outputSchema } = buildCommitMessagePrompt({ | ||
| branch: input.branch, | ||
| stagedSummary: input.stagedSummary, | ||
| stagedPatch: input.stagedPatch, | ||
| includeBranch: input.includeBranch === true, | ||
| }); | ||
|
|
||
| return runClaudeJson({ | ||
| operation: "generateCommitMessage", | ||
| cwd: input.cwd, | ||
| prompt, | ||
| outputSchemaJson: outputSchema, | ||
| ...(input.model ? { model: input.model } : {}), | ||
| }).pipe( | ||
| Effect.map( | ||
| (generated) => | ||
| ({ | ||
| subject: sanitizeCommitSubject(generated.subject), | ||
| body: generated.body.trim(), | ||
| ...("branch" in generated && typeof generated.branch === "string" | ||
| ? { branch: sanitizeFeatureBranchName(generated.branch) } | ||
| : {}), | ||
| }) satisfies CommitMessageGenerationResult, | ||
| ), | ||
| ); | ||
| }; | ||
|
|
||
| const generatePrContent: TextGenerationShape["generatePrContent"] = (input) => { | ||
| const { prompt, outputSchema } = buildPrContentPrompt({ | ||
| baseBranch: input.baseBranch, | ||
| headBranch: input.headBranch, | ||
| commitSummary: input.commitSummary, | ||
| diffSummary: input.diffSummary, | ||
| diffPatch: input.diffPatch, | ||
| }); | ||
|
|
||
| return runClaudeJson({ | ||
| operation: "generatePrContent", | ||
| cwd: input.cwd, | ||
| prompt, | ||
| outputSchemaJson: outputSchema, | ||
| ...(input.model ? { model: input.model } : {}), | ||
| }).pipe( | ||
| Effect.map( | ||
| (generated) => | ||
| ({ | ||
| title: sanitizePrTitle(generated.title), | ||
| body: generated.body.trim(), | ||
| }) satisfies PrContentGenerationResult, | ||
| ), | ||
| ); | ||
| }; | ||
|
|
||
| const generateBranchName: TextGenerationShape["generateBranchName"] = (input) => { | ||
| return Effect.gen(function* () { | ||
| const { prompt, outputSchema } = buildBranchNamePrompt({ | ||
| message: input.message, | ||
| attachments: input.attachments, | ||
| }); | ||
|
|
||
| const generated = yield* runClaudeJson({ | ||
| operation: "generateBranchName", | ||
| cwd: input.cwd, | ||
| prompt, | ||
| outputSchemaJson: outputSchema, | ||
| ...(input.model ? { model: input.model } : {}), | ||
| }); | ||
|
|
||
| return { | ||
| branch: sanitizeBranchFragment(generated.branch), | ||
| } satisfies BranchNameGenerationResult; | ||
| }); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Claude branch name generation silently drops image attachmentsMedium Severity The Claude Additional Locations (1)
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I believe out of scope for this PR ? Do we have cases where we infer the branch name / commit messages from images ? |
||
| }; | ||
|
|
||
| return { | ||
| generateCommitMessage, | ||
| generatePrContent, | ||
| generateBranchName, | ||
| } satisfies TextGenerationShape; | ||
| }); | ||
|
|
||
| export const ClaudeTextGenerationLive = Layer.effect(TextGeneration, makeClaudeTextGeneration); | ||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Claude CLI JSON schema may break on Windows
Medium Severity
The Claude CLI receives the JSON schema as an inline command-line argument via
--json-schema jsonSchemaStr, while on Windowsshellis set totrue. The JSON string contains double quotes and braces thatcmd.exemay interpret or mangle. The Codex implementation avoids this by writing the schema to a temp file and passing the file path instead.Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this can be dismissed: the schema should always be simple enough to never cause issues (for now), there should never be any special character outside the quotes and braces from the json in this case? which should already be properly handled by cmd.exe