339 lines · 10.4 KB · TypeScript
RawDownload
/** * 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 { resolveApiModelId } from "@t3tools/shared/model";import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { TextGenerationError } from "@t3tools/contracts";import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts";import {  buildBranchNamePrompt,  buildCommitMessagePrompt,  buildPrContentPrompt,  buildThreadTitlePrompt,} from "../Prompts.ts";import {  normalizeCliError,  sanitizeCommitSubject,  sanitizePrTitle,  sanitizeThreadTitle,  toJsonSchemaObject,} from "../Utils.ts";import { normalizeClaudeModelOptionsWithCapabilities } from "@t3tools/shared/model";import { ServerSettingsService } from "../../serverSettings.ts";import { getClaudeModelCapabilities } from "../../provider/Layers/ClaudeProvider.ts"; 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 serverSettingsService = yield* Effect.service(ServerSettingsService);   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 = 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: ClaudeModelSelection;  }): Effect.fn.Return<S["Type"], TextGenerationError, S["DecodingServices"]> {    const jsonSchemaStr = JSON.stringify(toJsonSchemaObject(outputSchemaJson));    const normalizedOptions = normalizeClaudeModelOptionsWithCapabilities(      getClaudeModelCapabilities(modelSelection.model),      modelSelection.options,    );    const settings = {      ...(typeof normalizedOptions?.thinking === "boolean"        ? { alwaysThinkingEnabled: normalizedOptions.thinking }        : {}),      ...(normalizedOptions?.fastMode ? { fastMode: true } : {}),    };     const claudeSettings = yield* Effect.map(      serverSettingsService.getSettings,      (settings) => settings.providers.claudeAgent,    ).pipe(Effect.catch(() => Effect.undefined));     const runClaudeCommand = Effect.fn("runClaudeJson.runClaudeCommand")(function* () {      const command = ChildProcess.make(        claudeSettings?.binaryPath || "claude",        [          "-p",          "--output-format",          "json",          "--json-schema",          jsonSchemaStr,          "--model",          resolveApiModelId(modelSelection),          ...(normalizedOptions?.effort ? ["--effort", normalizedOptions.effort] : []),          ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []),          "--dangerously-skip-permissions",        ],        {          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: "generatePrContent",        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),    };  });   const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn(    "ClaudeTextGeneration.generateThreadTitle",  )(function* (input) {    const { prompt, outputSchema } = buildThreadTitlePrompt({      message: input.message,      attachments: input.attachments,    });     if (input.modelSelection.provider !== "claudeAgent") {      return yield* new TextGenerationError({        operation: "generateThreadTitle",        detail: "Invalid model selection.",      });    }     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 TextGenerationShape;}); export const ClaudeTextGenerationLive = Layer.effect(TextGeneration, makeClaudeTextGeneration);