use std::process::ExitCode;
use link_assistant_router::cli::{AuthOp, ImportProvider};
use link_assistant_router::subscription::{SubscriptionProvider, SubscriptionReader};
pub async fn run_import(
config: &link_assistant_router::config::Config,
op: &AuthOp,
) -> Option<ExitCode> {
let requested: Vec<(ImportProvider, String)> = match op {
AuthOp::Claude {
from_claude_home: Some(source),
..
} => vec![(ImportProvider::Claude, source.clone())],
AuthOp::Codex {
from_codex_home: Some(source),
..
} => vec![(ImportProvider::Codex, source.clone())],
AuthOp::Import { all: true, .. } => [
ImportProvider::Claude,
ImportProvider::Codex,
ImportProvider::Gemini,
ImportProvider::Qwen,
ImportProvider::Gh,
]
.into_iter()
.map(|provider| (provider, String::new()))
.collect(),
AuthOp::Import {
provider: Some(provider),
dir,
..
} => vec![(*provider, dir.clone().unwrap_or_default())],
_ => return None,
};
let adopting_everything = matches!(op, AuthOp::Import { all: true, .. });
let mut failed = false;
for (provider, source) in requested {
let outcome = match provider {
ImportProvider::Gh => import_github(config, &source),
other => {
let Some(subscription) = subscription_of(other) else {
continue;
};
import_provider(config, subscription, &source).await
}
};
if let Err(error) = outcome {
if adopting_everything {
println!("{}: nothing to adopt ({error})", provider_label(provider));
continue;
}
eprintln!("error: {error}");
failed = true;
}
}
Some(if failed {
ExitCode::from(1)
} else {
ExitCode::SUCCESS
})
}
const fn subscription_of(provider: ImportProvider) -> Option<SubscriptionProvider> {
match provider {
ImportProvider::Claude => Some(SubscriptionProvider::Claude),
ImportProvider::Codex => Some(SubscriptionProvider::Codex),
ImportProvider::Gemini => Some(SubscriptionProvider::Gemini),
ImportProvider::Qwen => Some(SubscriptionProvider::Qwen),
ImportProvider::Gh => None,
}
}
const fn provider_label(provider: ImportProvider) -> &'static str {
match provider {
ImportProvider::Claude => "claude",
ImportProvider::Codex => "codex",
ImportProvider::Gemini => "gemini",
ImportProvider::Qwen => "qwen",
ImportProvider::Gh => "github",
}
}
fn import_github(
config: &link_assistant_router::config::Config,
source: &str,
) -> Result<(), String> {
use link_assistant_router::github_proxy;
let directory = Some(source)
.filter(|source| !source.trim().is_empty())
.map(std::path::PathBuf::from)
.or_else(github_proxy::gh_config_directory)
.ok_or_else(|| {
String::from("no gh configuration directory; name one, or set GH_CONFIG_DIR")
})?;
let token = github_proxy::token_from_gh_config(&directory).ok_or_else(|| {
format!(
"no GitHub credential in {}; run `gh auth login` there first",
directory.display()
)
})?;
let path = github_proxy::store_credential(std::path::Path::new(&config.data_dir), &token)?;
println!(
"github imported {} from {}",
path.display(),
directory.display()
);
println!("github note: the GitHub routes are mounted at startup; restart to serve them");
Ok(())
}
async fn import_provider(
config: &link_assistant_router::config::Config,
provider: SubscriptionProvider,
source: &str,
) -> Result<(), String> {
let user_home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
let source_home = if source.trim().is_empty() {
std::path::PathBuf::from(&user_home).join(provider.home_subdir())
} else {
std::path::PathBuf::from(source)
};
let destination_home = provider_home(config, provider, &user_home);
if source_home == destination_home {
return Err(format!(
"{provider} is already read from {}, so there is nothing to adopt",
destination_home.display()
));
}
let from = SubscriptionReader::new(provider, &source_home);
let (document, origin) = from
.read_document_for_import()
.map_err(|error| format!("no {provider} credential to import: {error}"))?;
let token = from
.read_token()
.map_err(|error| format!("the {provider} credential could not be read: {error}"))?;
let where_from = match origin {
link_assistant_router::platform_keychain::Origin::Keychain => {
link_assistant_router::platform_keychain::service_name(provider).map_or_else(
|| String::from("the platform keychain"),
|service| format!("keychain {service:?}"),
)
}
link_assistant_router::platform_keychain::Origin::File => {
from.discover_credential_path().map_or_else(
|| source_home.display().to_string(),
|path| path.display().to_string(),
)
}
};
let verdict = probe_credential(provider, &token).await;
let installed =
SubscriptionReader::new(provider, &destination_home).install_document(&document)?;
println!(
"{provider:<8} imported {} from {where_from}",
installed.display()
);
println!("{provider:<8} {}, {verdict}", describe_credential(&token));
println!(
"{provider:<8} note: the source keeps working; the two now share one rotating \
chain, and a revocation at the vendor ends both"
);
Ok(())
}
pub fn describe_credential(
token: &link_assistant_router::subscription::SubscriptionToken,
) -> String {
let now = chrono::Utc::now().timestamp_millis();
let expiry = token.expires_at_ms.map_or_else(
|| String::from("no recorded expiry"),
|expires_at| {
let minutes = (expires_at - now) / 60_000;
if expires_at <= now {
format!("EXPIRED {} ago", humanize_minutes(-minutes))
} else {
format!("expires in {}", humanize_minutes(minutes))
}
},
);
let refresh = if token.refresh_token.is_some() {
"refresh token present"
} else {
"NO refresh token, so it cannot be renewed"
};
format!("{expiry}, {refresh}")
}
async fn probe_credential(
provider: SubscriptionProvider,
token: &link_assistant_router::subscription::SubscriptionToken,
) -> &'static str {
let client = reqwest::Client::new();
match link_assistant_router::model_catalog::fetch_provider_catalog(
&client, provider, token, None,
)
.await
{
Ok(_) => "accepted by the vendor",
Err(error) if link_assistant_router::model_catalog::is_credential_rejection(&error) => {
"REJECTED by the vendor — importing it anyway, but it will not serve"
}
Err(_) => "not verified (the vendor could not be reached)",
}
}
pub fn humanize_minutes(minutes: i64) -> String {
if minutes < 90 {
return format!("{minutes} minutes");
}
let hours = minutes / 60;
if hours < 48 {
return format!("{hours} hours");
}
format!("{} days", hours / 24)
}
pub fn provider_home(
config: &link_assistant_router::config::Config,
provider: SubscriptionProvider,
user_home: &str,
) -> std::path::PathBuf {
match provider {
SubscriptionProvider::Claude => config.login.claude_code_home.clone(),
SubscriptionProvider::Codex => config.login.codex_home.clone(),
SubscriptionProvider::Gemini | SubscriptionProvider::Qwen => {
provider.resolve_home(user_home)
}
}
}