Skip to content
282 changes: 282 additions & 0 deletions apps/server/src/git/Layers/ClaudeTextGeneration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
/**
* 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 } from "./textGenerationUtils.ts";

const CLAUDE_REASONING_EFFORT = "low";
const CLAUDE_TIMEOUT_MS = 180_000;

/** Build a JSON-schema string suitable for the Claude CLI `--json-schema` flag. */
function toClaudeJsonSchemaString(schema: Schema.Top): string {
const document = Schema.toJsonSchemaDocument(schema);
const schemaObj =
document.definitions && Object.keys(document.definitions).length > 0
? { ...document.schema, $defs: document.definitions }
: document.schema;
return JSON.stringify(schemaObj);
}

/**
* 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 = toClaudeJsonSchemaString(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;
});
};

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

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