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: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ See [CLI-agent presets](#cli-agent-presets) for Codex, pi, and opencode.
- **Merge conflict resolution** — `aic resolve` proposes per-file resolutions you review, then finalizes the merge
- **Live reasoning** — watch the model think as it decides the split
- **Conventional Commits** — messages follow the [v1.0.0 spec](https://www.conventionalcommits.org/)
- **Interactive setup** — `aic setup` is menu-driven; `aic use` switches between saved provider profiles
- **Interactive setup** — `aic setup` is menu-driven; `aic use` switches between saved provider profiles and CLI agents (claude, codex, pi, opencode)

## Installation

Expand All @@ -107,7 +107,7 @@ Shell completions: `aic completion` (bash, fish, zsh, nushell).
| `aic` | Commit staged files. If nothing is staged, auto-split all unstaged changes into hunk-level atomic commits. |
| `aic resolve` | Resolve git merge conflicts via the LLM. Review each file, then finalize. |
| `aic setup` | Menu-driven config: API provider, CLI agent, or pre-commit confirmation. |
| `aic use <provider>` | Switch to a provider already configured via `aic setup`. |
| `aic use <name>` | Switch to a provider already configured via `aic setup`, or to a CLI agent (claude, codex, pi, opencode). |
| `aic list` | Show resolved config and where each value comes from. |
| `aic update` | Update to the latest release. |
| `aic completion` | Install shell completions. |
Expand Down
4 changes: 2 additions & 2 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ aic 只发送一条 prompt 并读取回答 —— 绝不在 tool-use 模式下
- **Merge 冲突解决** —— `aic resolve` 逐文件给出方案供你审核,然后完成 merge
- **实时推理** —— 观看模型思考拆分方案的全过程
- **Conventional Commits** —— message 遵循 [v1.0.0 规范](https://www.conventionalcommits.org/)
- **交互式配置** —— `aic setup` 菜单驱动;`aic use` 在已保存的 provider 之间切换
- **交互式配置** —— `aic setup` 菜单驱动;`aic use` 在已保存的 provider 与 CLI agent(claude、codex、pi、opencode)之间切换

## 安装

Expand All @@ -107,7 +107,7 @@ Shell 补全:`aic completion`(bash、fish、zsh、nushell)。
| `aic` | 提交已 stage 的文件。若无 stage 内容,自动将所有未暂存改动拆分为 hunk 级别的原子提交。 |
| `aic resolve` | 通过 LLM 解决 git merge 冲突。逐文件审核后完成 merge。 |
| `aic setup` | 菜单驱动配置:API provider、CLI agent、或提交前确认。 |
| `aic use <provider>` | 切换到已通过 `aic setup` 配置过的 provider。 |
| `aic use <name>` | 切换到已通过 `aic setup` 配置过的 provider,或切换到 CLI agent(claude、codex、pi、opencode)。 |
| `aic list` | 展示已 resolve 的 config 及每个值的来源。 |
| `aic update` | 更新到最新版本。 |
| `aic completion` | 安装 shell 补全。 |
Expand Down
71 changes: 69 additions & 2 deletions src/core/cli/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,38 @@
use clap::{Parser, Subcommand};

use crate::llm::{Provider, cli_agent::PRESETS};

/// The `aic use <name>` vocabulary: CLI-agent presets first (they win at
/// match time — `aic use claude` is the claude code CLI agent, not the
/// Anthropic API provider), then every registry provider name and alias that
/// doesn't collide with a preset. The single source of truth for both the
/// clap possible values ([`use_values`]) and the completion test that pins
/// them — the shell can never offer a word `aic use` rejects, or hide one it
/// accepts.
pub(crate) fn use_vocabulary() -> Vec<&'static str> {
let mut words: Vec<&str> = PRESETS.to_vec();
words.extend(
Provider::all()
.iter()
.flat_map(|p| std::iter::once(p.name()).chain(p.aliases().iter().copied())),
);
// Order-preserving dedupe: a provider alias shadowed by a preset (claude)
// disappears from the vocabulary instead of appearing twice.
let mut seen = std::collections::HashSet::new();
words.into_iter().filter(|w| seen.insert(*w)).collect()
}

/// Flat clap possible values so shell completion offers exactly what
/// `aic use` accepts — built from [`use_vocabulary`].
fn use_values() -> clap::builder::PossibleValuesParser {
clap::builder::PossibleValuesParser::new(
use_vocabulary()
.into_iter()
.map(clap::builder::PossibleValue::new)
.collect::<Vec<_>>(),
)
}

#[derive(Parser)]
#[command(
name = "aic",
Expand All @@ -21,9 +54,12 @@ pub enum Commands {
Update,
/// Resolve git merge conflicts in the working tree via the LLM
Resolve,
/// Switch the active API provider to one already configured via `aic setup`
/// Switch the active backend: an API provider already configured via
/// `aic setup`, or a CLI agent (claude, codex, pi, opencode)
Use {
/// Provider name or alias (e.g. openai, anthropic, gemini, deepseek)
/// API provider name/alias (e.g. openai, anthropic, gemini), or a
/// CLI agent (claude, codex, pi, opencode)
#[arg(value_parser = use_values(), ignore_case = true)]
provider: String,
},
/// Install shell completion script
Expand All @@ -34,3 +70,34 @@ pub enum Commands {
/// aic completion
Completion,
}

#[cfg(test)]
mod tests {
use super::*;

/// The `use` vocabulary contract: presets first (they win at match
/// time), every registry canonical name and alias present, and a
/// preset-shadowed alias (claude) appearing exactly once — so completion
/// and clap acceptance can never drift apart (both derive from this).
#[test]
fn use_vocabulary_lists_presets_first_and_dedupes_shadowed_aliases() {
let words = use_vocabulary();
for (i, preset) in PRESETS.iter().enumerate() {
assert_eq!(&words[i], preset, "presets must lead the vocabulary");
}
for p in Provider::all() {
assert!(words.contains(&p.name()), "{} missing", p.name());
for alias in p.aliases() {
assert!(words.contains(alias), "{alias} missing");
}
}
// The shadowed Anthropic alias: exactly one claude — the CLI agent.
assert_eq!(
words.iter().filter(|&&w| w == "claude").count(),
1,
"got {words:?}"
);
let unique: std::collections::HashSet<&&str> = words.iter().collect();
assert_eq!(unique.len(), words.len(), "no duplicates: {words:?}");
}
}
33 changes: 33 additions & 0 deletions src/core/completion/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,39 @@ mod tests {
assert!(body.contains("aic"));
}

/// `aic use <TAB>` must offer the full `use` vocabulary — CLI-agent
/// presets plus every provider canonical name and alias not shadowed by
/// a preset — instead of the `_default` action, which completes nothing
/// (the reported "completion not working for aic use"). Asserts exact
/// equality with [`cli::use_vocabulary`] (the single source both clap
/// and this script derive from), not loose `contains` — the arg's help
/// text already names several providers, so a loose check would pass
/// even with an empty value list.
#[test]
fn zsh_completion_offers_use_vocabulary() {
let mut buf = Vec::new();
write_completion(Shell::Zsh, &mut buf);
let script = String::from_utf8(buf).expect("completion output must be valid UTF-8");

let spec = script
.lines()
.find(|l| l.contains("':provider"))
.unwrap_or_else(|| panic!("no provider spec line in zsh script:\n{script}"));
let (_, values) = spec
.rsplit_once(":(")
.unwrap_or_else(|| panic!("provider spec carries no value list:\n{spec}"));
let offered: Vec<&str> = values
.trim_end_matches(|c: char| !c.is_alphanumeric() && c != '-')
.split_whitespace()
.collect();

assert_eq!(
offered,
cli::use_vocabulary(),
"zsh script must offer exactly the `aic use` vocabulary"
);
}

/// `detect_shell` maps `$SHELL` (basename) to a supported shell; unknown
/// names and an unset variable yield `None`, so the completion prompt can
/// fall back to a manual pick. Uses `temp_env` to avoid unsafe env
Expand Down
111 changes: 80 additions & 31 deletions src/core/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;

use crate::llm::cli_agent::{cli_preset, is_preset};
use crate::llm::{BaseUrlRequirement, DEFAULT_PROVIDER, LLM, Provider};

/// The CLI-agent Backend's four config fields — a unit (command + argv
Expand Down Expand Up @@ -798,16 +799,33 @@ pub fn run_list() -> Result<()> {
Ok(())
}

/// Pure core of `aic use <name>`: validate the name, bank the currently
/// active provider's live top-level state into the memory bank, then activate
/// the target profile (restore its key/model/base_url and force the API
/// backend). Split from [`run_use`] (which owns the load/save/print IO) so the
/// switch contract — source banked, target restored, backend forced to API —
/// is unit-testable without the real config file.
/// Pure core of `aic use <name>`: a CLI-agent preset name (claude, codex,
/// pi, opencode — case-insensitive) switches the CLI-agent Backend to that
/// preset; anything else is a provider switch — validate the name, bank the
/// currently active provider's live top-level state into the memory bank,
/// then activate the target profile (restore its key/model/base_url and
/// force the API backend). Split from [`run_use`] (which owns the
/// load/save/print IO) so the switch contracts are unit-testable without
/// the real config file.
///
/// Errors: unknown provider name; a known name with no banked profile (run
/// `aic setup` to add one).
/// Errors (provider arm): unknown provider name; a known name with no banked
/// profile (run `aic setup` to add one).
fn apply_use(mut config: Config, name: &str) -> Result<Config> {
// CLI-agent presets win over provider names: `aic use claude` switches
// to the claude code CLI agent, not the Anthropic API provider. Presets
// are stateless (auth is the CLI's own), so switching just overwrites
// the CLI fields and flips the discriminator; the API fields stay
// dormant-but-intact for a later switch back (ADR 0011).
if let Some(spec) = cli_preset(name) {
config.cli = CliConfig {
command: Some(spec.command),
args: Some(spec.args),
timeout_secs: Some(spec.timeout_secs),
encoding: Some(spec.encoding),
};
config.backend_kind = Some(BackendKind::Cli);
return Ok(config);
}
if !Provider::is_known_name(name) {
anyhow::bail!(
"unknown provider '{name}'; pick one of: {}",
Expand Down Expand Up @@ -858,33 +876,64 @@ fn apply_use(mut config: Config, name: &str) -> Result<Config> {
Ok(config)
}

/// `aic use <provider>` — switch the active API provider by restoring a
/// remembered profile, without re-entering the key/model. The provider must
/// already have been configured via `aic setup` (so it has an entry in the
/// `providers` bank). Switches the active backend to API (a CLI-agent user
/// who runs `aic use` is asking for the API path); any stored CLI fields
/// stay dormant for a switch back via `aic setup`, per ADR 0011.
/// Pure core of `aic use`'s output: the stdout line naming the switched-to
/// Backend, plus (API arm only, when the restored profile has no key) the
/// stderr note. Split from [`run_use`] (cf. [`list_lines`]/[`run_list`]) so
/// the print contracts — agent line, no key-note on the CLI arm — are
/// unit-testable without capturing process stdout. Only called on a config
/// [`apply_use`] just returned, so both arms' `expect`s are structural.
fn use_messages(config: &Config) -> (String, Option<String>) {
match config.backend_kind {
Some(BackendKind::Cli) => {
let command = config
.cli
.active_command()
.expect("apply_use's preset arm always sets a command");
(format!("Switched to CLI agent {command}."), None)
}
_ => {
let normalized = config
.backend
.as_deref()
.expect("apply_use's provider arm always sets `backend`");
let had_key = config
.api_key
.as_deref()
.map(|k| !k.is_empty())
.unwrap_or(false);
let note = (!had_key).then(|| {
format!(
"note: {normalized} has no saved API key — run `aic setup` to add one if \
it's needed"
)
});
(format!("Switched to {normalized}."), note)
}
}
}

/// `aic use <name>` — switch the active Backend: a CLI-agent preset name
/// (claude, codex, pi, opencode) activates that CLI agent (no setup needed —
/// the agent reuses its own auth, so it works even on a machine with no
/// config yet); a provider name restores a remembered profile without
/// re-entering the key/model (the provider must already have been configured
/// via `aic setup`). The inactive Backend's fields stay dormant for a switch
/// back, per ADR 0011.
pub fn run_use(name: &str) -> Result<()> {
let mut config = Config::load()
.ok()
.flatten()
.context("no config found — run `aic setup` to configure a provider first")?;
let mut config = match Config::load().ok().flatten() {
Some(c) => c,
// Presets are stateless (auth is the CLI's own): a fresh machine can
// switch to one with no config file at all.
None if is_preset(name) => Config::default(),
None => anyhow::bail!("no config found — run `aic setup` to configure a provider first"),
};
config = apply_use(config, name)?;

// The activated profile's key (now in the top-level row after apply_use).
let normalized = config.backend.as_deref().unwrap_or(name);
let had_key = config
.api_key
.as_deref()
.map(|k| !k.is_empty())
.unwrap_or(false);
let (line, note) = use_messages(&config);
config.save()?;

println!("Switched to {normalized}.");
if !had_key {
eprintln!(
"note: {normalized} has no saved API key — run `aic setup` to add one if it's needed"
);
println!("{line}");
if let Some(note) = note {
eprintln!("{note}");
}
Ok(())
}
Expand Down
Loading
Loading