use super::agents::parse_agent_md;
use super::agents::update_agent_md_frontmatter_full;
use super::defaults::{write_config, write_config_commented};
use super::loader::load_config_file;
use super::paths::{collet_home, config_file_path, ensure_config_dir, logs_dir};
use super::secrets::{save_encrypted_key, save_web_credentials};
use super::types::{ConfigFile, ProviderEntry};
use super::wizard::presets::{
CLI_PRESETS, cli_preset_to_provider, find_cli_preset, scan_cli_presets,
};
use super::wizard::{compute_optimal_assignments, wizard_style};
use crate::common::{AgentError, Result};
use std::path::PathBuf;
pub fn cmd_secure(args: &[String]) -> Result<()> {
let is_web = args.iter().any(|a| a == "--web");
if is_web {
eprintln!("Web username (default: collet):");
let mut username = String::new();
std::io::stdin().read_line(&mut username)?;
let username = username.trim();
let username = if username.is_empty() {
None
} else {
Some(username.to_string())
};
let password = rpassword::prompt_password("Web password: ")
.map_err(|e| AgentError::Config(format!("Failed to read password: {e}")))?;
let password = password.trim();
if password.is_empty() {
return Err(AgentError::Config("Empty password. Aborted.".to_string()));
}
save_web_credentials(username, password)?;
} else {
let key = rpassword::prompt_password("Paste your API key: ")
.map_err(|e| AgentError::Config(format!("Failed to read key: {e}")))?;
let key = key.trim();
if key.is_empty() {
return Err(AgentError::Config("Empty key. Aborted.".to_string()));
}
save_encrypted_key(key)?;
}
Ok(())
}
pub fn cmd_unsecure(args: &[String]) -> Result<()> {
let is_web = args.iter().any(|a| a == "--web");
let dir = ensure_config_dir()?;
let path = dir.join("config.toml");
if !path.exists() {
eprintln!("ℹ️ No config file found.");
return Ok(());
}
let content = std::fs::read_to_string(&path)?;
let mut cf = toml::from_str::<ConfigFile>(&content).unwrap_or_default();
if is_web {
cf.web.username = None;
cf.web.password_enc = None;
eprintln!("✅ Web credentials removed.");
} else {
cf.api.api_key_enc = None;
eprintln!("✅ API key removed.");
}
let toml_str = toml::to_string_pretty(&cf)
.map_err(|e| AgentError::Config(format!("Failed to serialize config: {e}")))?;
std::fs::write(&path, toml_str).map_err(|e| {
AgentError::Config(format!("Failed to write config {}: {e}", path.display()))
})?;
Ok(())
}
pub fn cmd_status() -> Result<()> {
use super::types::Config;
let path = config_file_path();
let file_exists = path.exists();
eprintln!("collet status");
eprintln!("─────────────────────────────");
eprintln!(
"config: {} {}",
path.display(),
if file_exists {
"✅"
} else {
"❌ (run: collet setup)"
}
);
match Config::load() {
Ok(c) => {
let masked_key = if c.api_key.len() > 8 {
format!(
"{}...{}",
&c.api_key[..4],
&c.api_key[c.api_key.len() - 4..]
)
} else {
"***".into()
};
eprintln!();
eprintln!("[api]");
eprintln!(" key: {masked_key} ✅");
eprintln!(" base_url: {}", c.base_url);
eprintln!(" model: {}", c.model);
eprintln!(" max_tokens: {}", c.max_tokens);
eprintln!();
eprintln!("[agent]");
eprintln!(" timeout: {}s", c.tool_timeout_secs);
eprintln!(" task_timeout: {}s", c.task_timeout_secs);
eprintln!(" max_iter: {}", c.max_iterations);
eprintln!(" max_cont: {}", c.max_continuations);
eprintln!(" breaker: {}", c.circuit_breaker_threshold);
eprintln!();
eprintln!("[context]");
eprintln!(" max_tokens: {}", c.context_max_tokens);
eprintln!(" compact_at: {:.0}%", c.compaction_threshold * 100.0);
eprintln!();
eprintln!("[hooks]");
eprintln!(" commit: {}", c.auto_commit);
eprintln!(" lint: {}", c.lint_cmd.as_deref().unwrap_or("-"));
eprintln!(" test: {}", c.test_cmd.as_deref().unwrap_or("-"));
eprintln!();
eprintln!("[ui]");
eprintln!(" theme: {}", c.theme);
eprintln!();
eprintln!("[collaboration]");
eprintln!(" mode: {}", c.collaboration.mode);
eprintln!(" max_agents: {}", c.collaboration.max_agents);
eprintln!(" strategy: {:?}", c.collaboration.strategy);
eprintln!(" consensus: {}", c.collaboration.require_consensus);
eprintln!(" conflicts: {:?}", c.collaboration.conflict_resolution);
eprintln!();
eprintln!("[web]");
eprintln!(" host: {}", c.web.host);
eprintln!(" port: {}", c.web.port);
eprintln!(" username: {}", c.web.username);
eprintln!(
" password: {}",
if c.web.password.is_some() {
"***"
} else {
"(none)"
}
);
eprintln!();
eprintln!("[paths]");
eprintln!(" home: {}", collet_home(None).display());
eprintln!(" logs: {}", logs_dir().display());
}
Err(e) => {
eprintln!();
eprintln!(" ❌ {e}");
eprintln!();
eprintln!("Fix: collet setup (or: collet secure)");
}
}
Ok(())
}
pub fn cmd_clis(args: &[String]) -> Result<()> {
match args.first().map(|s| s.as_str()) {
Some("help") | Some("--help") | Some("-h") => {
print_clis_usage();
Ok(())
}
Some("add") => cmd_clis_add(&args[1..]),
Some("remove") => cmd_clis_remove(&args[1..]),
Some("auto") => cmd_clis_auto(),
Some("list") | None => cmd_clis_list(),
_ => {
print_clis_usage();
Ok(())
}
}
}
fn print_clis_usage() {
eprintln!("collet clis — Manage CLI coding agent providers");
eprintln!();
eprintln!("USAGE:");
eprintln!(" collet clis [COMMAND]");
eprintln!();
eprintln!("COMMANDS:");
eprintln!(" list Show registered CLI providers and detected binaries (default)");
eprintln!(" add [name] Register a CLI coding agent as a provider");
eprintln!(" remove [name] Remove a registered CLI provider");
eprintln!(" auto Auto-detect and register/unregister CLI agents");
eprintln!();
eprintln!("DESCRIPTION:");
eprintln!(" Manages external CLI coding agents (claude, codex, gemini, aider, etc.)");
eprintln!(" as first-class providers. Detected agents can be used as fallback providers");
eprintln!(" in agent definitions.");
eprintln!();
eprintln!("EXAMPLES:");
eprintln!(" collet clis List all CLI agents");
eprintln!(" collet clis auto Auto-detect and sync");
eprintln!(" collet clis add claude Register Claude Code CLI");
eprintln!(" collet clis remove aider Remove Aider");
}
fn cmd_clis_list() -> Result<()> {
let (_path, config) = load_config_for_edit()?;
let detected = scan_cli_presets();
eprintln!("\x1b[1mRegistered CLI providers:\x1b[0m");
let cli_providers: Vec<_> = config
.providers
.iter()
.filter(|p| p.cli.is_some())
.collect();
if cli_providers.is_empty() {
eprintln!(" (none)");
} else {
for p in &cli_providers {
let bin = p.cli.as_deref().unwrap_or("?");
let status = if p.cli_available() {
"\x1b[32m●\x1b[0m"
} else {
"\x1b[31m●\x1b[0m"
};
let models = if p.models.is_empty() {
String::new()
} else {
format!(" ({})", p.models.join(", "))
};
eprintln!(" {status} {name:<20} cli={bin}{models}", name = p.name);
}
}
eprintln!();
eprintln!("\x1b[1mDetected on PATH (not yet registered):\x1b[0m");
let registered_bins: Vec<_> = cli_providers
.iter()
.filter_map(|p| p.cli.as_deref())
.collect();
let unregistered: Vec<_> = detected
.iter()
.filter(|d| !registered_bins.contains(&d.binary))
.collect();
if unregistered.is_empty() {
eprintln!(" (none)");
} else {
for d in &unregistered {
eprintln!(
" \x1b[33m○\x1b[0m {name:<20} binary={binary}",
name = d.name,
binary = d.binary
);
}
}
eprintln!();
eprintln!("Run \x1b[1mcollet clis auto\x1b[0m to register all detected CLIs.");
Ok(())
}
fn cmd_clis_add(args: &[String]) -> Result<()> {
let query = match args.first() {
Some(q) => q.clone(),
None => {
eprintln!("Available CLI presets:");
for (i, p) in CLI_PRESETS.iter().enumerate() {
let available = std::process::Command::new("which")
.arg(p.binary)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
let mark = if available {
"\x1b[32m●\x1b[0m"
} else {
"\x1b[31m●\x1b[0m"
};
eprintln!(
" {mark} [{i:>2}] {label:<20} ({binary})",
label = p.label,
binary = p.binary
);
}
eprint!("\nSelect preset (name or number): ");
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
input.trim().to_string()
}
};
let preset = if let Ok(idx) = query.parse::<usize>() {
CLI_PRESETS.get(idx)
} else {
find_cli_preset(&query)
};
let preset = match preset {
Some(p) => p,
None => {
eprintln!("Unknown CLI preset: {query}");
return Ok(());
}
};
let (path, mut config) = load_config_for_edit()?;
if config.providers.iter().any(|p| p.name == preset.name) {
eprintln!("Provider '{}' already registered.", preset.name);
return Ok(());
}
let entry = cli_preset_to_provider(preset);
eprintln!(
"Adding CLI provider: {} (binary: {})",
entry.name, preset.binary
);
config.providers.push(entry);
write_config(&path, &config)?;
eprintln!(
"\x1b[32m✓\x1b[0m Registered. Use in agents: provider = \"{}\"",
preset.name
);
Ok(())
}
fn cmd_clis_remove(args: &[String]) -> Result<()> {
let (path, mut config) = load_config_for_edit()?;
let cli_names: Vec<String> = config
.providers
.iter()
.filter(|p| p.cli.is_some())
.map(|p| p.name.clone())
.collect();
if cli_names.is_empty() {
eprintln!("No CLI providers registered.");
return Ok(());
}
let name = match args.first() {
Some(n) => n.clone(),
None => {
eprintln!("Registered CLI providers:");
for (i, name) in cli_names.iter().enumerate() {
eprintln!(" [{i}] {name}");
}
eprint!("\nSelect to remove (name or number): ");
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let input = input.trim().to_string();
if let Ok(idx) = input.parse::<usize>() {
cli_names.get(idx).cloned().unwrap_or(input)
} else {
input
}
}
};
let before = config.providers.len();
config.providers.retain(|p| p.name != name);
if config.providers.len() < before {
write_config(&path, &config)?;
eprintln!("\x1b[32m✓\x1b[0m Removed CLI provider: {name}");
} else {
eprintln!("Provider '{name}' not found.");
}
Ok(())
}
fn cmd_clis_auto() -> Result<()> {
let (path, mut config) = load_config_for_edit()?;
let detected = scan_cli_presets();
let mut added = 0;
let mut removed = 0;
for preset in &detected {
if !config.providers.iter().any(|p| p.name == preset.name) {
eprintln!(
" \x1b[32m+\x1b[0m Adding {name} ({binary})",
name = preset.name,
binary = preset.binary
);
config.providers.push(cli_preset_to_provider(preset));
added += 1;
}
}
let detected_names: Vec<&str> = detected.iter().map(|d| d.name).collect();
let to_remove: Vec<String> = config
.providers
.iter()
.filter(|p: &&ProviderEntry| p.is_cli() && !detected_names.contains(&p.name.as_str()))
.map(|p| p.name.clone())
.collect();
for name in &to_remove {
eprintln!(" \x1b[31m-\x1b[0m Removing {name} (binary not found)");
config.providers.retain(|p| p.name != *name);
removed += 1;
}
if added > 0 || removed > 0 {
write_config(&path, &config)?;
}
eprintln!("\x1b[32m✓\x1b[0m {added} added, {removed} removed.");
Ok(())
}
pub fn cmd_provider(args: &[String]) -> Result<()> {
let sub = args.first().map(|s| s.as_str());
match sub {
Some("help") | Some("--help") | Some("-h") => {
print_provider_usage();
Ok(())
}
Some("add") => cmd_provider_add(&args[1..]),
Some("remove") => cmd_provider_remove(),
Some("list") => cmd_provider_list(),
Some("use") => cmd_provider_use(&args[1..]),
Some("models") => cmd_provider_models(&args[1..]),
_ => {
print_provider_usage();
Ok(())
}
}
}
fn print_provider_usage() {
eprintln!("collet provider — Manage LLM API providers");
eprintln!();
eprintln!("USAGE:");
eprintln!(" collet provider <COMMAND>");
eprintln!();
eprintln!("COMMANDS:");
eprintln!(" add [name] Register a provider (or add model to existing)");
eprintln!(" remove Remove a registered provider");
eprintln!(" list Show registered providers and models");
eprintln!(" use [name] Switch the active provider");
eprintln!(" models Add/remove models for a provider");
eprintln!();
eprintln!("DESCRIPTION:");
eprintln!(" Manages LLM API provider connections. Providers define the base URL,");
eprintln!(" API key, and available models for each backend (OpenAI, Anthropic,");
eprintln!(" Google, local endpoints, etc.).");
eprintln!();
eprintln!("EXAMPLES:");
eprintln!(" collet provider add anthropic");
eprintln!(" collet provider list");
eprintln!(" collet provider use openai");
eprintln!(" collet provider models openai");
}
fn find_preset(query: &str) -> Option<&'static super::wizard::presets::ProviderPreset> {
super::wizard::presets::find_provider_preset(query)
}
pub(super) fn load_config_for_edit() -> Result<(PathBuf, ConfigFile)> {
let dir = ensure_config_dir()?;
let path = dir.join("config.toml");
let cf = if path.exists() {
let content = std::fs::read_to_string(&path)?;
toml::from_str::<ConfigFile>(&content).unwrap_or_default()
} else {
ConfigFile::default()
};
Ok((path, cf))
}
struct ProviderInput {
preset_label: String,
base_url: String,
models: Vec<String>,
encrypted_api_key: Option<String>,
}
fn gather_provider_inputs(args: &[String]) -> Result<ProviderInput> {
use super::secrets::{decrypt_key, encrypt_key};
use super::wizard::presets::PROVIDERS;
use super::wizard::ui::{
prompt_api_key, prompt_with_default, wizard_confirm, wizard_multi_select, wizard_select,
};
use wizard_style as s;
let preset = if let Some(name) = args.first() {
find_preset(name).ok_or_else(|| {
AgentError::Config(format!(
"Unknown provider '{name}'. Run `collet provider add` to see available providers."
))
})?
} else {
let labels: Vec<&str> = PROVIDERS.iter().map(|p| p.label).collect();
let idx = wizard_select("Select provider", &labels, 0)?;
&PROVIDERS[idx]
};
eprintln!(
"\n {}{}Adding provider: {}{}\n",
s::BOLD,
s::CYAN,
preset.label,
s::RESET
);
let api_key = if !preset.api_key_required {
eprintln!(
" {}No API key required (local server).{}",
s::DIM,
s::RESET
);
String::new()
} else {
let env_key = if !preset.env_key_hint.is_empty() {
std::env::var(preset.env_key_hint)
.ok()
.filter(|v| !v.is_empty())
} else {
None
};
if let Some(ref env_val) = env_key {
let masked = if env_val.len() > 8 {
let start: String = env_val.chars().take(4).collect();
let end: String = env_val
.chars()
.rev()
.take(4)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
format!("{start}...{end}")
} else {
"***".to_string()
};
eprintln!(
" {}Found {} in environment: {}{}",
s::GREEN,
preset.env_key_hint,
masked,
s::RESET
);
if wizard_confirm(
&format!("Use {} from environment?", preset.env_key_hint),
true,
)? {
env_val.clone()
} else {
prompt_api_key()?
}
} else {
if !preset.env_key_hint.is_empty() {
eprintln!(
" {}Hint: set {} env var for auto-detection{}",
s::DIM,
preset.env_key_hint,
s::RESET
);
}
prompt_api_key()?
}
};
let base_url = if preset.base_url.is_empty() {
prompt_with_default(
"Base URL",
"https://api.example.com/v1",
&mut std::io::stdin().lock(),
)?
} else {
prompt_with_default("Base URL", preset.base_url, &mut std::io::stdin().lock())?
};
let selected_models: Vec<String> = if !preset.recommended_models.is_empty() {
let mut choices: Vec<&str> = preset.recommended_models.to_vec();
choices.push("(custom)");
let configured: Vec<bool> = choices.iter().map(|&m| m == preset.default_model).collect();
let indices = wizard_multi_select("Models (Space to toggle)", &choices, &configured)?;
let mut models = Vec::new();
let custom_idx = choices.len() - 1;
for idx in &indices {
if *idx == custom_idx {
let input = prompt_with_default(
"Custom models (comma-separated)",
"",
&mut std::io::stdin().lock(),
)?;
for m in input.split(',') {
let m = m.trim();
if !m.is_empty() {
models.push(m.to_string());
}
}
} else {
models.push(choices[*idx].to_string());
}
}
if models.is_empty() {
models.push(preset.default_model.to_string());
}
models
} else {
let default_model = if preset.default_model.is_empty() {
"gpt-4o"
} else {
preset.default_model
};
let input = prompt_with_default(
"Models (comma-separated)",
default_model,
&mut std::io::stdin().lock(),
)?;
input
.split(',')
.map(|m| m.trim().to_string())
.filter(|m| !m.is_empty())
.collect()
};
let encrypted_api_key = if api_key.is_empty() {
None
} else {
let enc = encrypt_key(&api_key)?;
let decrypted = decrypt_key(&enc)?;
if decrypted != api_key {
return Err(AgentError::Config(
"Encryption verification failed.".to_string(),
));
}
Some(enc)
};
Ok(ProviderInput {
preset_label: preset.label.to_string(),
base_url,
models: selected_models,
encrypted_api_key,
})
}
fn cmd_provider_add(args: &[String]) -> Result<()> {
use wizard_style as s;
let input = gather_provider_inputs(args)?;
let (path, mut cf) = load_config_for_edit()?;
let entry_name = input.preset_label;
if let Some(existing) = cf
.providers
.iter_mut()
.find(|p| p.name.eq_ignore_ascii_case(&entry_name))
{
let mut added = Vec::new();
for m in input.models {
if !existing
.all_models()
.iter()
.any(|em| em.eq_ignore_ascii_case(&m))
{
existing.models.push(m.clone());
added.push(m);
}
}
if input.encrypted_api_key.is_some() {
existing.api_key_enc = input.encrypted_api_key;
}
if added.is_empty() {
eprintln!(
"\n {}{}All selected models already registered for '{}'.{}",
s::BOLD,
s::YELLOW,
entry_name,
s::RESET
);
} else {
write_config_commented(&path, &cf)?;
eprintln!(
"\n {}{}✅ Models added to '{}': {}{}",
s::BOLD,
s::GREEN,
entry_name,
added.join(", "),
s::RESET
);
}
return Ok(());
}
let active_model = input.models.first().cloned();
cf.providers.insert(
0,
ProviderEntry {
name: entry_name.clone(),
base_url: input.base_url,
api_key_enc: input.encrypted_api_key,
models: input.models,
cli: None,
cli_args: Vec::new(),
cli_yolo_args: Vec::new(),
cli_model_env: None,
cli_skip_model: false,
cli_yolo_env: Vec::new(),
cli_max_turns_flag: None,
},
);
if cf.api.provider.is_none() || cf.providers.is_empty() {
cf.api.provider = Some(entry_name.clone());
cf.api.model = active_model;
cf.api.api_key_enc = cf.providers.first().and_then(|p| p.api_key_enc.clone());
cf.api.api_key = None;
}
write_config_commented(&path, &cf)?;
eprintln!(
"\n {}{}✅ Provider '{}' registered.{}",
s::BOLD,
s::GREEN,
entry_name,
s::RESET
);
offer_agent_remap(&path, &mut cf)?;
Ok(())
}
fn offer_agent_remap(path: &std::path::Path, cf: &mut ConfigFile) -> Result<()> {
use super::wizard::ui::wizard_confirm;
use wizard_style as s;
let agents_dir = collet_home(None).join("agents");
if !agents_dir.exists() || compute_optimal_assignments(&cf.providers).is_none() {
return Ok(());
}
let optimal = compute_optimal_assignments(&cf.providers).unwrap();
eprintln!();
eprintln!(
" {}Optimal agent assignments across all providers:{}",
s::DIM,
s::RESET
);
eprintln!(
" {} architect → {}/{}{}",
s::DIM,
optimal.architect.0,
optimal.architect.1,
s::RESET
);
eprintln!(
" {} code → {}/{}{}",
s::DIM,
optimal.code.0,
optimal.code.1,
s::RESET
);
eprintln!(
" {} ask → {}/{}{}",
s::DIM,
optimal.ask.0,
optimal.ask.1,
s::RESET
);
eprintln!();
let coord_spec = format!("{}/{}", optimal.architect.0, optimal.architect.1);
let worker_spec = format!("{}/{}", optimal.code.0, optimal.code.1);
let mut seeded = false;
if cf.collaboration.coordinator_model.is_none() {
cf.collaboration.coordinator_model = Some(coord_spec.clone());
seeded = true;
}
if cf.collaboration.worker_model.is_none() {
cf.collaboration.worker_model = Some(worker_spec.clone());
seeded = true;
}
if seeded {
write_config_commented(path, cf)?;
}
if cf.collaboration.coordinator_model.as_deref() == Some(&coord_spec) {
eprintln!(
" {}coordinator_model = {:?}{}",
s::DIM,
coord_spec,
s::RESET
);
}
if cf.collaboration.worker_model.as_deref() == Some(&worker_spec) {
eprintln!(
" {}worker_model = {:?}{}",
s::DIM,
worker_spec,
s::RESET
);
}
eprintln!();
if !wizard_confirm("Update agent models now?", false)? {
eprintln!(
" {}To change later: edit .collet/agents/{{code,architect,ask}}.md{}",
s::DIM,
s::RESET
);
return Ok(());
}
let mut updated = 0usize;
if let Ok(entries) = std::fs::read_dir(&agents_dir) {
let mut files: Vec<_> = entries
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|x| x == "md"))
.collect();
files.sort_by_key(|e| e.file_name());
for entry in files {
let path = entry.path();
let agent = parse_agent_md(&path);
let tier = agent
.as_ref()
.and_then(|a| a.tier.as_deref())
.unwrap_or("medium");
let agent_name = agent
.as_ref()
.map(|a| a.name.as_str())
.or_else(|| path.file_stem().and_then(|s| s.to_str()))
.unwrap_or("unknown");
let (provider, mdl) = match tier {
"heavy" => &optimal.architect,
"light" => &optimal.ask,
_ => &optimal.code,
};
update_agent_md_frontmatter_full(&path, mdl, provider, None);
eprintln!(
" {}✓{} {} [{}] → {}/{}",
s::GREEN,
s::RESET,
agent_name,
tier,
provider,
mdl
);
updated += 1;
}
}
if updated == 0 {
eprintln!(" {}(no agent files found){}", s::DIM, s::RESET);
}
eprintln!();
Ok(())
}
fn cmd_provider_remove() -> Result<()> {
use super::wizard::ui::wizard_select;
use wizard_style as s;
let (path, mut cf) = load_config_for_edit()?;
if cf.providers.is_empty() {
eprintln!("No providers registered.");
return Ok(());
}
let names: Vec<String> = cf.providers.iter().map(|p| p.name.clone()).collect();
let labels: Vec<&str> = names.iter().map(|s| s.as_str()).collect();
let idx = wizard_select("Remove provider", &labels, 0)?;
let removed_name = names[idx].clone();
cf.providers.remove(idx);
if cf.api.provider.as_deref() == Some(&removed_name) {
if let Some(first) = cf.providers.first() {
cf.api.provider = Some(first.name.clone());
cf.api.model = first.models.first().cloned();
cf.api.api_key_enc = first.api_key_enc.clone();
} else {
cf.api.provider = None;
cf.api.model = None;
cf.api.api_key_enc = None;
}
cf.api.api_key = None;
}
write_config(&path, &cf)?;
eprintln!(
" {}{}✅ Provider '{}' removed.{}",
s::BOLD,
s::GREEN,
removed_name,
s::RESET
);
Ok(())
}
fn cmd_provider_list() -> Result<()> {
use wizard_style as s;
let cf = load_config_file()?;
if cf.providers.is_empty() {
eprintln!("No providers registered. Run: collet provider add");
return Ok(());
}
let active = cf.api.provider.as_deref().unwrap_or("");
eprintln!();
eprintln!(" {}{}Registered providers:{}", s::BOLD, s::CYAN, s::RESET);
eprintln!();
for p in &cf.providers {
let is_active = p.name.eq_ignore_ascii_case(active);
let marker = if is_active {
format!("{}●{}", s::GREEN, s::RESET)
} else {
format!("{}○{}", s::DIM, s::RESET)
};
let url_display = if p.base_url.len() > 40 {
format!("{}…", &p.base_url[..39])
} else {
p.base_url.clone()
};
eprintln!(
" {} {}{}{} {}({}){}",
marker,
s::WHITE,
p.name,
s::RESET,
s::DIM,
url_display,
s::RESET
);
let all = p.all_models();
if !all.is_empty() {
eprintln!(" models: {}{}{}", s::DIM, all.join(", "), s::RESET);
}
}
eprintln!();
Ok(())
}
fn cmd_provider_use(args: &[String]) -> Result<()> {
use super::wizard::ui::wizard_select;
use wizard_style as s;
let (path, mut cf) = load_config_for_edit()?;
if cf.providers.is_empty() {
eprintln!("No providers registered. Run: collet provider add");
return Ok(());
}
let entry = if let Some(name) = args.first() {
let q = name.to_lowercase();
cf.providers
.iter()
.find(|p| p.name.to_lowercase() == q || p.name.to_lowercase().contains(&q))
.cloned()
.ok_or_else(|| {
AgentError::Config(format!(
"No registered provider matches '{name}'. Run: collet provider list"
))
})?
} else {
let names: Vec<String> = cf.providers.iter().map(|p| p.name.clone()).collect();
let labels: Vec<&str> = names.iter().map(|s| s.as_str()).collect();
let idx = wizard_select("Switch active provider", &labels, 0)?;
cf.providers[idx].clone()
};
cf.api.provider = Some(entry.name.clone());
let default_model = entry.all_models().into_iter().next().map(|s| s.to_string());
cf.api.model = default_model.clone();
cf.api.api_key_enc = entry.api_key_enc.clone();
cf.api.api_key = None;
write_config(&path, &cf)?;
let model_display = default_model.as_deref().unwrap_or("(none)");
eprintln!(
" {}{}✅ Active provider: {} (model: {}){}",
s::BOLD,
s::GREEN,
entry.name,
model_display,
s::RESET
);
Ok(())
}
fn cmd_provider_models(args: &[String]) -> Result<()> {
use super::wizard::ui::{prompt_with_default, wizard_select};
use wizard_style as s;
let (path, mut cf) = load_config_for_edit()?;
if cf.providers.is_empty() {
eprintln!("No providers registered. Run: collet provider add");
return Ok(());
}
let names: Vec<String> = cf.providers.iter().map(|p| p.name.clone()).collect();
let labels: Vec<&str> = names.iter().map(|s| s.as_str()).collect();
let prov_idx = if let Some(name) = args.first() {
let q = name.to_lowercase();
names
.iter()
.position(|n| n.to_lowercase() == q || n.to_lowercase().contains(&q))
.ok_or_else(|| AgentError::Config(format!("No provider matches '{name}'")))?
} else {
wizard_select("Select provider", &labels, 0)?
};
let provider = &cf.providers[prov_idx];
let all = provider.all_models();
eprintln!(
"\n {}{}Provider: {}{}",
s::BOLD,
s::CYAN,
provider.name,
s::RESET
);
eprintln!(
" Current models: {}{}{}\n",
s::DIM,
all.join(", "),
s::RESET
);
let action_idx = wizard_select("Action", &["Add model", "Remove model"], 0)?;
if action_idx == 0 {
let model = prompt_with_default("Model name", "", &mut std::io::stdin().lock())?;
if model.is_empty() {
eprintln!(" {}No model name provided.{}", s::DIM, s::RESET);
return Ok(());
}
let provider = &mut cf.providers[prov_idx];
if provider
.all_models()
.iter()
.any(|m| m.eq_ignore_ascii_case(&model))
{
eprintln!(
" {}{}Model '{}' already exists.{}",
s::BOLD,
s::YELLOW,
model,
s::RESET
);
return Ok(());
}
provider.models.push(model.clone());
write_config(&path, &cf)?;
eprintln!(
" {}{}✅ Model '{}' added.{}",
s::BOLD,
s::GREEN,
model,
s::RESET
);
} else {
let provider = &cf.providers[prov_idx];
let all: Vec<String> = provider
.all_models()
.iter()
.map(|m| m.to_string())
.collect();
if all.len() <= 1 {
eprintln!(
" {}Cannot remove the only model. Use 'provider remove' instead.{}",
s::DIM,
s::RESET
);
return Ok(());
}
let model_labels: Vec<&str> = all.iter().map(|s| s.as_str()).collect();
let rm_idx = wizard_select("Remove model", &model_labels, 0)?;
let removed = all[rm_idx].clone();
let provider = &mut cf.providers[prov_idx];
provider
.models
.retain(|m| !m.eq_ignore_ascii_case(&removed));
write_config(&path, &cf)?;
eprintln!(
" {}{}✅ Model '{}' removed.{}",
s::BOLD,
s::GREEN,
removed,
s::RESET
);
}
Ok(())
}