diff --git a/.github/workflows/pr-validate.yml b/.github/workflows/pr-validate.yml new file mode 100644 index 0000000..a832517 --- /dev/null +++ b/.github/workflows/pr-validate.yml @@ -0,0 +1,119 @@ +name: PR Validate + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + checks: + name: Typecheck, format, and build + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "24" + + - name: Install pnpm + run: npm install -g pnpm + + - name: Install dependencies + run: pnpm install + + - name: Typecheck + run: pnpm run typecheck + + - name: Check formatting + run: pnpm exec prettier . --check + + - name: Build project + env: + GOOGLE_CLIENT_ID: pr-validation-not-a-real-client-id + GOOGLE_CLIENT_SECRET: pr-validation-not-a-real-secret + run: pnpm run build + + publish-preview: + name: Publish preview + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Install semver CLI + run: npm install -g semver + + - name: Detect publish on merge + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + HEAD_VERSION=$(node -p "require('./package.json').version") + PACKAGE_NAME=$(node -p "require('./package.json').name") + git show "${BASE_SHA}:package.json" > /tmp/base-package.json + BASE_VERSION=$(node -p "require('/tmp/base-package.json').version") + + echo "Package: $PACKAGE_NAME" + echo "Base: $BASE_VERSION" + echo "Head: $HEAD_VERSION" + + if ! semver "$HEAD_VERSION" >/dev/null; then + echo "::error::Head version '$HEAD_VERSION' is not valid semver." + { + echo "### Invalid version" + echo "" + echo "\`$HEAD_VERSION\` is not a valid semver string." + } >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi + + if [ "$HEAD_VERSION" != "$BASE_VERSION" ] && ! semver "$HEAD_VERSION" -r ">$BASE_VERSION" >/dev/null; then + echo "::error::Version change $BASE_VERSION -> $HEAD_VERSION is not a valid forward bump." + { + echo "### Invalid version bump" + echo "" + echo "\`$BASE_VERSION\` -> \`$HEAD_VERSION\` is not a valid forward semver bump." + } >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi + + PUBLISHED=$(npm view "${PACKAGE_NAME}@${HEAD_VERSION}" version 2>/dev/null || true) + + if [ -n "$PUBLISHED" ]; then + if [ "$HEAD_VERSION" = "$BASE_VERSION" ]; then + { + echo "### No publish on merge" + echo "" + echo "Version pinned at \`$HEAD_VERSION\` and already published on NPM — merging will not publish \`$PACKAGE_NAME\`." + } >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + echo "::error::Version $HEAD_VERSION is already published on NPM." + { + echo "### Invalid version bump" + echo "" + echo "Version \`$HEAD_VERSION\` is already published on NPM. Bump to a new unpublished version." + } >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi + + if [ "$HEAD_VERSION" = "$BASE_VERSION" ]; then + { + echo "### Will publish on merge" + echo "" + echo "Version pinned at \`$HEAD_VERSION\` but not yet published on NPM — merging will publish \`${PACKAGE_NAME}@${HEAD_VERSION}\`." + } >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + { + echo "### Will publish on merge" + echo "" + echo "Merging will publish \`${PACKAGE_NAME}@${HEAD_VERSION}\` (bumped from \`$BASE_VERSION\`)." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index 36d0bb4..50eaed8 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,7 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json dist # Temporary files -/tmp \ No newline at end of file +/tmp + +# Claude folder +.claude \ No newline at end of file diff --git a/README.md b/README.md index 3010682..8ec7c36 100644 --- a/README.md +++ b/README.md @@ -90,9 +90,15 @@ commit setup You will be prompted to choose: -- **Auth method**: OAuth (sign in with your Google or ChatGPT account) or API Key (paste your own key) +- **AI provider**: Google Gemini, OpenAI, or Anthropic +- **Auth method**: + - Google Gemini: Google OAuth or API key + - OpenAI: Sign in with ChatGPT or API key + - Anthropic: Claude setup-token or API key - **Commit convention**: Conventional, Imperative, or Custom +If you want to use your claude.ai subscription with Anthropic, run `claude setup-token` in another terminal first, then paste the generated setup-token during `commit setup`. + To re-authenticate at any time: ```bash @@ -151,12 +157,9 @@ commit --help ## Providers -- **Google Gemini** — API key or Google OAuth -- **OpenAI** — API key or sign in with your ChatGPT Plus/Pro subscription - -More providers coming soon: - -- **Anthropic** (Claude) +- **Google Gemini** — Google OAuth or API key +- **OpenAI** — Sign in with your ChatGPT Plus/Pro subscription or API key +- **Anthropic** (Claude) — Claude setup-token (`claude setup-token`) or API key Contributions and feedback are welcome! diff --git a/index.ts b/index.ts index 62d8f91..059c2b7 100755 --- a/index.ts +++ b/index.ts @@ -1,8 +1,8 @@ -import { Commit } from "@/app/commands/commit"; -import { Setup } from "@/app/commands/setup"; -import { Doctor } from "@/app/commands/doctor"; -import { ModelCommand } from "@/app/commands/model"; -import { parseArgs, showHelp, showVersion } from "@/app/cli"; +import { Commit } from "@/cli/commit"; +import { Setup } from "@/cli/setup"; +import { Doctor } from "@/cli/doctor"; +import { ModelCommand } from "@/cli/model"; +import { parseArgs, showHelp, showVersion } from "@/cli/parser"; import { Future } from "@/libs/future"; import color from "picocolors"; diff --git a/package.json b/package.json index a6034a5..0b0a4fc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rafaeelricco/commit-tools", - "version": "0.2.3", + "version": "0.2.4", "type": "module", "bin": { "commit": "./dist/index.js" diff --git a/src/app/commands/commit.ts b/src/cli/commit.ts similarity index 78% rename from src/app/commands/commit.ts rename to src/cli/commit.ts index 1dc31bf..18b3e7b 100644 --- a/src/app/commands/commit.ts +++ b/src/cli/commit.ts @@ -1,23 +1,16 @@ export { Commit }; import * as p from "@clack/prompts"; +import * as repo from "@/infra/git/repo"; import { Future } from "@/libs/future"; -import { loadConfig } from "@/lib/storage/config"; -import { Setup } from "@/app/commands/setup"; -import { CommitConvention, type Config, type ProviderConfig } from "@/domain/config/config"; -import { resolveProvider } from "@/app/services/resolveProvider"; -import { - checkIsGitRepo, - getStagedDiff, - performCommit, - performPush, - getCurrentBranch, - hasUpstream -} from "@/lib/git/repo"; -import { generateCommitMessage, refineCommitMessage } from "@/app/services/llm"; +import { loadConfig } from "@/infra/storage/config"; +import { Setup } from "@/cli/setup"; +import { type CommitConvention, type Config, type ProviderConfig } from "@/domain/config/config"; +import { resolveProvider } from "@/domain/llm/auth-resolver"; +import { generateCommitMessage, refineCommitMessage } from "@/domain/llm/router"; import { Nothing, type Maybe, Just } from "@/libs/maybe"; -import { loading } from "@/lib/ui/spinner"; +import { loading } from "@/infra/ui/spinner"; import color from "picocolors"; @@ -47,9 +40,14 @@ class Commit { } run(): Future { - return checkIsGitRepo() + return repo + .checkIsGitRepo() .chain(() => this.diff()) - .chain((diff) => this.generate(diff).chain((message) => this.interact(diff, message))) + .chain((diff) => + this.generate(diff, this.config.commit_convention, this.config.custom_template).chain((message) => + this.interact(diff, message) + ) + ) .mapRej((e) => { if (e instanceof Error) { p.log.error(color.red(e.message)); @@ -59,19 +57,14 @@ class Commit { } diff(): Future { - return getStagedDiff(); + return repo.getStagedDiff(); } - generate(diff: string, convention?: CommitConvention, template?: string): Future { + generate(diff: string, convention: CommitConvention, template: Maybe = Nothing()): Future { return loading( "Generating commit message...", "Message generated!", - generateCommitMessage( - this.providerConfig, - diff, - convention ?? this.config.commit_convention, - template ?? this.config.custom_template.maybe(undefined, (t) => t) - ) + generateCommitMessage(this.providerConfig, diff, convention, template) ); } @@ -80,7 +73,7 @@ class Commit { } commit(message: string): Future { - return performCommit(message); + return repo.performCommit(message); } push(branch?: string, publish = false, forceWithLease = false): Future { @@ -94,7 +87,7 @@ class Commit { : publish ? "Published successfully!" : "Pushed successfully!"; - return loading(startMsg, endMsg, performPush(branch, publish, forceWithLease)).map(() => {}); + return loading(startMsg, endMsg, repo.performPush(branch, publish, forceWithLease)).map(() => {}); } interact(diff: string, message: string): Future { @@ -105,7 +98,9 @@ class Commit { case "commit_push": return this.handleCommitAndPush(message); case "regenerate": - return this.generate(diff, "imperative").chain((msg) => this.interact(diff, msg)); + return this.generate(diff, this.config.commit_convention, this.config.custom_template).chain((msg) => + this.interact(diff, msg) + ); case "adjust": return this.handleAdjust(diff, message); case "cancel": @@ -157,15 +152,17 @@ class Commit { } private pushAfterCommit(): Future { - return hasUpstream().chain((exists) => - exists ? - this.push().chainRej((err) => (isNonFastForwardError(err) ? this.promptForceWithLease() : Future.reject(err))) - : this.promptPublishBranch() - ); + return repo + .hasUpstream() + .chain((exists) => + exists ? + this.push().chainRej((err) => (isNonFastForwardError(err) ? this.promptForceWithLease() : Future.reject(err))) + : this.promptPublishBranch() + ); } private promptPublishBranch(): Future { - return getCurrentBranch().chain((branch) => + return repo.getCurrentBranch().chain((branch) => Future.attemptP(async () => { const publish = await p.confirm({ message: `Branch '${branch}' has no upstream. Publish to origin?` diff --git a/src/app/commands/doctor.ts b/src/cli/doctor.ts similarity index 97% rename from src/app/commands/doctor.ts rename to src/cli/doctor.ts index 30cdcda..60b9ea3 100644 --- a/src/app/commands/doctor.ts +++ b/src/cli/doctor.ts @@ -1,10 +1,10 @@ export { Doctor }; import { Future } from "@/libs/future"; -import { CONFIG_FILE, loadConfig } from "@/lib/storage/config"; +import { CONFIG_FILE, loadConfig } from "@/infra/storage/config"; import { type AuthMethod, type ProviderConfig } from "@/domain/config/config"; import { access } from "node:fs/promises"; -import { environment } from "@/app/integrations"; +import { environment } from "@/infra/env"; import color from "picocolors"; import Table from "cli-table3"; diff --git a/src/app/commands/model.ts b/src/cli/model.ts similarity index 81% rename from src/app/commands/model.ts rename to src/cli/model.ts index fd38c5f..fb83e06 100644 --- a/src/app/commands/model.ts +++ b/src/cli/model.ts @@ -4,10 +4,11 @@ import * as p from "@clack/prompts"; import { Future } from "@/libs/future"; import { type Config, type ProviderConfig } from "@/domain/config/config"; -import { loadConfig, saveConfig } from "@/lib/storage/config"; -import { resolveProvider } from "@/app/services/resolveProvider"; -import { fetchModels, selectModelInteractively } from "@/domain/commit/model"; -import { loading } from "@/lib/ui/spinner"; +import { loadConfig, saveConfig } from "@/infra/storage/config"; +import { resolveProvider } from "@/domain/llm/auth-resolver"; +import { fetchModels } from "@/domain/commit/models"; +import { selectModelInteractively } from "@/infra/ui/model-picker"; +import { loading } from "@/infra/ui/spinner"; import color from "picocolors"; diff --git a/src/app/cli.ts b/src/cli/parser.ts similarity index 100% rename from src/app/cli.ts rename to src/cli/parser.ts diff --git a/src/app/commands/setup.ts b/src/cli/setup.ts similarity index 95% rename from src/app/commands/setup.ts rename to src/cli/setup.ts index f1e5641..d956470 100644 --- a/src/app/commands/setup.ts +++ b/src/cli/setup.ts @@ -4,14 +4,15 @@ import * as p from "@clack/prompts"; import type { Option } from "@clack/prompts"; import { Future } from "@/libs/future"; -import { saveConfig } from "@/lib/storage/config"; +import { saveConfig } from "@/infra/storage/config"; import { CommitConvention, type Config, type ProviderConfig } from "@/domain/config/config"; -import { performOAuthFlow, validateOAuthTokens } from "@/lib/auth/google"; -import { performOpenAIOAuthFlow, validateOpenAITokens } from "@/lib/auth/openai"; -import { validateAnthropicApiKey, validateAnthropicSetupToken } from "@/lib/auth/anthropic"; +import { performOAuthFlow, validateOAuthTokens } from "@/infra/auth/google"; +import { performOpenAIOAuthFlow, validateOpenAITokens } from "@/infra/auth/openai"; +import { validateAnthropicApiKey, validateAnthropicSetupToken } from "@/infra/auth/anthropic"; import { Just, Nothing } from "@/libs/maybe"; -import { loading } from "@/lib/ui/spinner"; -import { fetchModels, selectModelInteractively } from "@/domain/commit/model"; +import { loading } from "@/infra/ui/spinner"; +import { fetchModels } from "@/domain/commit/models"; +import { selectModelInteractively } from "@/infra/ui/model-picker"; import color from "picocolors"; diff --git a/src/domain/commit/model.ts b/src/domain/commit/models.ts similarity index 83% rename from src/domain/commit/model.ts rename to src/domain/commit/models.ts index 45287d3..c396680 100644 --- a/src/domain/commit/model.ts +++ b/src/domain/commit/models.ts @@ -1,9 +1,9 @@ -export { fetchModels, selectModelInteractively }; +export { fetchModels }; import { Future } from "@/libs/future"; import { Model, type ProviderConfig } from "@/domain/config/config"; -import { getOpenAIAccessToken } from "@/lib/auth/openai"; -import { anthropicOAuthHeaders } from "@/lib/auth/anthropic"; +import { getOpenAIAccessToken } from "@/infra/auth/openai"; +import { anthropicOAuthHeaders } from "@/infra/auth/anthropic"; import OpenAI from "openai"; @@ -130,26 +130,3 @@ const fetchModels = ( return fetchAnthropicModels(authMethod); } }; - -const selectModelInteractively = (models: Model[]): Future => - Future.attemptP(async () => { - const { render } = await import("ink"); - const React = await import("react"); - const { ModelSelector } = await import("@/lib/ui/components/model-selector"); - - return new Promise((resolve, reject) => { - const { unmount } = render( - React.createElement(ModelSelector, { - models, - onSelect: (modelId: string) => { - unmount(); - resolve(modelId); - }, - onCancel: () => { - unmount(); - reject(new Error("Selection cancelled")); - } - }) - ); - }); - }); diff --git a/src/domain/commit/prompts.ts b/src/domain/commit/prompts.ts index a80f967..74107f1 100644 --- a/src/domain/commit/prompts.ts +++ b/src/domain/commit/prompts.ts @@ -1,8 +1,10 @@ export { getPrompt, getRefinePrompt }; import { CommitConvention } from "@/domain/config/config"; +import { Just, Nothing, type Maybe } from "@/libs/maybe"; +import { absurd } from "@/libs/types"; -function getPrompt(diff: string, convention: CommitConvention, customTemplate?: string): string { +function getPrompt(diff: string, convention: CommitConvention, customTemplate: Maybe = Nothing()): string { switch (convention) { case "conventional": return promptConventional(diff); @@ -11,7 +13,7 @@ function getPrompt(diff: string, convention: CommitConvention, customTemplate?: case "custom": return promptCustom(diff, customTemplate); default: - return promptImperative(diff); + return absurd(convention, "CommitConvention"); } } @@ -90,14 +92,14 @@ function promptConventional(gitDiff: string): string { • Line 2: blank. • Remaining lines: each line is a bullet starting with "- ". -`; + `; } function promptImperative(gitDiff: string): string { return ` You are an expert software engineer and version control specialist. - Your job is to read git diffs and output high-quality comic messages + Your job is to read git diffs and output high-quality commit messages that follow these rules. @@ -210,14 +212,13 @@ function promptImperative(gitDiff: string): string { `; } -function promptCustom(gitDiff: string, template?: string): string { - if (!template) { - return promptImperative(gitDiff); - } - - const processedTemplate = template.replace("{diff}", gitDiff); - - return ` +function promptCustom(gitDiff: string, template: Maybe): string { + switch (true) { + case template instanceof Nothing: + return promptImperative(gitDiff); + case template instanceof Just: { + const processedTemplate = template.value.replace("{diff}", gitDiff); + return ` You are an expert software engineer and version control specialist. Your job is to read git diffs and output high-quality commit messages @@ -235,6 +236,11 @@ function promptCustom(gitDiff: string, template?: string): string { 4. Do NOT wrap the commit message in quotes or code fences. `; + } + default: + template satisfies never; + return promptImperative(gitDiff); + } } function getRefinePrompt(params: { diff: string; currentMessage: string; adjustment: string }): { diff --git a/src/app/services/resolveProvider.ts b/src/domain/llm/auth-resolver.ts similarity index 71% rename from src/app/services/resolveProvider.ts rename to src/domain/llm/auth-resolver.ts index 7d289f8..c73328f 100644 --- a/src/app/services/resolveProvider.ts +++ b/src/domain/llm/auth-resolver.ts @@ -3,9 +3,10 @@ export { resolveProvider }; import { Future } from "@/libs/future"; import { Just, Nothing, type Maybe } from "@/libs/maybe"; import { type Config, type ProviderConfig, type RefreshTokens } from "@/domain/config/config"; -import { ensureFreshTokens } from "@/lib/auth/google"; -import { ensureFreshOpenAITokens } from "@/lib/auth/openai"; -import { updateGoogleTokens, updateOpenAITokens } from "@/lib/storage/config"; +import { ensureFreshTokens } from "@/infra/auth/google"; +import { ensureFreshOpenAITokens } from "@/infra/auth/openai"; +import { updateGoogleTokens, updateOpenAITokens } from "@/infra/storage/config"; +import { absurd } from "@/libs/types"; type DetectTokenChange = (original: T, fresh: T) => Maybe; type RefreshProvider = (tokens: T) => Future; @@ -32,30 +33,27 @@ const refreshAndPersist: RefreshAndPersistFlow = (tokens, refresh, persist) => const resolveProvider: ResolveProvider = (config) => { const { ai } = config; - const { auth_method } = ai; - switch (auth_method.type) { + switch (ai.auth_method.type) { case "api_key": case "anthropic_setup_token": return Future.resolve(ai); case "google_oauth": - return refreshAndPersist(auth_method.content, ensureFreshTokens, updateGoogleTokens).map((tokens) => ({ + return refreshAndPersist(ai.auth_method.content, ensureFreshTokens, updateGoogleTokens).map((tokens) => ({ provider: ai.provider, model: ai.model, auth_method: { type: "google_oauth", content: tokens } })); case "openai_oauth": - return refreshAndPersist(auth_method.content, ensureFreshOpenAITokens, updateOpenAITokens).map((tokens) => ({ + return refreshAndPersist(ai.auth_method.content, ensureFreshOpenAITokens, updateOpenAITokens).map((tokens) => ({ provider: ai.provider, model: ai.model, auth_method: { type: "openai_oauth", content: tokens } })); - default: { - const _exhaustiveCheck: never = auth_method; - return Future.reject(new Error(`Unknown auth method: ${JSON.stringify(_exhaustiveCheck)}`)); - } + default: + return absurd(ai.auth_method, "AuthMethod"); } }; diff --git a/src/domain/llm/response-parser.ts b/src/domain/llm/response-parser.ts new file mode 100644 index 0000000..37b0ac3 --- /dev/null +++ b/src/domain/llm/response-parser.ts @@ -0,0 +1,79 @@ +export { type RawResponse, extractResponse, finalizeText }; + +import { Future } from "@/libs/future"; +import { absurd } from "@/libs/types"; + +const EMPTY_RESPONSE_ERROR = "Empty AI response"; + +const finalizeText = (raw: string | null | undefined): Future => { + const trimmed = (raw ?? "").trim(); + return trimmed.length === 0 ? Future.reject(new Error(EMPTY_RESPONSE_ERROR)) : Future.resolve(trimmed); +}; + +type TextBlock = { type: "text"; text: string }; +type AnthropicContent = Array<{ type: string; text?: string }>; + +type GeminiSDKLike = { response: { text: () => string | null | undefined } }; +type GeminiRESTLike = { + candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>; +}; + +type OpenAIDirectLike = { output_text?: string | null }; +type OpenAIStreamLike = { + response: { + output: Array<{ type: string; content?: Array<{ type: string; text?: string }> }>; + output_text?: string | null; + }; + doneEventText: string; + deltaSnapshotText: string; +}; + +type RawResponse = + | { provider: "gemini"; source: "sdk"; value: GeminiSDKLike } + | { provider: "gemini"; source: "rest"; value: GeminiRESTLike } + | { provider: "anthropic"; value: { content: AnthropicContent } } + | { provider: "openai"; source: "direct"; value: OpenAIDirectLike } + | { provider: "openai"; source: "stream"; value: OpenAIStreamLike }; + +const extractAnthropicText = (content: AnthropicContent): string => + content + .filter((b): b is TextBlock => b.type === "text" && typeof b.text === "string") + .map((b) => b.text) + .join(""); + +const extractOpenAIStreamText = (raw: OpenAIStreamLike): string => { + const fromOutput = raw.response.output + .flatMap((item) => (item.type === "message" ? (item.content ?? []) : [])) + .map((c) => (c.type === "output_text" ? (c.text ?? "") : "")) + .join(""); + + const candidates = [fromOutput, raw.response.output_text ?? "", raw.doneEventText, raw.deltaSnapshotText]; + return candidates.find((v) => v.trim().length > 0) ?? ""; +}; + +const extractResponse = (raw: RawResponse): Future => { + switch (raw.provider) { + case "gemini": + switch (raw.source) { + case "sdk": + return finalizeText(raw.value.response.text()); + case "rest": + return finalizeText(raw.value.candidates?.[0]?.content?.parts?.[0]?.text); + default: + return absurd(raw, "RawResponse.gemini"); + } + case "anthropic": + return finalizeText(extractAnthropicText(raw.value.content)); + case "openai": + switch (raw.source) { + case "direct": + return finalizeText(raw.value.output_text); + case "stream": + return finalizeText(extractOpenAIStreamText(raw.value)); + default: + return absurd(raw, "RawResponse.openai"); + } + default: + return absurd(raw, "RawResponse"); + } +}; diff --git a/src/app/services/llm.ts b/src/domain/llm/router.ts similarity index 73% rename from src/app/services/llm.ts rename to src/domain/llm/router.ts index a573a11..ddaa7e8 100644 --- a/src/app/services/llm.ts +++ b/src/domain/llm/router.ts @@ -2,10 +2,11 @@ export { type GenerateContentParams, generateCommitMessage, refineCommitMessage import { Future } from "@/libs/future"; import { type ProviderConfig, type CommitConvention } from "@/domain/config/config"; -import { generateContentWithGemini } from "@/app/services/gemini"; -import { generateContentWithOpenAI } from "@/app/services/openai"; -import { generateContentWithAnthropic } from "@/app/services/anthropic"; +import { generateContentWithGemini } from "@/infra/llm/gemini"; +import { generateContentWithOpenAI } from "@/infra/llm/openai"; +import { generateContentWithAnthropic } from "@/infra/llm/anthropic"; import { getPrompt, getRefinePrompt } from "@/domain/commit/prompts"; +import { Maybe, Nothing } from "@/libs/maybe"; type GenerateContentParams = { readonly prompt: string; @@ -27,11 +28,8 @@ const generateCommitMessage = ( config: ProviderConfig, diff: string, convention: CommitConvention, - customTemplate?: string -): Future => - generateContent(config, { - prompt: getPrompt(diff, convention, customTemplate) - }); + customTemplate: Maybe = Nothing() +): Future => generateContent(config, { prompt: getPrompt(diff, convention, customTemplate) }); const refineCommitMessage = ( config: ProviderConfig, diff --git a/src/lib/auth/anthropic.ts b/src/infra/auth/anthropic.ts similarity index 100% rename from src/lib/auth/anthropic.ts rename to src/infra/auth/anthropic.ts diff --git a/src/lib/auth/google.ts b/src/infra/auth/google.ts similarity index 98% rename from src/lib/auth/google.ts rename to src/infra/auth/google.ts index f950d28..633718f 100644 --- a/src/lib/auth/google.ts +++ b/src/infra/auth/google.ts @@ -1,10 +1,10 @@ export { performOAuthFlow, createAuthenticatedClient, ensureFreshTokens, validateOAuthTokens, getAccessToken }; import { type OAuthTokens } from "@/domain/config/config"; -import { SUCCESS_HTML, ERROR_HTML } from "@/lib/auth/templates"; +import { SUCCESS_HTML, ERROR_HTML } from "@/infra/auth/templates"; import { OAuth2Client, CodeChallengeMethod } from "google-auth-library"; import { Future } from "@/libs/future"; -import { environment } from "@/app/integrations"; +import { environment } from "@/infra/env"; import { randomBytes, createHash } from "node:crypto"; import { createServer, type Server } from "node:http"; diff --git a/src/lib/auth/openai.ts b/src/infra/auth/openai.ts similarity index 99% rename from src/lib/auth/openai.ts rename to src/infra/auth/openai.ts index 890c0fe..3e261c4 100644 --- a/src/lib/auth/openai.ts +++ b/src/infra/auth/openai.ts @@ -1,7 +1,7 @@ export { performOpenAIOAuthFlow, ensureFreshOpenAITokens, validateOpenAITokens, getOpenAIAccessToken }; import { type OpenAITokens } from "@/domain/config/config"; -import { SUCCESS_HTML, ERROR_HTML } from "@/lib/auth/templates"; +import { SUCCESS_HTML, ERROR_HTML } from "@/infra/auth/templates"; import { Future } from "@/libs/future"; import { randomBytes, createHash } from "node:crypto"; import { createServer, type Server } from "node:http"; diff --git a/src/lib/auth/templates.ts b/src/infra/auth/templates.ts similarity index 100% rename from src/lib/auth/templates.ts rename to src/infra/auth/templates.ts diff --git a/src/app/integrations.ts b/src/infra/env.ts similarity index 100% rename from src/app/integrations.ts rename to src/infra/env.ts diff --git a/src/lib/git/repo.ts b/src/infra/git/repo.ts similarity index 100% rename from src/lib/git/repo.ts rename to src/infra/git/repo.ts diff --git a/src/app/services/anthropic.ts b/src/infra/llm/anthropic.ts similarity index 59% rename from src/app/services/anthropic.ts rename to src/infra/llm/anthropic.ts index 0d622bc..bc20a37 100644 --- a/src/app/services/anthropic.ts +++ b/src/infra/llm/anthropic.ts @@ -3,39 +3,31 @@ export { generateContentWithAnthropic }; import Anthropic from "@anthropic-ai/sdk"; import { type Config } from "@/domain/config/config"; -import { type GenerateContentParams } from "@/app/services/llm"; +import { type GenerateContentParams } from "@/domain/llm/router"; import { Future } from "@/libs/future"; -import { anthropicOAuthHeaders, CLAUDE_CODE_SYSTEM_PROMPT } from "@/lib/auth/anthropic"; +import { anthropicOAuthHeaders, CLAUDE_CODE_SYSTEM_PROMPT } from "@/infra/auth/anthropic"; +import { absurd } from "@/libs/types"; +import { extractResponse } from "@/domain/llm/response-parser"; type AnthropicConfig = Extract; type TextBlock = { type: "text"; text: string }; -const MAX_TOKENS = 4096; - const toError = (error: unknown): Error => (error instanceof Error ? error : new Error(String(error))); -const extractText = (content: Array<{ type: string; text?: string }>): string => - content - .filter((block): block is TextBlock => block.type === "text" && typeof block.text === "string") - .map((block) => block.text) - .join(""); - const callAnthropicWithApiKey = (apiKey: string, model: string, params: GenerateContentParams): Future => Future.attemptP(async () => { const client = new Anthropic({ apiKey }); - const response = await client.messages.create({ + return await client.messages.create({ model, - max_tokens: MAX_TOKENS, + max_tokens: 4096, ...(params.systemInstruction !== undefined ? { system: params.systemInstruction } : {}), messages: [{ role: "user", content: params.prompt }] }); - - const text = extractText(response.content); - if (!text.trim()) throw new Error("Empty AI response"); - return text.trim(); - }).mapRej(toError); + }) + .mapRej(toError) + .chain((response) => extractResponse({ provider: "anthropic", value: response })); const callAnthropicWithSetupToken = ( authToken: string, @@ -49,22 +41,21 @@ const callAnthropicWithSetupToken = ( defaultHeaders: anthropicOAuthHeaders() }); - const systemBlocks: TextBlock[] = [ - { type: "text", text: CLAUDE_CODE_SYSTEM_PROMPT }, - ...(params.systemInstruction !== undefined ? [{ type: "text" as const, text: params.systemInstruction }] : []) - ]; + const systemBlocks: TextBlock[] = [{ type: "text", text: CLAUDE_CODE_SYSTEM_PROMPT }]; - const response = await client.messages.create({ + if (params.systemInstruction !== undefined) { + systemBlocks.push({ type: "text", text: params.systemInstruction }); + } + + return await client.messages.create({ model, - max_tokens: MAX_TOKENS, + max_tokens: 4096, system: systemBlocks, messages: [{ role: "user", content: params.prompt }] }); - - const text = extractText(response.content); - if (!text.trim()) throw new Error("Empty AI response"); - return text.trim(); - }).mapRej(toError); + }) + .mapRej(toError) + .chain((response) => extractResponse({ provider: "anthropic", value: response })); const generateContentWithAnthropic = ( config: AnthropicConfig, @@ -78,9 +69,7 @@ const generateContentWithAnthropic = ( case "google_oauth": case "openai_oauth": return Future.reject(new Error(`Unsupported auth method for Anthropic: ${config.auth_method.type}`)); - default: { - const _exhaustiveCheck: never = config.auth_method; - return Future.reject(new Error(`Unknown auth method: ${JSON.stringify(_exhaustiveCheck)}`)); - } + default: + return absurd(config.auth_method, "AuthMethod"); } }; diff --git a/src/app/services/gemini.ts b/src/infra/llm/gemini.ts similarity index 78% rename from src/app/services/gemini.ts rename to src/infra/llm/gemini.ts index 992ce22..3d28a38 100644 --- a/src/app/services/gemini.ts +++ b/src/infra/llm/gemini.ts @@ -3,9 +3,10 @@ export { type GeminiAuthCredentials, generateContentWithGemini, getAuthCredentia import { GoogleGenerativeAI } from "@google/generative-ai"; import { Future } from "@/libs/future"; import { type Config, type OAuthTokens } from "@/domain/config/config"; -import { getAccessToken } from "@/lib/auth/google"; +import { getAccessToken } from "@/infra/auth/google"; import { Just, Nothing, type Maybe } from "@/libs/maybe"; -import { type GenerateContentParams } from "@/app/services/llm"; +import { type GenerateContentParams } from "@/domain/llm/router"; +import { extractResponse } from "@/domain/llm/response-parser"; type GeminiConfig = Extract; @@ -34,19 +35,17 @@ const generateContentWithApiKey = ( params: GenerateContentParams ): Future => { const genAI = new GoogleGenerativeAI(apiKey); - const geminiModel = genAI.getGenerativeModel({ - model, - ...(params.systemInstruction !== undefined ? { systemInstruction: params.systemInstruction } : {}) - }); + const modelParams: Parameters[0] = { model }; - return Future.attemptP(async () => { - const result = await geminiModel.generateContent(params.prompt); + if (params.systemInstruction !== undefined) { + modelParams.systemInstruction = params.systemInstruction; + } - const text = result.response.text() ?? ""; + const geminiModel = genAI.getGenerativeModel(modelParams); - if (!text || !text.trim()) throw new Error("Empty AI response"); - return text.trim(); - }).mapRej(toError); + return Future.attemptP(async () => await geminiModel.generateContent(params.prompt)) + .mapRej(toError) + .chain((result) => extractResponse({ provider: "gemini", source: "sdk", value: result })); }; const generateContentWithOAuth = ( @@ -81,20 +80,14 @@ const generateContentWithOAuth = ( throw new Error(`Gemini API error (${response.status}): ${errorBody}`); } - const json = (await response.json()) as { + return (await response.json()) as { promptFeedback?: unknown; usageMetadata?: unknown; candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>; }; - - const text = json.candidates?.[0]?.content?.parts?.[0]?.text; - - if (!text || !text.trim()) { - throw new Error("Empty AI response"); - } - - return text.trim(); - }).mapRej(toError) + }) + .mapRej(toError) + .chain((json) => extractResponse({ provider: "gemini", source: "rest", value: json })) ); const generateContentWithGemini = (config: GeminiConfig, params: GenerateContentParams): Future => { diff --git a/src/app/services/openai.ts b/src/infra/llm/openai.ts similarity index 68% rename from src/app/services/openai.ts rename to src/infra/llm/openai.ts index b04be49..558f224 100644 --- a/src/app/services/openai.ts +++ b/src/infra/llm/openai.ts @@ -1,9 +1,10 @@ export { generateContentWithOpenAI }; import { type Config, type OpenAITokens } from "@/domain/config/config"; -import { type GenerateContentParams } from "@/app/services/llm"; +import { type GenerateContentParams } from "@/domain/llm/router"; import { Future } from "@/libs/future"; -import { getOpenAIAccessToken } from "@/lib/auth/openai"; +import { getOpenAIAccessToken } from "@/infra/auth/openai"; +import { extractResponse } from "@/domain/llm/response-parser"; import OpenAI from "openai"; @@ -14,17 +15,14 @@ const toError = (error: unknown): Error => (error instanceof Error ? error : new const callOpenAIWithApiKey = (authToken: string, model: string, params: GenerateContentParams): Future => Future.attemptP(async () => { const client = new OpenAI({ apiKey: authToken }); - const response = await client.responses.create({ + return await client.responses.create({ model, instructions: params.systemInstruction ?? null, input: params.prompt }); - - const text = response.output_text ?? ""; - - if (!text || !text.trim()) throw new Error("Empty AI response"); - return text.trim(); - }).mapRej(toError); + }) + .mapRej(toError) + .chain((response) => extractResponse({ provider: "openai", source: "direct", value: response })); const callOpenAIWithOAuth = (authToken: string, model: string, params: GenerateContentParams): Future => Future.attemptP(async () => { @@ -52,26 +50,10 @@ const callOpenAIWithOAuth = (authToken: string, model: string, params: GenerateC }); const response = await stream.finalResponse(); - - const extractedText = response.output - .flatMap((item) => (item.type === "message" ? item.content : [])) - .map((c) => (c.type === "output_text" ? c.text : "")) - .join(""); - - const outputText = response.output_text ?? ""; - const candidates = [ - { source: "output", value: extractedText }, - { source: "output_text", value: outputText }, - { source: "done_event", value: doneEventText }, - { source: "delta_snapshot", value: deltaSnapshotText } - ] as const; - const selected = candidates.find((candidate) => candidate.value.trim().length > 0); - const text = selected?.value.trim() ?? ""; - - if (!text.trim()) throw new Error("Empty AI response"); - - return text.trim(); - }).mapRej(toError); + return { response, doneEventText, deltaSnapshotText }; + }) + .mapRej(toError) + .chain((bundle) => extractResponse({ provider: "openai", source: "stream", value: bundle })); const generateContentWithApiKey = ( apiKey: string, diff --git a/src/lib/storage/config.ts b/src/infra/storage/config.ts similarity index 100% rename from src/lib/storage/config.ts rename to src/infra/storage/config.ts diff --git a/src/infra/ui/model-picker.ts b/src/infra/ui/model-picker.ts new file mode 100644 index 0000000..b32e57e --- /dev/null +++ b/src/infra/ui/model-picker.ts @@ -0,0 +1,27 @@ +export { selectModelInteractively }; + +import { Future } from "@/libs/future"; +import { Model } from "@/domain/config/config"; + +const selectModelInteractively = (models: Model[]): Future => + Future.attemptP(async () => { + const { render } = await import("ink"); + const React = await import("react"); + const { ModelSelector } = await import("@/infra/ui/model-selector"); + + return new Promise((resolve, reject) => { + const { unmount } = render( + React.createElement(ModelSelector, { + models, + onSelect: (modelId: string) => { + unmount(); + resolve(modelId); + }, + onCancel: () => { + unmount(); + reject(new Error("Selection cancelled")); + } + }) + ); + }); + }); diff --git a/src/lib/ui/components/model-selector.tsx b/src/infra/ui/model-selector.tsx similarity index 100% rename from src/lib/ui/components/model-selector.tsx rename to src/infra/ui/model-selector.tsx diff --git a/src/lib/ui/spinner.ts b/src/infra/ui/spinner.ts similarity index 100% rename from src/lib/ui/spinner.ts rename to src/infra/ui/spinner.ts diff --git a/src/libs/types.ts b/src/libs/types.ts index fa9a969..aafa799 100644 --- a/src/libs/types.ts +++ b/src/libs/types.ts @@ -1,7 +1,23 @@ /* This module holds useful type-level functions. */ -export { type UnionPick }; +export { type UnionPick, absurd }; + +// Exhaustiveness guard for discriminated unions. +// +// Call in the `default` branch of a switch (or any unreachable position) to +// force the compiler to verify every variant was handled. Adding a new variant +// makes `value` no longer assignable to `never`, turning the missed case into +// a compile-time error. +// +// switch (x.type) { +// case "a": return ... +// case "b": return ... +// default: return absurd(x, "UserEvent"); +// } +function absurd(value: never, label: string): never { + throw new Error(`${label}: unhandled variant ${JSON.stringify(value)}`); +} type UnionToIntersection = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never;