Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,12 @@ commit setup

You will be prompted to choose:

- **AI provider**: Google Gemini, OpenAI, or Anthropic
- **AI provider**: Google Gemini, OpenAI, Anthropic, or xAI
- **Auth method**:
- Google Gemini: Google OAuth or API key
- OpenAI: Sign in with ChatGPT or API key
- Anthropic: Claude setup-token or API key
- xAI: Sign in with Grok 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`.
Expand Down Expand Up @@ -212,6 +213,7 @@ commit --help
- **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
- **xAI** (Grok) — Sign in with your SuperGrok/X Premium subscription or API key

Contributions and feedback are welcome!

Expand Down
49 changes: 35 additions & 14 deletions src/cli/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import * as repo from "@/infra/git/repo";
import { Future } from "@/libs/future";
import { configFile, loadConfig } from "@/infra/storage/config";
import { type AuthMethod, type ProviderConfig } from "@/domain/config/config";
import { Just, type Maybe } from "@/libs/maybe";
import { Just, Nothing, type Maybe } from "@/libs/maybe";
import { absurd } from "@/libs/types";
import { access } from "node:fs/promises";
import { environment } from "@/infra/env";
Expand Down Expand Up @@ -82,18 +82,19 @@ class Doctor {

rows.push(["Auth Method", color.green(authMethodLabel(authMethod)), authMethodDescription(ai)]);

if (ai.auth_method.type === "google_oauth" || ai.auth_method.type === "openai_oauth") {
const now = Date.now();
const expiryDate = ai.auth_method.content.expiry_date; // For API Key we don't have this, that's why we have this `if (...) {}` block
const isExpired = expiryDate <= now;
const expiryStr = new Date(expiryDate).toLocaleString();

rows.push([
"Token Status",
isExpired ? color.yellow("Expired") : color.green("Valid"),
isExpired ? `Expired at ${expiryStr} (will auto-refresh)` : `Expires at ${expiryStr}`
]);
}
rows.push(
...tokenExpiry(ai.auth_method).maybe<CheckRow[]>([], (expiryDate) => {
const isExpired = expiryDate <= Date.now();
const expiryStr = new Date(expiryDate).toLocaleString();
return [
[
"Token Status",
isExpired ? color.yellow("Expired") : color.green("Valid"),
isExpired ? `Expired at ${expiryStr} (will auto-refresh)` : `Expires at ${expiryStr}`
]
];
})
);

return rows;
})
Expand Down Expand Up @@ -165,17 +166,33 @@ function renderModelInfo(ai: ProviderConfig): string {
return ai.effort instanceof Just ? `${base} (${ai.effort.value} effort)` : base;
}

/** `Nothing` for auth methods that carry no expiry, so a new variant is a compile error rather than a missing row. */
function tokenExpiry(authMethod: ProviderConfig["auth_method"]): Maybe<number> {
switch (authMethod.type) {
case "google_oauth":
case "openai_oauth":
case "xai_oauth":
return Just(authMethod.content.expiry_date);
case "api_key":
case "anthropic_setup_token":
return Nothing();
default:
return absurd(authMethod, "AuthMethod");
}
}

function authMethodLabel(authMethod: AuthMethod): string {
switch (authMethod) {
case "google_oauth":
case "openai_oauth":
case "xai_oauth":
return "OAuth";
case "anthropic_setup_token":
return "Setup Token";
case "api_key":
return "API Key";
default:
return "This should never happen. Please run 'commit-tools setup' to create a new configuration.";
return absurd(authMethod, "AuthMethod");
}
}

Expand All @@ -185,6 +202,8 @@ function authMethodDescription(ai: ProviderConfig): string {
return "Google OAuth 2.0";
case "openai_oauth":
return "OpenAI Codex OAuth";
case "xai_oauth":
return "Grok Subscription OAuth";
case "anthropic_setup_token":
return "Claude Setup-Token";
case "api_key":
Expand All @@ -195,6 +214,8 @@ function authMethodDescription(ai: ProviderConfig): string {
return "Anthropic API Key";
case "gemini":
return "Google AI Studio API Key";
case "xai":
return "xAI API Key";
}
default:
return "This should never happen. Please run 'commit-tools setup' to create a new configuration.";
Expand Down
31 changes: 29 additions & 2 deletions src/cli/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { saveConfig } from "@/infra/storage/config";
import { CommitConvention, type Config, type Model, type ProviderConfig } from "@/domain/config/config";
import { performOAuthFlow, type GoogleOAuthPhase } from "@/infra/auth/google";
import { performOpenAIOAuthFlow, validateOpenAITokens } from "@/infra/auth/openai";
import { performXaiOAuthFlow } from "@/infra/auth/xai";
import { validateAnthropicApiKey, validateAnthropicSetupToken } from "@/infra/auth/anthropic";
import { Just, Nothing } from "@/libs/maybe";
import { bracketStatus, loading } from "@/infra/ui/spinner";
Expand All @@ -21,7 +22,7 @@ type SetupPreferences = {
readonly convention: CommitConvention;
readonly customTemplate: string | undefined;
readonly provider: ProviderConfig["provider"];
readonly authMethod: "google_oauth" | "openai_oauth" | "api_key" | "anthropic_setup_token";
readonly authMethod: "google_oauth" | "openai_oauth" | "xai_oauth" | "api_key" | "anthropic_setup_token";
};

class Setup {
Expand All @@ -36,7 +37,8 @@ class Setup {
options: [
{ value: "gemini", label: "Google" },
{ value: "openai", label: "OpenAI" },
{ value: "anthropic", label: "Anthropic" }
{ value: "anthropic", label: "Anthropic" },
{ value: "xai", label: "xAI" }
],
initialValue: "gemini" as const
});
Expand Down Expand Up @@ -88,6 +90,8 @@ class Setup {
return this.setupOAuth();
case "openai_oauth":
return this.setupOpenAIOAuth();
case "xai_oauth":
return this.setupXaiOAuth();
case "anthropic_setup_token":
return this.setupAnthropicSetupToken();
case "api_key":
Expand Down Expand Up @@ -142,6 +146,12 @@ class Setup {
.chain((authMethod) => this.finalizeSetup(authMethod));
}

private setupXaiOAuth(): Future<Error, void> {
p.log.info("Opening browser for Grok sign-in...");

return performXaiOAuthFlow().chain((tokens) => this.finalizeSetup({ type: "xai_oauth" as const, content: tokens }));
}

private setupApiKey(): Future<Error, void> {
const { message, validate } = apiKeyPromptFor(this.preferences.provider);
return Future.attemptP(async () => {
Expand Down Expand Up @@ -225,6 +235,19 @@ function getAuthMethodOptions(provider: ProviderConfig["provider"]): Option<Setu
hint: "Paste an Anthropic API key (sk-ant-api...)"
}
];
case "xai":
return [
{
value: "xai_oauth",
label: "Sign in with Grok (recommended)",
hint: "Uses your SuperGrok / X Premium subscription"
},
{
value: "api_key",
label: "API Key",
hint: "Paste an xAI API key (xai-...)"
}
];
}
}

Expand All @@ -236,6 +259,8 @@ function getInitialValue(provider: ProviderConfig["provider"]): SetupPreferences
return "google_oauth";
case "anthropic":
return "anthropic_setup_token";
case "xai":
return "xai_oauth";
}
}

Expand All @@ -254,5 +279,7 @@ function apiKeyPromptFor(provider: ProviderConfig["provider"]): ApiKeyPrompt {
return { message: "Enter your GOOGLE_API_KEY:", validate: genericApiKeyValidator };
case "anthropic":
return { message: "Enter your ANTHROPIC_API_KEY (sk-ant-api...):", validate: validateAnthropicApiKey };
case "xai":
return { message: "Enter your XAI_API_KEY (xai-...):", validate: genericApiKeyValidator };
}
}
35 changes: 34 additions & 1 deletion src/domain/commit/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ import { Future } from "@/libs/future";
import { OPENAI_EFFORTS, type Model, type OpenAIEffort, type OpenAIModelEffort, type ProviderConfig } from "@/domain/config/config";
import { getOpenAIAccessToken } from "@/infra/auth/openai";
import { anthropicOAuthHeaders } from "@/infra/auth/anthropic";
import { xaiApiKeyOptions, xaiOAuthOptions } from "@/infra/auth/xai";
import { unsupportedAuth } from "@/domain/llm/auth-error";
import { absurd } from "@/libs/types";
import { Just, Nothing, type Maybe } from "@/libs/maybe";

import OpenAI from "openai";
import OpenAI, { type ClientOptions } from "openai";

type CodexModel = {
readonly slug: string;
Expand Down Expand Up @@ -132,6 +135,34 @@ const fetchAnthropicModels = (authMethod: ProviderConfig["auth_method"]): Future
.map((m) => ({ id: m.id, description: m.display_name ?? "", openaiEffort: Nothing<OpenAIModelEffort>() }));
});

const fetchXaiModelsWith = (options: ClientOptions): Future<Error, Model[]> =>
Future.attemptP(async () => {
const list = await new OpenAI(options).models.list();
const models: Array<{ id: string }> = [];
for await (const model of list) {
models.push(model);
}
return models
.filter((m) => m.id.startsWith("grok-") && !m.id.includes("-image"))
.sort((a, b) => a.id.localeCompare(b.id))
.map((m) => ({ id: m.id, description: "", openaiEffort: Nothing<OpenAIModelEffort>() }));
}).mapRej((error) => new Error(`Failed to fetch xAI models: ${error instanceof Error ? error.message : String(error)}`));

const fetchXaiModels = (authMethod: ProviderConfig["auth_method"]): Future<Error, Model[]> => {
switch (authMethod.type) {
case "api_key":
return fetchXaiModelsWith(xaiApiKeyOptions(authMethod.content));
case "xai_oauth":
return fetchXaiModelsWith(xaiOAuthOptions(authMethod.content.access_token));
case "google_oauth":
case "openai_oauth":
case "anthropic_setup_token":
return unsupportedAuth("xai", authMethod.type);
default:
return absurd(authMethod, "AuthMethod");
}
};

const fetchModels = (provider: ProviderConfig["provider"], authMethod: ProviderConfig["auth_method"]): Future<Error, Model[]> => {
switch (provider) {
case "openai":
Expand All @@ -140,5 +171,7 @@ const fetchModels = (provider: ProviderConfig["provider"], authMethod: ProviderC
return fetchGeminiModels(authMethod);
case "anthropic":
return fetchAnthropicModels(authMethod);
case "xai":
return fetchXaiModels(authMethod);
}
};
31 changes: 22 additions & 9 deletions src/domain/config/config.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,25 @@
export {
type CommitConvention,
type OAuthTokens,
type OpenAITokens,
type BearerTokens,
type RefreshTokens,
type AuthMethod,
type ProviderConfig,
type OpenAIEffort,
type OpenAIModelEffort,
type XaiEffort,
type AnthropicEffort,
type GeminiEffort,
type Model,
Config,
schema_OAuthTokens,
schema_OpenAITokens,
schema_BearerTokens,
schema_AuthMethod,
schema_ProviderConfig,
resolveAuthMethod,
AI_PROVIDERS,
COMMIT_CONVENTIONS,
OPENAI_EFFORTS,
XAI_EFFORTS,
ANTHROPIC_EFFORTS,
GEMINI_EFFORTS
};
Expand All @@ -35,8 +36,6 @@ import type AnthropicPkg from "@anthropic-ai/sdk";
const COMMIT_CONVENTIONS = ["conventional", "imperative", "custom"] as const;
type CommitConvention = (typeof COMMIT_CONVENTIONS)[number];

const AI_PROVIDERS = ["gemini", "openai", "anthropic"] as const;

const schema_OAuthTokens = s.object({
access_token: s.string,
refresh_token: s.string,
Expand All @@ -46,14 +45,14 @@ const schema_OAuthTokens = s.object({
});
type OAuthTokens = s.Infer<typeof schema_OAuthTokens>;

const schema_OpenAITokens = s.object({
const schema_BearerTokens = s.object({
access_token: s.string,
refresh_token: s.string,
expiry_date: s.number
});
type OpenAITokens = s.Infer<typeof schema_OpenAITokens>;
type BearerTokens = s.Infer<typeof schema_BearerTokens>;

type RefreshTokens = OAuthTokens | OpenAITokens;
type RefreshTokens = OAuthTokens | BearerTokens;

const schema_AuthMethod = s.discriminatedUnion([
s.variant({
Expand All @@ -66,16 +65,21 @@ const schema_AuthMethod = s.discriminatedUnion([
}),
s.variant({
type: "openai_oauth",
content: schema_OpenAITokens
content: schema_BearerTokens
}),
s.variant({
type: "anthropic_setup_token",
content: s.string
}),
s.variant({
type: "xai_oauth",
content: schema_BearerTokens
})
]);
type AuthMethod = s.Infer<typeof schema_AuthMethod>["type"];

const OPENAI_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh"] as const satisfies readonly NonNullable<OpenAIPkg.Reasoning["effort"]>[];
const XAI_EFFORTS = ["low", "high"] as const satisfies readonly NonNullable<OpenAIPkg.Reasoning["effort"]>[];
const ANTHROPIC_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const satisfies readonly NonNullable<AnthropicPkg.OutputConfig["effort"]>[];
const GEMINI_EFFORTS = [ThinkingLevel.MINIMAL, ThinkingLevel.LOW, ThinkingLevel.MEDIUM, ThinkingLevel.HIGH] as const satisfies readonly ThinkingLevel[];

Expand All @@ -84,6 +88,7 @@ type OpenAIModelEffort = {
readonly options: readonly [OpenAIEffort, ...OpenAIEffort[]];
readonly defaultValue: OpenAIEffort;
};
type XaiEffort = (typeof XAI_EFFORTS)[number];
type AnthropicEffort = (typeof ANTHROPIC_EFFORTS)[number];
type GeminiEffort = (typeof GEMINI_EFFORTS)[number];

Expand All @@ -105,6 +110,12 @@ const schema_ProviderConfig = s.discriminatedUnion([
model: s.string,
auth_method: schema_AuthMethod,
effort: s.optionalMaybe(s.stringEnum([...ANTHROPIC_EFFORTS]))
}),
s.variant({
provider: "xai",
model: s.string,
auth_method: schema_AuthMethod,
effort: s.optionalMaybe(s.stringEnum([...XAI_EFFORTS]))
})
]);
type ProviderConfig = s.Infer<typeof schema_ProviderConfig>;
Expand All @@ -117,6 +128,8 @@ const resolveAuthMethod = (ai: ProviderConfig, auth_method: ProviderConfig["auth
return { provider: "anthropic", model: ai.model, auth_method, effort: ai.effort };
case "gemini":
return { provider: "gemini", model: ai.model, auth_method, effort: ai.effort };
case "xai":
return { provider: "xai", model: ai.model, auth_method, effort: ai.effort };
default:
return absurd(ai, "ProviderConfig");
}
Expand Down
Loading