417 lines · 13.7 KB · TypeScript
RawDownload
/** * ClaudeTextGeneration – Text generation layer using the Claude CLI. * * Implements the same TextGeneration service contract as CodexTextGeneration but * delegates to the `claude` CLI (`claude -p`) with structured JSON output * instead of the `codex exec` CLI. * * @module ClaudeTextGeneration */import * as Effect from "effect/Effect";import * as FileSystem from "effect/FileSystem";import * as Option from "effect/Option";import * as Schema from "effect/Schema";import * as Stream from "effect/Stream";import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { type ClaudeSettings, type ModelSelection } from "@t3tools/contracts";import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { TextGenerationError } from "@t3tools/contracts";import * as TextGeneration from "./TextGeneration.ts";import {  buildBranchNamePrompt,  buildCommitMessagePrompt,  buildPrContentPrompt,  buildThreadTitlePrompt,} from "./TextGenerationPrompts.ts";import {  normalizeCliError,  sanitizeCommitSubject,  sanitizePrTitle,  sanitizeThreadTitle,  toJsonSchemaObject,} from "./TextGenerationUtils.ts";import {  getModelSelectionStringOptionValue,  getProviderOptionDescriptors,} from "@t3tools/shared/model";import {  BUNDLED_CLAUDE_MODEL_CATALOG,  type ClaudeModelCatalog,  getClaudeCatalogModelCapabilities,  isClaudeCatalogUltracodeEffort,  normalizeClaudeCatalogEffort,  resolveClaudeCatalogApiModelId,  resolveClaudeCatalogEffort,  resolveClaudeModelSlug,  scopeClaudeModelCatalog,} from "../provider/ClaudeModelCatalog.ts";import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts"; const CLAUDE_TIMEOUT_MS = 180_000; /** * Schema for the wrapper JSON returned by `claude -p --output-format json`. * Verbose mode wraps the result in an array of conversation messages. */const ClaudeOutputEnvelope = Schema.Struct({  structured_output: Schema.Unknown,});const ClaudeOutputMessage = Schema.Struct({  type: Schema.String,  structured_output: Schema.optionalKey(Schema.Unknown),});const isClaudeOutputEnvelope = Schema.is(ClaudeOutputEnvelope); const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));const decodeClaudeOutput = Schema.decodeEffect(  Schema.fromJsonString(Schema.Union([ClaudeOutputEnvelope, Schema.Array(ClaudeOutputMessage)])),); export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* (  claudeSettings: ClaudeSettings,  environment?: NodeJS.ProcessEnv,  modelCatalog: Effect.Effect<ClaudeModelCatalog> = Effect.succeed(BUNDLED_CLAUDE_MODEL_CATALOG),) {  const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;  const fileSystem = yield* FileSystem.FileSystem;  const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment);  const scopedModelCatalog = modelCatalog.pipe(    Effect.map((catalog) => scopeClaudeModelCatalog(catalog, claudeSettings.customModels)),  );   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"),      ),    );   const encodeJsonForOperation = (    operation:      | "generateCommitMessage"      | "generatePrContent"      | "generateBranchName"      | "generateThreadTitle",    value: unknown,    detail: string,  ): Effect.Effect<string, TextGenerationError> =>    encodeJsonString(value).pipe(      Effect.mapError(        (cause) =>          new TextGenerationError({            operation,            detail,            cause,          }),      ),    );   /**   * Spawn the Claude CLI with structured JSON output and return the parsed,   * schema-validated result.   */  const runClaudeJson = Effect.fn("runClaudeJson")(function* <S extends Schema.Top>({    operation,    cwd,    prompt,    outputSchemaJson,    modelSelection,  }: {    operation:      | "generateCommitMessage"      | "generatePrContent"      | "generateBranchName"      | "generateThreadTitle";    cwd: string;    prompt: string;    outputSchemaJson: S;    modelSelection: ModelSelection;  }): Effect.fn.Return<S["Type"], TextGenerationError, S["DecodingServices"]> {    const catalog = yield* scopedModelCatalog;    const resolvedModelSelection = {      ...modelSelection,      model: resolveClaudeModelSlug(catalog, modelSelection.model),    };    const jsonSchemaStr = yield* encodeJsonForOperation(      operation,      toJsonSchemaObject(outputSchemaJson),      "Failed to encode structured output schema.",    );    const caps = getClaudeCatalogModelCapabilities(catalog, resolvedModelSelection.model);    const descriptors = getProviderOptionDescriptors({      caps,      selections: resolvedModelSelection.options,    });    const findDescriptor = (id: string) => descriptors.find((descriptor) => descriptor.id === id);    const rawEffortSelection = getModelSelectionStringOptionValue(resolvedModelSelection, "effort");    const resolvedEffort = resolveClaudeCatalogEffort(      catalog,      resolvedModelSelection.model,      rawEffortSelection,    );    const cliEffort = normalizeClaudeCatalogEffort(      catalog,      resolvedEffort,      resolvedModelSelection.model,    );    const ultracode = isClaudeCatalogUltracodeEffort(resolvedEffort);    const thinkingDescriptor = findDescriptor("thinking");    const fastModeDescriptor = findDescriptor("fastMode");    const thinking =      thinkingDescriptor?.type === "boolean" ? thinkingDescriptor.currentValue : undefined;    const fastMode =      fastModeDescriptor?.type === "boolean" ? fastModeDescriptor.currentValue : undefined;    const settings = {      disableAllHooks: true,      ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}),      ...(fastMode ? { fastMode: true } : {}),      ...(ultracode ? { ultracode: true } : {}),    };    const settingsJson = yield* encodeJsonForOperation(      operation,      settings,      "Failed to encode Claude CLI settings.",    );     const runClaudeCommand = Effect.fn("runClaudeJson.runClaudeCommand")(function* () {      // Titles need only the supplied prompt, not configuration from the checkout.      const workingDirectory =        operation === "generateThreadTitle"          ? yield* fileSystem              .makeTempDirectoryScoped({ prefix: "t3code-claude-title-" })              .pipe(                Effect.mapError((cause) =>                  normalizeCliError("claude", operation, cause, "Failed to create title directory"),                ),              )          : cwd;      const spawnCommand = yield* resolveSpawnCommand(        claudeSettings.binaryPath || "claude",        [          "-p",          "--output-format",          "json",          "--json-schema",          jsonSchemaStr,          "--model",          resolveClaudeCatalogApiModelId(catalog, resolvedModelSelection),          ...(cliEffort ? ["--effort", cliEffort] : []),          "--settings",          settingsJson,          // Metadata prompts need no executable capabilities, even when they contain a skill name.          "--tools",          "",          "--disable-slash-commands",          "--strict-mcp-config",          "--permission-mode",          "dontAsk",        ],        { env: claudeEnvironment },      );      const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, {        env: claudeEnvironment,        cwd: workingDirectory,        shell: spawnCommand.shell,        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 output = yield* decodeClaudeOutput(rawStdout).pipe(      Effect.catchTags({        SchemaError: (cause) =>          Effect.fail(            new TextGenerationError({              operation,              detail: "Claude CLI returned unexpected output format.",              cause,            }),          ),      }),    );    const envelope = isClaudeOutputEnvelope(output)      ? output      : output.findLast((message) => message.type === "result");     const decodeOutput = Schema.decodeEffect(outputSchemaJson);    return yield* decodeOutput(envelope?.structured_output).pipe(      Effect.catchTags({        SchemaError: (cause) =>          Effect.fail(            new TextGenerationError({              operation,              detail: "Claude returned invalid structured output.",              cause,            }),          ),      }),    );  });   // ---------------------------------------------------------------------------  // TextGeneration service methods  // ---------------------------------------------------------------------------   const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] =    Effect.fn("ClaudeTextGeneration.generateCommitMessage")(function* (input) {      const { prompt, outputSchema } = buildCommitMessagePrompt({        branch: input.branch,        stagedSummary: input.stagedSummary,        stagedPatch: input.stagedPatch,        includeBranch: input.includeBranch === true,        policy: input.policy,      });       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: TextGeneration.TextGeneration["Service"]["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,        policy: input.policy,        changeRequestTemplate: input.changeRequestTemplate,      });       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: TextGeneration.TextGeneration["Service"]["generateBranchName"] =    Effect.fn("ClaudeTextGeneration.generateBranchName")(function* (input) {      const { prompt, outputSchema } = buildBranchNamePrompt({        message: input.message,        attachments: input.attachments,      });       const generated = yield* runClaudeJson({        operation: "generateBranchName",        cwd: input.cwd,        prompt,        outputSchemaJson: outputSchema,        modelSelection: input.modelSelection,      });       return {        branch: sanitizeBranchFragment(generated.branch),      };    });   const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] =    Effect.fn("ClaudeTextGeneration.generateThreadTitle")(function* (input) {      const { prompt, outputSchema } = buildThreadTitlePrompt({        message: input.message,        previousTitle: input.previousTitle,        attachments: input.attachments,      });       const generated = yield* runClaudeJson({        operation: "generateThreadTitle",        cwd: input.cwd,        prompt,        outputSchemaJson: outputSchema,        modelSelection: input.modelSelection,      });       return {        title: sanitizeThreadTitle(generated.title),      };    });   return {    generateCommitMessage,    generatePrContent,    generateBranchName,    generateThreadTitle,  } satisfies TextGeneration.TextGeneration["Service"];});