link-assistant-router 1.10.0

Link.Assistant.Router — Claude MAX OAuth proxy and token gateway for Anthropic APIs
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
//! `auth` and `tls` command definitions.
//!
//! Split from `cli.rs` to keep that file within the repository's 1000-line
//! limit.

use clap::Subcommand;

use super::{AuthFlow, CLAUDE_AUTH_FLOWS, CODEX_AUTH_FLOWS, auth_flow_parser};

/// A login this machine already holds that the router can adopt.
///
/// Not `SubscriptionProvider`: that enum is about subscriptions the proxy
/// serves models from, and GitHub is a credential the router presents upstream
/// rather than a subscription. Import spans both, so it needs a name for the
/// union (issue #278).
#[derive(Clone, Copy, Debug, Eq, PartialEq, clap::ValueEnum)]
pub enum ImportProvider {
    #[value(name = "claude", alias = "anthropic")]
    Claude,
    #[value(name = "codex", alias = "chatgpt")]
    Codex,
    #[value(name = "gemini", alias = "google")]
    Gemini,
    #[value(name = "qwen", alias = "qwen-code")]
    Qwen,
    #[value(name = "gh", alias = "github")]
    Gh,
}

impl ImportProvider {
    /// The built-in credential this name refers to, if any.
    ///
    /// `auth clear` takes a free-form name so it can also withdraw an API-key
    /// provider added through `providers add` (issue #561). The built-in names
    /// keep their exact spellings and aliases, so this resolves them before a
    /// name is looked up in the provider store.
    #[must_use]
    pub fn from_name(value: &str) -> Option<Self> {
        match value.trim().to_ascii_lowercase().as_str() {
            "claude" | "anthropic" => Some(Self::Claude),
            "codex" | "chatgpt" => Some(Self::Codex),
            "gemini" | "google" => Some(Self::Gemini),
            "qwen" | "qwen-code" => Some(Self::Qwen),
            "gh" | "github" => Some(Self::Gh),
            _ => None,
        }
    }
}

/// Provider authorization operations.
#[derive(Debug, Subcommand)]
pub enum AuthOp {
    /// Adopt a login this machine already has, without a browser.
    ///
    /// Authorizing means "go get a new credential, interactively"; importing
    /// means "adopt one that already exists". They differ in prerequisites, in
    /// side effects, and in whether a human has to be present — which decides
    /// whether a headless deployment can be provisioned at all (issue #278).
    ///
    /// Runs on the deployment being provisioned: it installs into the
    /// credential home of the machine executing it, and no router accepts a
    /// credential over HTTP. With another router selected this refuses and
    /// names it, rather than answering about the local home (issue #291); use
    /// `auth claude` or `auth codex` to authorize a remote deployment.
    ///
    /// The per-provider flags on the authorize commands keep working.
    Import {
        /// Which login to adopt. Omit with `--all`.
        #[arg(
            value_enum,
            required_unless_present_any = ["all", "resume"],
            conflicts_with = "resume"
        )]
        provider: Option<ImportProvider>,
        /// Where to read it from. A named directory is read exactly as given.
        ///
        /// Omitted, it defaults to the vendor client's conventional directory —
        /// `~/.claude`, `~/.codex`, `~/.config/gh` — and there, on macOS for
        /// Claude, the login Keychain is consulted too and wins when it holds
        /// the newer credential. Naming a directory says *this* credential from
        /// *there*, so the machine-wide store is left out of it (issue #285).
        ///
        /// `$CLAUDE_CODE_HOME` and `$CODEX_HOME` are deliberately *not* the
        /// source: in a deployment they name this router's own credential
        /// directory — the destination — so reading the source through them
        /// would make every unqualified import refuse itself (issue #307). Pass
        /// the directory to read from another location.
        #[arg(requires = "provider", conflicts_with = "resume")]
        dir: Option<String>,
        /// Adopt every login this machine has.
        ///
        /// The case that motivates a verb: provisioning a deployment from a
        /// machine already logged in to several providers, without knowing each
        /// flag name and default path. Run it on that deployment — import
        /// writes the executing machine's credential home (issue #291).
        #[arg(long, conflicts_with_all = ["provider", "resume"])]
        all: bool,
        /// Install only if no recognized credential exists after taking the
        /// shared refresh/login lock.
        #[arg(long, conflicts_with = "all")]
        if_absent: bool,
        /// Assert support for non-destructive access-token validation and an
        /// atomic reference to one writable vendor-owned credential file.
        /// Older Router versions reject this spelling, allowing deployment
        /// tooling to fail closed before importing a credential.
        ///
        /// The internal field keeps its historical name for source
        /// compatibility; this flag never bypasses positive validation.
        #[arg(long = "safe-refresh-chain-import-v1")]
        force: bool,
        /// Require that the credential be *followed* rather than copied.
        ///
        /// A refresh token is a rotating series, not a value: whoever redeems a
        /// link invalidates it for every other holder. So a deployment holding
        /// its own copy and the vendor CLI beside it are two refreshers of one
        /// chain, and whichever loses the race is left with `invalid_grant` —
        /// which looks exactly like a revocation from the losing side (issue
        /// #574). Following installs a reference to the vendor client's own
        /// credential file instead, so both advance one chain: a rotation by
        /// either is seen by the other, with no re-import and no restart.
        ///
        /// This is already what an import does when it can. The flag makes it a
        /// requirement: if a reference cannot be established — the credential
        /// lives only in the platform keychain, names no writable source, or its
        /// directory cannot be written atomically — the import refuses and says
        /// which, rather than silently falling back to a copy that will drift.
        #[arg(long, conflicts_with = "snapshot")]
        follow: bool,
        /// Take a one-time copy instead of following the source.
        ///
        /// The historical behaviour for callers that want a credential frozen at
        /// import time, and a deployment that must not write to the source's
        /// directory at all. A copy drifts: the vendor client will rotate past
        /// it, so this is the shape that eventually needs a re-import.
        #[arg(long)]
        snapshot: bool,
        /// Emit one stable JSON result envelope instead of human-readable
        /// progress. Operational failures are represented in the envelope and
        /// still produce a non-zero exit status.
        #[arg(long)]
        json: bool,
        /// Retry one retained refresh-chain transaction by its opaque ID.
        ///
        /// Router resolves the private candidate directory; callers never need
        /// to discover or construct an internal filesystem path.
        #[arg(
            long,
            value_name = "TRANSACTION_ID",
            conflicts_with_all = ["provider", "dir", "all"]
        )]
        resume: Option<String>,
        #[command(flatten)]
        target: AuthTarget,
    },
    /// Remove a stored login from this deployment.
    ///
    /// Withdrawal is the most destructive thing this tool does and had no
    /// name: it was four flags, the widest of them attached to a command
    /// called `status`, so `auth --help` said nothing about it at all. The
    /// per-command `--clear` flags keep working (issue #305).
    ///
    /// Removes credentials on the machine it runs on. No router accepts a
    /// withdrawal over HTTP, so with another router selected this refuses and
    /// names it — silently rewriting "there" as "here" is unrecoverable for an
    /// OAuth credential, which then needs a fresh browser login on a machine
    /// that may not have a browser.
    Clear {
        /// Which login to remove: `claude`, `codex`, `gemini`, `qwen`, `gh`, or
        /// the name of a provider added through `providers add`. Omit with
        /// `--all`.
        ///
        /// A free-form name rather than a fixed enum: an API key stored by
        /// `providers add` authorizes this deployment against an upstream
        /// vendor exactly as an OAuth login does, and refusing to name one here
        /// left `auth` unable to withdraw a credential it reports (issue #561).
        #[arg(required_unless_present = "all")]
        provider: Option<String>,
        /// Remove every login this deployment holds.
        #[arg(long, conflicts_with = "provider")]
        all: bool,
        /// Confirm removing more than one credential without a prompt.
        #[arg(long)]
        yes: bool,
        #[command(flatten)]
        target: AuthTarget,
    },
    /// Authorize an Anthropic Claude subscription.
    Claude {
        /// Supply the copied code without prompting on stdin.
        #[arg(long)]
        code: Option<String>,
        /// Force an OAuth flow instead of automatic selection.
        #[arg(long, value_parser = auth_flow_parser(&CLAUDE_AUTH_FLOWS), default_value = "auto")]
        flow: AuthFlow,
        /// Scope set to request: `full` (Claude Code `/login` equivalent) or
        /// `setup-token` for `user:inference` only. Defaults to what
        /// `LOGIN_CLI_ARGS` selects, then `full`.
        #[arg(long)]
        mode: Option<String>,
        /// Adopt an existing Claude login instead of authorizing.
        ///
        /// Reads the credential a vendor client already holds and installs it as
        /// this deployment's (issue #274). Default: `~/.claude`, where on macOS
        /// the login Keychain is consulted as well and wins when it is the live
        /// one. A directory named explicitly is read as given (issue #285).
        #[arg(long, value_name = "DIR", num_args = 0..=1, default_missing_value = "")]
        from_claude_home: Option<String>,
        /// Remove the stored credential instead of authorizing.
        #[arg(long, conflicts_with_all = ["code", "mode", "from_claude_home"])]
        clear: bool,
        #[command(flatten)]
        target: AuthTarget,
    },
    /// Authorize an `OpenAI` Codex / `ChatGPT` subscription.
    Codex {
        /// Force an OAuth flow instead of automatic selection.
        #[arg(long, value_parser = auth_flow_parser(&CODEX_AUTH_FLOWS), default_value = "auto")]
        flow: AuthFlow,
        /// Local callback port registered for the Codex OAuth client.
        #[arg(long, default_value_t = 1455)]
        port: u16,
        /// Adopt an existing Codex login instead of authorizing.
        ///
        /// Default: `~/.codex` (issue #274).
        #[arg(long, value_name = "DIR", num_args = 0..=1, default_missing_value = "")]
        from_codex_home: Option<String>,
        /// Remove the stored credential instead of authorizing.
        #[arg(long, conflicts_with = "from_codex_home")]
        clear: bool,
        #[command(flatten)]
        target: AuthTarget,
    },
    /// Store the GitHub credential the proxy presents upstream.
    ///
    /// The router mediates GitHub traffic on behalf of callers, so it needs an
    /// operator credential of its own. Reading it from a mounted `gh` config
    /// means a deployment can reuse an existing login instead of minting a
    /// separate token (issue #263).
    Gh {
        /// Read the credential from a mounted `gh` configuration directory
        /// (default: `$GH_CONFIG_DIR`, else `~/.config/gh`).
        #[arg(long, value_name = "DIR")]
        from_gh_config: Option<String>,
        /// Read the credential as one line from standard input instead.
        #[arg(long, conflicts_with = "from_gh_config")]
        token_stdin: bool,
        /// Report what is currently stored without changing it.
        #[arg(long, conflicts_with_all = ["from_gh_config", "token_stdin"])]
        status: bool,
        /// Remove the stored credential instead of storing one.
        #[arg(long, conflicts_with_all = ["from_gh_config", "token_stdin", "status"])]
        clear: bool,
        #[command(flatten)]
        target: AuthTarget,
    },
    /// Report whether each provider credential is usable, expired, or absent.
    Status {
        /// Remove every stored credential, for decommissioning a deployment.
        ///
        /// Withdraws each provider's credential and the GitHub one in a single
        /// step, so an operator tearing down a test deployment does not have to
        /// know three separate paths (issue #268).
        /// `router auth clear --all` is the same operation with a name.
        #[arg(long = "clear-all")]
        clear_all: bool,
        /// Confirm removing more than one credential without a prompt.
        ///
        /// An OAuth login cannot be put back without a browser, and this is
        /// the widest blast radius in the tool — five credentials in one call,
        /// on a command called `status` (issue #305).
        #[arg(long, requires = "clear_all")]
        yes: bool,
        #[command(flatten)]
        target: AuthTarget,
    },
}

/// What `auth gh` may do when a router other than this machine is selected.
///
/// A GitHub credential is read from the router's own data directory at startup
/// and no endpoint accepts one over HTTP, so there is nothing to store
/// remotely. Acting locally under a success message is what left a workstation
/// holding a token it never needed while the targeted deployment had none
/// (issue #283), so storing refuses and only the read-only query answers.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum RemoteGh {
    /// Report this machine's credential, saying whose it is.
    DescribeLocal,
    /// Refuse: the credential cannot reach the selected router from here.
    Refuse,
}

impl AuthOp {
    /// What this `auth gh` invocation may do against a selected router.
    ///
    /// `None` for anything that is not `auth gh`.
    #[must_use]
    pub const fn remote_gh(&self) -> Option<RemoteGh> {
        match self {
            Self::Gh { status: true, .. } => Some(RemoteGh::DescribeLocal),
            Self::Gh { .. } => Some(RemoteGh::Refuse),
            _ => None,
        }
    }
}

/// Whether an `auth import` invocation may act on the machine running it.
///
/// Import installs into the credential home of the executing machine, and no
/// router accepts a credential document over HTTP, so an import aimed at a
/// different deployment has nothing it can do. Deciding that here — beside the
/// flags it reads — keeps the rule unit-testable rather than reachable only by
/// spawning the binary against a live server (issue #291).
///
/// `None` for anything that is not an `import`: the per-provider
/// `--from-*-home` flags carry no target of their own.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum ImportTarget {
    /// Act here: `--local`, `--managed`, or no selection at all.
    Local,
    /// A different router was named or selected; resolve it and refuse.
    Remote,
}

impl AuthOp {
    /// Whether this `auth import` may install into the local credential home.
    ///
    /// Answers from the flags alone. A bare invocation is [`Self::may_be_remote`]
    /// because a *persisted* selection also counts as naming a target, which
    /// only resolution can determine.
    #[must_use]
    pub const fn import_target(&self) -> Option<ImportTarget> {
        match self {
            Self::Import { target, .. } => {
                if target.local || target.managed {
                    Some(ImportTarget::Local)
                } else {
                    Some(ImportTarget::Remote)
                }
            }
            _ => None,
        }
    }

    /// Whether this invocation must resolve a target before importing.
    ///
    /// `false` short-circuits resolution entirely, so `--local` never contacts
    /// a server and never fails because one is unreachable.
    #[must_use]
    pub const fn may_be_remote(&self) -> bool {
        matches!(self.import_target(), Some(ImportTarget::Remote))
    }
}

/// Which router an `auth` command acts on.
///
/// `auth` used to always write a local credential even when a server was
/// selected, so the obvious `server use` → `auth` → `with` sequence left the
/// targeted router unauthorized and failed later as a 401 (issue #246). The
/// default now follows the selection, exactly as `with` does; these make the
/// choice explicit when the default is not what is wanted.
#[derive(Debug, Clone, Default, clap::Args)]
pub struct AuthTarget {
    /// Act on this machine even when a server is selected.
    #[arg(long, conflicts_with = "server")]
    pub local: bool,
    /// Act on this router instead of the selected one.
    #[arg(long, value_name = "URL", conflicts_with = "local")]
    pub server: Option<String>,
    /// Private management origin when it differs from the inference origin.
    #[arg(long, value_name = "URL", conflicts_with = "local")]
    pub management_server: Option<String>,
    /// Start a disposable managed container even if a router is already
    /// listening locally (issue #250).
    ///
    /// Accepted by the commands that can use one — `with`, `configure` and
    /// `auth`. The families that only read or change router state refuse it
    /// and name `--local`, because there it started nothing and quietly meant
    /// `--local` anyway (issue #315).
    #[arg(long, conflicts_with_all = ["local", "server", "management_server"])]
    pub managed: bool,
}

/// TLS subcommands.
///
/// The artefact `ca` prints is a trust anchor, so answering for the wrong
/// machine does not produce a wrong report — it produces trust in the wrong
/// key. `tls` therefore takes the same target flags as every other
/// state-touching family, and says so when it cannot answer for the target
/// (issue #308).
#[derive(Debug, Subcommand)]
pub enum TlsOp {
    /// Print the generated certificate in PEM form.
    ///
    /// A client that must trust a self-signed router reads it from here, so a
    /// private-network deployment can distribute trust without a CA (issue
    /// #263).
    Ca {
        #[command(flatten)]
        target: AuthTarget,
    },
    /// Generate the self-signed certificate without starting the server.
    Generate {
        /// Names the certificate is valid for, comma-separated. A sidecar is
        /// reached by its network alias, so that name must be present.
        #[arg(long, value_name = "NAMES", default_value = "localhost")]
        dns: String,
        #[command(flatten)]
        target: AuthTarget,
    },
}

impl TlsOp {
    /// Which router this certificate operation acts on.
    #[must_use]
    pub const fn target(&self) -> &AuthTarget {
        match self {
            Self::Ca { target } | Self::Generate { target, .. } => target,
        }
    }
}