use std::io::{self, BufRead, Write};
use std::path::PathBuf;
use std::process::ExitCode;
use crate::llm::expand_user_path;
use crate::oauth::{run_device_flow, DeviceFlowConfig};
const PROVIDERS: &[&str] = &[
"codex",
"openai",
"openrouter",
"ollama",
"github-copilot",
"oauth-custom",
"custom",
];
pub async fn run(provider_arg: Option<&str>) -> ExitCode {
let provider = match provider_arg {
Some(name) => name.to_string(),
None => match prompt_provider_choice() {
Ok(p) => p,
Err(e) => {
eprintln!("error: {e}");
return ExitCode::from(1);
}
},
};
let result = match provider.as_str() {
"codex" => setup_codex_dispatch().await,
"openai" => setup_static_key("openai", "https://api.openai.com"),
"openrouter" => setup_static_key("openrouter", "https://openrouter.ai/api"),
"ollama" => setup_ollama(),
"github-copilot" => setup_github_copilot().await,
"oauth-custom" => setup_oauth_custom().await,
"custom" => setup_custom(),
other => Err(format!(
"unknown provider `{other}` (known: {})",
PROVIDERS.join(", ")
)),
};
match result {
Ok(snippet) => {
match write_auto_load(&provider, &snippet) {
Ok(path) => {
println!();
println!("✓ provider registered for auto-load via {}", path.display());
println!(
" Programs that emit LlmCall {{ provider: \"{provider}\", ... }} will use this automatically — no editing required."
);
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("error writing auto-load file: {e}");
ExitCode::from(1)
}
}
}
Err(e) => {
eprintln!("error: {e}");
ExitCode::from(1)
}
}
}
fn write_auto_load(provider: &str, llm_provider_block: &str) -> Result<PathBuf, String> {
let dir = auto_load_dir()?;
std::fs::create_dir_all(&dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
let path = dir.join(format!("{provider}.thal"));
let safe_provider_id = provider
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect::<String>();
let body = format!(
"// Auto-generated by `thal setup {provider}`. Edit or delete\n\
// to modify; rerun the wizard to refresh.\n\
\n\
reaction __Auto_{safe_provider_id}_provider {{\n\
when: Boot(b)\n\
emit:\n\
{llm_provider_block}\n\
}}\n"
);
std::fs::write(&path, body).map_err(|e| format!("write {}: {e}", path.display()))?;
Ok(path)
}
fn auto_load_dir() -> Result<PathBuf, String> {
crate::auto_load_dir()
.ok_or_else(|| "cannot determine config dir (set XDG_CONFIG_HOME or HOME)".to_string())
}
fn prompt_provider_choice() -> Result<String, String> {
let mut stderr = io::stderr().lock();
let _ = writeln!(stderr, "select a provider:");
for (i, name) in PROVIDERS.iter().enumerate() {
let label = match *name {
"codex" => "Codex (read token from ~/.codex/auth.json)",
"openai" => "OpenAI (paste API key)",
"openrouter" => "OpenRouter (paste API key)",
"ollama" => "Ollama (local, no auth)",
"github-copilot" => "GitHub Copilot (browser OAuth, device flow)",
"oauth-custom" => "Custom OAuth device flow (provide endpoints)",
"custom" => "Custom OpenAI-compatible endpoint (paste token)",
_ => name,
};
let _ = writeln!(stderr, " {}) {label}", i + 1);
}
let _ = write!(stderr, "> ");
let _ = stderr.flush();
drop(stderr);
let line = read_line()?;
let trimmed = line.trim();
if let Ok(n) = trimmed.parse::<usize>() {
if (1..=PROVIDERS.len()).contains(&n) {
return Ok(PROVIDERS[n - 1].to_string());
}
}
if PROVIDERS.contains(&trimmed) {
return Ok(trimmed.to_string());
}
Err(format!(
"unrecognized choice `{trimmed}` (expected 1..{} or a name)",
PROVIDERS.len()
))
}
fn read_line() -> Result<String, String> {
let stdin = io::stdin();
let mut line = String::new();
stdin
.lock()
.read_line(&mut line)
.map_err(|e| format!("read stdin: {e}"))?;
Ok(line)
}
fn ask(question: &str, default: Option<&str>) -> Result<String, String> {
let mut stderr = io::stderr().lock();
if let Some(d) = default {
let _ = write!(stderr, "{question} [{d}]: ");
} else {
let _ = write!(stderr, "{question}: ");
}
let _ = stderr.flush();
drop(stderr);
let line = read_line()?;
let trimmed = line.trim();
if trimmed.is_empty() {
match default {
Some(d) => Ok(d.to_string()),
None => Err("empty input".into()),
}
} else {
Ok(trimmed.to_string())
}
}
async fn setup_codex_dispatch() -> Result<String, String> {
let our_tokens = config_dir()
.ok()
.map(|d| d.join("codex.tokens.json"))
.filter(|p| p.exists());
if let Some(path) = our_tokens {
eprintln!(
"Found existing Thal-managed Codex tokens at {}.",
path.display()
);
let answer = ask(
"refresh the auto-load file, or re-run OAuth to mint new tokens? [refresh/oauth]",
Some("refresh"),
)?;
if answer.starts_with("o") || answer.starts_with("O") {
return setup_codex_oauth().await;
}
return Ok(codex_provider_block(&path.display().to_string(), ".access_token"));
}
let codex_cli = expand_user_path("~/.codex/auth.json");
if codex_cli.exists() {
eprintln!("Found existing {} (from Codex CLI).", codex_cli.display());
let answer = ask(
"use existing Codex CLI auth, or run a fresh OAuth login? [existing/oauth]",
Some("existing"),
)?;
if answer.starts_with("o") || answer.starts_with("O") {
return setup_codex_oauth().await;
}
return setup_codex_from_auth_json();
}
eprintln!("No Codex tokens found.");
let answer = ask("run browser-based OAuth now? [Y/n]", Some("y"))?;
if answer.starts_with("n") || answer.starts_with("N") {
return Err(
"no Codex auth available. log in via Codex CLI first, or re-run and accept the OAuth prompt"
.to_string(),
);
}
setup_codex_oauth().await
}
fn codex_provider_block(token_file: &str, token_jq: &str) -> String {
format!(
" LlmProvider {{\n name: \"codex\"\n kind: \"codex_responses\"\n base_url: \"https://chatgpt.com/backend-api/codex\"\n token_file: \"{token_file}\"\n token_jq: \"{token_jq}\"\n }}"
)
}
async fn setup_codex_oauth() -> Result<String, String> {
let tokens = crate::oauth_openai::run_codex_oauth()
.await
.map_err(|e| e.to_string())?;
let dir = config_dir()?;
std::fs::create_dir_all(&dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
let path = dir.join("codex.tokens.json");
let body = serde_json::to_string_pretty(&serde_json::json!({
"access_token": tokens.access_token,
"refresh_token": tokens.refresh_token,
"last_refresh": tokens.last_refresh,
}))
.map_err(|e| format!("serialize tokens: {e}"))?;
std::fs::write(&path, body).map_err(|e| format!("write {}: {e}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
}
eprintln!("\n✓ saved Codex tokens to {} (mode 0600)", path.display());
Ok(format!(
" LlmProvider {{\n name: \"codex\"\n kind: \"codex_responses\"\n base_url: \"https://chatgpt.com/backend-api/codex\"\n token_file: \"{}\"\n token_jq: \".access_token\"\n }}",
path.display()
))
}
fn setup_codex_from_auth_json() -> Result<String, String> {
let path_literal = "~/.codex/auth.json";
let expanded = expand_user_path(path_literal);
if !expanded.exists() {
return Err(format!(
"{} not found. log in via Codex CLI first, or pass `setup custom` to point at a different token file",
expanded.display()
));
}
let raw = std::fs::read_to_string(&expanded)
.map_err(|e| format!("read {}: {e}", expanded.display()))?;
let value: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| format!("parse {} as JSON: {e}", expanded.display()))?;
let candidates = collect_string_paths(&value);
if candidates.is_empty() {
return Err(format!(
"{} contains no string fields — auth file shape isn't recognized",
expanded.display()
));
}
let preferred = ["tokens.access_token", "access_token", "OPENAI_API_KEY"];
let detected = preferred
.iter()
.find(|p| candidates.iter().any(|c| c == *p));
let chosen = match detected {
Some(p) => {
eprintln!(
"{} contains {} string field(s); using `{p}` (recommended).",
expanded.display(),
candidates.len()
);
p.to_string()
}
None => {
eprintln!("{} contains:", expanded.display());
for (i, c) in candidates.iter().enumerate() {
eprintln!(" {}) {c}", i + 1);
}
let answer = ask("which field is the bearer token?", None)?;
if let Ok(n) = answer.parse::<usize>() {
candidates
.get(n - 1)
.ok_or_else(|| "out of range".to_string())?
.to_string()
} else if candidates.contains(&answer) {
answer
} else {
return Err(format!("unrecognized field `{answer}`"));
}
}
};
let base_url = ask(
"base_url for the Codex backend",
Some("https://chatgpt.com/backend-api/codex"),
)?;
Ok(format!(
" LlmProvider {{\n name: \"codex\"\n kind: \"codex_responses\"\n base_url: \"{base_url}\"\n token_file: \"{path_literal}\"\n token_jq: \".{chosen}\"\n }}"
))
}
fn setup_static_key(name: &str, default_base_url: &str) -> Result<String, String> {
let key = ask(&format!("paste your {name} API key"), None)?;
if key.is_empty() {
return Err("empty key".into());
}
let dir = config_dir()?;
std::fs::create_dir_all(&dir)
.map_err(|e| format!("create {}: {e}", dir.display()))?;
let token_path = dir.join(format!("{name}.token"));
std::fs::write(&token_path, &key)
.map_err(|e| format!("write {}: {e}", token_path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&token_path, std::fs::Permissions::from_mode(0o600));
}
eprintln!("saved key to {} (mode 0600)", token_path.display());
let base_url = ask("base_url", Some(default_base_url))?;
Ok(format!(
" LlmProvider {{\n name: \"{name}\"\n kind: \"openai_compat\"\n base_url: \"{base_url}\"\n token_file: \"{}\"\n }}",
token_path.display()
))
}
fn setup_ollama() -> Result<String, String> {
let base_url = ask("Ollama base_url", Some("http://localhost:11434"))?;
let dir = config_dir()?;
std::fs::create_dir_all(&dir)
.map_err(|e| format!("create {}: {e}", dir.display()))?;
let token_path = dir.join("ollama.token");
if !token_path.exists() {
std::fs::write(&token_path, "ollama-no-auth")
.map_err(|e| format!("write {}: {e}", token_path.display()))?;
}
eprintln!(
"wrote placeholder token to {} (Ollama ignores it)",
token_path.display()
);
Ok(format!(
" LlmProvider {{\n name: \"ollama\"\n kind: \"openai_compat\"\n base_url: \"{base_url}\"\n token_file: \"{}\"\n }}",
token_path.display()
))
}
async fn setup_github_copilot() -> Result<String, String> {
let config = DeviceFlowConfig {
name: "github-copilot".into(),
device_authorization_endpoint: "https://github.com/login/device/code".into(),
token_endpoint: "https://github.com/login/oauth/access_token".into(),
client_id: "Iv1.b507a08c87ecfe98".into(),
scope: Some("read:user".into()),
};
let token = run_device_flow(&config).await.map_err(|e| e.to_string())?;
let token_path = save_token("github-copilot", &token.access_token)?;
eprintln!(
"\nCaveat: GitHub Copilot's chat completion endpoint may need additional headers \
beyond bearer auth. Getting the OAuth token works; getting LlmCall against \
api.githubcopilot.com to succeed end-to-end may need follow-up.\n"
);
Ok(format!(
" LlmProvider {{\n name: \"github-copilot\"\n kind: \"openai_compat\"\n base_url: \"https://api.githubcopilot.com\"\n token_file: \"{}\"\n }}",
token_path.display()
))
}
async fn setup_oauth_custom() -> Result<String, String> {
let name = ask("provider name", None)?;
let device_url = ask("device authorization endpoint", None)?;
let token_url = ask("token endpoint", None)?;
let client_id = ask("client_id", None)?;
let scope_raw = ask("scope (blank for none)", Some(""))?;
let scope = if scope_raw.trim().is_empty() {
None
} else {
Some(scope_raw.trim().to_string())
};
let base_url = ask("API base_url for LlmCall", None)?;
let config = DeviceFlowConfig {
name: name.clone(),
device_authorization_endpoint: device_url,
token_endpoint: token_url,
client_id,
scope,
};
let token = run_device_flow(&config).await.map_err(|e| e.to_string())?;
let token_path = save_token(&name, &token.access_token)?;
Ok(format!(
" LlmProvider {{\n name: \"{name}\"\n kind: \"openai_compat\"\n base_url: \"{base_url}\"\n token_file: \"{}\"\n }}",
token_path.display()
))
}
fn save_token(name: &str, token: &str) -> Result<PathBuf, String> {
let dir = config_dir()?;
std::fs::create_dir_all(&dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
let path = dir.join(format!("{name}.token"));
std::fs::write(&path, token).map_err(|e| format!("write {}: {e}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
}
eprintln!("\n✓ saved token to {} (mode 0600)", path.display());
Ok(path)
}
fn setup_custom() -> Result<String, String> {
let name = ask("provider name (used in `LlmCall { provider: ... }`)", None)?;
let base_url = ask("base_url", None)?;
let token_file = ask("token file path", None)?;
let token_jq_raw = ask(
"JSON path inside token file (blank if file is plain text)",
Some(""),
)?;
let token_jq = token_jq_raw.trim();
let mut block = format!(
" LlmProvider {{\n name: \"{name}\"\n kind: \"openai_compat\"\n base_url: \"{base_url}\"\n token_file: \"{token_file}\""
);
if !token_jq.is_empty() {
let prefixed = if token_jq.starts_with('.') {
token_jq.to_string()
} else {
format!(".{token_jq}")
};
block.push_str(&format!("\n token_jq: \"{prefixed}\""));
}
block.push_str("\n }");
Ok(block)
}
fn config_dir() -> Result<PathBuf, String> {
if let Some(d) = std::env::var_os("XDG_CONFIG_HOME") {
return Ok(PathBuf::from(d).join("thal"));
}
if let Some(home) = std::env::var_os("HOME") {
return Ok(PathBuf::from(home).join(".config").join("thal"));
}
Err("cannot determine config dir (set XDG_CONFIG_HOME or HOME)".into())
}
fn collect_string_paths(value: &serde_json::Value) -> Vec<String> {
let mut out = Vec::new();
if let serde_json::Value::Object(map) = value {
for (k, v) in map {
match v {
serde_json::Value::String(_) => out.push(k.clone()),
serde_json::Value::Object(inner) => {
for (k2, v2) in inner {
if matches!(v2, serde_json::Value::String(_)) {
out.push(format!("{k}.{k2}"));
}
}
}
_ => {}
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn collects_top_level_string_keys() {
let v: serde_json::Value =
serde_json::from_str(r#"{"a":"x","b":42,"c":"y"}"#).expect("parse");
let paths = collect_string_paths(&v);
assert_eq!(paths, vec!["a", "c"]);
}
#[test]
fn collects_nested_string_keys() {
let v: serde_json::Value = serde_json::from_str(
r#"{"tokens":{"access_token":"x","id_token":"y","expires_in":3600}}"#,
)
.expect("parse");
let paths = collect_string_paths(&v);
assert!(paths.contains(&"tokens.access_token".to_string()));
assert!(paths.contains(&"tokens.id_token".to_string()));
assert!(!paths.iter().any(|p| p.contains("expires_in")));
}
#[test]
fn collects_codex_like_shape() {
let v: serde_json::Value = serde_json::from_str(
r#"{
"OPENAI_API_KEY":"sk-x",
"tokens":{"access_token":"a","id_token":"b","refresh_token":"r"},
"last_refresh":"2026-01-01"
}"#,
)
.expect("parse");
let paths = collect_string_paths(&v);
assert!(paths.iter().any(|p| p == "OPENAI_API_KEY"));
assert!(paths.iter().any(|p| p == "tokens.access_token"));
assert!(paths.iter().any(|p| p == "last_refresh"));
}
}