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/// Normalize wrapper-owned flags that appear after the client positional.
10///
11/// Before an explicit `--`, a flag owned by the wrapper is accepted in either
12/// position. Everything else is protected behind a clap argument boundary and
13/// reaches the client verbatim.
14#[must_use]
15pub fn protect_client_arguments(arguments: Vec<OsString>, nested: bool) -> Vec<OsString> {
16    let start = if nested {
17        arguments
18            .iter()
19            .position(|argument| argument == "with")
20            .map_or(arguments.len(), |position| position + 1)
21    } else {
22        1
23    };
24    let value_options = [
25        "--server",
26        "--token",
27        "--model",
28        "--run-ttl-hours",
29        "--run-max-requests",
30    ];
31    // Derived from the client table rather than hand-listed: a name missing
32    // here silently stops protecting that client's arguments, which is what a
33    // hardcoded copy invites every time a name changes (issue #220).
34    let clients: Vec<&str> = crate::clients::ClientKind::ALL
35        .iter()
36        .flat_map(|kind| [kind.canonical_name(), kind.legacy_name()])
37        .collect();
38    let boolean_options = [
39        "--global",
40        "--undo",
41        "--non-interactive",
42        "--interactive",
43        "--token-stdin",
44    ];
45    let mut position = start;
46    while position < arguments.len() {
47        let value = arguments[position].to_string_lossy();
48        if value_options.contains(&value.as_ref()) {
49            position += 2;
50            continue;
51        }
52        if value_options
53            .iter()
54            .any(|option| value.starts_with(&format!("{option}=")))
55        {
56            position += 1;
57            continue;
58        }
59        if clients.contains(&value.as_ref()) {
60            let client = arguments[position].clone();
61            let prefix = arguments[..position].to_vec();
62            let mut wrapper = Vec::new();
63            let mut forwarded = Vec::new();
64            let mut cursor = position + 1;
65            let mut explicit_boundary = false;
66            while cursor < arguments.len() {
67                let item = arguments[cursor].to_string_lossy();
68                if explicit_boundary {
69                    forwarded.push(arguments[cursor].clone());
70                    cursor += 1;
71                    continue;
72                }
73                if item == "--" {
74                    explicit_boundary = true;
75                    cursor += 1;
76                    continue;
77                }
78                if boolean_options.contains(&item.as_ref()) {
79                    wrapper.push(arguments[cursor].clone());
80                    cursor += 1;
81                    continue;
82                }
83                if value_options.contains(&item.as_ref()) {
84                    wrapper.push(arguments[cursor].clone());
85                    if let Some(value) = arguments.get(cursor + 1) {
86                        wrapper.push(value.clone());
87                        cursor += 2;
88                    } else {
89                        cursor += 1;
90                    }
91                    continue;
92                }
93                if value_options
94                    .iter()
95                    .any(|option| item.starts_with(&format!("{option}=")))
96                {
97                    wrapper.push(arguments[cursor].clone());
98                    cursor += 1;
99                    continue;
100                }
101                forwarded.push(arguments[cursor].clone());
102                cursor += 1;
103            }
104            let mut normalized = prefix;
105            normalized.extend(wrapper);
106            normalized.push(client);
107            if !forwarded.is_empty() {
108                normalized.push("--".into());
109                normalized.extend(forwarded);
110            }
111            return normalized;
112        }
113        position += 1;
114    }
115    arguments
116}
117
118/// Options shared by `router with` and the standalone `with-router` binary.
119#[derive(Clone, Debug, Args)]
120#[command(trailing_var_arg = true)]
121pub struct WithArgs {
122    /// Permanently configure the client instead of launching it temporarily.
123    #[arg(long)]
124    pub global: bool,
125    /// Restore the exact configuration saved by a previous `--global` call.
126    #[arg(long, requires = "global")]
127    pub undo: bool,
128    /// Force the client's non-interactive/one-shot mode.
129    #[arg(long, conflicts_with = "interactive")]
130    pub non_interactive: bool,
131    /// Force the client's interactive mode.
132    #[arg(long, conflicts_with = "non_interactive")]
133    pub interactive: bool,
134    /// Router origin. No local server is started when this is supplied.
135    #[arg(long)]
136    pub server: Option<String>,
137    /// Router token. Prefer the environment or `--token-stdin` to shell history.
138    #[arg(long, hide_env_values = true, conflicts_with = "token_stdin")]
139    pub token: Option<String>,
140    /// Read the router token as one line from standard input.
141    #[arg(long, conflicts_with = "token")]
142    pub token_stdin: bool,
143    /// Model passed to the client instead of the integration default.
144    #[arg(long)]
145    pub model: Option<String>,
146    /// Lifetime of an automatically minted per-run token.
147    #[arg(long, default_value_t = 1)]
148    pub run_ttl_hours: i64,
149    /// Optional request budget for an automatically minted per-run token.
150    #[arg(long)]
151    pub run_max_requests: Option<u64>,
152    /// Client integration to launch or configure.
153    #[arg(value_enum)]
154    pub client: ClientKind,
155    /// Arguments forwarded to the client. Use `--` to make the boundary explicit.
156    #[arg(value_name = "CLIENT_ARGS", allow_hyphen_values = true)]
157    pub client_args: Vec<OsString>,
158}
159
160/// Persistent and managed-local server operations.
161#[derive(Debug, Subcommand)]
162pub enum ServerOp {
163    /// Persist a remote router URL and optional token, or clear that selection.
164    Use {
165        /// Remote router origin to persist.
166        server: Option<String>,
167        /// Token to persist with owner-only permissions.
168        #[arg(long, hide_env_values = true, conflicts_with = "token_stdin")]
169        token: Option<String>,
170        /// Read the token as one line from standard input.
171        #[arg(long, conflicts_with = "token")]
172        token_stdin: bool,
173        /// Clear the persisted remote selection and return to automatic local mode.
174        #[arg(long)]
175        clear: bool,
176        /// Default request budget for tokens minted for wrapper runs.
177        #[arg(long)]
178        run_max_requests: Option<u64>,
179    },
180    /// Show the selected server source and managed-container lifecycle.
181    Status,
182    /// Start the shared managed local container.
183    Start,
184    /// Reveal and claim the managed router's bootstrap administrator credential.
185    Claim,
186    /// Stop the shared managed local container without deleting its state.
187    Stop,
188    /// Remove the managed container and volume, destroying saved credentials.
189    Remove {
190        /// Confirm destructive removal without an interactive prompt.
191        #[arg(long)]
192        yes: bool,
193    },
194    /// Reap a crashed wrapper's managed-server reference.
195    #[command(hide = true)]
196    Reap { pid: u32 },
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn wrapper_flags_after_client_are_protected() {
205        for option in [
206            "--global",
207            "--undo",
208            "--non-interactive",
209            "--interactive",
210            "--token-stdin",
211        ] {
212            let arguments = ["router", "with", "codex", option, "prompt"]
213                .into_iter()
214                .map(OsString::from)
215                .collect();
216            assert_eq!(
217                protect_client_arguments(arguments, true),
218                ["router", "with", option, "codex", "--", "prompt"].map(OsString::from),
219                "{option} after the client must remain wrapper-owned"
220            );
221        }
222    }
223
224    #[test]
225    fn value_wrapper_flags_after_client_are_accepted() {
226        for (option, value) in [
227            ("--server", "https://router.test"),
228            ("--token", "test-token"),
229            ("--model", "gpt-test"),
230            ("--run-ttl-hours", "2"),
231            ("--run-max-requests", "3"),
232        ] {
233            let arguments = ["with-router", "codex", option, value, "hi"]
234                .into_iter()
235                .map(OsString::from)
236                .collect();
237            assert_eq!(
238                protect_client_arguments(arguments, false),
239                ["with-router", option, value, "codex", "--", "hi"].map(OsString::from),
240                "{option} VALUE after the client must remain wrapper-owned"
241            );
242
243            let equals = format!("{option}={value}");
244            let arguments = ["with-router", "codex", &equals, "hi"]
245                .into_iter()
246                .map(OsString::from)
247                .collect();
248            assert_eq!(
249                protect_client_arguments(arguments, false),
250                [
251                    OsString::from("with-router"),
252                    OsString::from(&equals),
253                    OsString::from("codex"),
254                    OsString::from("--"),
255                    OsString::from("hi"),
256                ],
257                "{option}=VALUE after the client must remain wrapper-owned"
258            );
259        }
260    }
261
262    #[test]
263    fn explicit_boundary_forwards_every_colliding_wrapper_flag_verbatim() {
264        for option in [
265            "--global",
266            "--undo",
267            "--non-interactive",
268            "--interactive",
269            "--token-stdin",
270            "--server",
271            "--token",
272            "--model",
273            "--run-ttl-hours",
274            "--run-max-requests",
275        ] {
276            let arguments = ["with-router", "codex", "--", option, "client-value"]
277                .into_iter()
278                .map(OsString::from)
279                .collect::<Vec<_>>();
280            assert_eq!(
281                protect_client_arguments(arguments.clone(), false),
282                arguments,
283                "{option} after -- must be forwarded to the client"
284            );
285        }
286    }
287
288    #[test]
289    fn option_values_that_match_clients_are_not_boundaries() {
290        let arguments = ["with-router", "--model", "codex", "qwen", "hello"]
291            .into_iter()
292            .map(OsString::from)
293            .collect();
294        assert_eq!(
295            protect_client_arguments(arguments, false),
296            ["with-router", "--model", "codex", "qwen", "--", "hello"].map(OsString::from)
297        );
298    }
299}