use std::io::{self, Write};
use std::path::Path;
use recursive::providers::ProviderPreset;
#[derive(Debug)]
pub(crate) enum PresetChoice<'a> {
Preset(&'a ProviderPreset),
Manual,
}
pub(crate) fn resolve_preset_choice<'a>(
input: &str,
presets: &[&'static ProviderPreset],
default_preset: &'static ProviderPreset,
) -> PresetChoice<'a> {
if input == "0" {
return PresetChoice::Manual;
}
if input.is_empty() {
return PresetChoice::Preset(default_preset);
}
if let Ok(idx) = input.parse::<usize>() {
if idx >= 1 && idx <= presets.len() {
return PresetChoice::Preset(presets[idx - 1]);
}
}
if let Some(p) = recursive::find_preset(input) {
return PresetChoice::Preset(p);
}
PresetChoice::Preset(default_preset)
}
fn detect_current_preset(config_path: &Path) -> Option<String> {
if !config_path.exists() {
return None;
}
let cfg = recursive::config_file::FileConfig::load_from(config_path)
.ok()
.flatten()?;
let provider = cfg.provider?;
if let Some(preset_id) = provider.preset.as_deref() {
if let Some(preset) = recursive::find_preset(preset_id) {
return Some(format!(
"preset={} (model={}, api_base={})",
preset.id, preset.default_model, preset.api_base
));
}
return Some(format!("preset={} (not in catalog)", preset_id));
}
if let Some(api_base) = provider.api_base.as_deref() {
if let Some(preset) = recursive::providers::find_preset_by_api_base(api_base) {
return Some(format!(
"preset={} (by api_base match, model={})",
preset.id,
provider.model.as_deref().unwrap_or(&preset.default_model)
));
}
}
Some(format!(
"model={}, api_base={}",
provider.model.as_deref().unwrap_or("(none)"),
provider.api_base.as_deref().unwrap_or("(none)")
))
}
pub(crate) async fn run_init(
provider_prefill: Option<String>,
model_prefill: Option<String>,
api_key_prefill: Option<String>,
) -> anyhow::Result<()> {
let config_path = recursive::config_file::config_file_path()
.ok_or_else(|| anyhow::anyhow!("cannot determine home directory"))?;
if let Some(current) = detect_current_preset(&config_path) {
println!(" Existing config: {}", config_path.display());
println!(" Current: {current}\n");
} else {
println!();
}
let mut input = String::new();
let (provider_type, api_base, default_model, key_env, key_url, resolved_preset_id) =
match provider_prefill.as_deref() {
Some(id) => {
let preset = recursive::find_preset(id).ok_or_else(|| {
anyhow::anyhow!(
"--provider {:?} not found in providers.toml. Valid ids: {}",
id,
recursive::all_presets()
.iter()
.map(|p| p.id.as_str())
.collect::<Vec<_>>()
.join(", ")
)
})?;
println!(" Provider (from --provider): {}", preset.name);
(
preset.provider_type.clone(),
preset.api_base.clone(),
preset.default_model.clone(),
preset.key_env.clone(),
preset.key_url.clone(),
Some(preset.id.clone()),
)
}
None => {
let presets = recursive::all_presets();
let anthropic_preset = recursive::find_preset("anthropic").unwrap();
println!("Select a provider (or press Enter for Anthropic):\n");
let international: Vec<_> =
presets.iter().filter(|p| !p.mainland_accessible).collect();
let mainland: Vec<_> = presets
.iter()
.filter(|p| p.mainland_accessible && p.id != "ollama")
.collect();
let local: Vec<_> = presets.iter().filter(|p| p.id == "ollama").collect();
let mut all_entries: Vec<&recursive::ProviderPreset> = Vec::new();
println!(" International:");
for (i, p) in international.iter().enumerate() {
let num = i + 1;
let key_hint = if p.key_env.is_empty() {
"(no key needed)".to_string()
} else {
format!("[{}]", p.key_env)
};
println!(
" {num:>2}) {:<22} {:<26} {}",
p.name, p.default_model, key_hint
);
all_entries.push(p);
}
println!("\n Mainland China (直连):");
for (i, p) in mainland.iter().enumerate() {
let num = all_entries.len() + i + 1;
let key_hint = if p.key_env.is_empty() {
"(no key needed)".to_string()
} else {
format!("[{}]", p.key_env)
};
println!(
" {num:>2}) {:<22} {:<26} {}",
p.name, p.default_model, key_hint
);
all_entries.push(p);
}
println!("\n Local:");
for (i, p) in local.iter().enumerate() {
let num = all_entries.len() + i + 1;
let key_hint = if p.key_env.is_empty() {
"(no key needed)".to_string()
} else {
format!("[{}]", p.key_env)
};
println!(
" {num:>2}) {:<22} {:<26} {}",
p.name, p.default_model, key_hint
);
all_entries.push(p);
}
println!("\n Other: enter 0 to specify custom API base manually");
print!("\nChoice [1]: ");
io::stdout().flush()?;
io::stdin().read_line(&mut input)?;
let trimmed = input.trim();
let choice = resolve_preset_choice(trimmed, &all_entries, anthropic_preset);
match choice {
PresetChoice::Preset(p) => (
p.provider_type.clone(),
p.api_base.clone(),
p.default_model.clone(),
p.key_env.clone(),
p.key_url.clone(),
Some(p.id.clone()),
),
PresetChoice::Manual => {
println!("\nAPI base URL");
print!("API base: ");
io::stdout().flush()?;
input.clear();
io::stdin().read_line(&mut input)?;
let manual_base = input.trim().to_string();
if let Some(preset) =
recursive::providers::find_preset_by_api_base(&manual_base)
{
println!(
" Matched preset: {} (default_model={})",
preset.id, preset.default_model
);
(
preset.provider_type.clone(),
manual_base,
preset.default_model.clone(),
preset.key_env.clone(),
preset.key_url.clone(),
Some(preset.id.clone()),
)
} else {
let manual_provider_type = if manual_base.contains("anthropic") {
"anthropic"
} else {
"openai"
};
let manual_default_model = if manual_base.contains("deepseek") {
"deepseek-chat"
} else if manual_base.contains("bigmodel") {
"glm-4-flash"
} else if manual_base.contains("anthropic") {
"claude-sonnet-4-6"
} else if manual_base.contains("localhost")
|| manual_base.contains("11434")
{
"qwen2.5-coder"
} else {
"gpt-4o-mini"
};
(
manual_provider_type.to_string(),
manual_base,
manual_default_model.to_string(),
String::new(),
String::new(),
None,
)
}
}
}
}
};
let model = if let Some(m) = model_prefill {
println!(" Model (from --model): {m}");
m
} else {
print!("\nModel [{}]: ", default_model);
io::stdout().flush()?;
input.clear();
io::stdin().read_line(&mut input)?;
if input.trim().is_empty() {
default_model.clone()
} else {
input.trim().to_string()
}
};
let mut api_key_was_env_prefilled = false;
let api_key = if let Some(k) = api_key_prefill {
println!(" API key (from --api-key): set (won't echo)");
k
} else if key_env.is_empty() {
String::new()
} else {
match std::env::var(&key_env) {
Ok(existing) if !existing.is_empty() => {
println!("\n ✓ {key_env} detected, using it (skipping write to config file).");
println!(" To override, pass --api-key or unset {key_env}.");
api_key_was_env_prefilled = true;
existing
}
_ => {
if !key_url.is_empty() {
println!("\n Get your key at: {key_url}");
}
print!("\nAPI key ({}): ", key_env);
io::stdout().flush()?;
input.clear();
io::stdin().read_line(&mut input)?;
let key = input.trim().to_string();
if key.is_empty() {
println!("\n Warning: no API key set. Add it later with one of:");
println!(" recursive config set provider.api_key <KEY>");
println!(" export {key_env}=<KEY>");
}
key
}
}
};
if let Some(preset_id) = resolved_preset_id.as_deref() {
recursive::config_file::set_value("provider.preset", preset_id)?;
} else {
recursive::config_file::set_value("provider.type", &provider_type)?;
recursive::config_file::set_value("provider.api_base", &api_base)?;
}
recursive::config_file::set_value("provider.model", &model)?;
if !api_key.is_empty() && !api_key_was_env_prefilled {
recursive::config_file::set_value("provider.api_key", &api_key)?;
}
println!("\n Config saved to: {}", config_path.display());
println!("\n You can now run:");
println!(" recursive — interactive REPL");
println!(" recursive -p \"hello\" — one-shot");
println!(" recursive config show — verify settings");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn all_presets() -> Vec<&'static ProviderPreset> {
recursive::all_presets().iter().collect()
}
fn default_preset() -> &'static ProviderPreset {
recursive::find_preset("anthropic").unwrap()
}
#[test]
fn empty_input_returns_default() {
let presets = all_presets();
match resolve_preset_choice("", &presets, default_preset()) {
PresetChoice::Preset(p) => assert_eq!(p.id, "anthropic"),
other => panic!("expected Preset(anthropic), got {other:?}"),
}
}
#[test]
fn numeric_one_returns_first() {
let presets = all_presets();
let first = presets[0].id.clone();
match resolve_preset_choice("1", &presets, default_preset()) {
PresetChoice::Preset(p) => assert_eq!(p.id, first),
other => panic!("expected Preset({first}), got {other:?}"),
}
}
#[test]
fn numeric_out_of_range_falls_back_to_default() {
let presets = all_presets();
match resolve_preset_choice("999", &presets, default_preset()) {
PresetChoice::Preset(p) => assert_eq!(p.id, "anthropic"),
other => panic!("expected Preset(anthropic), got {other:?}"),
}
}
#[test]
fn string_id_match() {
let presets = all_presets();
match resolve_preset_choice("deepseek", &presets, default_preset()) {
PresetChoice::Preset(p) => assert_eq!(p.id, "deepseek"),
other => panic!("expected Preset(deepseek), got {other:?}"),
}
}
#[test]
fn string_id_no_match_falls_back_to_default() {
let presets = all_presets();
match resolve_preset_choice("nonsense", &presets, default_preset()) {
PresetChoice::Preset(p) => assert_eq!(p.id, "anthropic"),
other => panic!("expected Preset(anthropic), got {other:?}"),
}
}
#[test]
fn zero_triggers_manual() {
let presets = all_presets();
match resolve_preset_choice("0", &presets, default_preset()) {
PresetChoice::Manual => {}
other => panic!("expected Manual, got {other:?}"),
}
}
}