Skip to main content

link_assistant_router/
cli.rs

1//! Command-line interface for the router.
2//!
3//! Issue #7 R3 mandates a `lino-arguments`-based CLI on top of clap. This
4//! module defines the subcommands and exposes a single [`Cli`] entry-point
5//! parsed by [`lino_arguments::Parser`] (which is a clap-compatible drop-in
6//! that additionally reads `.lenv` files at startup).
7//!
8//! The main subcommands serve HTTP, manage tokens and accounts, configure
9//! local agentic clients, and diagnose environment, OAuth, and storage state.
10
11// The CLI struct intentionally has many independent boolean toggles
12// (`--disable-openai-api`, `--disable-anthropic-api`, etc.). Refactoring
13// into enums would obscure the 1:1 mapping with the documented flags.
14#![allow(clippy::struct_excessive_bools)]
15
16use std::path::PathBuf;
17use std::time::Duration;
18
19use clap::builder::{PossibleValuesParser, TypedValueParser};
20use clap::{Subcommand, ValueEnum};
21use lino_arguments::Parser as LinoParser;
22
23use crate::config::{
24    ApiFormat, BuildArgs, Config, ConfigError, RoutingMode, StoragePolicy, UpstreamProvider,
25    default_activitypub_public_key_pem, default_data_dir,
26};
27use crate::subscription::SubscriptionProvider;
28
29mod auth_ops;
30mod client_ops;
31mod configure;
32mod deploy_args;
33mod store_ops;
34mod targets;
35mod value_parsers;
36mod with;
37
38pub use self::auth_ops::{AuthOp, AuthTarget, ImportProvider, ImportTarget, RemoteGh, TlsOp};
39pub use self::client_ops::ClientOp;
40pub use self::configure::ConfigureArgs;
41pub use self::deploy_args::{DeployArgs, UsageArgs};
42pub use self::store_ops::{AccountOp, ProviderOp, TokenOp};
43use self::value_parsers::parse_truthy;
44pub use self::with::{ServerOp, WithArgs, protect_client_arguments};
45
46/// Parse the CLI, hiding options that cannot affect the subcommand shown.
47///
48/// `with` and `configure` return before the server configuration is built, so
49/// none of the binary's ~28 global options — `--host`, `--port`,
50/// `--storage-policy`, `--upstream-base-url` and the rest — reaches them.
51/// Clap lists a global under every subcommand, so `with --help` advertised
52/// them as options of `with`: `--verbose` was accepted and produced no
53/// logging, and `--port` written after the client name went to the client
54/// (issue #312). Listing options that cannot work is worse than omitting them.
55///
56/// Only the *help* changes. A global still parses wherever it always did, so
57/// no existing invocation breaks.
58#[must_use]
59pub fn parse_arguments(arguments: Vec<std::ffi::OsString>) -> Cli {
60    use clap::{CommandFactory as _, FromArgMatches as _};
61
62    let mut command = Cli::command();
63    // Globals are declared on the root and propagated into every subcommand
64    // when the parser is built, so they can only be hidden before that — and
65    // only for the invocations they cannot affect. `router tokens list --help`
66    // still lists them, because there they work.
67    if names_a_client_launcher(&arguments) {
68        command = command.mut_args(|argument| {
69            if argument.is_global_set() {
70                argument.hide(true)
71            } else {
72                argument
73            }
74        });
75    }
76    // The usage strings that hide the globals are written with a `{name}`
77    // placeholder, because clap does not interpolate one there. Substituting
78    // the invoked name here keeps both properties at once: the error usage
79    // line still omits globals that are not required (issue #312), and it
80    // names the binary the reader actually ran rather than hardcoding `router`
81    // under both installed names (issue #315).
82    let invoked = arguments
83        .first()
84        .map(std::path::Path::new)
85        .and_then(std::path::Path::file_stem)
86        .map_or_else(
87            || "router".to_string(),
88            |name| name.to_string_lossy().into_owned(),
89        );
90    command = substitute_usage_name(command, &invoked);
91    let matches = command.get_matches_from(arguments);
92    Cli::from_arg_matches(&matches).unwrap_or_else(|error| error.exit())
93}
94
95/// The subcommands whose usage line is written out, and what follows the name.
96///
97/// Written out because clap's generated *error* usage lists every configured
98/// global as required (issue #312); the leading binary name is substituted at
99/// parse time rather than hardcoded, so it is the one the reader invoked
100/// (issue #315). One table, so the two rules cannot drift apart.
101const OVERRIDDEN_USAGE: [(&[&str], &str); 14] = [
102    (&["configure"], "configure [OPTIONS] <CLIENT>"),
103    (&["clients", "setup"], "clients setup [OPTIONS] <CLIENT>"),
104    (&["clients", "show"], "clients show [OPTIONS] <CLIENT>"),
105    (&["clients", "remove"], "clients remove [OPTIONS] <CLIENT>"),
106    (&["clients", "doctor"], "clients doctor [OPTIONS] <CLIENT>"),
107    (&["tokens", "rotate"], "tokens rotate [OPTIONS] <ID>"),
108    (&["tokens", "revoke"], "tokens revoke [OPTIONS] <ID>"),
109    (&["tokens", "show"], "tokens show [OPTIONS] <ID>"),
110    (
111        &["providers", "add"],
112        "providers add [OPTIONS] --name <NAME> --base-url <BASE_URL>",
113    ),
114    (&["providers", "show"], "providers show [OPTIONS] <NAME>"),
115    (
116        &["providers", "remove"],
117        "providers remove [OPTIONS] <NAME>",
118    ),
119    (
120        &["providers", "import"],
121        "providers import [OPTIONS] <PATH>",
122    ),
123    (
124        &["auth", "import"],
125        "auth import [OPTIONS] [PROVIDER] [DIR]",
126    ),
127    (&["auth", "clear"], "auth clear [OPTIONS] [PROVIDER]"),
128];
129
130/// Write each overridden usage line with the name that was actually invoked.
131fn substitute_usage_name(mut command: clap::Command, invoked: &str) -> clap::Command {
132    for (path, usage) in OVERRIDDEN_USAGE {
133        command = with_subcommand(command, path, &format!("{invoked} {usage}"));
134    }
135    command
136}
137
138/// Apply `usage` to the subcommand reached by `path`.
139fn with_subcommand(command: clap::Command, path: &[&str], usage: &str) -> clap::Command {
140    let Some((head, rest)) = path.split_first() else {
141        return command.override_usage(usage.to_string());
142    };
143    command.mut_subcommand(head, |subcommand| with_subcommand(subcommand, rest, usage))
144}
145
146/// Whether this invocation is one that returns before the server config exists.
147///
148/// Read off argv rather than the parsed command, because the decision has to be
149/// made before parsing. Only the first bare word is consulted, so a *value*
150/// that happens to be `with` cannot flip it.
151///
152/// `tls` joins them because it reads and writes one certificate directory and
153/// starts no server: `--port`, `--upstream-base-url` and `--routing-mode`
154/// cannot change what it does, and listing twenty such options above the three
155/// that matter is what issue #312 removed from `with` (issue #308).
156fn names_a_client_launcher(arguments: &[std::ffi::OsString]) -> bool {
157    arguments
158        .iter()
159        .skip(1)
160        .map(|argument| argument.to_string_lossy().into_owned())
161        .find(|argument| !argument.starts_with('-'))
162        .is_some_and(|argument| argument == "with" || argument == "configure" || argument == "tls")
163}
164
165/// Top-level CLI parser.
166#[derive(LinoParser)]
167// `router` is the canonical name — what the project, its repository and its
168// documentation call this tool (issue #222). It is pinned here rather than
169// taken from `argv[0]` so `--version` reads the same whichever of the two
170// installed names was invoked.
171#[command(
172    name = "router",
173    about = "Claude MAX OAuth proxy and token gateway for Anthropic APIs",
174    version
175)]
176pub struct Cli {
177    /// Subcommand to run. Defaults to `serve` when omitted.
178    #[command(subcommand)]
179    pub command: Option<Command>,
180
181    /// IP address or network alias to resolve once and bind (legacy --host).
182    #[arg(long, env = "ROUTER_HOST", default_value = "0.0.0.0", global = true)]
183    pub host: String,
184
185    /// Port to bind the HTTP server to.
186    #[arg(long, env = "ROUTER_PORT", default_value = "8080", global = true)]
187    pub port: u16,
188
189    /// Primary listener (`ADDR=combined|inference-only,http|tls`). When set,
190    /// these replace the legacy single listener and may be repeated.
191    #[arg(
192        long = "listener",
193        env = "LISTENERS",
194        value_delimiter = ';',
195        global = true,
196        value_name = "ADDR=KIND,TRANSPORT"
197    )]
198    pub listeners: Vec<String>,
199
200    /// Verbose logging.
201    #[arg(long, env = "VERBOSE", global = true, value_parser = parse_truthy)]
202    pub verbose: bool,
203
204    /// JWT signing secret (or `TOKEN_SECRET` env).
205    #[arg(long, env = "TOKEN_SECRET", global = true, hide_env_values = true)]
206    pub token_secret: Option<String>,
207
208    /// Claude Code home directory (primary account credentials).
209    #[arg(long, env = "CLAUDE_CODE_HOME", global = true)]
210    pub claude_code_home: Option<String>,
211
212    /// Upstream base URL.
213    #[arg(
214        long,
215        env = "UPSTREAM_BASE_URL",
216        default_value = "https://api.anthropic.com",
217        global = true
218    )]
219    pub upstream_base_url: String,
220
221    /// Restrict the proxy to a specific upstream API format.
222    #[arg(long, env = "UPSTREAM_API_FORMAT", global = true)]
223    pub api_format: Option<String>,
224
225    /// Routing mode: direct, cli, hybrid.
226    #[arg(long, env = "ROUTING_MODE", default_value = "direct", global = true)]
227    pub routing_mode: String,
228
229    /// Storage policy: memory, text, binary, both.
230    #[arg(long, env = "STORAGE_POLICY", default_value = "both", global = true)]
231    pub storage_policy: String,
232
233    /// Data directory for the persistent token store.
234    #[arg(long, env = "DATA_DIR", global = true)]
235    pub data_dir: Option<PathBuf>,
236
237    /// Treat this directory as the home for every client configuration root,
238    /// instead of `$HOME` and the clients' own override variables.
239    ///
240    /// Global, like `--data-dir`. Declared on the `clients` subcommand it had
241    /// to precede it — `clients list --home /tmp` was an error while
242    /// `clients --home /tmp list` worked, for one flag and not its neighbour
243    /// (issue #314).
244    #[arg(long, value_name = "DIR", global = true)]
245    pub home: Option<PathBuf>,
246
247    /// Path to the local Claude CLI binary used by the CLI backend.
248    #[arg(long, env = "CLAUDE_CLI_BIN", global = true)]
249    pub claude_cli_bin: Option<PathBuf>,
250    /// Path to the local Codex CLI binary used by credential recovery.
251    #[arg(long, env = "CODEX_CLI_BIN", global = true)]
252    pub codex_cli_bin: Option<PathBuf>,
253
254    /// Upstream provider: auto, anthropic, codex, gemini, qwen, gonka, crater,
255    /// or openai-compatible.
256    #[arg(long, env = "UPSTREAM_PROVIDER", default_value = "auto", global = true)]
257    pub upstream_provider: String,
258
259    /// Gonka direct-wallet key (unsupported; rejected before startup).
260    #[arg(long, env = "GONKA_PRIVATE_KEY", global = true, hide_env_values = true)]
261    pub gonka_private_key: Option<String>,
262    /// API key for a Gonka-compatible broker.
263    #[arg(long, env = "GONKA_API_KEY", global = true, hide_env_values = true)]
264    pub gonka_api_key: Option<String>,
265    /// Explicit Gonka-compatible broker URL.
266    #[arg(long, env = "GONKA_SOURCE_URL", global = true)]
267    pub gonka_source_url: Option<String>,
268
269    /// Optional Gonka model declared by the operator and used when omitted.
270    #[arg(long, env = "GONKA_MODEL", default_value = "", global = true)]
271    pub gonka_model: String,
272
273    /// Upstream model used when an Anthropic-dialect request is bridged to a
274    /// non-Anthropic upstream (e.g. Claude Code against the Codex provider).
275    #[arg(long, env = "ANTHROPIC_BRIDGE_MODEL", global = true)]
276    pub bridge_model: Option<String>,
277
278    /// How to pick a bridge model from the live catalog when `--bridge-model`
279    /// is unset: `first-advertised` (default) or `last-advertised`.
280    #[arg(long, env = "BRIDGE_MODEL_POLICY", global = true)]
281    pub bridge_model_policy: Option<String>,
282
283    /// Append one JSON line per authorised request to this file, recording the
284    /// router token id and label. Disabled when unset.
285    #[arg(long, env = "AUDIT_LOG", global = true)]
286    pub audit_log: Option<PathBuf>,
287
288    /// Redacted log of complete client and upstream exchanges, one record
289    /// per line in links notation. Defaults to `DATA_DIR/requests`.
290    #[arg(long, env = "REQUEST_LOG", global = true)]
291    pub request_log: Option<PathBuf>,
292
293    /// Maximum size of each token's request log; oldest complete records are
294    /// discarded. Applies per token, so the store's total is this bound times
295    /// the number of tokens with recorded traffic — cap that with
296    /// `--request-log-max-total-bytes`.
297    #[arg(
298        long,
299        env = "REQUEST_LOG_MAX_BYTES",
300        default_value_t = crate::request_log::DEFAULT_MAX_BYTES,
301        global = true
302    )]
303    pub request_log_max_bytes: u64,
304
305    /// Maximum size of the whole request log across every token; the least
306    /// recently written token directories are removed first. `0` disables the
307    /// total cap.
308    #[arg(
309        long,
310        env = "REQUEST_LOG_MAX_TOTAL_BYTES",
311        default_value_t = crate::request_log::DEFAULT_MAX_TOTAL_BYTES,
312        global = true
313    )]
314    pub request_log_max_total_bytes: u64,
315
316    /// Maximum request body accepted by proxy surfaces. Independent of the
317    /// request-log capture bound.
318    #[arg(
319        long,
320        env = "MAX_PROXY_REQUEST_BYTES",
321        default_value_t = crate::config::DEFAULT_MAX_PROXY_REQUEST_BYTES,
322        global = true
323    )]
324    pub max_proxy_request_bytes: usize,
325
326    /// Remote `ForgeFed` inbox for the crater provider.
327    #[arg(long, env = "CRATER_FORGEFED_INBOX", global = true)]
328    pub crater_forgefed_inbox: Option<String>,
329
330    /// Local actor URI used by the crater provider.
331    #[arg(long, env = "CRATER_FORGEFED_ACTOR", global = true)]
332    pub crater_forgefed_actor: Option<String>,
333
334    /// Remote ticket tracker or project URI used as the `ForgeFed` `Offer` target.
335    #[arg(long, env = "CRATER_FORGEFED_TARGET", global = true)]
336    pub crater_forgefed_target: Option<String>,
337
338    /// Delay between crater task-resolution polls.
339    #[arg(
340        long,
341        env = "CRATER_POLL_INTERVAL_MS",
342        default_value_t = 1000,
343        global = true
344    )]
345    pub crater_poll_interval_ms: u64,
346
347    /// Maximum seconds to wait for crater task resolution.
348    #[arg(
349        long,
350        env = "CRATER_POLL_TIMEOUT_SECS",
351        default_value_t = 120,
352        global = true
353    )]
354    pub crater_poll_timeout_secs: u64,
355
356    /// Stored provider name for generic OpenAI-compatible upstream routing.
357    #[arg(
358        long,
359        env = "OPENAI_COMPATIBLE_PROVIDER_NAME",
360        default_value = "litellm",
361        global = true
362    )]
363    pub openai_compatible_provider_name: String,
364
365    /// Generic OpenAI-compatible upstream API base URL, usually ending in /v1.
366    #[arg(
367        long,
368        env = "OPENAI_COMPATIBLE_BASE_URL",
369        default_value = "http://localhost:4000/v1",
370        global = true
371    )]
372    pub openai_compatible_base_url: String,
373
374    /// Generic OpenAI-compatible upstream API key. Prefer provider DB import
375    /// for long-lived deployments so the key is encrypted at rest.
376    #[arg(
377        long,
378        env = "OPENAI_COMPATIBLE_API_KEY",
379        global = true,
380        hide_env_values = true
381    )]
382    pub openai_compatible_api_key: Option<String>,
383
384    /// Environment variable that contains the OpenAI-compatible upstream key.
385    #[arg(long, env = "OPENAI_COMPATIBLE_API_KEY_ENV", global = true)]
386    pub openai_compatible_api_key_env: Option<String>,
387
388    /// Default model for OpenAI-compatible upstream requests without `model`.
389    #[arg(long, env = "OPENAI_COMPATIBLE_MODEL", global = true)]
390    pub openai_compatible_model: Option<String>,
391
392    /// Comma-separated models exposed for the OpenAI-compatible provider.
393    #[arg(
394        long,
395        env = "OPENAI_COMPATIBLE_MODELS",
396        value_delimiter = ',',
397        global = true
398    )]
399    pub openai_compatible_models: Vec<String>,
400
401    /// Canonical managed clients supported by this provider adapter.
402    #[arg(
403        long,
404        env = "OPENAI_COMPATIBLE_SUPPORTED_CLIENTS",
405        value_delimiter = ',',
406        global = true
407    )]
408    pub openai_compatible_supported_clients: Vec<String>,
409
410    /// Public base URL for the `ActivityPub` actor.
411    #[arg(long, env = "ACTIVITYPUB_ACTOR_BASE_URL", global = true)]
412    pub activitypub_actor_base_url: Option<String>,
413
414    /// Public key PEM advertised by the `ActivityPub` actor.
415    #[arg(long, env = "ACTIVITYPUB_PUBLIC_KEY_PEM", global = true)]
416    pub activitypub_public_key_pem: Option<String>,
417
418    /// Disable the OpenAI-compatible API surface.
419    #[arg(
420        long,
421        env = "DISABLE_OPENAI_API",
422        global = true,
423        value_parser = parse_truthy
424    )]
425    pub disable_openai_api: bool,
426
427    /// Disable the Anthropic (direct) proxy surface.
428    #[arg(
429        long,
430        env = "DISABLE_ANTHROPIC_API",
431        global = true,
432        value_parser = parse_truthy
433    )]
434    pub disable_anthropic_api: bool,
435
436    /// Disable `/api/management/metrics`, `/api/management/usage` and
437    /// `/api/management/accounts` endpoints.
438    #[arg(
439        long,
440        env = "DISABLE_METRICS",
441        global = true,
442        value_parser = parse_truthy
443    )]
444    pub disable_metrics: bool,
445
446    /// Expose only neutral health and AI inference/catalog routes on the main
447    /// listener. Management, GitHub/Git, and `ActivityPub` routes are omitted.
448    #[arg(
449        long,
450        env = "INFERENCE_ONLY",
451        global = true,
452        num_args = 0..=1,
453        default_value_t = false,
454        default_missing_value = "true",
455        value_parser = parse_truthy
456    )]
457    pub inference_only: bool,
458
459    /// Comma-separated list of additional account credential directories.
460    #[arg(
461        long,
462        env = "ADDITIONAL_ACCOUNT_DIRS",
463        value_delimiter = ',',
464        global = true
465    )]
466    pub additional_account_dirs: Vec<PathBuf>,
467
468    /// New-session account policy: round-robin, fill-first, or least-used.
469    #[arg(
470        long,
471        env = "ACCOUNT_ROUTING_STRATEGY",
472        default_value = "round-robin",
473        global = true
474    )]
475    pub account_routing_strategy: String,
476
477    /// Default seconds to cool an account after a quota response.
478    #[arg(
479        long,
480        env = "ACCOUNT_COOLDOWN_SECS",
481        default_value_t = 60,
482        global = true
483    )]
484    pub account_cooldown_secs: u64,
485
486    /// Seconds an inactive conversation remains on its selected account.
487    #[arg(
488        long,
489        env = "SESSION_AFFINITY_TTL_SECS",
490        default_value_t = 3600,
491        global = true
492    )]
493    pub session_affinity_ttl_secs: u64,
494
495    /// Per-account request caps (primary first); zero means unknown/unlimited.
496    #[arg(
497        long,
498        env = "ACCOUNT_REQUEST_LIMITS",
499        value_delimiter = ',',
500        global = true
501    )]
502    pub account_request_limits: Vec<usize>,
503
504    /// Enable experimental compatibility shims (XML history, spoofing, …).
505    #[arg(
506        long,
507        env = "EXPERIMENTAL_COMPATIBILITY",
508        global = true,
509        value_parser = parse_truthy
510    )]
511    pub experimental_compatibility: bool,
512
513    /// Risk-accept one exact consumer-subscription bridge (CLIENT:PROVIDER).
514    #[arg(
515        long = "allow-subscription-bridge",
516        env = "SUBSCRIPTION_BRIDGE_OVERRIDES",
517        value_delimiter = ',',
518        global = true
519    )]
520    pub subscription_bridge_overrides: Vec<String>,
521    /// Trust a reviewed proxy fingerprint for a native client identity.
522    #[arg(
523        long = "allow-proxied-client",
524        env = "PROXIED_CLIENT_OVERRIDES",
525        value_delimiter = ',',
526        global = true
527    )]
528    pub proxied_client_overrides: Vec<String>,
529    /// Flat bootstrap Bearer key accepted alongside admin-scoped tokens.
530    #[arg(long, env = "TOKEN_ADMIN_KEY", global = true, hide_env_values = true)]
531    pub admin_key: Option<String>,
532    /// Admin UI listener port; omitted or `0` keeps it disabled.
533    #[arg(long, env = "ADMIN_PORT", global = true)]
534    pub admin_port: Option<u16>,
535
536    /// Address the admin UI binds to. Loopback by default so binding the proxy
537    /// to `0.0.0.0` does not publish the UI as a side effect.
538    #[arg(long, env = "ADMIN_HOST", default_value = "127.0.0.1", global = true)]
539    pub admin_host: String,
540
541    /// How long an unconfirmed first-visitor admin claim stays valid.
542    #[arg(
543        long,
544        env = "ADMIN_CLAIM_TTL_SECS",
545        default_value_t = crate::admin::DEFAULT_CANDIDATE_TTL_SECS,
546        global = true
547    )]
548    pub admin_claim_ttl_secs: u64,
549
550    /// Leave the admin endpoints (`/api/management/tokens*`,
551    /// `/api/management/providers*`, `/api/management/login*`) open to
552    /// unauthenticated callers.
553    ///
554    /// Off by default. Without it, a deployment that configures no admin
555    /// credential mints a one-off admin token at startup and prints it once.
556    ///
557    /// Accepted as a bare flag, and from the environment as `1`/`0`,
558    /// `true`/`false`, `yes`/`no` or `on`/`off` — clap's plain `bool` would
559    /// reject the `=1` spelling every other switch in the deployment docs uses.
560    #[arg(
561        long,
562        env = "ALLOW_ANONYMOUS_ADMIN",
563        global = true,
564        num_args = 0..=1,
565        default_value_t = false,
566        default_missing_value = "true",
567        value_parser = parse_truthy
568    )]
569    pub allow_anonymous_admin: bool,
570
571    /// Telegram Bot API token. Unset keeps the Telegram admin channel off;
572    /// setting it starts an outbound long-polling bot that accepts admin
573    /// commands in private chats only.
574    #[arg(
575        long,
576        env = "TELEGRAM_BOT_TOKEN",
577        global = true,
578        hide_env_values = true
579    )]
580    pub telegram_bot_token: Option<String>,
581
582    /// VK community access token. Unset keeps the VK admin channel off.
583    #[arg(long, env = "VK_BOT_TOKEN", global = true, hide_env_values = true)]
584    pub vk_bot_token: Option<String>,
585
586    /// VK community id the bot token belongs to; required alongside
587    /// `--vk-bot-token` because VK long polling addresses a community.
588    #[arg(long, env = "VK_GROUP_ID", global = true)]
589    pub vk_group_id: Option<u64>,
590
591    /// How long a chat message carrying a secret survives before the bot
592    /// deletes it. Zero keeps secrets in the chat history.
593    #[arg(
594        long,
595        env = "CHAT_ADMIN_SECRET_TTL_SECS",
596        default_value_t = crate::chat_admin::DEFAULT_SECRET_TTL_SECS,
597        global = true
598    )]
599    pub chat_admin_secret_ttl_secs: u64,
600
601    /// Sensitive chat commands (`/start`, credential presentation, issuance)
602    /// allowed per user per minute. Zero disables the limit.
603    #[arg(
604        long,
605        env = "CHAT_ADMIN_RATE_LIMIT_PER_MINUTE",
606        default_value_t = crate::chat_admin::DEFAULT_RATE_LIMIT_PER_MINUTE,
607        global = true
608    )]
609    pub chat_admin_rate_limit_per_minute: u32,
610
611    /// Enable MPP 402 charge challenges on OpenAI-compatible endpoints.
612    #[arg(long, env = "MPP_ENABLE", global = true, value_parser = parse_truthy)]
613    pub mpp_enable: bool,
614
615    /// Per-request MPP charge amount for OpenAI-compatible endpoints.
616    #[arg(long, env = "MPP_AMOUNT", default_value = "0.00", global = true)]
617    pub mpp_amount: String,
618
619    /// Currency or asset for MPP `OpenAI` endpoint charges.
620    #[arg(long, env = "MPP_CURRENCY", default_value = "USD", global = true)]
621    pub mpp_currency: String,
622
623    /// Recipient wallet, merchant account, or payment address for MPP charges.
624    #[arg(long, env = "MPP_RECIPIENT", global = true)]
625    pub mpp_recipient: Option<String>,
626
627    /// Optional MPP payment method identifier, such as tempo or stripe.
628    #[arg(long, env = "MPP_METHOD", global = true)]
629    pub mpp_method: Option<String>,
630
631    /// Disable the interactive login API (`/api/management/login`).
632    #[arg(
633        long,
634        env = "DISABLE_LOGIN_API",
635        global = true,
636        value_parser = parse_truthy
637    )]
638    pub disable_login_api: bool,
639
640    /// Program the login API drives on a PTY.
641    #[arg(
642        long,
643        env = "LOGIN_CLI_COMMAND",
644        default_value = "claude",
645        global = true
646    )]
647    pub login_cli_command: String,
648
649    /// Arguments passed to the login program.
650    #[arg(long, env = "LOGIN_CLI_ARGS", value_delimiter = ',', global = true)]
651    pub login_cli_args: Vec<String>,
652
653    /// How long a pending login stays valid while waiting for the human.
654    #[arg(
655        long,
656        env = "LOGIN_SESSION_TTL_SECS",
657        default_value = "900",
658        global = true
659    )]
660    pub login_session_ttl_secs: u64,
661
662    /// Maximum number of simultaneously pending logins.
663    #[arg(long, env = "LOGIN_MAX_SESSIONS", default_value = "4", global = true)]
664    pub login_max_sessions: usize,
665}
666
667/// Subcommands.
668#[derive(Debug, Subcommand)]
669pub enum Command {
670    /// Start the HTTP server (default if no subcommand given).
671    Serve,
672    /// Token-management subcommands.
673    ///
674    /// Acts on the selected server when there is one: the deployment answers
675    /// these over its admin API, so managing a remote router's tokens no
676    /// longer means `ssh` or a hand-written `curl` (issues #293, #294).
677    Tokens {
678        #[command(subcommand)]
679        op: TokenOp,
680    },
681    /// Account-management subcommands.
682    ///
683    /// Acts on the selected server when there is one (issue #294).
684    Accounts {
685        #[command(subcommand)]
686        op: AccountOp,
687    },
688    /// Provider-management subcommands.
689    ///
690    /// Acts on the selected server when there is one (issue #294).
691    Providers {
692        #[command(subcommand)]
693        op: ProviderOp,
694    },
695    /// Inspect and manage local agentic CLI configuration.
696    ///
697    /// `router configure <client>` is the command for pointing a client at the
698    /// router; these read and remove what is there (issue #296).
699    Clients {
700        #[command(subcommand)]
701        op: ClientOp,
702    },
703    /// Launch an agentic CLI against this router with a safe client profile.
704    ///
705    /// Claude defaults to a persistent Router-owned profile; other clients keep
706    /// their documented extension or profile behavior (issue #536).
707    ///
708    /// Everything after the client name is passed to the client verbatim;
709    /// router options go before it (issue #299).
710    With(WithArgs),
711    /// Point a client at the router permanently.
712    ///
713    /// One name, one targeting rule and one reversal for what used to be two
714    /// commands that disagreed on the address, the credential, the undo
715    /// mechanism and the client list (issue #296). `clients setup` and
716    /// `with --global` still work.
717    Configure(ConfigureArgs),
718    /// Select and manage the server used by `with`.
719    Server {
720        #[command(subcommand)]
721        op: ServerOp,
722    },
723    /// Obtain or inspect vendor subscription credentials.
724    Auth {
725        #[command(subcommand)]
726        op: AuthOp,
727    },
728    /// Show remaining limits for subscriptions available to a client token.
729    Usage(UsageArgs),
730    /// Bring a containerised Router up locally, ready for `router with`.
731    ///
732    /// Converges rather than runs: each step first checks whether the desired
733    /// state already holds, reports what it found, and only then acts, so
734    /// re-running is cheap and safe and a converged deployment changes nothing
735    /// (issue #570). Acts on the machine it runs on.
736    Deploy(DeployArgs),
737    /// Print environment + config diagnostics.
738    ///
739    /// Reports on the machine it runs on, so it stays local: the files, config
740    /// and credentials it inspects are this machine's. With another router
741    /// selected it says so and names it rather than describing local state as
742    /// though it were the target (issue #294).
743    Doctor {
744        #[command(flatten)]
745        target: AuthTarget,
746    },
747    /// TLS certificate management for a self-signed deployment.
748    Tls {
749        #[command(subcommand)]
750        op: TlsOp,
751    },
752    /// Summarise the request log and flag anomalies.
753    ///
754    /// The log is the router's only record of what actually happened, and it
755    /// had to be read with one-liners invented on the spot — which produced
756    /// confident wrong answers in both directions (issue #234).
757    Logs {
758        #[command(subcommand)]
759        op: LogsOp,
760    },
761}
762
763/// What to ask of the request log.
764#[derive(Debug, Subcommand)]
765pub enum LogsOp {
766    /// Shape of the log: exchanges, records, statuses, time span, size.
767    Summary {
768        /// Restrict to one token's log directory, by its hashed name.
769        ///
770        /// Named `--token-id` because `--token` means a credential in `with`,
771        /// `server use` and `clients setup`, and one flag name meaning two
772        /// things is what makes a CLI unusable from memory (issue #314). The
773        /// old spelling is still accepted.
774        #[arg(long = "token-id", alias = "token", value_name = "HASHED_NAME")]
775        token: Option<String>,
776        /// Emit JSON, for a monitoring check rather than a human.
777        #[arg(long)]
778        json: bool,
779        #[command(flatten)]
780        target: AuthTarget,
781    },
782    /// Anomalies worth a name, with the correlation ids to inspect.
783    ///
784    /// Exits non-zero when any are found, so it works as a health gate.
785    Anomalies {
786        /// Restrict to one token's log directory, by its hashed name.
787        #[arg(long = "token-id", alias = "token", value_name = "HASHED_NAME")]
788        token: Option<String>,
789        /// Emit JSON, for a monitoring check rather than a human.
790        #[arg(long)]
791        json: bool,
792        #[command(flatten)]
793        target: AuthTarget,
794    },
795    /// One exchange, decoded and in order.
796    Show {
797        correlation_id: String,
798        /// Restrict to one token's log directory, by its hashed name.
799        #[arg(long = "token-id", alias = "token", value_name = "HASHED_NAME")]
800        token: Option<String>,
801        #[command(flatten)]
802        target: AuthTarget,
803    },
804}
805
806/// Authorization-flow override. `auto` selects the provider's supported flow.
807#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)]
808pub enum AuthFlow {
809    /// Select the best supported flow automatically.
810    #[default]
811    Auto,
812    /// OAuth device authorization (only when advertised by the provider).
813    Device,
814    /// Copy/paste authorization code flow.
815    Code,
816    /// Local OAuth callback listener.
817    Loopback,
818    /// Disposable vendor CLI compatibility flow.
819    Cli,
820}
821
822/// OAuth flows implemented by the Claude authorization command.
823pub const CLAUDE_AUTH_FLOWS: [AuthFlow; 3] = [AuthFlow::Auto, AuthFlow::Code, AuthFlow::Cli];
824
825/// OAuth flows implemented by the Codex authorization command.
826pub const CODEX_AUTH_FLOWS: [AuthFlow; 3] = [AuthFlow::Auto, AuthFlow::Device, AuthFlow::Loopback];
827
828fn auth_flow_parser(flows: &'static [AuthFlow]) -> impl TypedValueParser<Value = AuthFlow> {
829    PossibleValuesParser::new(flows.iter().filter_map(ValueEnum::to_possible_value)).map(|value| {
830        AuthFlow::from_str(&value, false)
831            .unwrap_or_else(|_| unreachable!("possible-values parser returned an unknown flow"))
832    })
833}
834
835impl Cli {
836    /// Build a [`Config`] from the parsed CLI / env / `.lenv` values.
837    pub fn into_config(&self) -> Result<Config, ConfigError> {
838        let port = self.port.to_string();
839        let token_secret = self.token_secret.clone();
840        let process_home = std::env::var_os("HOME")
841            .filter(|home| !home.is_empty())
842            .map_or_else(|| PathBuf::from("/root"), PathBuf::from);
843        let client_home = self.home.clone().unwrap_or_else(|| process_home.clone());
844        let claude_home = self.claude_code_home.clone().unwrap_or_else(|| {
845            client_home
846                .join(SubscriptionProvider::Claude.home_subdir())
847                .to_string_lossy()
848                .into_owned()
849        });
850        let codex_home = if self.home.is_some() {
851            client_home.join(SubscriptionProvider::Codex.home_subdir())
852        } else {
853            SubscriptionProvider::Codex.resolve_home(&process_home.to_string_lossy())
854        };
855        let api_format = self
856            .api_format
857            .as_deref()
858            .map(|value| ApiFormat::from_str_opt(value).ok_or(ConfigError::InvalidApiFormat))
859            .transpose()?;
860        let routing_mode =
861            RoutingMode::from_str_opt(&self.routing_mode).ok_or(ConfigError::InvalidRoutingMode)?;
862        let upstream_provider = UpstreamProvider::from_str_opt(&self.upstream_provider)
863            .ok_or(ConfigError::InvalidUpstreamProvider)?;
864        let storage_policy = StoragePolicy::from_str_opt(&self.storage_policy)
865            .ok_or(ConfigError::InvalidStoragePolicy)?;
866        let account_routing_strategy =
867            crate::accounts::SelectionStrategy::from_str_opt(&self.account_routing_strategy)
868                .ok_or(ConfigError::InvalidAccountRoutingStrategy)?;
869        let data_dir = self.data_dir.clone().unwrap_or_else(default_data_dir);
870        let activitypub_actor_base_url = self
871            .activitypub_actor_base_url
872            .clone()
873            .unwrap_or_else(|| format!("http://{}:{}", self.host, self.port));
874        let crater_actor = self
875            .crater_forgefed_actor
876            .clone()
877            .filter(|value| !value.is_empty())
878            .unwrap_or_else(|| {
879                format!(
880                    "{}/api/services/activitypub/actor/code",
881                    activitypub_actor_base_url.trim_end_matches('/')
882                )
883            });
884        let crater = crate::crater::CraterConfig::new(
885            self.crater_forgefed_inbox
886                .clone()
887                .filter(|value| !value.is_empty()),
888            &crater_actor,
889            self.crater_forgefed_target
890                .clone()
891                .filter(|value| !value.is_empty()),
892            Duration::from_millis(self.crater_poll_interval_ms),
893            Duration::from_secs(self.crater_poll_timeout_secs),
894        );
895        let activitypub_public_key_pem = self
896            .activitypub_public_key_pem
897            .clone()
898            .unwrap_or_else(default_activitypub_public_key_pem);
899        let openai_compatible = crate::providers::OpenAICompatibleConfig {
900            provider_name: self.openai_compatible_provider_name.clone(),
901            base_url: self.openai_compatible_base_url.clone(),
902            api_key: self
903                .openai_compatible_api_key
904                .clone()
905                .filter(|s| !s.is_empty()),
906            api_key_env: self
907                .openai_compatible_api_key_env
908                .clone()
909                .filter(|s| !s.is_empty()),
910            default_model: self
911                .openai_compatible_model
912                .clone()
913                .filter(|s| !s.is_empty()),
914            models: self.openai_compatible_models.clone(),
915            supported_clients: self.openai_compatible_supported_clients.clone(),
916        };
917        let mut config = Config::build(BuildArgs {
918            host: &self.host,
919            port: &port,
920            token_secret: token_secret.as_deref(),
921            claude_code_home: &claude_home,
922            upstream_base_url: &self.upstream_base_url,
923            verbose: self.verbose,
924            max_proxy_request_bytes: self.max_proxy_request_bytes,
925            api_format,
926            routing_mode,
927            storage_policy,
928            data_dir,
929            claude_cli_bin: self.claude_cli_bin.clone(),
930            codex_cli_bin: self.codex_cli_bin.clone(),
931            upstream_provider,
932            gonka_private_key: self.gonka_private_key.clone().filter(|s| !s.is_empty()),
933            gonka_api_key: self.gonka_api_key.clone().filter(|s| !s.is_empty()),
934            gonka_source_url: self.gonka_source_url.clone().filter(|s| !s.is_empty()),
935            gonka_model: self.gonka_model.clone(),
936            bridge_model: self.bridge_model.clone().filter(|s| !s.is_empty()),
937            bridge_model_policy: self.bridge_model_policy.clone(),
938            audit_log: self
939                .audit_log
940                .as_ref()
941                .map(|p| p.to_string_lossy().into_owned())
942                .filter(|s| !s.is_empty()),
943            crater,
944            openai_compatible,
945            activitypub_actor_base_url,
946            activitypub_public_key_pem,
947            enable_openai_api: !self.disable_openai_api,
948            enable_anthropic_api: !self.disable_anthropic_api,
949            enable_metrics: !self.disable_metrics,
950            inference_only: self.inference_only,
951            listeners: self.listeners.clone(),
952            additional_account_dirs: self.additional_account_dirs.clone(),
953            account_routing_strategy,
954            account_cooldown_secs: self.account_cooldown_secs,
955            session_affinity_ttl_secs: self.session_affinity_ttl_secs,
956            account_request_limits: self.account_request_limits.clone(),
957            experimental_compatibility: self.experimental_compatibility,
958            subscription_bridge_overrides: self.subscription_bridge_overrides.clone(),
959            proxied_client_overrides: self.proxied_client_overrides.clone(),
960            admin_key: self.admin_key.clone().filter(|s| !s.is_empty()),
961            admin_ui: crate::config::admin_ui_config(
962                self.admin_port,
963                &self.admin_host,
964                self.admin_claim_ttl_secs,
965            )?,
966            allow_anonymous_admin: self.allow_anonymous_admin,
967            chat_admin: crate::config::chat_admin_config(
968                self.telegram_bot_token.clone(),
969                self.vk_bot_token.clone(),
970                self.vk_group_id,
971                self.chat_admin_secret_ttl_secs,
972                u64::from(self.chat_admin_rate_limit_per_minute),
973            ),
974            login: crate::login::LoginConfig {
975                enabled: !self.disable_login_api,
976                command: self.login_cli_command.clone(),
977                args: self.login_cli_args.clone(),
978                session_ttl: Duration::from_secs(self.login_session_ttl_secs),
979                max_sessions: self.login_max_sessions,
980                codex_home,
981                ..crate::login::LoginConfig::default()
982            },
983            mpp: crate::mpp::MppConfig {
984                enabled: self.mpp_enable,
985                amount: self.mpp_amount.clone(),
986                currency: self.mpp_currency.clone(),
987                recipient: self.mpp_recipient.clone().unwrap_or_default(),
988                method: self.mpp_method.clone().filter(|s| !s.is_empty()),
989            },
990        })?;
991        config.client_home = client_home;
992        config.isolated_client_home = self.home.is_some();
993        Ok(config)
994    }
995}
996
997#[cfg(test)]
998#[path = "cli_tests.rs"]
999mod tests;