Skip to main content

link_assistant_router/cli/
with.rs

1//! Temporary client launcher and managed-server CLI arguments.
2
3use std::ffi::OsString;
4
5use clap::{Args, Subcommand};
6
7use crate::clients::ClientKind;
8
9/// Insert the client-argument boundary, and say when a name changed hands.
10///
11/// One rule, holdable in the head: **everything after the client name belongs
12/// to the client, verbatim.** Router options go before it.
13///
14/// What this replaces was a list of twelve names claimed back from the client
15/// wherever they appeared, and the list was neither complete nor derivable
16/// from anything a user could see: `--port`, `--host`, `--data-dir` and
17/// `--managed` are router options and reached the client, while `--model`,
18/// `--token`, `--server` and `--interactive` are router options and did not.
19/// Both directions failed silently — `claude --model opus[1m]` was intercepted
20/// and validated against the router's catalog, and `with claude --managed`
21/// started nothing (issue #299).
22///
23/// The boundary is the first bare word that names a client, found by skipping
24/// options and, for options that take one, their values — taken from the
25/// parser itself rather than a hand-kept list, so a value that happens to
26/// equal a client name (`--data-dir agent`) no longer shifts the split.
27///
28/// `--` is still accepted and still consumed, for anyone who prefers to write
29/// the boundary out.
30#[must_use]
31pub fn protect_client_arguments(arguments: Vec<OsString>, nested: bool) -> Vec<OsString> {
32    let start = if nested {
33        arguments
34            .iter()
35            .position(|argument| argument == "with")
36            .map_or(arguments.len(), |position| position + 1)
37    } else {
38        1
39    };
40    let options = wrapper_options();
41    let clients: Vec<&str> = ClientKind::ALL
42        .iter()
43        .flat_map(|kind| [kind.canonical_name(), kind.legacy_name()])
44        .collect();
45    let mut position = start;
46    while position < arguments.len() {
47        let value = arguments[position].to_string_lossy().into_owned();
48        if value == "--" {
49            // An explicit boundary the user wrote: the client name follows it.
50            position += 1;
51            continue;
52        }
53        if value.starts_with('-') {
54            let name = value
55                .split_once('=')
56                .map_or(value.as_str(), |(name, _)| name);
57            let takes_a_value = !value.contains('=') && options.contains(&(name.to_string(), true));
58            position += if takes_a_value { 2 } else { 1 };
59            continue;
60        }
61        if !clients.contains(&value.as_str()) {
62            position += 1;
63            continue;
64        }
65        let mut normalized = arguments[..=position].to_vec();
66        let forwarded = &arguments[position + 1..];
67        if forwarded.is_empty() {
68            return normalized;
69        }
70        let explicit = forwarded.first().is_some_and(|argument| argument == "--");
71        let forwarded = if explicit { &forwarded[1..] } else { forwarded };
72        if !explicit
73            && matches!(value.as_str(), "claude" | "claude-code")
74            && forwarded
75                .first()
76                .is_some_and(|argument| argument == "--reset-to-default-configuration")
77        {
78            let mut normalized = arguments[..position].to_vec();
79            normalized.push("--reset-to-default-configuration".into());
80            normalized.push(arguments[position].clone());
81            if forwarded.len() > 1 {
82                normalized.push("--".into());
83                normalized.extend(forwarded[1..].iter().cloned());
84            }
85            return normalized;
86        }
87        // The forward itself is silent. Narrating a rule that behaved exactly
88        // as documented turned a settled design decision (issue #299) into a
89        // recurring interruption printed into the client's own terminal, once
90        // per matching flag, with no way for a reader to say "yes, I know".
91        // `with --help` documents the boundary, and `--` states it explicitly
92        // for anyone who wants it stated (issue #330).
93        normalized.push("--".into());
94        normalized.extend(forwarded.iter().cloned());
95        return normalized;
96    }
97    arguments
98}
99
100/// Every option `with` itself accepts, and whether it takes a value.
101///
102/// Read off the parser rather than written down beside it: a hand-kept copy is
103/// what made the split undiscoverable and incomplete, and it drifts every time
104/// an option is added (issue #299).
105fn wrapper_options() -> std::collections::HashSet<(String, bool)> {
106    use clap::CommandFactory as _;
107
108    let mut options = std::collections::HashSet::new();
109    let mut collect = |command: &clap::Command| {
110        for argument in command.get_arguments() {
111            // A flag declares zero values; anything else takes one. Clap
112            // leaves `num_args` unset for both, so the switch is identified by
113            // its `ArgAction`, which is what actually decides.
114            let takes_a_value = argument.get_num_args().map_or_else(
115                || {
116                    !matches!(
117                        argument.get_action(),
118                        clap::ArgAction::SetTrue
119                            | clap::ArgAction::SetFalse
120                            | clap::ArgAction::Count
121                            | clap::ArgAction::Help
122                            | clap::ArgAction::Version
123                    )
124                },
125                |range| range.takes_values(),
126            );
127            if let Some(long) = argument.get_long() {
128                options.insert((format!("--{long}"), takes_a_value));
129            }
130            for alias in argument.get_all_aliases().unwrap_or_default() {
131                options.insert((format!("--{alias}"), takes_a_value));
132            }
133            if let Some(short) = argument.get_short() {
134                options.insert((format!("-{short}"), takes_a_value));
135            }
136        }
137    };
138    let root = crate::cli::Cli::command();
139    collect(&root);
140    if let Some(with) = root.find_subcommand("with") {
141        collect(with);
142    }
143    options
144}
145
146/// Options shared by `router with` and the standalone `with-router` binary.
147#[derive(Clone, Debug, Args)]
148#[command(trailing_var_arg = true)]
149pub struct WithArgs {
150    /// Permanently configure the client instead of launching it temporarily.
151    #[arg(long)]
152    pub global: bool,
153    /// Restore the exact configuration saved by a previous `--global` call.
154    #[arg(long, requires = "global")]
155    pub undo: bool,
156    /// Force the client's non-interactive/one-shot mode.
157    ///
158    /// By default a bare positional is read as a prompt and starts a one-shot
159    /// run, a flag is read as an option passed to a session, and streams that
160    /// are not a terminal are one-shot. Reading *any* forwarded argument as a
161    /// task turned `--resume`, `--continue` and `--verbose` into batch runs
162    /// (issue #297).
163    #[arg(long, conflicts_with = "interactive")]
164    pub non_interactive: bool,
165    /// Force the client's interactive mode.
166    #[arg(long, conflicts_with = "non_interactive")]
167    pub interactive: bool,
168    /// Extend the client's normal user configuration for this launch.
169    ///
170    /// Claude uses a dedicated persistent Router-owned profile by default.
171    /// This explicitly restores the older process-overlay behavior, without
172    /// writing the user's Claude files. Other extensible clients already use
173    /// their normal configuration and accept this for compatibility (#536).
174    #[arg(long)]
175    pub extend_global_config: bool,
176    /// Give the client a configuration directory of its own.
177    ///
178    /// Claude uses a persistent profile owned by Router by default, so
179    /// onboarding happens once and Router sessions remain resumable without
180    /// inheriting the user's theme, permissions, MCP servers, credentials, or
181    /// model cache. Other clients retain their existing profile behavior.
182    ///
183    /// `--extend-global-config` explicitly layers Claude routing onto the real
184    /// profile instead. Nothing the user owns is written or modified.
185    ///
186    /// Isolation remains right for CI and clean-room reproductions, where
187    /// passing a flag is normal and cheap.
188    ///
189    /// It is a no-op for `codex`, `gemini`, `opencode` and `agent`: those are
190    /// routed through a file the router writes, so they never use the user's
191    /// own directory with or without it. The run says so rather than accepting
192    /// the flag silently (issue #312). What they get instead is a profile of
193    /// their own that persists between runs, so sessions stay resumable
194    /// (issue #298); `--isolated-config` makes that profile disposable.
195    #[arg(long, conflicts_with = "extend_global_config")]
196    pub isolated_config: bool,
197    /// Replace only the persistent Router-owned Claude profile before launch.
198    #[arg(long, conflicts_with_all = ["global", "undo", "isolated_config", "extend_global_config"])]
199    pub reset_to_default_configuration: bool,
200    /// Confirm a requested profile reset without an interactive prompt.
201    #[arg(long, requires = "reset_to_default_configuration")]
202    pub yes: bool,
203    /// Start a disposable managed container even if a router is already
204    /// listening locally.
205    ///
206    /// The default reuses a running local router (issue #250); CI and
207    /// clean-room reproductions want a fresh instance on purpose.
208    #[arg(long, conflicts_with_all = ["server", "management_server"])]
209    pub managed: bool,
210    /// Router origin. No local server is started when this is supplied.
211    #[arg(long)]
212    pub server: Option<String>,
213    /// Private management origin when it differs from `--server`.
214    #[arg(long, value_name = "URL", conflicts_with = "local")]
215    pub management_server: Option<String>,
216    /// Use the router running on this machine, not the selected one.
217    ///
218    /// `with` had `--server` and `--managed` but not `--local`, so it carried
219    /// half the target vocabulary every other family has (issue #314).
220    #[arg(long, conflicts_with_all = ["server", "management_server", "managed"])]
221    pub local: bool,
222    /// Router token. Prefer the environment or `--token-stdin` to shell history.
223    #[arg(long, hide_env_values = true, conflicts_with = "token_stdin")]
224    pub token: Option<String>,
225    /// Read the router token as one line from standard input.
226    #[arg(long, conflicts_with = "token")]
227    pub token_stdin: bool,
228    /// Model the client is launched with.
229    ///
230    /// Without this the client keeps the model its own configuration selects,
231    /// and `with` changes only how that model is reached — the same rule
232    /// `--global` follows. A router that picked one by catalog order replaced
233    /// the user's choice silently, and the client's status line then presented
234    /// the substitution as though the user had made it (issue #295).
235    ///
236    /// A client whose configuration embeds the router's catalog — `opencode`,
237    /// `qwen`, `agent` — is always given an id, because it cannot start
238    /// without one.
239    #[arg(long)]
240    pub model: Option<String>,
241    /// Let the router choose a model from the target's live catalog.
242    ///
243    /// It reports what it picked and why. Without this no model is named and
244    /// the client's own configuration decides.
245    #[arg(long, conflicts_with = "model")]
246    pub pick_model: bool,
247    /// Label recorded on the router for this run's token.
248    ///
249    /// Each run mints a token on the target and the label is stored there. It
250    /// defaults to the client name and a short suffix; it used to be the name
251    /// of the directory the run was launched from, so a deployment
252    /// accumulated a list of the projects its users work in (issue #316).
253    /// Anything sent to a router someone else operates should be something you
254    /// chose to send.
255    #[arg(long)]
256    pub label: Option<String>,
257    /// Lifetime of an automatically minted per-run token, in hours.
258    ///
259    /// `--ttl-hours` is accepted too, so the name matches `tokens issue`,
260    /// `tokens rotate` and `configure` (issue #314).
261    ///
262    /// Defaults to a day because this token is revoked when the client exits:
263    /// the run already bounds its life, and the clock was a second bound that
264    /// could only fire early. At one hour it routinely did — an interactive
265    /// session that outlived the hour died mid-work with `401 Token has
266    /// expired`, and the client answered with its own `/login` advice about
267    /// an unrelated credential (issue #341).
268    #[arg(long, alias = "ttl-hours", default_value_t = 24 * 7)]
269    pub run_ttl_hours: i64,
270    /// Keep a fixed expiry instead of extending it while the run is in use.
271    ///
272    /// By default the per-run token's expiry slides: every request served
273    /// with it pushes the expiry to `now + --run-ttl-hours`, so a session
274    /// that is still being used never hits the wall, and one abandoned for
275    /// longer than the window still expires. That is what the bound is for --
276    /// the run's own life already bounds the token, since it is revoked when
277    /// the client exits, and a fixed clock could only ever fire early
278    /// (issue #354).
279    ///
280    /// Pass this to keep the old behaviour: the expiry set at issue time is
281    /// final, whatever the session is doing.
282    #[arg(long)]
283    pub fixed_run_ttl: bool,
284    /// Optional request budget for an automatically minted per-run token.
285    #[arg(long)]
286    pub run_max_requests: Option<u64>,
287    /// Client integration to launch or configure.
288    #[arg(value_enum)]
289    pub client: ClientKind,
290    /// Arguments forwarded to the client. Use `--` to make the boundary explicit.
291    #[arg(value_name = "CLIENT_ARGS", allow_hyphen_values = true)]
292    pub client_args: Vec<OsString>,
293}
294
295/// Persistent and managed-local server operations.
296#[derive(Debug, Subcommand)]
297pub enum ServerOp {
298    /// Persist a remote router URL and optional token, or clear that selection.
299    Use {
300        /// Remote router origin to persist.
301        server: Option<String>,
302        /// Private management origin when it differs from the inference origin.
303        #[arg(long, value_name = "URL")]
304        management_server: Option<String>,
305        /// PEM CA bundle to trust for the selected inference origin.
306        #[arg(long, value_name = "PATH")]
307        ca_cert: Option<std::path::PathBuf>,
308        /// PEM CA bundle to trust for a separate management origin.
309        #[arg(long, value_name = "PATH", requires = "management_server")]
310        management_ca_cert: Option<std::path::PathBuf>,
311        /// Token to persist with owner-only permissions.
312        #[arg(long, hide_env_values = true, conflicts_with = "token_stdin")]
313        token: Option<String>,
314        /// Read the token as one line from standard input.
315        #[arg(long, conflicts_with = "token")]
316        token_stdin: bool,
317        /// Clear the persisted remote selection and return to automatic local mode.
318        #[arg(long)]
319        clear: bool,
320        /// Default request budget for tokens minted for wrapper runs.
321        #[arg(long)]
322        run_max_requests: Option<u64>,
323    },
324    /// Show the selected server source and managed-container lifecycle.
325    Status,
326    /// Start the shared managed local container.
327    Start,
328    /// Reveal and claim the managed router's bootstrap administrator credential.
329    Claim,
330    /// Stop the shared managed local container without deleting its state.
331    Stop,
332    /// Remove the managed container and volume, destroying saved credentials.
333    Remove {
334        /// Confirm destructive removal without an interactive prompt.
335        #[arg(long)]
336        yes: bool,
337    },
338    /// Reap a crashed wrapper's managed-server reference.
339    #[command(hide = true)]
340    Reap { pid: u32 },
341}
342
343impl WithArgs {
344    /// The permanent-setup request `--global` / `--undo` really is.
345    ///
346    /// `with --global` predates `configure` and stays as an accepted spelling,
347    /// so it maps onto the same arguments rather than keeping a second
348    /// implementation that can disagree with it (issue #296).
349    #[must_use]
350    pub fn as_configure(&self) -> crate::cli::ConfigureArgs {
351        crate::cli::ConfigureArgs {
352            client: Some(self.client),
353            all: false,
354            undo: self.undo,
355            target: crate::cli::AuthTarget {
356                local: self.local,
357                server: self.server.clone(),
358                management_server: self.management_server.clone(),
359                managed: self.managed,
360            },
361            token: self.token.clone(),
362            token_stdin: self.token_stdin,
363            ttl_hours: 8760,
364        }
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    fn split(arguments: &[&str], nested: bool) -> Vec<String> {
373        protect_client_arguments(arguments.iter().map(OsString::from).collect(), nested)
374            .iter()
375            .map(|value| value.to_string_lossy().into_owned())
376            .collect()
377    }
378
379    /// One rule: everything after the client name is the client's.
380    ///
381    /// What this replaces claimed twelve names back from the client wherever
382    /// they appeared, by no principle a user could state — `--port` reached
383    /// the client, `--model` was eaten and validated against the router's
384    /// catalog (issue #299).
385    #[test]
386    fn everything_after_the_client_name_reaches_the_client() {
387        for option in [
388            "--global",
389            "--undo",
390            "--non-interactive",
391            "--interactive",
392            "--token-stdin",
393            "--model",
394            "--server",
395            "--managed",
396            "--isolated-config",
397            "--port",
398        ] {
399            let split = split(&["router", "with", "codex", option, "value"], true);
400            assert_eq!(
401                split,
402                ["router", "with", "codex", "--", option, "value"],
403                "{option} after the client name must reach the client"
404            );
405        }
406    }
407
408    /// The model the client understands can be named without a boundary. The
409    /// router intercepted it, validated `opus[1m]` against its own catalog and
410    /// aborted a run the client would have accepted (issues #236, #299).
411    #[test]
412    fn a_client_model_reaches_the_client_and_a_router_model_does_not() {
413        let split = split(
414            &["with-router", "--model", "A", "qwen", "--model", "B"],
415            false,
416        );
417        let boundary = split.iter().position(|value| value == "--").expect("--");
418        assert!(split[..boundary].windows(2).any(|p| p == ["--model", "A"]));
419        assert!(
420            split[boundary + 1..]
421                .windows(2)
422                .any(|p| p == ["--model", "B"])
423        );
424    }
425
426    /// A router option's value is skipped when looking for the boundary, so a
427    /// value that happens to name a client no longer shifts the split.
428    #[test]
429    fn an_option_value_that_names_a_client_is_not_the_boundary() {
430        for option in ["--model", "--data-dir", "--upstream-provider"] {
431            let split = split(&["with-router", option, "codex", "qwen", "hello"], false);
432            assert_eq!(
433                split,
434                ["with-router", option, "codex", "qwen", "--", "hello"],
435                "{option}'s value must not be read as the client name"
436            );
437        }
438    }
439
440    /// An explicit boundary is accepted and consumed exactly once.
441    #[test]
442    fn an_explicit_boundary_is_not_doubled() {
443        let split = split(&["with-router", "codex", "--", "--global", "hi"], false);
444        assert_eq!(split, ["with-router", "codex", "--", "--global", "hi"]);
445        assert_eq!(split.iter().filter(|value| *value == "--").count(), 1);
446    }
447
448    /// A client launched with nothing after it needs no boundary at all.
449    #[test]
450    fn a_bare_client_is_left_alone() {
451        assert_eq!(
452            split(&["with-router", "codex"], false),
453            ["with-router", "codex"]
454        );
455    }
456
457    /// The one documented post-client Router operation is deliberately
458    /// normalized before clap sees the client boundary. Every other argument
459    /// remains protected by issue #299's forwarding rule (issue #536).
460    #[test]
461    fn the_exact_claude_reset_operation_moves_before_the_client_boundary() {
462        assert_eq!(
463            split(
464                &[
465                    "router",
466                    "with",
467                    "claude",
468                    "--reset-to-default-configuration",
469                    "--resume",
470                    "session-id",
471                ],
472                true,
473            ),
474            [
475                "router",
476                "with",
477                "--reset-to-default-configuration",
478                "claude",
479                "--",
480                "--resume",
481                "session-id",
482            ]
483        );
484        assert_eq!(
485            split(
486                &[
487                    "router",
488                    "with",
489                    "codex",
490                    "--reset-to-default-configuration"
491                ],
492                true,
493            ),
494            [
495                "router",
496                "with",
497                "codex",
498                "--",
499                "--reset-to-default-configuration",
500            ],
501            "the exception must not claim the same client argument from another client"
502        );
503    }
504
505    /// The option table comes from the parser, so it cannot drift from it.
506    #[test]
507    fn the_option_table_is_read_from_the_parser() {
508        let options = wrapper_options();
509        assert!(
510            options.contains(&("--model".to_string(), true)),
511            "--model takes a value"
512        );
513        assert!(
514            options.contains(&("--global".to_string(), false)),
515            "--global does not"
516        );
517        assert!(
518            options.iter().any(|(name, _)| name == "--isolated-config"),
519            "an option missing from a hand-kept list is the defect this prevents"
520        );
521    }
522}