Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
272 changes: 272 additions & 0 deletions apps/server/src/git/Layers/ClaudeTextGeneration.ts
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",
],
Copy link
Contributor

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 Windows shell is set to true. The JSON string contains double quotes and braces that cmd.exe may interpret or mangle. The Codex implementation avoids this by writing the schema to a temp file and passing the file path instead.

Fix in Cursor Fix in Web

Copy link
Author

@keyzou keyzou Mar 24, 2026

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

{
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;
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude branch name generation silently drops image attachments

Medium Severity

The Claude generateBranchName implementation doesn't materialize or pass image attachments to the CLI, unlike the Codex implementation which calls materializeImageAttachments and passes imagePaths via --image flags. The prompt still tells the model to "use images as primary context for visual/UI issues," but the actual image files are never sent — only textual metadata about them is included. This means branch name generation with image attachments produces worse results on Claude compared to Codex.

Additional Locations (1)
Fix in Cursor Fix in Web

Copy link
Author

@keyzou keyzou Mar 24, 2026

Choose a reason for hiding this comment

The 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);
Loading