1use std::path::Path;
4use std::process::ExitCode;
5
6use crate::cli::ClientOp;
7use crate::clients::{ClientKind, ClientManager, ManagedCredential, TokenSource};
8use crate::config::Config;
9use crate::storage::build_token_store;
10use crate::token::{IssueRequest, TokenManager};
11
12pub const CLIENT_TOKEN_ENV: &str = "LINK_ASSISTANT_ROUTER_TOKEN";
14pub const CLIENT_TOKEN_ENV_ALIAS: &str = "LINK_ASSISTANT_TOKEN";
16
17pub async fn run(config: &Config, home: Option<&Path>, op: &ClientOp) -> ExitCode {
19 let manager = match home {
20 Some(home) => ClientManager::isolated(home),
21 None => match ClientManager::from_env() {
22 Ok(manager) => manager,
23 Err(error) => return failed(error),
24 },
25 };
26 match op {
27 ClientOp::List => list(&manager),
28 ClientOp::Setup {
29 client,
30 token,
31 token_stdin,
32 base_url,
33 ttl_hours,
34 } => {
35 let supplied = match resolve_supplied_token(token.clone(), *token_stdin) {
36 Ok(token) => token,
37 Err(error) => return failed(error),
38 };
39 setup(
40 config,
41 &manager,
42 *client,
43 supplied.as_deref(),
44 base_url.as_deref(),
45 *ttl_hours,
46 )
47 .await
48 }
49 ClientOp::Show { client } => show(&manager, *client),
50 ClientOp::Remove {
51 client,
52 revoke_supplied,
53 force,
54 } => remove(config, &manager, *client, *revoke_supplied, *force),
55 ClientOp::Doctor { client } => match manager.doctor(*client).await {
56 Ok(message) => {
57 println!("ok: {message}");
58 ExitCode::SUCCESS
59 }
60 Err(error) => failed(error),
61 },
62 }
63}
64
65fn resolve_supplied_token(
68 token: Option<String>,
69 token_stdin: bool,
70) -> Result<Option<String>, Box<dyn std::error::Error + Send + Sync>> {
71 if token_stdin {
72 return crate::server_command::read_token().map(Some);
73 }
74 if let Some(token) = token {
75 return Ok(Some(token));
76 }
77 Ok(std::env::var(CLIENT_TOKEN_ENV)
78 .or_else(|_| std::env::var(CLIENT_TOKEN_ENV_ALIAS))
79 .ok()
80 .map(|token| token.trim().to_string())
81 .filter(|token| !token.is_empty()))
82}
83
84fn list(manager: &ClientManager) -> ExitCode {
85 println!(
86 "{:<12} {:<9} {:<11} {:<19} config",
87 "client", "installed", "configured", "dialect"
88 );
89 for client in ClientKind::ALL {
90 match manager.status(client) {
91 Ok(status) => println!(
92 "{:<12} {:<9} {:<11} {:<19} {}",
93 status.client,
94 status.installed,
95 status.configured,
96 status.dialect,
97 status.config_path.display()
98 ),
99 Err(error) => return failed(format!("could not read {client}: {error}")),
100 }
101 }
102 ExitCode::SUCCESS
103}
104
105async fn setup(
106 config: &Config,
107 manager: &ClientManager,
108 client: ClientKind,
109 supplied_token: Option<&str>,
110 base_url: Option<&str>,
111 ttl_hours: i64,
112) -> ExitCode {
113 if supplied_token.is_some_and(|token| !token.starts_with("la_sk_")) {
114 eprintln!(
115 "error: the supplied router token must begin with la_sk_ (checked --token, --token-stdin, then {CLIENT_TOKEN_ENV})"
116 );
117 return ExitCode::from(2);
118 }
119 let base_url = base_url.map_or_else(|| local_client_base_url(config), str::to_string);
120 if let Some(limitation) = client.setup_limitation() {
121 return failed(limitation);
122 }
123 if client.token_env().is_none() {
124 return failed(format!(
125 "{} has no router token environment",
126 client.display_name()
127 ));
128 }
129 let (token, credential) = match supplied_token {
130 Some(token) => (
131 token.to_string(),
132 ManagedCredential {
133 client: client.to_string(),
134 source: TokenSource::Supplied,
135 token_id: local_token_id(config, token),
138 label: None,
139 issued_at: None,
140 },
141 ),
142 None => match issue_client_token(config, client, ttl_hours) {
143 Ok((token, id)) => (
144 token,
145 ManagedCredential {
146 client: client.to_string(),
147 source: TokenSource::Minted,
148 token_id: Some(id),
149 label: Some(format!("client-{client}")),
150 issued_at: Some(chrono::Utc::now().timestamp()),
151 },
152 ),
153 Err(error) => return failed(error),
154 },
155 };
156 let models = if matches!(
157 client,
158 ClientKind::Opencode | ClientKind::QwenCode | ClientKind::Agent
159 ) {
160 match manager.catalog(&base_url, &token).await {
161 Ok(models) => models,
162 Err(error) => return failed(error),
163 }
164 } else {
165 Vec::new()
166 };
167 let result = match manager.setup(client, &base_url, &models) {
168 Ok(result) => result,
169 Err(error) => return failed(error),
170 };
171 let environment_path = match manager.write_environment(client, &base_url, &token) {
172 Ok(path) => path,
173 Err(error) => return failed(error),
174 };
175 if let Err(error) = manager.write_credential_metadata(client, &credential) {
178 return failed(error);
179 }
180 if client == ClientKind::GrokCli {
181 println!(
182 "{} uses shell environment; no client config was changed",
183 client.display_name()
184 );
185 } else if result.changed {
186 println!(
187 "configured {} in {}",
188 client.display_name(),
189 result.path.display()
190 );
191 } else {
192 println!(
193 "{} is already configured in {}",
194 client.display_name(),
195 result.path.display()
196 );
197 }
198 if let Some(backup) = result.backup {
199 println!("backup: {}", backup.display());
200 }
201 println!(
202 "credentials: {} (mode 0600); run: source {}",
203 environment_path.display(),
204 shell_quote(&environment_path.display().to_string())
205 );
206 println!("The token is not stored in the client config or printed to the terminal.");
207 ExitCode::SUCCESS
208}
209
210fn show(manager: &ClientManager, client: ClientKind) -> ExitCode {
211 match manager.status(client) {
212 Ok(status) => {
213 println!(
214 "{}",
215 serde_json::to_string_pretty(&status).unwrap_or_default()
216 );
217 ExitCode::SUCCESS
218 }
219 Err(error) => failed(error),
220 }
221}
222
223fn remove(
229 config: &Config,
230 manager: &ClientManager,
231 client: ClientKind,
232 revoke_supplied: bool,
233 force: bool,
234) -> ExitCode {
235 let credential = match manager.credential_metadata(client) {
236 Ok(credential) => credential,
237 Err(error) if force => {
238 eprintln!("warning: {error}; continuing because --force was given");
239 None
240 }
241 Err(error) => return failed(error),
242 };
243 let revoked = match revoke_managed_credential(config, credential.as_ref(), revoke_supplied) {
244 Ok(revoked) => revoked,
245 Err(error) => {
246 if !force {
247 eprintln!("error: {error}");
248 eprintln!(
249 "the credential file was left in place; revoke the token with `link-assistant-router tokens revoke <ID>` against the router's DATA_DIR and rerun `link-assistant-router clients remove {client}`, or pass --force to delete the local settings anyway"
250 );
251 return ExitCode::from(1);
252 }
253 eprintln!("warning: {error}; continuing because --force was given");
254 None
255 }
256 };
257 match manager.remove(client) {
258 Ok(result) => {
259 if result.changed {
260 println!("removed router settings from {}", result.path.display());
261 } else {
262 println!(
263 "no managed router settings found in {}",
264 result.path.display()
265 );
266 }
267 if let Some(backup) = result.backup {
268 println!("backup: {}", backup.display());
269 }
270 if let Some(id) = revoked {
271 println!("revoked managed token {id}");
272 }
273 ExitCode::SUCCESS
274 }
275 Err(error) => failed(error),
276 }
277}
278
279fn revoke_managed_credential(
281 config: &Config,
282 credential: Option<&ManagedCredential>,
283 revoke_supplied: bool,
284) -> Result<Option<String>, Box<dyn std::error::Error>> {
285 let Some(credential) = credential else {
286 return Ok(None);
287 };
288 let wanted = credential.revocable_by_default()
289 || (revoke_supplied && credential.source == TokenSource::Supplied);
290 if !wanted {
291 return Ok(None);
292 }
293 let Some(id) = credential.token_id.as_deref() else {
294 if revoke_supplied {
295 return Err(format!(
296 "the token configured for {} was supplied by the operator and this router does not recognise it, so it cannot be revoked here",
297 credential.client
298 )
299 .into());
300 }
301 return Ok(None);
302 };
303 token_manager(config)?.revoke_token(id)?;
304 Ok(Some(id.to_string()))
305}
306
307fn local_token_id(config: &Config, token: &str) -> Option<String> {
309 token_manager(config)
310 .ok()?
311 .validate_token(token)
312 .ok()
313 .map(|claims| claims.sub)
314}
315
316fn token_manager(config: &Config) -> Result<TokenManager, Box<dyn std::error::Error>> {
317 if !config.data_dir.exists() {
318 std::fs::create_dir_all(&config.data_dir)?;
319 }
320 let store = build_token_store(config.storage_policy, &config.data_dir)?;
321 Ok(TokenManager::with_store(&config.token_secret, store))
322}
323
324fn issue_client_token(
325 config: &Config,
326 client: ClientKind,
327 ttl_hours: i64,
328) -> Result<(String, String), Box<dyn std::error::Error>> {
329 let manager = token_manager(config)?;
330 Ok(manager.issue_with_id(&IssueRequest {
331 ttl_hours,
332 label: &format!("client-{client}"),
333 account: None,
334 max_requests: None,
335 max_tokens: None,
336 rate_limit_per_minute: None,
337 scope: "",
338 })?)
339}
340
341fn local_client_base_url(config: &Config) -> String {
342 let host = match config.listen_addr.ip() {
343 std::net::IpAddr::V4(ip) if ip.is_unspecified() => "127.0.0.1".to_string(),
344 std::net::IpAddr::V6(ip) if ip.is_unspecified() => "[::1]".to_string(),
345 ip => ip.to_string(),
346 };
347 format!("http://{host}:{}", config.listen_addr.port())
348}
349
350fn shell_quote(value: &str) -> String {
351 format!("'{}'", value.replace('\'', "'\\''"))
352}
353
354fn failed(error: impl std::fmt::Display) -> ExitCode {
355 eprintln!(
356 "error: {}",
357 crate::login_url::redact_secrets(&error.to_string())
358 );
359 ExitCode::from(1)
360}