@2kw/ai 5.2.0-dev.8 → 5.3.0-dev.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,7 +19,7 @@ The CLI notifies you when a newer version is available.
19
19
  ## Quick Start
20
20
 
21
21
  ```bash
22
- # Authenticate (get an API key from your 2kw.ai dashboard 7-day free trial)
22
+ # Authenticate opens your browser, no API key needed (7-day free trial)
23
23
  2kw auth login
24
24
 
25
25
  # Define a schema and run an extraction
@@ -38,21 +38,46 @@ All commands work identically as `bb` for quick access — e.g. `bb schemas list
38
38
 
39
39
  ## Authentication
40
40
 
41
- The CLI supports multiple authentication methods (highest priority first):
41
+ Sign in via the browser (recommended):
42
+
43
+ ```bash
44
+ 2kw auth login
45
+ ```
46
+
47
+ This opens your browser, asks you to confirm a short code, and signs the CLI
48
+ in with your user account — no API key to copy. Your session lasts 7 days of
49
+ inactivity and renews itself while you keep using the CLI. If you belong to
50
+ several organizations you pick one at login, and can switch any time with
51
+ `2kw context set-org` — no re-login needed. `2kw auth logout` clears the local
52
+ credentials and signs the session out server-side (best-effort).
53
+
54
+ For CI, scripts, or air-gapped machines, store an org API key instead
55
+ (create one under Settings → API Keys in the web app):
56
+
57
+ ```bash
58
+ 2kw auth login --api-key sk_... # save a key directly
59
+ 2kw auth login --manual # prompted setup (legacy flow)
60
+ ```
61
+
62
+ Explicit API keys always take priority over a stored browser session
63
+ (highest priority first):
42
64
 
43
65
  | Method | Example |
44
66
  |--------|---------|
45
67
  | CLI flags | `--api-key sk_... --base-url https://...` |
46
68
  | Environment variables | `AI_2KW_API_KEY`, `AI_2KW_BASE_URL` (legacy: `BACKBONE_*`) |
47
69
  | Local `.2kw` file | JSON file in the current directory (legacy: `.backbone`) |
48
- | Config store | Set via `2kw auth login` |
70
+ | Config store | Set via `2kw auth login` (browser session or saved key) |
49
71
 
50
72
  ```bash
51
- 2kw auth login # Interactive setup
52
- 2kw auth status # Verify credentials
53
- 2kw auth logout # Clear stored credentials
73
+ 2kw auth status # Verify credentials (works for both auth types)
74
+ 2kw auth logout # Clear credentials and send a server-side sign-out
54
75
  ```
55
76
 
77
+ Note: `config get apiKey --json` returns a masked value — stored secrets do
78
+ not round-trip through stdout. Scripts needing the raw key should use
79
+ `AI_2KW_API_KEY` or read the config file (path shown by `config list`).
80
+
56
81
  ## Contexts
57
82
 
58
83
  kubectl-style contexts switch between organizations and environments:
@@ -1,3 +1,93 @@
1
1
  import { Command } from "commander";
2
- export declare function makeAuthCommand(): Command;
2
+ import { setContext, setActiveContext, type ResolvedConfig } from "../lib/config.js";
3
+ import { getSessionInfo, listOrganizations, pollDeviceToken, requestDeviceCode, revokeSession, setActiveOrganization } from "../lib/auth-service.js";
4
+ import { runDevicePolling, selectOrganization } from "../lib/auth-session.js";
5
+ type SessionConfig = Extract<ResolvedConfig, {
6
+ kind: "session";
7
+ }>;
8
+ /**
9
+ * Group an 8-character device user code as XXXX-XXXX.
10
+ *
11
+ * Purely presentational: the code is read off a terminal and typed into a
12
+ * browser, and a grouped one survives that trip with fewer mistakes. Anything
13
+ * that is not exactly 8 characters (a service configured for another length,
14
+ * or a code that already carries its own separator) is shown untouched.
15
+ */
16
+ export declare function formatUserCode(code: string): string;
17
+ /** Injection points for {@link deviceFlowLogin}; all default to the real thing. */
18
+ export interface DeviceLoginDeps {
19
+ requestCode?: typeof requestDeviceCode;
20
+ poll?: typeof pollDeviceToken;
21
+ runPolling?: typeof runDevicePolling;
22
+ sessionInfo?: typeof getSessionInfo;
23
+ listOrgs?: typeof listOrganizations;
24
+ chooseOrg?: typeof selectOrganization;
25
+ setActiveOrg?: typeof setActiveOrganization;
26
+ /** Used to clean up a session that never got an organization. */
27
+ revoke?: typeof revokeSession;
28
+ openBrowser?: (url: string) => Promise<void>;
29
+ saveContext?: typeof setContext;
30
+ activateContext?: typeof setActiveContext;
31
+ }
32
+ export interface DeviceLoginTarget {
33
+ contextName: string;
34
+ baseUrl: string;
35
+ authUrl: string;
36
+ }
37
+ /**
38
+ * The browser login, end to end: ask for a device code, wait for the user to
39
+ * approve it, then record who they are and which organization they act as.
40
+ *
41
+ * A refused or expired approval is reported and swallowed (exit code 1, no
42
+ * context written) because it is the user's answer, not a fault. Everything
43
+ * else — an unreachable auth service, a rejected client id — propagates to
44
+ * `runAction`, which renders it once and machine-readably under `--json`.
45
+ *
46
+ * By design this is an interactive surface: it prints a code, opens a browser,
47
+ * and may ask which organization to use. Its own outcomes are therefore prose
48
+ * even under `--json` — a device approval that was denied is a conversation,
49
+ * not a payload. Faults still leave through handleError and are rendered as
50
+ * JSON there.
51
+ */
52
+ export declare function deviceFlowLogin(target: DeviceLoginTarget, deps?: DeviceLoginDeps): Promise<void>;
53
+ /** Injection point for {@link performLogout}; defaults to the real call. */
54
+ export interface LogoutDeps {
55
+ revoke?: typeof revokeSession;
56
+ }
57
+ /**
58
+ * Sign out: drop the local credentials, then tell the auth service.
59
+ *
60
+ * That order is deliberate. `revokeSession` has no timeout, so an auth host
61
+ * that black-holes the request would hang the command with the credentials
62
+ * still on disk — while the user, having read "Credentials cleared", believes
63
+ * they are signed out. Clearing first makes the local half unconditional and
64
+ * leaves the network call as what it is: best-effort, and the only part that
65
+ * can fail. A host that refuses the connection fails fast either way.
66
+ *
67
+ * The token and URL are copied out first because the store no longer holds
68
+ * them by the time they are used.
69
+ */
70
+ export declare function performLogout(deps?: LogoutDeps): Promise<void>;
71
+ /** How the caller wants the status rendered. */
72
+ export interface SessionStatusView {
73
+ json: boolean;
74
+ contextName: string;
75
+ multiContext: boolean;
76
+ }
77
+ /** Injection points for {@link sessionStatus}. */
78
+ export interface SessionStatusDeps {
79
+ /** The probe that decides "authenticated" — normally a bound
80
+ * {@link countModels}. Required: it is the whole test. */
81
+ countModels: () => Promise<number>;
82
+ /** Identity lookup; defaults to the real auth-service call. */
83
+ sessionInfo?: typeof getSessionInfo;
84
+ }
85
+ /** `auth status` for a browser-session context. */
86
+ export declare function sessionStatus(config: SessionConfig, view: SessionStatusView, deps: SessionStatusDeps): Promise<void>;
87
+ /**
88
+ * @param deviceDeps forwarded to {@link deviceFlowLogin} — a seam for tests
89
+ * that need to prove the browser flow was NOT entered.
90
+ */
91
+ export declare function makeAuthCommand(deviceDeps?: DeviceLoginDeps): Command;
92
+ export {};
3
93
  //# sourceMappingURL=auth.d.ts.map
@@ -1,8 +1,12 @@
1
1
  import { Command } from "commander";
2
2
  import chalk from "chalk";
3
- import { store, resolveConfig, isJsonOutput, getActiveContextName, getActiveContext, getContextCount, setContext, setActiveContext, deleteContext, DEFAULT_BASE_URL, } from "../lib/config.js";
3
+ import { store, resolveConfig, isJsonOutput, getActiveContextName, getActiveContext, getContextCount, setContext, setActiveContext, deleteContext, defaultAuthUrlFor, DEFAULT_BASE_URL, } from "../lib/config.js";
4
4
  import { getClient, runAction } from "../lib/client.js";
5
5
  import { classifyAuthFailure } from "../lib/errors.js";
6
+ import { CLI_CLIENT_ID, getSessionInfo, listOrganizations, pollDeviceToken, requestDeviceCode, revokeSession, setActiveOrganization, } from "../lib/auth-service.js";
7
+ import { DeviceApprovalError, runDevicePolling, selectOrganization, SessionExpiredError, } from "../lib/auth-session.js";
8
+ import { withSpinner } from "../lib/output.js";
9
+ import { maskApiKey } from "../lib/redact.js";
6
10
  import { createInterface } from "node:readline/promises";
7
11
  // Human-readable, actionable line per failure kind for the non-JSON output.
8
12
  const FAILURE_HINTS = {
@@ -11,62 +15,326 @@ const FAILURE_HINTS = {
11
15
  UNREACHABLE: "Could not reach the host. Check the base URL and your connection.",
12
16
  UNKNOWN: 'Validation failed. Run "2kw auth login" and, if it persists, check the base URL.',
13
17
  };
14
- export function makeAuthCommand() {
18
+ /**
19
+ * Group an 8-character device user code as XXXX-XXXX.
20
+ *
21
+ * Purely presentational: the code is read off a terminal and typed into a
22
+ * browser, and a grouped one survives that trip with fewer mistakes. Anything
23
+ * that is not exactly 8 characters (a service configured for another length,
24
+ * or a code that already carries its own separator) is shown untouched.
25
+ */
26
+ export function formatUserCode(code) {
27
+ return code.length === 8 ? `${code.slice(0, 4)}-${code.slice(4)}` : code;
28
+ }
29
+ /** Trailing slashes make `${authUrl}/api/auth/...` double up — drop them once, here. */
30
+ function stripTrailingSlash(url) {
31
+ return url.replace(/\/+$/, "");
32
+ }
33
+ /**
34
+ * Open the approval page in the user's browser, and shrug if that is not
35
+ * possible.
36
+ *
37
+ * `open` is imported dynamically so a headless box never pays for it, and every
38
+ * failure is swallowed: over SSH or in a container there is no browser to open,
39
+ * which is precisely the situation the device grant exists for — the URL and
40
+ * code are already on screen.
41
+ */
42
+ async function tryOpenBrowser(url) {
43
+ try {
44
+ const { default: open } = await import("open");
45
+ await open(url);
46
+ }
47
+ catch {
48
+ // No browser, no display, no default handler. The printed URL still works.
49
+ }
50
+ }
51
+ /**
52
+ * The browser login, end to end: ask for a device code, wait for the user to
53
+ * approve it, then record who they are and which organization they act as.
54
+ *
55
+ * A refused or expired approval is reported and swallowed (exit code 1, no
56
+ * context written) because it is the user's answer, not a fault. Everything
57
+ * else — an unreachable auth service, a rejected client id — propagates to
58
+ * `runAction`, which renders it once and machine-readably under `--json`.
59
+ *
60
+ * By design this is an interactive surface: it prints a code, opens a browser,
61
+ * and may ask which organization to use. Its own outcomes are therefore prose
62
+ * even under `--json` — a device approval that was denied is a conversation,
63
+ * not a payload. Faults still leave through handleError and are rendered as
64
+ * JSON there.
65
+ */
66
+ export async function deviceFlowLogin(target, deps = {}) {
67
+ const { requestCode = requestDeviceCode, poll = pollDeviceToken, runPolling = runDevicePolling, sessionInfo = getSessionInfo, listOrgs = listOrganizations, chooseOrg = selectOrganization, setActiveOrg = setActiveOrganization, revoke = revokeSession, openBrowser = tryOpenBrowser, saveContext = setContext, activateContext = setActiveContext, } = deps;
68
+ const { contextName, baseUrl, authUrl } = target;
69
+ const device = await requestCode(authUrl, CLI_CLIENT_ID);
70
+ console.log("");
71
+ console.log(`Open ${chalk.cyan(device.verification_uri_complete)}`);
72
+ console.log(`and confirm the code ${chalk.bold(formatUserCode(device.user_code))}`);
73
+ console.log("");
74
+ await openBrowser(device.verification_uri_complete);
75
+ let sessionToken;
76
+ try {
77
+ sessionToken = await withSpinner("Waiting for approval in the browser...", () => runPolling({
78
+ poll: () => poll(authUrl, CLI_CLIENT_ID, device.device_code),
79
+ intervalSec: device.interval,
80
+ expiresInSec: device.expires_in,
81
+ }));
82
+ }
83
+ catch (err) {
84
+ // The user's own answer — say it plainly and stop. Anything else (a refused
85
+ // client id, a network that never came back) is a fault, and belongs to
86
+ // handleError, which is what puts it in front of a --json consumer.
87
+ if (!(err instanceof DeviceApprovalError))
88
+ throw err;
89
+ console.error(chalk.red(err.message));
90
+ process.exitCode = 1;
91
+ return;
92
+ }
93
+ const { email } = await sessionInfo(authUrl, sessionToken);
94
+ // Everything from here to the successful write is guarded: until the session
95
+ // token reaches the config store, a failure leaves it live server-side with
96
+ // nothing on this machine able to use it — an account with no organization at
97
+ // all being the case that guarantees it, a refused set-active or a read-only
98
+ // store the ones that merely make it likely. Revoking is best-effort: the
99
+ // failure that got us here is the one worth reporting, not a failed cleanup.
100
+ let org;
101
+ try {
102
+ org = await chooseOrg(await listOrgs(authUrl, sessionToken));
103
+ await setActiveOrg(authUrl, sessionToken, org.id);
104
+ // A whole entry, not a patch: this context may have held an API key, and a
105
+ // leftover one would silently outrank the session on the next command.
106
+ saveContext(contextName, {
107
+ baseUrl,
108
+ authUrl,
109
+ sessionToken,
110
+ organizationId: org.id,
111
+ });
112
+ }
113
+ catch (err) {
114
+ try {
115
+ await revoke(authUrl, sessionToken);
116
+ }
117
+ catch {
118
+ // Nothing to do; the session expires on its own.
119
+ }
120
+ throw err;
121
+ }
122
+ // Outside the guard on purpose: the session is on disk now, so a failure to
123
+ // mark the context active still leaves a credential the user can reach.
124
+ activateContext(contextName);
125
+ console.log(chalk.green(`Logged in as ${email}`));
126
+ console.log(chalk.dim(` — organization: ${org.name}`));
127
+ console.log(chalk.dim(`Config stored at: ${store.path}`));
128
+ }
129
+ /**
130
+ * The pre-device-flow login: take an API key from the flag or the terminal and
131
+ * store it. Still the path for CI-style credentials and for anyone whose
132
+ * machine cannot run the browser flow.
133
+ */
134
+ async function manualLogin(opts) {
135
+ let apiKey = opts.apiKey;
136
+ let baseUrl = opts.baseUrl;
137
+ const contextName = getActiveContextName();
138
+ const currentCtx = getActiveContext();
139
+ if (!apiKey || !baseUrl) {
140
+ const rl = createInterface({
141
+ input: process.stdin,
142
+ output: process.stdout,
143
+ });
144
+ try {
145
+ if (!baseUrl) {
146
+ const defaultUrl = currentCtx?.baseUrl ?? DEFAULT_BASE_URL;
147
+ baseUrl = await rl.question(`Base URL [${defaultUrl}]: `);
148
+ if (!baseUrl)
149
+ baseUrl = defaultUrl;
150
+ }
151
+ if (!apiKey) {
152
+ apiKey = await rl.question("API Key (sk_...): ");
153
+ }
154
+ }
155
+ finally {
156
+ rl.close();
157
+ }
158
+ }
159
+ if (!apiKey) {
160
+ console.error(chalk.red("API key is required."));
161
+ process.exit(1);
162
+ }
163
+ setContext(contextName, { apiKey, baseUrl });
164
+ setActiveContext(contextName);
165
+ console.log(chalk.green("Credentials saved successfully."));
166
+ console.log(chalk.dim(`Config stored at: ${store.path}`));
167
+ }
168
+ /**
169
+ * Sign out: drop the local credentials, then tell the auth service.
170
+ *
171
+ * That order is deliberate. `revokeSession` has no timeout, so an auth host
172
+ * that black-holes the request would hang the command with the credentials
173
+ * still on disk — while the user, having read "Credentials cleared", believes
174
+ * they are signed out. Clearing first makes the local half unconditional and
175
+ * leaves the network call as what it is: best-effort, and the only part that
176
+ * can fail. A host that refuses the connection fails fast either way.
177
+ *
178
+ * The token and URL are copied out first because the store no longer holds
179
+ * them by the time they are used.
180
+ */
181
+ export async function performLogout(deps = {}) {
182
+ const { revoke = revokeSession } = deps;
183
+ const contextName = getActiveContextName();
184
+ const ctx = getActiveContext();
185
+ const sessionToken = ctx?.sessionToken;
186
+ const authUrl = ctx?.authUrl;
187
+ if (getContextCount() <= 1) {
188
+ // Last context — clear it by resetting the store
189
+ store.store = { activeContext: "default", contexts: {} };
190
+ }
191
+ else {
192
+ deleteContext(contextName);
193
+ console.log(chalk.dim(`Removed context "${contextName}". Switched to "${getActiveContextName()}".`));
194
+ }
195
+ console.log(chalk.green("Credentials cleared."));
196
+ // The wording claims only what happened — better-auth answers 200 whether or
197
+ // not the bearer resolved to a session, so "revoked" would be a guess. A
198
+ // session that was already dead is not a failed logout either.
199
+ if (sessionToken && authUrl) {
200
+ try {
201
+ await revoke(authUrl, sessionToken);
202
+ console.log(chalk.dim("Sign-out sent to the auth service."));
203
+ }
204
+ catch {
205
+ console.log(chalk.yellow("Could not revoke the server-side session (already expired?)."));
206
+ }
207
+ }
208
+ }
209
+ /** The credential check both status paths run: cheap, and it needs real auth. */
210
+ async function countModels(command) {
211
+ const client = getClient(command);
212
+ const { data } = await client.GET("/v1/models");
213
+ return data?.data?.length ?? 0;
214
+ }
215
+ /** `auth status` for a browser-session context. */
216
+ export async function sessionStatus(config, view, deps) {
217
+ const { json, contextName, multiContext } = view;
218
+ const { countModels: probe, sessionInfo = getSessionInfo } = deps;
219
+ // Best-effort identity: better-auth answers 200 with a null body for a dead
220
+ // session, which auth-service turns into an error — so any failure here means
221
+ // "no user to name", never a reason to stop.
222
+ let email;
223
+ try {
224
+ email = (await sessionInfo(config.authUrl, config.sessionToken)).email;
225
+ }
226
+ catch {
227
+ email = undefined;
228
+ }
229
+ const identity = {
230
+ ...(multiContext ? { context: contextName } : {}),
231
+ baseUrl: config.baseUrl,
232
+ authType: "session",
233
+ ...(email ? { email } : {}),
234
+ ...(config.organizationId ? { organizationId: config.organizationId } : {}),
235
+ authUrl: config.authUrl,
236
+ };
237
+ const printIdentityLines = () => {
238
+ if (multiContext) {
239
+ console.log(` Context: ${contextName}`);
240
+ }
241
+ console.log(` Base URL: ${config.baseUrl}`);
242
+ console.log(` Auth: browser session (${config.authUrl})`);
243
+ if (email) {
244
+ console.log(` User: ${email}`);
245
+ }
246
+ if (config.organizationId) {
247
+ console.log(` Org: ${config.organizationId}`);
248
+ }
249
+ };
250
+ try {
251
+ const modelCount = await probe();
252
+ if (json) {
253
+ console.log(JSON.stringify({ authenticated: true, ...identity, modelCount }));
254
+ }
255
+ else {
256
+ console.log(chalk.green("Authenticated"));
257
+ printIdentityLines();
258
+ console.log(` Models: ${modelCount} available`);
259
+ }
260
+ }
261
+ catch (err) {
262
+ // A dead session already has its own rendering in handleError — catching it
263
+ // here would print the diagnosis twice, once as a guess.
264
+ if (err instanceof SessionExpiredError)
265
+ throw err;
266
+ // The same classification the human hint is chosen by, reported instead of
267
+ // kept — a --json caller should not have to string-match a hint it cannot
268
+ // even see to learn that the host was unreachable. Same key as the API-key
269
+ // branch below, so one consumer handles both auth types.
270
+ const failureKind = classifyAuthFailure(err);
271
+ process.exitCode = 1;
272
+ if (json) {
273
+ console.log(JSON.stringify({
274
+ authenticated: false,
275
+ ...identity,
276
+ failureKind,
277
+ error: "Failed to validate credentials",
278
+ }));
279
+ }
280
+ else {
281
+ console.log(chalk.yellow("Credentials configured but validation failed."));
282
+ printIdentityLines();
283
+ // A host that never answered says nothing about the session, so do not
284
+ // send the user off to re-login over what is probably a dropped network.
285
+ const hint = failureKind === "UNREACHABLE"
286
+ ? FAILURE_HINTS.UNREACHABLE
287
+ : 'Session may have expired — run "2kw auth login".';
288
+ console.log(chalk.dim(` ${hint}`));
289
+ }
290
+ }
291
+ }
292
+ /**
293
+ * @param deviceDeps forwarded to {@link deviceFlowLogin} — a seam for tests
294
+ * that need to prove the browser flow was NOT entered.
295
+ */
296
+ export function makeAuthCommand(deviceDeps = {}) {
15
297
  const cmd = new Command("auth").description("Manage authentication");
16
298
  cmd
17
299
  .command("login")
18
- .description("Configure API credentials")
19
- .option("--api-key <key>", "API key")
300
+ .description("Sign in through the browser (or with an API key)")
301
+ .option("--api-key <key>", "Save an API key instead of signing in through the browser")
302
+ .option("--manual", "Prompt for an API key instead of signing in through the browser")
20
303
  .option("--base-url <url>", "Base URL")
21
- .action(async (opts) => {
22
- let apiKey = opts.apiKey;
23
- let baseUrl = opts.baseUrl;
24
- const contextName = getActiveContextName();
25
- const currentCtx = getActiveContext();
26
- if (!apiKey || !baseUrl) {
27
- const rl = createInterface({
28
- input: process.stdin,
29
- output: process.stdout,
30
- });
31
- try {
32
- if (!baseUrl) {
33
- const defaultUrl = currentCtx?.baseUrl ?? DEFAULT_BASE_URL;
34
- baseUrl = await rl.question(`Base URL [${defaultUrl}]: `);
35
- if (!baseUrl)
36
- baseUrl = defaultUrl;
37
- }
38
- if (!apiKey) {
39
- apiKey = await rl.question("API Key (sk_...): ");
40
- }
41
- }
42
- finally {
43
- rl.close();
304
+ .option("--auth-url <url>", "Auth service URL (defaults to the one matching the base URL)")
305
+ .action(async (_opts, command) => {
306
+ // Merged, not own: the root program declares --api-key and --base-url as
307
+ // global options, and commander binds a flag to the first command that
308
+ // declares it while parsing — the root. This subcommand's own opts are
309
+ // therefore structurally EMPTY for exactly the two flags it documents, so
310
+ // `auth login --api-key sk_x` used to fall through to the browser flow.
311
+ //
312
+ // Reading them merged conflates the two spellings, and that is the
313
+ // intended behaviour: at the root, --api-key means "authenticate this one
314
+ // invocation", but combined with `auth login` the user has supplied a key
315
+ // AND asked to sign in, and saving it is the only reading that does
316
+ // anything at all.
317
+ const opts = command.optsWithGlobals();
318
+ await runAction(command, async () => {
319
+ if (opts.apiKey || opts.manual) {
320
+ await manualLogin(opts);
321
+ return;
44
322
  }
45
- }
46
- if (!apiKey) {
47
- console.error(chalk.red("API key is required."));
48
- process.exit(1);
49
- }
50
- setContext(contextName, { apiKey, baseUrl });
51
- setActiveContext(contextName);
52
- console.log(chalk.green("Credentials saved successfully."));
53
- console.log(chalk.dim(`Config stored at: ${store.path}`));
323
+ const contextName = getActiveContextName();
324
+ const currentCtx = getActiveContext();
325
+ const baseUrl = opts.baseUrl ?? currentCtx?.baseUrl ?? DEFAULT_BASE_URL;
326
+ const authUrl = stripTrailingSlash(opts.authUrl ?? currentCtx?.authUrl ?? defaultAuthUrlFor(baseUrl));
327
+ // Which service is about to be handed the login is not obvious once it
328
+ // is derived from the base URL, so say it before opening a browser.
329
+ console.log(chalk.dim(`Auth service: ${authUrl} (override with --auth-url)`));
330
+ await deviceFlowLogin({ contextName, baseUrl, authUrl }, deviceDeps);
331
+ });
54
332
  });
55
333
  cmd
56
334
  .command("logout")
57
335
  .description("Clear stored credentials")
58
- .action(() => {
59
- const contextName = getActiveContextName();
60
- const count = getContextCount();
61
- if (count <= 1) {
62
- // Last context — clear it by resetting the store
63
- store.store = { activeContext: "default", contexts: {} };
64
- }
65
- else {
66
- deleteContext(contextName);
67
- console.log(chalk.dim(`Removed context "${contextName}". Switched to "${getActiveContextName()}".`));
68
- }
69
- console.log(chalk.green("Credentials cleared."));
336
+ .action(async (_opts, command) => {
337
+ await runAction(command, () => performLogout());
70
338
  });
71
339
  cmd
72
340
  .command("status")
@@ -90,15 +358,20 @@ export function makeAuthCommand() {
90
358
  }
91
359
  return;
92
360
  }
93
- const { apiKey, baseUrl } = config;
94
- const keyPreview = apiKey.slice(0, 7) + "..." + apiKey.slice(-4);
95
361
  const multiContext = getContextCount() > 1;
96
362
  const contextName = getActiveContextName();
363
+ if (config.kind === "session") {
364
+ await sessionStatus(config, { json, contextName, multiContext }, { countModels: () => countModels(command) });
365
+ return;
366
+ }
367
+ const { apiKey, baseUrl } = config;
368
+ // The shared mask, not a local slice: on a key of 11 characters or
369
+ // fewer the two slices overlap and print it in full — every one of
370
+ // these four uses, --json included.
371
+ const keyPreview = maskApiKey(apiKey);
97
372
  // Try to validate by listing models (lightweight call)
98
373
  try {
99
- const client = getClient(command);
100
- const { data } = await client.GET("/v1/models");
101
- const modelCount = data?.data?.length ?? 0;
374
+ const modelCount = await countModels(command);
102
375
  if (json) {
103
376
  console.log(JSON.stringify({
104
377
  authenticated: true,
@@ -1,3 +1,19 @@
1
1
  import { Command } from "commander";
2
+ declare const ALLOWED_KEYS: readonly ["apiKey", "baseUrl"];
3
+ type ConfigKey = (typeof ALLOWED_KEYS)[number];
4
+ /**
5
+ * Write one config key onto the active context.
6
+ *
7
+ * Setting `apiKey` also tears down any browser session on that context.
8
+ * Resolution checks `sessionToken` before `apiKey` (see
9
+ * resolveConfigFromSources), so a key stored alongside a live session would be
10
+ * shadowed forever — the user would have set a credential that never gets used
11
+ * and never gets mentioned. Clearing the session makes the write mean what it
12
+ * says. Reported back so the caller can say it out loud.
13
+ */
14
+ export declare function applyConfigSet(key: ConfigKey, value: string): {
15
+ clearedSession: boolean;
16
+ };
2
17
  export declare function makeConfigCommand(): Command;
18
+ export {};
3
19
  //# sourceMappingURL=config.d.ts.map