use std::path::Path;
use std::process::ExitCode;
use crate::cli::ClientOp;
use crate::clients::{ClientKind, ClientManager};
use crate::config::Config;
use crate::storage::build_token_store;
use crate::token::{IssueRequest, TokenManager};
pub const CLIENT_TOKEN_ENV: &str = "LINK_ASSISTANT_ROUTER_TOKEN";
pub const CLIENT_TOKEN_ENV_ALIAS: &str = "LINK_ASSISTANT_TOKEN";
pub async fn run(config: &Config, home: Option<&Path>, op: &ClientOp) -> ExitCode {
let manager = match home {
Some(home) => ClientManager::isolated(home),
None => match ClientManager::from_env() {
Ok(manager) => manager,
Err(error) => return failed(error),
},
};
match op {
ClientOp::List => list(&manager),
ClientOp::Setup {
client,
token,
token_stdin,
base_url,
ttl_hours,
} => {
let supplied = match resolve_supplied_token(token.clone(), *token_stdin) {
Ok(token) => token,
Err(error) => return failed(error),
};
setup(
config,
&manager,
*client,
supplied.as_deref(),
base_url.as_deref(),
*ttl_hours,
)
.await
}
ClientOp::Show { client } => show(&manager, *client),
ClientOp::Remove { client } => remove(&manager, *client),
ClientOp::Doctor { client } => match manager.doctor(*client).await {
Ok(message) => {
println!("ok: {message}");
ExitCode::SUCCESS
}
Err(error) => failed(error),
},
}
}
fn resolve_supplied_token(
token: Option<String>,
token_stdin: bool,
) -> Result<Option<String>, Box<dyn std::error::Error + Send + Sync>> {
if token_stdin {
return crate::server_command::read_token().map(Some);
}
if let Some(token) = token {
return Ok(Some(token));
}
Ok(std::env::var(CLIENT_TOKEN_ENV)
.or_else(|_| std::env::var(CLIENT_TOKEN_ENV_ALIAS))
.ok()
.map(|token| token.trim().to_string())
.filter(|token| !token.is_empty()))
}
fn list(manager: &ClientManager) -> ExitCode {
println!(
"{:<12} {:<9} {:<11} {:<19} config",
"client", "installed", "configured", "dialect"
);
for client in ClientKind::ALL {
match manager.status(client) {
Ok(status) => println!(
"{:<12} {:<9} {:<11} {:<19} {}",
status.client,
status.installed,
status.configured,
status.dialect,
status.config_path.display()
),
Err(error) => return failed(format!("could not read {client}: {error}")),
}
}
ExitCode::SUCCESS
}
async fn setup(
config: &Config,
manager: &ClientManager,
client: ClientKind,
supplied_token: Option<&str>,
base_url: Option<&str>,
ttl_hours: i64,
) -> ExitCode {
if supplied_token.is_some_and(|token| !token.starts_with("la_sk_")) {
eprintln!(
"error: the supplied router token must begin with la_sk_ (checked --token, --token-stdin, then {CLIENT_TOKEN_ENV})"
);
return ExitCode::from(2);
}
let base_url = base_url.map_or_else(|| local_client_base_url(config), str::to_string);
if let Some(limitation) = client.setup_limitation() {
return failed(limitation);
}
if client.token_env().is_none() {
return failed(format!(
"{} has no router token environment",
client.display_name()
));
}
let token = match supplied_token {
Some(token) => token.to_string(),
None => match issue_client_token(config, client, ttl_hours) {
Ok(token) => token,
Err(error) => return failed(error),
},
};
let models = if matches!(
client,
ClientKind::Opencode | ClientKind::QwenCode | ClientKind::Agent
) {
match manager.catalog(&base_url, &token).await {
Ok(models) => models,
Err(error) => return failed(error),
}
} else {
Vec::new()
};
let result = match manager.setup(client, &base_url, &models) {
Ok(result) => result,
Err(error) => return failed(error),
};
let environment_path = match manager.write_environment(client, &base_url, &token) {
Ok(path) => path,
Err(error) => return failed(error),
};
if client == ClientKind::GrokCli {
println!(
"{} uses shell environment; no client config was changed",
client.display_name()
);
} else if result.changed {
println!(
"configured {} in {}",
client.display_name(),
result.path.display()
);
} else {
println!(
"{} is already configured in {}",
client.display_name(),
result.path.display()
);
}
if let Some(backup) = result.backup {
println!("backup: {}", backup.display());
}
println!(
"credentials: {} (mode 0600); run: source {}",
environment_path.display(),
shell_quote(&environment_path.display().to_string())
);
println!("The token is not stored in the client config or printed to the terminal.");
ExitCode::SUCCESS
}
fn show(manager: &ClientManager, client: ClientKind) -> ExitCode {
match manager.status(client) {
Ok(status) => {
println!(
"{}",
serde_json::to_string_pretty(&status).unwrap_or_default()
);
ExitCode::SUCCESS
}
Err(error) => failed(error),
}
}
fn remove(manager: &ClientManager, client: ClientKind) -> ExitCode {
match manager.remove(client) {
Ok(result) => {
if result.changed {
println!("removed router settings from {}", result.path.display());
} else {
println!(
"no managed router settings found in {}",
result.path.display()
);
}
if let Some(backup) = result.backup {
println!("backup: {}", backup.display());
}
ExitCode::SUCCESS
}
Err(error) => failed(error),
}
}
fn issue_client_token(
config: &Config,
client: ClientKind,
ttl_hours: i64,
) -> Result<String, Box<dyn std::error::Error>> {
if !config.data_dir.exists() {
std::fs::create_dir_all(&config.data_dir)?;
}
let store = build_token_store(config.storage_policy, &config.data_dir)?;
let manager = TokenManager::with_store(&config.token_secret, store);
Ok(manager.issue(&IssueRequest {
ttl_hours,
label: &format!("client-{client}"),
account: None,
max_requests: None,
max_tokens: None,
rate_limit_per_minute: None,
scope: "",
})?)
}
fn local_client_base_url(config: &Config) -> String {
let host = match config.listen_addr.ip() {
std::net::IpAddr::V4(ip) if ip.is_unspecified() => "127.0.0.1".to_string(),
std::net::IpAddr::V6(ip) if ip.is_unspecified() => "[::1]".to_string(),
ip => ip.to_string(),
};
format!("http://{host}:{}", config.listen_addr.port())
}
fn shell_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\\''"))
}
fn failed(error: impl std::fmt::Display) -> ExitCode {
eprintln!(
"error: {}",
crate::login_url::redact_secrets(&error.to_string())
);
ExitCode::from(1)
}