Skip to main content

link_assistant_router/
client_command.rs

1//! Output and dispatch layer for the `clients` CLI command.
2
3use 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
12/// Environment variable holding an existing router token for `clients setup`.
13pub const CLIENT_TOKEN_ENV: &str = "LINK_ASSISTANT_ROUTER_TOKEN";
14/// Compatibility alias shared with the client integrations themselves.
15pub const CLIENT_TOKEN_ENV_ALIAS: &str = "LINK_ASSISTANT_TOKEN";
16
17/// Run one local client-management operation.
18pub 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
65/// Resolve an existing router token from argv, standard input, or the
66/// environment, in that order. Returns `None` when setup should mint one.
67fn 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                // Recorded only when this router can recognise the token, so
136                // `--revoke-supplied` has an id to act on.
137                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    // Written after the secret so a recorded token id always describes a
176    // credential that actually exists on disk.
177    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
223/// Remove the local settings, revoking the credential they contain first.
224///
225/// Order matters: the token is revoked before the environment file that holds
226/// it is deleted. Deleting first would leave a live credential that nobody can
227/// name any more, which is exactly the regression from issue #190.
228fn 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
279/// Revoke the recorded token when removal owns it. Returns the revoked id.
280fn 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
307/// Recognise a supplied token issued by this router and return its record id.
308fn 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}