-
Notifications
You must be signed in to change notification settings - Fork 1.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
Changes from 16 commits
c14198f
85b874a
2ed6e75
96d1185
783f669
9fe39bc
75fe8da
71e18bc
ba74436
163bc30
f21ef2a
eb88f1e
b141993
ec38c62
53805a5
8b1fbe3
09c4130
94adce6
9134c00
5807f9b
839bc38
ef3a2f7
83a3dbd
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,291 @@ | ||
| /** | ||
| * 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 { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | ||
|
|
||
| import { TextGenerationError } from "../Errors.ts"; | ||
| import { 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> => | ||
| 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 = <S extends Schema.Top>({ | ||
| operation, | ||
| cwd, | ||
| prompt, | ||
| outputSchemaJson, | ||
| modelSelection, | ||
| }: { | ||
| operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName"; | ||
| cwd: string; | ||
| prompt: string; | ||
| outputSchemaJson: S; | ||
| modelSelection: ClaudeModelSelection; | ||
| }): 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", | ||
| modelSelection.model, | ||
| "--effort", | ||
| modelSelection.options?.effort ?? CLAUDE_REASONING_EFFORT, | ||
| "--dangerously-skip-permissions", | ||
| ], | ||
|
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 CLI JSON schema may break on WindowsMedium Severity The Claude CLI receives the JSON schema as an inline command-line argument via
Contributor
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 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 |
||
| { | ||
| 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: "generateCommitMessage", | ||
cursor[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| detail: "Invalid model selection.", | ||
| }); | ||
| } | ||
macroscopeapp[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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, | ||
| }); | ||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| return { | ||
| branch: sanitizeBranchFragment(generated.branch), | ||
| }; | ||
| }); | ||
|
|
||
| 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.
Duplicate readStreamAsString helper across both providers
Low Severity
The
readStreamAsStringhelper is defined identically in bothClaudeTextGeneration.tsandCodexTextGeneration.ts, differing only in the CLI name string passed tonormalizeCliError. Given that other shared logic (prompts, sanitizers,normalizeCliError,toJsonSchemaObject) was extracted toUtils.tsandPrompts.ts, this helper could also be parameterized by CLI name and shared fromUtils.ts.Additional Locations (1)
apps/server/src/git/Layers/CodexTextGeneration.ts#L42-L56