diff --git a/.github/homebrew/git-aicommit.rb b/.github/homebrew/git-aicommit.rb index f6d48ee..750e0d2 100644 --- a/.github/homebrew/git-aicommit.rb +++ b/.github/homebrew/git-aicommit.rb @@ -5,7 +5,7 @@ # ruby -c .github/homebrew/git-aicommit.rb # brew style .github/homebrew/git-aicommit.rb class GitAicommit < Formula - desc "Generate git commit messages from staged diffs using Claude" + desc "Generate git commit messages using Codex or Claude" homepage "https://github.com/getkono/git-aicommit" version "__VERSION__" license any_of: ["MIT", "Apache-2.0"] @@ -38,12 +38,17 @@ def install def caveats <<~EOS - git-aicommit shells out to two tools this formula does NOT install: - * git (brew install git, or use your system git) - * claude the Claude Code CLI, which is not available in Homebrew. - Install and authenticate it per: + git-aicommit shells out to git and one supported agent CLI. This formula + does NOT install them: + * git brew install git, or use your system git + * codex install and authenticate OpenAI Codex: + https://developers.openai.com/codex/cli/ + * claude install and authenticate Claude Code: https://docs.claude.com/en/docs/claude-code + Only one agent CLI is required. When both are installed, Codex is used by + default; pass --agent claude to override it. + Invoke it as a git subcommand once installed: git aicommit EOS end diff --git a/Cargo.lock b/Cargo.lock index 22dc1e2..9c1a453 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,11 +4,12 @@ version = 4 [[package]] name = "agent-text" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cf3ad01940831ac8018fdea968cd18c2acafaea70e129dd50b909c1a5b1eb69" +checksum = "9f432625494ad4c9ecf8afa619254a212434aa311547c61ba05cab380264a6c5" dependencies = [ "async-trait", + "semver", "serde", "serde_json", "tempfile", @@ -236,6 +237,7 @@ dependencies = [ "indicatif", "thiserror", "tokio", + "which", ] [[package]] @@ -382,6 +384,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.229" @@ -607,6 +615,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "which" +version = "8.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f3ef584124b911bcc3875c2f1472e80f24361ceb789bd1c62b3e9a3df9ff43c" +dependencies = [ + "libc", +] + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 94b1486..c12d1fb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,11 +14,12 @@ homepage = "https://github.com/getkono/git-aicommit" [workspace.dependencies] aicommit-core = { version = "0.2.0", path = "crates/aicommit-core" } -agent-text = { version = "0.1.0", default-features = false } +agent-text = { version = "0.1.1", default-features = false } clap = { version = "4", features = ["derive"] } indicatif = "0.17" thiserror = "2" tokio = { version = "1", features = ["macros", "rt"] } +which = "8" # Must live at the workspace root: a `[profile.*]` table in a member manifest is # ignored (with only a warning), which would silently drop these from releases. diff --git a/README.md b/README.md index 2d849a4..5dde5b4 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,19 @@ # git-aicommit A tiny Rust CLI that drafts a commit message from your staged changes using +[OpenAI Codex](https://developers.openai.com/codex/cli/) or [Claude Code](https://docs.claude.com/en/docs/claude-code), then opens `git commit` with the message pre-filled so you can review, edit, or abort. -The model is picked automatically from the diff size — Haiku for everyday -commits, Sonnet for large ones — or pinned with `--model`. +When both agent CLIs are installed, Codex is preferred; use `--agent` to choose +explicitly. The model is picked automatically from the diff size or pinned with +`--model`. ## How it works 1. Parses standard `git commit` flags (see [Supported flags](#supported-flags)) to decide what to diff and how to prompt. 2. Checks you're in a git repo and that there's something to commit. 3. For plain and `--amend` commits, runs the `pre-commit` hook as an early check (`git hook run` on Git ≥ 2.36, executing the hook script directly on older git) — if it fails, the tool aborts before making any API call. (For `-a` and pathspec commits the staged index isn't what gets committed, so hooks run at commit time instead.) -4. Picks a model from the diff size (Haiku by default; Sonnet with higher effort for large or many-file diffs, announced on its own line) unless you pin one with `--model`, then feeds the relevant diff to `claude -p` over stdin. +4. Selects Codex first when available, otherwise Claude, unless you pin one with `--agent`. It picks a provider-specific model from the diff size (Luna/Terra for Codex, Haiku/Sonnet for Claude) unless you pin one with `--model`, then sends the relevant diff to that local agent CLI. 5. Cleans up the response and runs `git commit -e -m "" …`, inheriting your terminal so `$EDITOR` opens normally. Large diffs are truncated at 60KB to keep the prompt sane. @@ -20,8 +22,10 @@ Large diffs are truncated at 60KB to keep the prompt sane. - Rust (stable) - `git` (any reasonably recent version; the pre-commit pre-check uses `git hook run` on ≥ 2.36 and falls back to running the hook script directly on older git) -- [`claude`](https://docs.claude.com/en/docs/claude-code) CLI, installed and authenticated - (tested with Claude Code 2.x; requires a version that supports `--output-format json` and `--disable-slash-commands`) +- At least one supported agent CLI installed and authenticated: + - [`codex`](https://developers.openai.com/codex/cli/) 0.146.0 or newer + - [`claude`](https://docs.claude.com/en/docs/claude-code) 2.x with + `--output-format json` and `--disable-slash-commands` support ## Install @@ -31,8 +35,9 @@ Large diffs are truncated at 60KB to keep the prompt sane. brew install getkono/tap/git-aicommit ``` -`git-aicommit` calls the [`claude`](https://docs.claude.com/en/docs/claude-code) CLI (not in Homebrew) and -`git` at runtime — see [Requirements](#requirements). `brew info getkono/tap/git-aicommit` repeats this. +`git-aicommit` calls `git` and one of the supported agent CLIs at runtime; the +formula does not install those tools. See [Requirements](#requirements). +`brew info getkono/tap/git-aicommit` repeats this. **From crates.io** (requires Rust): @@ -67,7 +72,13 @@ git aicommit Your editor opens with the AI-generated message. Save to commit, or quit with an empty message to abort. -`git aicommit` aims to be a drop-in for `git commit`: it understands the common flags and forwards anything else straight through. By default it auto-selects the model from the diff size (`haiku`, or `sonnet` with higher effort for large/many-file diffs, announced when it isn't haiku); pass `--model ` to pin one (before any git flags). Run `git aicommit --help` for a summary, or `git aicommit --version` to print the version, build metadata, and binary path. +`git aicommit` aims to be a drop-in for `git commit`: it understands the common +flags and forwards anything else straight through. It uses Codex when `codex` +is on `PATH`, otherwise Claude when `claude` is available. Pass +`--agent codex|claude` to override that choice. By default it selects +`gpt-5.6-luna` or `gpt-5.6-terra` for Codex and `haiku` or `sonnet` for Claude, +escalating on large or many-file diffs. Pass `--model ` to pin a model for +the selected agent. `--agent` and `--model` must come before any Git flags. ### Supported flags @@ -83,6 +94,8 @@ git aicommit --amend # regenerate the message from the previous messag **Steer the AI:** ```sh +git aicommit --agent claude # override Codex-first detection +git aicommit --agent codex --model gpt-5.6-terra # pin both agent and model git aicommit -m "call out the perf fix" # an instruction, NOT a literal message (repeatable) git aicommit -t .gitmessage # make the output follow a template file ``` @@ -137,7 +150,9 @@ git aicommit -- --weird-filename - The prompt asks for Conventional Commits style (`feat:`, `fix:`, etc.), imperative subject ≤72 chars, optional body explaining the *why*. - When a commit bundles several unrelated changes, the message leads with the primary one in the subject and itemizes the rest as body bullets. A `git diff --stat` inventory of every changed file is sent alongside the diff so small or buried changes aren't dropped. - By default the editor opens so you can review before committing; quit with an empty message to abort. `-y`/`--yes` (or `--no-edit`) commits the generated message directly, and `--dry-run` never commits. -- No API key handling here; auth is delegated entirely to the `claude` CLI. +- No API key handling here; auth is delegated entirely to the selected local + `codex` or `claude` CLI. If neither executable is installed, generation exits + with an actionable error. ## Using it from your own application @@ -147,7 +162,9 @@ editor wanting an AI-drafted message for its commit box, say. Hand it a diff and it hands you a string; what you do with that string is yours. It never invokes `git`, and it accepts any async [`agent_text::Agent`](https://docs.rs/agent-text/latest/agent_text/trait.Agent.html). -The application chooses the concrete adapter; this CLI uses +The application chooses the concrete adapter; this CLI selects between +[`agent_text::Codex`](https://docs.rs/agent-text/latest/agent_text/struct.Codex.html) +and [`agent_text::ClaudeCode`](https://docs.rs/agent-text/latest/agent_text/struct.ClaudeCode.html). ```rust,no_run diff --git a/crates/aicommit-core/README.md b/crates/aicommit-core/README.md index 68416f7..9b47a16 100644 --- a/crates/aicommit-core/README.md +++ b/crates/aicommit-core/README.md @@ -38,6 +38,10 @@ println!("{}", generated.message); # } ``` +`auto_select` preserves the default Claude model tiers. Frontends selecting a +different provider can call `auto_select_with_models` with model names that +provider understands while reusing the same diff thresholds and effort policy. + See `examples/editor.rs` for a runnable version. `CommitRequest` also carries a template, steering instructions, a changed-file diff --git a/crates/aicommit-core/src/lib.rs b/crates/aicommit-core/src/lib.rs index 11b0fe6..8f96a7c 100644 --- a/crates/aicommit-core/src/lib.rs +++ b/crates/aicommit-core/src/lib.rs @@ -48,7 +48,7 @@ pub use agent_text::{Agent, GenerationRequest, Usage}; pub use error::{CoreError, Result}; pub use model::{ ESCALATE_DIFF_BYTES, ESCALATE_FILE_COUNT, Effort, LARGE_DIFF_MODEL, ModelChoice, - SMALL_DIFF_MODEL, auto_select, + SMALL_DIFF_MODEL, auto_select, auto_select_with_models, }; pub use prompt::{ DEFAULT_MAX_DIFF_BYTES, build_prompt, build_prompt_with_max, build_system_prompt, truncate_diff, diff --git a/crates/aicommit-core/src/model.rs b/crates/aicommit-core/src/model.rs index 0bb917a..2bcd815 100644 --- a/crates/aicommit-core/src/model.rs +++ b/crates/aicommit-core/src/model.rs @@ -38,14 +38,27 @@ pub const LARGE_DIFF_MODEL: &str = "sonnet"; /// secondary changes aren't lost in the summary. Pass the *full* diff length, /// before any truncation. pub fn auto_select(diff_len: usize, file_count: usize) -> ModelChoice { + auto_select_with_models(diff_len, file_count, SMALL_DIFF_MODEL, LARGE_DIFF_MODEL) +} + +/// Choose between caller-supplied small- and large-diff models. +/// +/// This keeps the selection thresholds and effort policy provider-neutral while +/// allowing a frontend to supply model names understood by its chosen agent. +pub fn auto_select_with_models( + diff_len: usize, + file_count: usize, + small_model: &str, + large_model: &str, +) -> ModelChoice { if diff_len >= ESCALATE_DIFF_BYTES || file_count >= ESCALATE_FILE_COUNT { ModelChoice { - model: LARGE_DIFF_MODEL.to_string(), + model: large_model.to_string(), effort: Some(Effort::Medium), } } else { ModelChoice { - model: SMALL_DIFF_MODEL.to_string(), + model: small_model.to_string(), effort: None, } } @@ -78,9 +91,24 @@ mod tests { assert_eq!(auto_select(100, ESCALATE_FILE_COUNT), large()); } + #[test] + fn auto_select_accepts_provider_model_names() { + assert_eq!( + auto_select_with_models(0, 1, "gpt-small", "gpt-large"), + ModelChoice::new("gpt-small") + ); + assert_eq!( + auto_select_with_models(ESCALATE_DIFF_BYTES, 1, "gpt-small", "gpt-large",), + ModelChoice { + model: "gpt-large".to_string(), + effort: Some(Effort::Medium), + } + ); + } + #[test] fn effort_wire_form() { - // Agent adapters pass this through; "medium" is what Claude expects. + // Bundled agent adapters pass these stable wire names through. assert_eq!(Effort::Medium.as_str(), "medium"); assert_eq!(Effort::Low.to_string(), "low"); assert_eq!(Effort::High.to_string(), "high"); diff --git a/crates/git-aicommit/Cargo.toml b/crates/git-aicommit/Cargo.toml index 9faebe5..b0ef883 100644 --- a/crates/git-aicommit/Cargo.toml +++ b/crates/git-aicommit/Cargo.toml @@ -1,9 +1,9 @@ [package] name = "git-aicommit" version = "1.4.0" -description = "Generate git commit messages from staged diffs using Claude." +description = "Generate git commit messages from staged diffs using local AI agent CLIs." readme = "../../README.md" -keywords = ["git", "commit", "ai", "claude", "cli"] +keywords = ["git", "commit", "ai", "claude", "codex"] categories = ["command-line-utilities", "development-tools"] edition.workspace = true rust-version.workspace = true @@ -14,8 +14,9 @@ homepage.workspace = true [dependencies] aicommit-core.workspace = true -agent-text = { workspace = true, features = ["claude-code"] } +agent-text = { workspace = true, features = ["claude-code", "codex"] } clap.workspace = true indicatif.workspace = true thiserror.workspace = true tokio.workspace = true +which.workspace = true diff --git a/crates/git-aicommit/src/agent.rs b/crates/git-aicommit/src/agent.rs new file mode 100644 index 0000000..b71a8b3 --- /dev/null +++ b/crates/git-aicommit/src/agent.rs @@ -0,0 +1,133 @@ +//! Selecting and configuring the local agent CLI used for generation. + +use agent_text::{Agent, ClaudeCode, Codex}; +use aicommit_core::ModelChoice; + +use crate::cli::AgentChoice; +use crate::error::{Error, Result}; + +const CODEX_SMALL_DIFF_MODEL: &str = "gpt-5.6-luna"; +const CODEX_LARGE_DIFF_MODEL: &str = "gpt-5.6-terra"; +const CLAUDE_SMALL_DIFF_MODEL: &str = "haiku"; +const CLAUDE_LARGE_DIFF_MODEL: &str = "sonnet"; + +impl AgentChoice { + pub(crate) fn binary(self) -> &'static str { + match self { + Self::Codex => "codex", + Self::Claude => "claude", + } + } + + pub(crate) fn display_name(self) -> &'static str { + match self { + Self::Codex => "OpenAI Codex", + Self::Claude => "Claude Code", + } + } + + pub(crate) fn model_tiers(self) -> (&'static str, &'static str) { + match self { + Self::Codex => (CODEX_SMALL_DIFF_MODEL, CODEX_LARGE_DIFF_MODEL), + Self::Claude => (CLAUDE_SMALL_DIFF_MODEL, CLAUDE_LARGE_DIFF_MODEL), + } + } + + pub(crate) fn build(self, choice: &ModelChoice) -> Box { + match self { + Self::Codex => { + let mut agent = Codex::new().with_default_model(choice.model.clone()); + if let Some(effort) = choice.effort { + agent = agent.with_default_effort(effort); + } + Box::new(agent) + } + Self::Claude => { + let mut agent = ClaudeCode::new().with_default_model(choice.model.clone()); + if let Some(effort) = choice.effort { + agent = agent.with_default_effort(effort); + } + Box::new(agent) + } + } + } +} + +/// Resolve an explicit choice or prefer Codex among the agents found on `PATH`. +pub(crate) fn select(requested: Option) -> Result { + resolve(requested, |agent| which::which(agent.binary()).is_ok()) +} + +fn resolve( + requested: Option, + mut is_available: impl FnMut(AgentChoice) -> bool, +) -> Result { + if let Some(agent) = requested { + return is_available(agent) + .then_some(agent) + .ok_or(Error::AgentUnavailable { + agent: agent.display_name(), + binary: agent.binary(), + }); + } + + [AgentChoice::Codex, AgentChoice::Claude] + .into_iter() + .find(|agent| is_available(*agent)) + .ok_or(Error::NoAgentAvailable) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn automatic_selection_prefers_codex() { + assert_eq!(resolve(None, |_| true).unwrap(), AgentChoice::Codex); + } + + #[test] + fn automatic_selection_uses_the_only_available_agent() { + assert_eq!( + resolve(None, |agent| agent == AgentChoice::Codex).unwrap(), + AgentChoice::Codex + ); + assert_eq!( + resolve(None, |agent| agent == AgentChoice::Claude).unwrap(), + AgentChoice::Claude + ); + } + + #[test] + fn automatic_selection_requires_an_agent() { + assert!(matches!( + resolve(None, |_| false), + Err(Error::NoAgentAvailable) + )); + } + + #[test] + fn explicit_selection_is_honored_or_rejected() { + assert_eq!( + resolve(Some(AgentChoice::Claude), |_| true).unwrap(), + AgentChoice::Claude + ); + assert!(matches!( + resolve(Some(AgentChoice::Codex), |agent| agent + == AgentChoice::Claude), + Err(Error::AgentUnavailable { + binary: "codex", + .. + }) + )); + } + + #[test] + fn agents_have_provider_specific_model_tiers() { + assert_eq!( + AgentChoice::Codex.model_tiers(), + ("gpt-5.6-luna", "gpt-5.6-terra") + ); + assert_eq!(AgentChoice::Claude.model_tiers(), ("haiku", "sonnet")); + } +} diff --git a/crates/git-aicommit/src/cli.rs b/crates/git-aicommit/src/cli.rs index 875ec6e..027269d 100644 --- a/crates/git-aicommit/src/cli.rs +++ b/crates/git-aicommit/src/cli.rs @@ -1,18 +1,29 @@ //! The command-line surface: the parsed argument struct and the help text. -use clap::Parser; +use clap::{Parser, ValueEnum}; + +/// The local agent CLI used to generate the commit message. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub(crate) enum AgentChoice { + Codex, + Claude, +} #[derive(Parser)] #[command( - about = "Generate git commit messages from staged diffs using Claude", + about = "Generate git commit messages from staged diffs using a local AI agent", // Let `-h`/`--help` fall through to git_args so we can print our own help, // which documents the git-commit flags we intercept (see `HELP`). disable_help_flag = true )] pub(crate) struct Args { - /// Claude model to use (passed directly to `claude --model`). + /// Local agent CLI to use. When omitted, prefer Codex, then Claude. + #[arg(long, value_enum)] + pub(crate) agent: Option, + + /// Model to use with the selected agent. /// Must come before any git flags. When omitted, the model is chosen - /// automatically from the diff size (see `aicommit_core::auto_select`). + /// automatically from the diff size. #[arg(long)] pub(crate) model: Option, @@ -45,15 +56,17 @@ git-aicommit {version} } pub(crate) const HELP: &str = "\ -git-aicommit — draft a commit message from your changes with Claude, then open `git commit`. +git-aicommit — draft a commit message with Codex or Claude, then open `git commit`. USAGE: - git aicommit [--model ] [git commit flags] [-- ...] + git aicommit [--agent ] [--model ] [git commit flags] [-- ...] HANDLED BY git-aicommit: - --model Claude model to use. Must come first. When omitted, it is - chosen automatically: haiku for small diffs, sonnet (with - higher effort) for large or many-file ones. + --agent Agent CLI to use: codex or claude. Must come first. When + omitted, uses codex if installed, then claude. + --model Model for the selected agent. Must come first. When + omitted, chooses by diff size: Luna/Terra for Codex or + Haiku/Sonnet for Claude. -V, --version Print version, build metadata, and binary path, then exit. -m, --message Steer the AI with an instruction (NOT a literal message). Repeatable. -t, --template Make the AI follow the format/structure in file . @@ -75,5 +88,32 @@ BYPASS (no AI; runs plain `git commit`): --fixup, --squash, -C/--reuse-message, -c/--reedit-message, -F/--file (the message comes from another commit or file, so there is nothing to generate). -Authentication is delegated entirely to the `claude` CLI. +Authentication is delegated entirely to the selected `codex` or `claude` CLI. "; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_agent_and_model_before_git_arguments() { + let args = Args::try_parse_from([ + "git-aicommit", + "--agent", + "claude", + "--model", + "sonnet", + "--amend", + ]) + .unwrap(); + + assert_eq!(args.agent, Some(AgentChoice::Claude)); + assert_eq!(args.model.as_deref(), Some("sonnet")); + assert_eq!(args.git_args, ["--amend"]); + } + + #[test] + fn rejects_unknown_agent() { + assert!(Args::try_parse_from(["git-aicommit", "--agent", "other"]).is_err()); + } +} diff --git a/crates/git-aicommit/src/error.rs b/crates/git-aicommit/src/error.rs index 596b879..8c46f98 100644 --- a/crates/git-aicommit/src/error.rs +++ b/crates/git-aicommit/src/error.rs @@ -15,6 +15,17 @@ pub(crate) type Result = std::result::Result; #[derive(Debug, thiserror::Error)] pub(crate) enum Error { + /// A specifically requested local agent executable was not found. + #[error("{agent} executable `{binary}` was not found on PATH")] + AgentUnavailable { + agent: &'static str, + binary: &'static str, + }, + + /// Neither supported local agent executable was found. + #[error("no supported agent CLI found on PATH; install `codex` or `claude`")] + NoAgentAvailable, + /// An intercepted git-commit flag was malformed (e.g. `-t` with no file). #[error("{0}")] Flags(String), diff --git a/crates/git-aicommit/src/git.rs b/crates/git-aicommit/src/git.rs index a8bd28b..61e1a30 100644 --- a/crates/git-aicommit/src/git.rs +++ b/crates/git-aicommit/src/git.rs @@ -191,7 +191,7 @@ pub(crate) fn previous_commit_message() -> Result { Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) } -/// Run the pre-commit hook for an early check (before spending Claude tokens). +/// Run the pre-commit hook for an early check (before spending agent tokens). /// /// `git hook run` only exists in Git ≥ 2.36; on older git it fails with /// "'hook' is not a git command" (issue #18). There we fall back to locating diff --git a/crates/git-aicommit/src/main.rs b/crates/git-aicommit/src/main.rs index 8a93178..67ebf8c 100644 --- a/crates/git-aicommit/src/main.rs +++ b/crates/git-aicommit/src/main.rs @@ -1,3 +1,4 @@ +mod agent; mod cli; mod error; mod flags; @@ -5,11 +6,10 @@ mod git; mod metrics; mod spinner; -use agent_text::ClaudeCode; -use aicommit_core::{CommitRequest, ModelChoice, auto_select}; +use aicommit_core::{CommitRequest, ModelChoice, auto_select_with_models}; use clap::Parser; -use cli::Args; +use cli::{AgentChoice, Args}; use error::{Error, Result}; use metrics::{fmt_size, metrics_line}; use spinner::Spinner; @@ -17,7 +17,7 @@ use spinner::Spinner; #[tokio::main(flavor = "current_thread")] async fn main() { let args = Args::parse(); - if let Err(e) = run(args.model.as_deref(), &args.git_args).await { + if let Err(e) = run(args.agent, args.model.as_deref(), &args.git_args).await { eprintln!("error: {e}"); std::process::exit(1); } @@ -27,7 +27,11 @@ async fn main() { /// `aicommit_core` for a message, then hand off to `git commit`. Everything git /// lives here; everything about the message lives in the library. `user_model` /// is the explicit `--model`, or `None` to auto-pick from the diff size. -async fn run(user_model: Option<&str>, git_args: &[String]) -> Result<()> { +async fn run( + requested_agent: Option, + user_model: Option<&str>, + git_args: &[String], +) -> Result<()> { // 0. Short-circuits that do no AI work. if flags::wants_help(git_args) { print!("{}", cli::HELP); @@ -42,6 +46,8 @@ async fn run(user_model: Option<&str>, git_args: &[String]) -> Result<()> { } let p = flags::classify_args(git_args)?; + // Fail before staging or hooks when no usable local agent is installed. + let selected_agent = agent::select(requested_agent)?; // 1. Ensure we're in a git repo. { @@ -68,14 +74,14 @@ async fn run(user_model: Option<&str>, git_args: &[String]) -> Result<()> { // diff is still surfaced to the model. Best-effort: empty on failure. let diff_stat = git::read_diff_stat(&p, &base); - // 4. Resolve the model: honor an explicit `--model`, otherwise auto-pick from - // the full (pre-truncation) diff size and file count. Announce an - // escalation away from the cheap model prominently, on its own line. + // 4. Resolve the model. Honor an explicit `--model`, otherwise use the + // selected provider's size-based tiers. + let (small_model, large_model) = selected_agent.model_tiers(); let choice = match user_model { Some(m) => ModelChoice::new(m), None => { - let c = auto_select(diff.len(), file_count); - if c.model != aicommit_core::SMALL_DIFF_MODEL { + let c = auto_select_with_models(diff.len(), file_count, small_model, large_model); + if c.model != small_model { let effort_note = c .effort .map(|e| format!(" (effort {e})")) @@ -127,16 +133,14 @@ async fn run(user_model: Option<&str>, git_args: &[String]) -> Result<()> { // 7. Generate. The library builds the prompt, truncates the diff, runs the // agent, and cleans the answer. - let mut agent = ClaudeCode::new().with_default_model(choice.model.clone()); - if let Some(effort) = choice.effort { - agent = agent.with_default_effort(effort); - } + let agent = selected_agent.build(&choice); let message = { let sp = Spinner::new(&format!( - "generating commit message with claude {}…", + "generating commit message with {} {}…", + selected_agent.display_name(), choice.model )); - let generated = aicommit_core::generate_commit_message(&request, &agent).await?; + let generated = aicommit_core::generate_commit_message(&request, agent.as_ref()).await?; sp.finish(format!( "commit message generated ({})", metrics_line(generated.usage.as_ref())