Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
c14198f
refactor: extract shared text generation utilities
keyzou Mar 22, 2026
85b874a
contracts: add provider-aware text generation model defaults
keyzou Mar 22, 2026
2ed6e75
feat: add TextGenerationProvider type and provider field to service i…
keyzou Mar 22, 2026
96d1185
feat: add Claude CLI text generation and routing layer
keyzou Mar 22, 2026
783f669
feat: add text generation provider settings UI and frontend wiring
keyzou Mar 22, 2026
9fe39bc
Extract Claude reasoning effort into named constant
keyzou Mar 23, 2026
75fe8da
Extract shared prompt builders and error normalization
keyzou Mar 23, 2026
71e18bc
chore: remove a bit of comment-slop
keyzou Mar 23, 2026
ba74436
Extract provider/model selection to ProviderModelPicker
keyzou Mar 23, 2026
163bc30
Fix type safety loss in buildCommitMessagePrompt output schema
keyzou Mar 24, 2026
f21ef2a
Extract shared toJsonSchemaObject helper for JSON Schema conversion
keyzou Mar 24, 2026
eb88f1e
Fix model picker missing selected model and restore reset button
keyzou Mar 24, 2026
b141993
Fix feature branch step ignoring selected text generation provider
keyzou Mar 24, 2026
ec38c62
chore: format with oxfmt
keyzou Mar 24, 2026
53805a5
Merge branch 'main' into t3code/pr-1323/feat/claude-text-generation
juliusmarminge Mar 25, 2026
8b1fbe3
unify on modelSelection pattern
juliusmarminge Mar 25, 2026
09c4130
Add model selection coverage to git action schema
juliusmarminge Mar 25, 2026
94adce6
forward settings properly
juliusmarminge Mar 25, 2026
9134c00
kewl
juliusmarminge Mar 25, 2026
5807f9b
Add model selection to git action tests
juliusmarminge Mar 25, 2026
839bc38
required
juliusmarminge Mar 25, 2026
ef3a2f7
kewl
juliusmarminge Mar 25, 2026
83a3dbd
Set codex git text generation effort to low
juliusmarminge Mar 25, 2026
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
291 changes: 291 additions & 0 deletions apps/server/src/git/Layers/ClaudeTextGeneration.ts
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"),
),
);
Copy link
Contributor

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 readStreamAsString helper is defined identically in both ClaudeTextGeneration.ts and CodexTextGeneration.ts, differing only in the CLI name string passed to normalizeCliError. Given that other shared logic (prompts, sanitizers, normalizeCliError, toJsonSchemaObject) was extracted to Utils.ts and Prompts.ts, this helper could also be parameterized by CLI name and shared from Utils.ts.

Additional Locations (1)
Fix in Cursor Fix in Web


/**
* 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",
],
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
Contributor 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.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",
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),
};
});

return {
generateCommitMessage,
generatePrContent,
generateBranchName,
} satisfies TextGenerationShape;
});

export const ClaudeTextGenerationLive = Layer.effect(TextGeneration, makeClaudeTextGeneration);
Loading
Loading