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}