From 7e11428545e61b730fe2fb9e209c9e5d206903d1 Mon Sep 17 00:00:00 2001 From: pomelo Date: Mon, 11 May 2026 16:44:09 +0800 Subject: [PATCH] refactor(cli): remove legacy `qwen auth` CLI subcommand, redirect to /auth TUI dialog (#3959) The `qwen auth` CLI subcommand (with subcommands like qwen-oauth, coding-plan, api-key, openrouter, status) has been superseded by the richer /auth TUI dialog introduced in the provider-first auth registry (#3864). Running `qwen auth` now prints a deprecation notice pointing users to the /auth TUI dialog (interactive), env vars (CI/headless), or /doctor (status check). Changes: - Replace auth.ts with a stub that prints a removal notice and exits - Delete handler.ts (734 lines), interactiveSelector.ts, and their tests (interactiveSelector.test.ts, openrouter.test.ts, status.test.ts) - Update /auth slash command to handle non-interactive/ACP modes gracefully - Enrich /doctor auth check with provider-aware diagnostics using findProviderByCredentials - Mark `auth` as a subcommand that handles its own exit in config.ts Co-authored-by: Qwen-Coder --- docs/design/openrouter-auth-and-models.md | 8 +- docs/users/configuration/auth.md | 97 +-- docs/users/features/commands.md | 20 +- docs/users/quickstart.md | 6 +- packages/cli/src/commands/auth.test.ts | 106 +++ packages/cli/src/commands/auth.ts | 137 ++-- packages/cli/src/commands/auth/handler.ts | 738 ------------------ .../commands/auth/interactiveSelector.test.ts | 421 ---------- .../src/commands/auth/interactiveSelector.ts | 166 ---- .../cli/src/commands/auth/openrouter.test.ts | 310 -------- packages/cli/src/commands/auth/status.test.ts | 707 ----------------- packages/cli/src/config/config.ts | 6 +- .../cli/src/ui/commands/authCommand.test.ts | 41 + packages/cli/src/ui/commands/authCommand.ts | 31 +- packages/cli/src/utils/doctorChecks.test.ts | 88 +++ packages/cli/src/utils/doctorChecks.ts | 42 +- packages/core/src/config/config.ts | 5 +- packages/core/src/config/storage.ts | 6 +- .../core/src/telemetry/trace-id-utils.test.ts | 6 +- 19 files changed, 403 insertions(+), 2538 deletions(-) create mode 100644 packages/cli/src/commands/auth.test.ts delete mode 100644 packages/cli/src/commands/auth/handler.ts delete mode 100644 packages/cli/src/commands/auth/interactiveSelector.test.ts delete mode 100644 packages/cli/src/commands/auth/interactiveSelector.ts delete mode 100644 packages/cli/src/commands/auth/openrouter.test.ts delete mode 100644 packages/cli/src/commands/auth/status.test.ts diff --git a/docs/design/openrouter-auth-and-models.md b/docs/design/openrouter-auth-and-models.md index 7c82476a654..98634550316 100644 --- a/docs/design/openrouter-auth-and-models.md +++ b/docs/design/openrouter-auth-and-models.md @@ -29,9 +29,11 @@ provider abstraction. The user-facing flows are: -- `qwen auth openrouter --key ` for automation or direct API-key setup. -- `qwen auth openrouter` for browser-based OAuth. -- `/auth` → API Key → OpenRouter for the TUI flow. +- `/auth` → OpenRouter for the interactive TUI flow. +- Environment variables for automation or direct API-key setup: + `OPENROUTER_API_KEY` plus `OPENAI_BASE_URL=https://openrouter.ai/api/v1`. +- `~/.qwen/settings.json` for scripted setup that needs explicit model provider + entries. Browser OAuth uses OpenRouter's PKCE flow and writes the exchanged API key into settings before refreshing auth as `AuthType.USE_OPENAI`. diff --git a/docs/users/configuration/auth.md b/docs/users/configuration/auth.md index 75c6ec45524..48173dad7c1 100644 --- a/docs/users/configuration/auth.md +++ b/docs/users/configuration/auth.md @@ -10,7 +10,7 @@ Qwen Code supports three authentication methods. Pick the one that matches how y > [!warning] > -> The Qwen OAuth free tier was discontinued on 2026-04-15. Existing cached tokens may continue working briefly, but new requests will be rejected. Please switch to Alibaba Cloud Coding Plan, [OpenRouter](https://openrouter.ai), [Fireworks AI](https://app.fireworks.ai), or another provider. Run `qwen auth` to configure. +> The Qwen OAuth free tier was discontinued on 2026-04-15. Existing cached tokens may continue working briefly, but new requests will be rejected. Please switch to Alibaba Cloud Coding Plan, [OpenRouter](https://openrouter.ai), [Fireworks AI](https://app.fireworks.ai), or another provider. Run `qwen` and use `/auth` to configure. - **How it works**: on first start, Qwen Code opens a browser login page. After you finish, credentials are cached locally so you usually won't need to log in again. - **Requirements**: a `qwen.ai` account + internet access (at least for the first login). @@ -23,15 +23,11 @@ Start the CLI and follow the browser flow: qwen ``` -Or authenticate directly without starting a session: - -```bash -qwen auth qwen-oauth -``` +Then run `/auth` and choose the OAuth provider from the interactive dialog. > [!note] > -> In non-interactive or headless environments (e.g., CI, SSH, containers), you typically **cannot** complete the OAuth browser login flow. +> In non-interactive or headless environments (e.g., CI, SSH, containers), you typically **cannot** complete the OAuth browser login flow. > In these cases, please use the Alibaba Cloud Coding Plan or API Key authentication method. ## šŸ’³ Option 2: Alibaba Cloud Coding Plan @@ -52,24 +48,22 @@ Alibaba Cloud Coding Plan is available in two regions: ### Interactive setup -You can set up Coding Plan authentication in two ways: - -**Option A: From the terminal (recommended for first-time setup)** - -```bash -# Interactive — prompts for region and API key -qwen auth coding-plan - -# Or non-interactive — pass region and key directly -qwen auth coding-plan --region china --key sk-sp-xxxxxxxxx -``` - -**Option B: Inside a Qwen Code session** - Enter `qwen` in the terminal to launch Qwen Code, then run the `/auth` command and select **Alibaba Cloud Coding Plan**. Choose your region, then enter your `sk-sp-xxxxxxxxx` key. After authentication, use the `/model` command to switch between all Alibaba Cloud Coding Plan supported models (including qwen3.5-plus, qwen3-coder-plus, qwen3-coder-next, qwen3-max, glm-4.7, and kimi-k2.5). +### Headless or scripted setup + +For CI, containers, or scripts, configure Coding Plan with environment variables or `settings.json` instead of the removed `qwen auth coding-plan` command. + +```bash +export BAILIAN_CODING_PLAN_API_KEY="sk-sp-xxxxxxxxx" +export OPENAI_BASE_URL="https://coding.dashscope.aliyuncs.com/v1" +export OPENAI_MODEL="qwen3-coder-plus" +``` + +Use `https://coding.dashscope.aliyuncs.com/v1` for the China (Beijing) endpoint, or `https://coding-intl.dashscope.aliyuncs.com/v1` for the international endpoint. + ### Alternative: configure via `settings.json` If you prefer to skip the interactive `/auth` flow, add the following to `~/.qwen/settings.json`: @@ -312,59 +306,20 @@ qwen --model "qwen3-coder-plus" qwen --model "qwen3.5-plus" ``` -## `qwen auth` CLI command +## Removed `qwen auth` CLI command -In addition to the in-session `/auth` slash command, Qwen Code provides a standalone `qwen auth` CLI command for managing authentication directly from the terminal — without starting an interactive session first. +The standalone `qwen auth` CLI command has been removed. Use these replacements instead: -### Interactive mode +| Previous use case | Replacement | +| -------------------------------- | ------------------------------------------------------------------------------------------- | +| Interactive authentication setup | Run `qwen`, then use `/auth` | +| Coding Plan setup | Use `/auth`, or set `BAILIAN_CODING_PLAN_API_KEY` with the Coding Plan base URL | +| OpenRouter setup | Use `/auth`, or set `OPENROUTER_API_KEY` and `OPENAI_BASE_URL=https://openrouter.ai/api/v1` | +| API-key or custom provider setup | Configure `~/.qwen/settings.json`, `.env`, or provider-specific environment variables | +| Check current authentication | Run `/doctor` inside Qwen Code | +| OAuth browser flow | Run `qwen` interactively and use `/auth`; OAuth cannot be configured with env vars alone | -Run `qwen auth` without arguments to get an interactive menu: - -```bash -qwen auth -``` - -You'll see a selector with arrow-key navigation: - -``` -Select authentication method: - - Alibaba Cloud Coding Plan - Paid Ā· Up to 6,000 requests/5 hrs Ā· All Alibaba Cloud Coding Plan Models - API Key - Bring your own API key - Qwen OAuth - Discontinued — switch to Coding Plan or API Key - -(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit) -``` - -### Subcommands - -| Command | Description | -| ---------------------------------------------------- | ------------------------------------------------- | -| `qwen auth` | Interactive authentication setup | -| `qwen auth coding-plan` | Authenticate with Alibaba Cloud Coding Plan | -| `qwen auth coding-plan --region china --key sk-sp-…` | Non-interactive Coding Plan setup (for scripting) | -| `qwen auth api-key` | Authenticate with an API key | -| `qwen auth qwen-oauth` | Authenticate with Qwen OAuth (discontinued) | -| `qwen auth status` | Show current authentication status | - -**Examples:** - -```bash -# Authenticate with Qwen OAuth directly -qwen auth qwen-oauth - -# Set up Coding Plan interactively (prompts for region and key) -qwen auth coding-plan - -# Set up Coding Plan non-interactively (useful for CI/scripting) -qwen auth coding-plan --region china --key sk-sp-xxxxxxxxx - -# Set up API key (ModelStudio Standard or custom provider) -qwen auth api-key - -# Check your current auth configuration -qwen auth status -``` +Legacy invocations such as `qwen auth status` now print a removal notice with these migration paths. ## Security notes diff --git a/docs/users/features/commands.md b/docs/users/features/commands.md index 3494a589b25..de566af738d 100644 --- a/docs/users/features/commands.md +++ b/docs/users/features/commands.md @@ -234,22 +234,18 @@ Commands for obtaining information and performing system settings. | `Ctrl/cmd+Z` | Undo input | Text editing | | `Ctrl/cmd+Shift+Z` | Redo input | Text editing | -### 1.10 CLI Auth Subcommands +### 1.10 Authentication Commands -In addition to the in-session `/auth` slash command, Qwen Code provides standalone CLI subcommands for managing authentication directly from the terminal: +Use `/auth` inside a Qwen Code session to configure authentication. Use `/doctor` to inspect the current authentication and environment status. -| Command | Description | -| ---------------------------------------------------- | ------------------------------------------------------------- | -| `qwen auth` | Interactive authentication setup | -| `qwen auth coding-plan` | Authenticate with Alibaba Cloud Coding Plan | -| `qwen auth coding-plan --region china --key sk-sp-…` | Non-interactive Coding Plan setup (for scripting) | -| `qwen auth api-key` | Authenticate with an API key | -| `qwen auth qwen-oauth` | ~~Authenticate with Qwen OAuth~~ (discontinued on 2026-04-15) | -| `qwen auth status` | Show current authentication status | +| Command | Description | +| --------- | ------------------------------------------ | +| `/auth` | Configure authentication interactively | +| `/doctor` | Show authentication and environment checks | -> [!tip] +> [!note] > -> These commands run outside of a Qwen Code session. Use them to configure authentication before starting a session, or in scripts and CI environments. See the [Authentication](../configuration/auth) page for full details. +> The standalone `qwen auth` CLI command has been removed. Legacy invocations such as `qwen auth status` print a removal notice with migration guidance. See the [Authentication](../configuration/auth) page for full details. ## 2. @ Commands (Introducing Files) diff --git a/docs/users/quickstart.md b/docs/users/quickstart.md index aba0a8c07ca..7b2bdb992ec 100644 --- a/docs/users/quickstart.md +++ b/docs/users/quickstart.md @@ -87,7 +87,7 @@ Choose your preferred authentication method: > [!tip] > -> You can also configure authentication directly from the terminal without starting a session by running `qwen auth`. Use `qwen auth status` to check your current configuration at any time. See the [Authentication](./configuration/auth) page for details. +> To configure authentication, start Qwen Code and run `/auth`. Use `/doctor` to check your current configuration at any time. See the [Authentication](./configuration/auth) page for details. ## Step 3: Start your first session @@ -230,9 +230,7 @@ Here are the most important commands for daily use: | --------------------- | ------------------------------------------------ | ----------------------------- | | `qwen` | start Qwen Code | `qwen` | | `/auth` | Change authentication method (in session) | `/auth` | -| `qwen auth` | Configure authentication from the terminal | `qwen auth` | -| `qwen auth api-key` | Configure API key authentication | `qwen auth api-key` | -| `qwen auth status` | Check current authentication status | `qwen auth status` | +| `/doctor` | Check current authentication and environment | `/doctor` | | `/help` | Display help information for available commands | `/help` or `/?` | | `/compress` | Replace chat history with summary to save Tokens | `/compress` | | `/clear` | Clear terminal screen content | `/clear` (shortcut: `Ctrl+L`) | diff --git a/packages/cli/src/commands/auth.test.ts b/packages/cli/src/commands/auth.test.ts new file mode 100644 index 00000000000..0ee7f13b911 --- /dev/null +++ b/packages/cli/src/commands/auth.test.ts @@ -0,0 +1,106 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import yargs from 'yargs'; +import { authCommand, buildRemovalNotice, printRemovalNotice } from './auth.js'; + +describe('auth command', () => { + const originalNoColor = process.env['NO_COLOR']; + const originalIsTTY = process.stdout.isTTY; + + beforeEach(() => { + process.env['NO_COLOR'] = '1'; + Object.defineProperty(process.stdout, 'isTTY', { + value: false, + configurable: true, + }); + }); + + afterEach(() => { + if (originalNoColor === undefined) { + delete process.env['NO_COLOR']; + } else { + process.env['NO_COLOR'] = originalNoColor; + } + Object.defineProperty(process.stdout, 'isTTY', { + value: originalIsTTY, + configurable: true, + }); + vi.restoreAllMocks(); + }); + + it('builds a removal notice with migration paths and no ANSI in non-TTY', () => { + const notice = buildRemovalNotice(); + + expect(notice).toContain('qwen auth has been removed'); + expect(notice).toContain('/auth'); + expect(notice).toContain('/doctor'); + expect(notice).toContain('BAILIAN_CODING_PLAN_API_KEY'); + expect(notice).toContain('https://coding.dashscope.aliyuncs.com/v1'); + expect(notice).toContain('https://coding-intl.dashscope.aliyuncs.com/v1'); + expect(notice).toContain('OPENROUTER_API_KEY'); + expect(notice).not.toContain('\x1b['); + expect(notice).not.toContain('v0.15.8'); + }); + + it('writes the notice before exiting', () => { + const exit = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('exit'); + }) as never); + const write = vi.spyOn(process.stdout, 'write').mockImplementation((( + _chunk: unknown, + callback?: () => void, + ) => { + callback?.(); + return true; + }) as never); + + expect(() => printRemovalNotice()).toThrow('exit'); + + expect(write).toHaveBeenCalledWith( + expect.any(String), + expect.any(Function), + ); + expect(exit).toHaveBeenCalledWith(0); + }); + + it.each([ + ['auth'], + ['auth status'], + ['auth api-key'], + ['auth qwen-oauth'], + ['auth openrouter --key test-key'], + ['auth coding-plan --region china --key sk-sp-test'], + ['auth --key test-key'], + ])('routes `%s` to the removal notice', async (command) => { + const exit = vi + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as never); + const write = vi.spyOn(process.stdout, 'write').mockImplementation((( + _chunk: unknown, + callback?: () => void, + ) => { + callback?.(); + return true; + }) as never); + + await yargs(command.split(' ')) + .scriptName('qwen') + .command(authCommand) + .strict() + .fail((message, error) => { + throw error ?? new Error(message); + }) + .parseAsync(); + + expect(write).toHaveBeenCalledWith( + expect.any(String), + expect.any(Function), + ); + expect(exit).toHaveBeenCalledWith(0); + }); +}); diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts index 61016dbb241..af200eea88f 100644 --- a/packages/cli/src/commands/auth.ts +++ b/packages/cli/src/commands/auth.ts @@ -4,98 +4,63 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { CommandModule, Argv } from 'yargs'; -import { - handleQwenAuth, - handleApiKeyAuth, - runInteractiveAuth, - showAuthStatus, -} from './auth/handler.js'; +import type { Argv, CommandModule } from 'yargs'; import { t } from '../i18n/index.js'; -// Define subcommands separately -const qwenOauthCommand = { - command: 'qwen-oauth', - describe: t('Authenticate using Qwen OAuth'), - handler: async () => { - await handleQwenAuth('qwen-oauth', {}); - }, +const shouldUseColor = () => + Boolean(process.stdout.isTTY && !process.env['NO_COLOR']); + +const color = (value: string, code: string) => + shouldUseColor() ? `\x1b[${code}m${value}\x1b[0m` : value; + +const cyan = (value: string) => color(value, '36'); +const yellow = (value: string) => color(value, '33'); + +export const buildRemovalNotice = (): string => + [ + '', + yellow(t('⚠ qwen auth has been removed.')), + '', + ` ${cyan(t('Interactive'))} → ${t('run qwen and use /auth to configure providers')}`, + ` ${cyan(t('CI / Headless'))} → ${t('set provider environment variables, for example OPENAI_API_KEY + OPENAI_BASE_URL + OPENAI_MODEL')}`, + ` ${t('or pass --openai-api-key, --openai-base-url, --model')}`, + ` ${cyan(t('Coding Plan'))} → ${t('set BAILIAN_CODING_PLAN_API_KEY and use the Coding Plan base URL for your region')}`, + ` ${t('China: https://coding.dashscope.aliyuncs.com/v1')}`, + ` ${t('International: https://coding-intl.dashscope.aliyuncs.com/v1')}`, + ` ${cyan(t('OpenRouter'))} → ${t('set OPENROUTER_API_KEY and OPENAI_BASE_URL=https://openrouter.ai/api/v1')}`, + ` ${cyan(t('Qwen OAuth'))} → ${t('run qwen interactively and use /auth; OAuth cannot be configured with env vars alone')}`, + ` ${cyan(t('Scripted'))} → ${t('edit ~/.qwen/settings.json, or run qwen interactively once')}`, + '', + ` ${t('Check auth status')} → ${cyan('/doctor')}`, + '', + ].join('\n'); + +export const printRemovalNotice = () => { + process.stdout.write(buildRemovalNotice(), () => process.exit(0)); }; -const codePlanCommand = { - command: 'coding-plan', - describe: t('Authenticate using Alibaba Cloud Coding Plan'), - builder: (yargs: Argv) => - yargs - .option('base-url', { - alias: 'u', - describe: t('Base URL for Coding Plan'), - type: 'string', - }) - .option('key', { - alias: 'k', - describe: t('API key for Coding Plan'), - type: 'string', - }), - handler: async (argv: { 'base-url'?: string; key?: string }) => { - const baseUrl = argv['base-url']; - const key = argv['key'] as string | undefined; - - if (baseUrl && key) { - await handleQwenAuth('coding-plan', { baseUrl, key }); - } else { - await handleQwenAuth('coding-plan', {}); - } - }, -}; - -const apiKeyCommand = { - command: 'api-key', - describe: t('Authenticate using an API key'), - handler: async () => { - await handleApiKeyAuth(); - }, -}; - -const openRouterCommand = { - command: 'openrouter', - describe: t('Authenticate using OpenRouter API key setup'), - builder: (yargs: Argv) => - yargs.option('key', { - alias: 'k', - describe: t('API key for OpenRouter'), - type: 'string', - }), - handler: async (argv: { key?: string }) => { - const key = argv['key'] as string | undefined; - await handleQwenAuth('openrouter', { key }); - }, -}; - -const statusCommand = { - command: 'status', - describe: t('Show current authentication status'), - handler: async () => { - await showAuthStatus(); - }, -}; +const legacySubcommands = [ + 'status', + 'coding-plan', + 'openrouter', + 'api-key', + 'qwen-oauth', +]; export const authCommand: CommandModule = { command: 'auth', - describe: t( - 'Configure Qwen authentication with OpenRouter, Coding Plan, API Key, or Qwen-OAuth', - ), - builder: (yargs: Argv) => - yargs - .command(qwenOauthCommand) - .command(codePlanCommand) - .command(openRouterCommand) - .command(apiKeyCommand) - .command(statusCommand) - .demandCommand(0) // Don't require a subcommand - .version(false), - handler: async () => { - // This handler is for when no subcommand is provided - show interactive menu - await runInteractiveAuth(); + describe: t('Configure authentication (removed)'), + builder: (yargs: Argv) => { + let y = yargs.version(false).strict(false); + for (const name of legacySubcommands) { + y = y.command({ + command: `${name} [legacyArgs..]`, + describe: false, + builder: (subYargs: Argv) => subYargs.strict(false), + handler: printRemovalNotice, + }); + } + return y; }, + handler: printRemovalNotice, }; diff --git a/packages/cli/src/commands/auth/handler.ts b/packages/cli/src/commands/auth/handler.ts deleted file mode 100644 index a55ce566ea2..00000000000 --- a/packages/cli/src/commands/auth/handler.ts +++ /dev/null @@ -1,738 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { - AuthType, - getErrorMessage, - type Config, - type ProviderModelConfig as ModelConfig, -} from '@qwen-code/qwen-code-core'; -import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; -import { t } from '../../i18n/index.js'; -import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; -import { applyProviderInstallPlan } from '../../auth/install/applyProviderInstallPlan.js'; -import { codingPlanProvider } from '../../auth/providers/alibaba/codingPlan.js'; -import { createOpenRouterProviderInstallPlan } from '../../auth/providers/oauth/openrouter.js'; -import { - buildInstallPlan, - resolveBaseUrl, - resolveMetadataKey, - getDefaultModelIds, - PROVIDER_METADATA_NS, -} from '../../auth/providerConfig.js'; -import { findProviderByCredentials } from '../../auth/allProviders.js'; -import { loadSettings, type LoadedSettings } from '../../config/settings.js'; -import { loadCliConfig } from '../../config/config.js'; -import type { CliArgs } from '../../config/config.js'; -import { InteractiveSelector } from './interactiveSelector.js'; -import { - createOpenRouterOAuthSession, - isOpenRouterConfig, - OPENROUTER_ENV_KEY, - runOpenRouterOAuthLogin, -} from '../../auth/providers/oauth/openrouterOAuth.js'; - -function formatElapsedTime(startMs: number): string { - return `${((Date.now() - startMs) / 1000).toFixed(2)}s`; -} - -interface QwenAuthOptions { - baseUrl?: string; - key?: string; -} - -interface MergedSettingsWithCodingPlan { - security?: { - auth?: { - selectedType?: string; - apiKey?: string; - baseUrl?: string; - }; - }; - model?: { - name?: string; - }; - modelProviders?: Record; - env?: Record; -} - -/** - * Creates a minimal CliArgs for auth command config loading - */ -function createMinimalArgv(): CliArgs { - return { - query: undefined, - model: undefined, - sandbox: undefined, - sandboxImage: undefined, - debug: undefined, - prompt: undefined, - promptInteractive: undefined, - yolo: undefined, - bare: undefined, - approvalMode: undefined, - telemetry: undefined, - checkpointing: undefined, - telemetryTarget: undefined, - telemetryOtlpEndpoint: undefined, - telemetryOtlpProtocol: undefined, - telemetryLogPrompts: undefined, - telemetryOutfile: undefined, - allowedMcpServerNames: undefined, - mcpConfig: undefined, - allowedTools: undefined, - acp: undefined, - experimentalAcp: undefined, - experimentalLsp: undefined, - extensions: [], - listExtensions: undefined, - openaiLogging: undefined, - openaiApiKey: undefined, - openaiBaseUrl: undefined, - openaiLoggingDir: undefined, - proxy: undefined, - includeDirectories: undefined, - screenReader: undefined, - inputFormat: undefined, - outputFormat: undefined, - includePartialMessages: undefined, - chatRecording: undefined, - continue: undefined, - resume: undefined, - sessionId: undefined, - maxSessionTurns: undefined, - coreTools: undefined, - excludeTools: undefined, - disabledSlashCommands: undefined, - authType: undefined, - channel: undefined, - systemPrompt: undefined, - appendSystemPrompt: undefined, - }; -} - -/** - * Loads settings and config for auth commands - */ -async function loadAuthConfig(settings: LoadedSettings) { - return loadCliConfig( - settings.merged, - createMinimalArgv(), - process.cwd(), - [], - { - userHooks: settings.getUserHooks(), - projectHooks: settings.getProjectHooks(), - }, - ); -} - -/** - * Handles the authentication process based on the specified command and options - */ -export async function handleQwenAuth( - command: 'qwen-oauth' | 'coding-plan' | 'openrouter', - options: QwenAuthOptions, -) { - try { - const settings = loadSettings(); - const config = await loadAuthConfig(settings); - - if (command === 'qwen-oauth') { - await handleQwenOAuth(config, settings); - } else if (command === 'coding-plan') { - await handleCodePlanAuth(config, settings, options); - } else if (command === 'openrouter') { - await handleOpenRouterAuth(config, settings, options); - } - - // Exit after authentication is complete - writeStdoutLine(t('Authentication completed successfully.')); - process.exit(0); - } catch (error) { - writeStderrLine(getErrorMessage(error)); - process.exit(1); - } -} - -/** - * Handles Qwen OAuth authentication - */ -async function handleQwenOAuth( - config: Config, - settings: LoadedSettings, -): Promise { - writeStdoutLine(t('Starting Qwen OAuth authentication...')); - - try { - await config.refreshAuth(AuthType.QWEN_OAUTH); - - // Persist the auth type - const authTypeScope = getPersistScopeForModelSelection(settings); - settings.setValue( - authTypeScope, - 'security.auth.selectedType', - AuthType.QWEN_OAUTH, - ); - - writeStdoutLine(t('Successfully authenticated with Qwen OAuth.')); - process.exit(0); - } catch (error) { - writeStderrLine( - t('Failed to authenticate with Qwen OAuth: {{error}}', { - error: getErrorMessage(error), - }), - ); - process.exit(1); - } -} - -/** - * Handles Alibaba Cloud Coding Plan authentication - */ -async function handleCodePlanAuth( - config: Config, - settings: LoadedSettings, - options: QwenAuthOptions, -): Promise { - const { baseUrl, key } = options; - - let selectedBaseUrl: string; - let selectedKey: string; - - if (baseUrl && key) { - selectedBaseUrl = baseUrl; - selectedKey = key; - } else { - selectedBaseUrl = await promptForCodingPlanBaseUrl(); - selectedKey = await promptForKey(t('Enter your Coding Plan API key: ')); - } - - writeStdoutLine(t('Processing Alibaba Cloud Coding Plan authentication...')); - - try { - const resolved = resolveBaseUrl(codingPlanProvider, selectedBaseUrl); - const installPlan = buildInstallPlan(codingPlanProvider, { - baseUrl: resolved, - apiKey: selectedKey, - modelIds: getDefaultModelIds(codingPlanProvider), - }); - await applyProviderInstallPlan(installPlan, { settings, config }); - - writeStdoutLine( - t('Successfully authenticated with Alibaba Cloud Coding Plan.'), - ); - } catch (error) { - writeStderrLine( - t('Failed to authenticate with Coding Plan: {{error}}', { - error: getErrorMessage(error), - }), - ); - process.exit(1); - } -} - -/** - * Handles OpenRouter API key setup. - */ -async function handleOpenRouterAuth( - config: Config, - settings: LoadedSettings, - options: QwenAuthOptions, -): Promise { - writeStdoutLine(t('Processing OpenRouter authentication...')); - - try { - const authStartMs = Date.now(); - let selectedKey = options.key; - - if (!selectedKey) { - const oauthStartMs = Date.now(); - const oauthSession = createOpenRouterOAuthSession(); - writeStdoutLine( - t( - 'Starting OpenRouter OAuth in your browser. If needed, open this link manually: {{authorizationUrl}}', - { - authorizationUrl: oauthSession.authorizationUrl, - }, - ), - ); - const oauthResult = await runOpenRouterOAuthLogin(undefined, { - session: oauthSession, - }); - writeStdoutLine( - t('Waited for OpenRouter browser authorization in {{elapsed}}.', { - elapsed: - typeof oauthResult.authorizationCodeWaitMs === 'number' - ? `${(oauthResult.authorizationCodeWaitMs / 1000).toFixed(2)}s` - : formatElapsedTime(oauthStartMs), - }), - ); - writeStdoutLine( - t('Exchanged OpenRouter auth code for API key in {{elapsed}}.', { - elapsed: - typeof oauthResult.apiKeyExchangeMs === 'number' - ? `${(oauthResult.apiKeyExchangeMs / 1000).toFixed(2)}s` - : formatElapsedTime(oauthStartMs), - }), - ); - writeStdoutLine( - t('OpenRouter OAuth callback completed in {{elapsed}}.', { - elapsed: formatElapsedTime(oauthStartMs), - }), - ); - selectedKey = oauthResult.apiKey; - } - - if (!selectedKey) { - throw new Error( - 'OpenRouter authentication completed without an API key.', - ); - } - - const modelsStartMs = Date.now(); - const installPlan = await createOpenRouterProviderInstallPlan({ - apiKey: selectedKey, - }); - await applyProviderInstallPlan(installPlan, { settings, config }); - writeStdoutLine( - t('Fetched OpenRouter models in {{elapsed}}.', { - elapsed: formatElapsedTime(modelsStartMs), - }), - ); - - writeStdoutLine( - t('Total OpenRouter setup time: {{elapsed}}.', { - elapsed: formatElapsedTime(authStartMs), - }), - ); - - writeStdoutLine(t('Successfully configured OpenRouter.')); - } catch (error) { - writeStderrLine( - t('Failed to configure OpenRouter: {{error}}', { - error: getErrorMessage(error), - }), - ); - process.exit(1); - } -} - -async function promptForCodingPlanBaseUrl(): Promise { - const baseUrlOptions = Array.isArray(codingPlanProvider.baseUrl) - ? codingPlanProvider.baseUrl - : []; - const selector = new InteractiveSelector( - baseUrlOptions.map((opt) => ({ - value: opt.url, - label: t(opt.label), - description: opt.url, - })), - t('Select Base URL for Coding Plan:'), - ); - - return await selector.select(); -} - -/** - * Generic raw-mode text input prompt. - * @param promptText - Text displayed before the cursor - * @param options.mask - If true, echoes '*' instead of the typed character (for passwords) - * @param options.defaultValue - Value returned when the user presses Enter on empty input - */ -async function promptForInput( - promptText: string, - options: { mask?: boolean; defaultValue?: string } = {}, -): Promise { - const { mask = false, defaultValue } = options; - const stdin = process.stdin; - const stdout = process.stdout; - - stdout.write(promptText); - - const wasRaw = stdin.isRaw; - if (stdin.setRawMode) { - stdin.setRawMode(true); - } - stdin.resume(); - - return new Promise((resolve, reject) => { - let input = ''; - - const onData = (chunk: string) => { - for (const char of chunk) { - switch (char) { - case '\r': // Enter - case '\n': - stdin.removeListener('data', onData); - if (stdin.setRawMode) { - stdin.setRawMode(wasRaw); - } - stdout.write('\n'); - resolve( - defaultValue !== undefined && !input ? defaultValue : input, - ); - return; - case '\x03': // Ctrl+C - stdin.removeListener('data', onData); - if (stdin.setRawMode) { - stdin.setRawMode(wasRaw); - } - stdout.write('^C\n'); - reject(new Error('Interrupted')); - return; - case '\x08': // Backspace - case '\x7F': // Delete - if (input.length > 0) { - input = input.slice(0, -1); - // Move cursor back, print space, move back again - stdout.write('\x1B[D \x1B[D'); - } - break; - default: - input += char; - stdout.write(mask ? '*' : char); - break; - } - } - }; - - stdin.on('data', onData); - }); -} - -/** - * Prompts the user to enter an API key (masked input) - */ -async function promptForKey( - promptText: string = t('Enter your Coding Plan API key: '), -): Promise { - return promptForInput(promptText, { mask: true }); -} - -/** - * Runs the interactive authentication flow - */ -export async function runInteractiveAuth() { - const selector = new InteractiveSelector( - [ - { - value: 'openrouter' as const, - label: t('OpenRouter'), - description: t( - 'API key setup Ā· OpenAI-compatible provider via OpenRouter', - ), - }, - { - value: 'coding-plan' as const, - label: t('Alibaba Cloud Coding Plan'), - description: t( - 'Paid Ā· Up to 6,000 requests/5 hrs Ā· All Alibaba Cloud Coding Plan Models', - ), - }, - { - value: 'api-key' as const, - label: t('API Key'), - description: t('Bring your own API key'), - }, - { - value: 'qwen-oauth' as const, - label: t('Qwen OAuth'), - description: t('Discontinued — switch to Coding Plan or API Key'), - }, - ], - t('Select authentication method:'), - ); - - let choice = await selector.select(); - - // If user selects discontinued Qwen OAuth, warn and re-prompt - while (choice === 'qwen-oauth') { - writeStdoutLine( - t( - '\n⚠ Qwen OAuth free tier was discontinued on 2026-04-15. Please select another option.\n', - ), - ); - choice = await selector.select(); - } - - if (choice === 'coding-plan') { - await handleQwenAuth('coding-plan', {}); - } else if (choice === 'api-key') { - await handleApiKeyAuth(); - } else if (choice === 'openrouter') { - await handleQwenAuth('openrouter', {}); - } -} - -/** - * Handles API Key authentication - directs user to documentation. - * - * Intentionally simplified: the full interactive provider setup is now - * available through the `/auth` slash command in the UI. The CLI sub-command - * (`qwen auth api-key`) serves as a lightweight fallback that points users - * to the docs. A future improvement could wire this into the provider - * registry for a fully interactive CLI flow. - */ -export async function handleApiKeyAuth() { - handleCustomApiKeyAuth(); -} - -/** - * Handles Custom API Key - prints docs link - */ -function handleCustomApiKeyAuth(): void { - writeStdoutLine(''); - writeStdoutLine( - t('You can configure your API key and models in settings.json'), - ); - writeStdoutLine(t('Refer to the documentation for setup instructions')); - writeStdoutLine( - ' https://qwenlm.github.io/qwen-code-docs/en/users/configuration/model-providers/\n', - ); - process.exit(0); -} - -/** - * Shows the current authentication status - */ -export async function showAuthStatus(): Promise { - try { - const settings = loadSettings(); - const mergedSettings = settings.merged as MergedSettingsWithCodingPlan; - - writeStdoutLine(t('\n=== Authentication Status ===\n')); - - // Check for selected auth type - const selectedType = mergedSettings.security?.auth?.selectedType; - - if (!selectedType) { - writeStdoutLine(t('āš ļø No authentication method configured.\n')); - writeStdoutLine(t('Run one of the following commands to get started:\n')); - writeStdoutLine( - t(' qwen auth openrouter - Configure OpenRouter API key'), - ); - writeStdoutLine( - t( - ' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan', - ), - ); - writeStdoutLine( - t(' qwen auth api-key - Authenticate with an API key'), - ); - writeStdoutLine( - t( - ' qwen auth qwen-oauth - Authenticate with Qwen OAuth (discontinued)', - ), - ); - writeStdoutLine(''); - writeStdoutLine(t('Or simply run:')); - writeStdoutLine( - t(' qwen auth - Interactive authentication setup\n'), - ); - process.exit(0); - } - - // Display status based on auth type - if (selectedType === AuthType.QWEN_OAUTH) { - writeStdoutLine(t('āœ“ Authentication Method: Qwen OAuth')); - writeStdoutLine(t(' Type: Free tier (discontinued 2026-04-15)')); - writeStdoutLine(t(' Limit: No longer available')); - writeStdoutLine(t(' Models: Qwen latest models')); - writeStdoutLine( - t('\n ⚠ Run /auth to switch to Coding Plan or another provider.\n'), - ); - } else if (selectedType === AuthType.USE_OPENAI) { - const modelName = mergedSettings.model?.name; - const openAiProviders = - mergedSettings.modelProviders?.[AuthType.USE_OPENAI] || []; - const activeConfig = modelName - ? openAiProviders.find((c) => c.id === modelName) - : openAiProviders[0]; - const isActiveOpenRouter = activeConfig - ? isOpenRouterConfig(activeConfig) - : false; - const hasOpenRouterApiKey = - !!process.env[OPENROUTER_ENV_KEY] || - !!mergedSettings.env?.[OPENROUTER_ENV_KEY]; - - const foundProvider = activeConfig - ? findProviderByCredentials(activeConfig.baseUrl, activeConfig.envKey) - : undefined; - const managedProvider = - foundProvider && resolveMetadataKey(foundProvider) - ? foundProvider - : undefined; - - if (isActiveOpenRouter) { - if (hasOpenRouterApiKey) { - writeStdoutLine(t('āœ“ Authentication Method: OpenRouter')); - - if (modelName) { - writeStdoutLine( - t(' Current Model: {{model}}', { model: modelName }), - ); - } - - writeStdoutLine(t(' Status: API key configured\n')); - } else { - writeStdoutLine( - t('āš ļø Authentication Method: OpenRouter (Incomplete)'), - ); - writeStdoutLine( - t(' Issue: API key not found in environment or settings\n'), - ); - writeStdoutLine(t(' Run `qwen auth openrouter` to re-configure.\n')); - } - } else if (managedProvider) { - const envKey = - typeof managedProvider.envKey === 'string' - ? managedProvider.envKey - : ''; - const metaKey = resolveMetadataKey(managedProvider)!; - const ns = (mergedSettings as Record)[ - PROVIDER_METADATA_NS - ] as Record | undefined; - const metadata = ns?.[metaKey] as - | { version?: string; baseUrl?: string } - | undefined; - const hasApiKey = - !!process.env[envKey] || !!mergedSettings.env?.[envKey]; - - if (hasApiKey) { - writeStdoutLine( - t('āœ“ Authentication Method: {{plan}}', { - plan: t(managedProvider.label), - }), - ); - - if (metadata?.baseUrl) { - writeStdoutLine( - t(' Base URL: {{baseUrl}}', { baseUrl: metadata.baseUrl }), - ); - } - - if (modelName) { - writeStdoutLine( - t(' Current Model: {{model}}', { model: modelName }), - ); - } - - if (metadata?.version) { - writeStdoutLine( - t(' Config Version: {{version}}', { - version: metadata.version.substring(0, 8) + '...', - }), - ); - } - - writeStdoutLine(t(' Status: API key configured\n')); - } else { - writeStdoutLine( - t('āš ļø Authentication Method: {{plan}} (Incomplete)', { - plan: t(managedProvider.label), - }), - ); - writeStdoutLine( - t(' Issue: API key not found in environment or settings\n'), - ); - writeStdoutLine( - t(' Run `qwen auth` to re-configure authentication.\n'), - ); - } - } else if (activeConfig) { - let hasApiKey: boolean; - if (activeConfig.envKey) { - hasApiKey = - !!process.env[activeConfig.envKey] || - !!mergedSettings.env?.[activeConfig.envKey]; - } else { - hasApiKey = - !!process.env['OPENAI_API_KEY'] || - !!mergedSettings.env?.['OPENAI_API_KEY'] || - !!mergedSettings.security?.auth?.apiKey; - } - - if (hasApiKey) { - writeStdoutLine( - t('āœ“ Authentication Method: OpenAI-compatible Provider'), - ); - - if (modelName) { - writeStdoutLine( - t(' Current Model: {{model}}', { model: modelName }), - ); - } - - const baseUrl = - activeConfig.baseUrl || mergedSettings.security?.auth?.baseUrl; - if (baseUrl) { - writeStdoutLine(t(' Base URL: {{baseUrl}}', { baseUrl })); - } - - writeStdoutLine(t(' Status: API key configured\n')); - } else { - writeStdoutLine( - t( - 'āš ļø Authentication Method: OpenAI-compatible Provider (Incomplete)', - ), - ); - writeStdoutLine( - t(' Issue: API key not found in environment or settings\n'), - ); - writeStdoutLine(t(' Run `qwen auth` to re-configure.\n')); - } - } else { - const hasGenericApiKey = - !!process.env['OPENAI_API_KEY'] || - !!mergedSettings.env?.['OPENAI_API_KEY'] || - !!mergedSettings.security?.auth?.apiKey; - - if (hasGenericApiKey) { - writeStdoutLine( - t('āœ“ Authentication Method: OpenAI-compatible Provider'), - ); - - if (modelName) { - writeStdoutLine( - t(' Current Model: {{model}}', { model: modelName }), - ); - } - - const baseUrl = mergedSettings.security?.auth?.baseUrl; - if (baseUrl) { - writeStdoutLine(t(' Base URL: {{baseUrl}}', { baseUrl })); - } - - writeStdoutLine(t(' Status: API key configured\n')); - } else { - writeStdoutLine( - t( - 'āš ļø Authentication Method: OpenAI-compatible Provider (Incomplete)', - ), - ); - writeStdoutLine( - t(' Issue: API key not found in environment or settings\n'), - ); - writeStdoutLine(t(' Run `qwen auth` to re-configure.\n')); - } - } - } else { - writeStdoutLine( - t('āœ“ Authentication Method: {{type}}', { type: selectedType }), - ); - writeStdoutLine(t(' Status: Configured\n')); - } - process.exit(0); - } catch (error) { - writeStderrLine( - t('Failed to check authentication status: {{error}}', { - error: getErrorMessage(error), - }), - ); - process.exit(1); - } -} diff --git a/packages/cli/src/commands/auth/interactiveSelector.test.ts b/packages/cli/src/commands/auth/interactiveSelector.test.ts deleted file mode 100644 index e580cb3bf9a..00000000000 --- a/packages/cli/src/commands/auth/interactiveSelector.test.ts +++ /dev/null @@ -1,421 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/* eslint-disable @typescript-eslint/no-explicit-any */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { InteractiveSelector } from './interactiveSelector.js'; -import { stdin, stdout } from 'node:process'; - -describe('InteractiveSelector', () => { - const mockOptions = [ - { value: 'option1', label: 'Option 1', description: 'First option' }, - { value: 'option2', label: 'Option 2', description: 'Second option' }, - { value: 'option3', label: 'Option 3', description: 'Third option' }, - ]; - - const mockPrompt = 'Select an option:'; - - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('constructor', () => { - it('should create an instance with default prompt', () => { - const selector = new InteractiveSelector(mockOptions); - expect(selector).toBeInstanceOf(InteractiveSelector); - }); - - it('should create an instance with custom prompt', () => { - const selector = new InteractiveSelector(mockOptions, mockPrompt); - expect(selector).toBeInstanceOf(InteractiveSelector); - }); - }); - - describe('select', () => { - it('should reject if raw mode is not available', async () => { - // Mock stdin without setRawMode - const originalSetRawMode = stdin.setRawMode; - (stdin as any).setRawMode = undefined; - - const selector = new InteractiveSelector(mockOptions, mockPrompt); - - await expect(selector.select()).rejects.toThrow( - 'Raw mode not available. Please run in an interactive terminal.', - ); - - // Restore - (stdin as any).setRawMode = originalSetRawMode; - }); - - it('should select first option with Enter key', async () => { - const mockSetRawMode = vi.fn(); - const mockResume = vi.fn(); - const mockSetEncoding = vi.fn(); - const mockRemoveListener = vi.fn(); - const mockOn = vi.fn((event: any, callback: any) => { - // Simulate Enter key press - setTimeout(() => callback('\r'), 0); - return stdin; - }); - - (stdin as any).isRaw = false; - (stdin as any).setRawMode = mockSetRawMode; - (stdin as any).resume = mockResume; - (stdin as any).setEncoding = mockSetEncoding; - (stdin as any).removeListener = mockRemoveListener; - (stdin as any).on = mockOn; - - const stdoutWriteSpy = vi - .spyOn(stdout, 'write') - .mockImplementation(() => true); - - const selector = new InteractiveSelector(mockOptions, mockPrompt); - const result = await selector.select(); - - expect(result).toBe('option1'); - expect(mockSetRawMode).toHaveBeenCalledWith(true); - expect(mockResume).toHaveBeenCalled(); - - stdoutWriteSpy.mockRestore(); - }); - - it('should select second option after arrow down then Enter', async () => { - let dataCallback!: (chunk: string) => void; - - const mockSetRawMode = vi.fn(); - const mockResume = vi.fn(); - const mockOn = vi.fn((event: any, callback: any) => { - dataCallback = callback; - return stdin; - }); - const mockRemoveListener = vi.fn(); - - (stdin as any).isRaw = false; - (stdin as any).setRawMode = mockSetRawMode; - (stdin as any).resume = mockResume; - (stdin as any).on = mockOn; - (stdin as any).removeListener = mockRemoveListener; - - const stdoutWriteSpy = vi - .spyOn(stdout, 'write') - .mockImplementation(() => true); - - const selector = new InteractiveSelector(mockOptions, mockPrompt); - const selectPromise = selector.select(); - - // Simulate arrow down - dataCallback('\x1B[B'); - - // Simulate Enter - setTimeout(() => dataCallback('\r'), 0); - - const result = await selectPromise; - - expect(result).toBe('option2'); - - stdoutWriteSpy.mockRestore(); - }); - - it('should handle arrow up navigation', async () => { - let dataCallback!: (chunk: string) => void; - - const mockSetRawMode = vi.fn(); - const mockResume = vi.fn(); - const mockOn = vi.fn((event: any, callback: any) => { - dataCallback = callback; - return stdin; - }); - const mockRemoveListener = vi.fn(); - - (stdin as any).isRaw = false; - (stdin as any).setRawMode = mockSetRawMode; - (stdin as any).resume = mockResume; - (stdin as any).on = mockOn; - (stdin as any).removeListener = mockRemoveListener; - - const stdoutWriteSpy = vi - .spyOn(stdout, 'write') - .mockImplementation(() => true); - - const selector = new InteractiveSelector(mockOptions, mockPrompt); - const selectPromise = selector.select(); - - // Move down twice - dataCallback('\x1B[B'); - dataCallback('\x1B[B'); - - // Move up once - dataCallback('\x1B[A'); - - // Simulate Enter - setTimeout(() => dataCallback('\r'), 0); - - const result = await selectPromise; - - expect(result).toBe('option2'); - - stdoutWriteSpy.mockRestore(); - }); - - it('should reject with Ctrl+C', async () => { - let dataCallback!: (chunk: string) => void; - - const mockSetRawMode = vi.fn(); - const mockResume = vi.fn(); - const mockOn = vi.fn((event: any, callback: any) => { - dataCallback = callback; - return stdin; - }); - const mockRemoveListener = vi.fn(); - - (stdin as any).isRaw = false; - (stdin as any).setRawMode = mockSetRawMode; - (stdin as any).resume = mockResume; - (stdin as any).on = mockOn; - (stdin as any).removeListener = mockRemoveListener; - - const selector = new InteractiveSelector(mockOptions, mockPrompt); - const selectPromise = selector.select(); - - // Simulate Ctrl+C - setTimeout(() => dataCallback('\x03'), 0); - - await expect(selectPromise).rejects.toThrow('Interrupted'); - }); - - it('should wrap around when navigating past last option', async () => { - let dataCallback!: (chunk: string) => void; - - const mockSetRawMode = vi.fn(); - const mockResume = vi.fn(); - const mockOn = vi.fn((event: any, callback: any) => { - dataCallback = callback; - return stdin; - }); - const mockRemoveListener = vi.fn(); - - (stdin as any).isRaw = false; - (stdin as any).setRawMode = mockSetRawMode; - (stdin as any).resume = mockResume; - (stdin as any).on = mockOn; - (stdin as any).removeListener = mockRemoveListener; - - const stdoutWriteSpy = vi - .spyOn(stdout, 'write') - .mockImplementation(() => true); - - const selector = new InteractiveSelector(mockOptions, mockPrompt); - const selectPromise = selector.select(); - - // Move down past last option (should wrap to first) - dataCallback('\x1B[B'); - dataCallback('\x1B[B'); - dataCallback('\x1B[B'); // Now at option1 again (wrapped) - - // Simulate Enter - setTimeout(() => dataCallback('\r'), 0); - - const result = await selectPromise; - - expect(result).toBe('option1'); - - stdoutWriteSpy.mockRestore(); - }); - - it('should wrap around when navigating before first option', async () => { - let dataCallback!: (chunk: string) => void; - - const mockSetRawMode = vi.fn(); - const mockResume = vi.fn(); - const mockOn = vi.fn((event: any, callback: any) => { - dataCallback = callback; - return stdin; - }); - const mockRemoveListener = vi.fn(); - - (stdin as any).isRaw = false; - (stdin as any).setRawMode = mockSetRawMode; - (stdin as any).resume = mockResume; - (stdin as any).on = mockOn; - (stdin as any).removeListener = mockRemoveListener; - - const stdoutWriteSpy = vi - .spyOn(stdout, 'write') - .mockImplementation(() => true); - - const selector = new InteractiveSelector(mockOptions, mockPrompt); - const selectPromise = selector.select(); - - // Move up from first option (should wrap to last) - dataCallback('\x1B[A'); - - // Simulate Enter - setTimeout(() => dataCallback('\r'), 0); - - const result = await selectPromise; - - expect(result).toBe('option3'); - - stdoutWriteSpy.mockRestore(); - }); - - it('should ignore arrow left/right keys', async () => { - let dataCallback!: (chunk: string) => void; - - const mockSetRawMode = vi.fn(); - const mockResume = vi.fn(); - const mockOn = vi.fn((event: any, callback: any) => { - dataCallback = callback; - return stdin; - }); - const mockRemoveListener = vi.fn(); - - (stdin as any).isRaw = false; - (stdin as any).setRawMode = mockSetRawMode; - (stdin as any).resume = mockResume; - (stdin as any).on = mockOn; - (stdin as any).removeListener = mockRemoveListener; - - const stdoutWriteSpy = vi - .spyOn(stdout, 'write') - .mockImplementation(() => true); - - const selector = new InteractiveSelector(mockOptions, mockPrompt); - const selectPromise = selector.select(); - - // Press arrow right (should be ignored) - dataCallback('\x1B[C'); - - // Press arrow left (should be ignored) - dataCallback('\x1B[D'); - - // Press Enter - should still select first option - setTimeout(() => dataCallback('\r'), 0); - - const result = await selectPromise; - - expect(result).toBe('option1'); - - stdoutWriteSpy.mockRestore(); - }); - - it('should handle newline character as Enter', async () => { - let dataCallback!: (chunk: string) => void; - - const mockSetRawMode = vi.fn(); - const mockResume = vi.fn(); - const mockOn = vi.fn((event: any, callback: any) => { - dataCallback = callback; - return stdin; - }); - const mockRemoveListener = vi.fn(); - - (stdin as any).isRaw = false; - (stdin as any).setRawMode = mockSetRawMode; - (stdin as any).resume = mockResume; - (stdin as any).on = mockOn; - (stdin as any).removeListener = mockRemoveListener; - - const stdoutWriteSpy = vi - .spyOn(stdout, 'write') - .mockImplementation(() => true); - - const selector = new InteractiveSelector(mockOptions, mockPrompt); - const selectPromise = selector.select(); - - // Simulate newline - setTimeout(() => dataCallback('\n'), 0); - - const result = await selectPromise; - - expect(result).toBe('option1'); - - stdoutWriteSpy.mockRestore(); - }); - }); - - describe('renderMenu', () => { - it('should render menu with correct formatting', () => { - const stdoutWriteSpy = vi - .spyOn(stdout, 'write') - .mockImplementation(() => true); - - const selector = new InteractiveSelector(mockOptions, mockPrompt); - - // Access private method for testing - (selector as any).renderMenu(); - - expect(stdoutWriteSpy).toHaveBeenCalled(); - const output = stdoutWriteSpy.mock.calls.map((call) => call[0]).join(''); - - expect(output).toContain('Select an option:'); - expect(output).toContain('Option 1'); - expect(output).toContain('Option 2'); - expect(output).toContain('Option 3'); - expect(output).toContain('First option'); - expect(output).toContain('Second option'); - expect(output).toContain('Third option'); - expect(output).toContain('↑ ↓'); - expect(output).toContain('Enter'); - expect(output).toContain('Ctrl+C'); - - stdoutWriteSpy.mockRestore(); - }); - - it('should highlight selected option', () => { - const stdoutWriteSpy = vi - .spyOn(stdout, 'write') - .mockImplementation(() => true); - - const selector = new InteractiveSelector(mockOptions, mockPrompt); - (selector as any).selectedIndex = 1; - (selector as any).renderMenu(); - - const output = stdoutWriteSpy.mock.calls.map((call) => call[0]).join(''); - - // Selected option should have cyan color code - expect(output).toContain('\x1B[36m'); - - stdoutWriteSpy.mockRestore(); - }); - - it('should calculate correct total lines', () => { - const selector = new InteractiveSelector(mockOptions, mockPrompt); - - // Access private method for testing - (selector as any).calculateTotalLines(); - - // Expected: 4 (prompt + empty + empty + instructions) + 3 (options) = 7 - expect((selector as any).calculateTotalLines()).toBe(7); - }); - - it('should handle options without descriptions', () => { - const simpleOptions = [ - { value: 'a', label: 'A' }, - { value: 'b', label: 'B' }, - ]; - - const stdoutWriteSpy = vi - .spyOn(stdout, 'write') - .mockImplementation(() => true); - - const selector = new InteractiveSelector(simpleOptions, mockPrompt); - (selector as any).renderMenu(); - - const output = stdoutWriteSpy.mock.calls.map((call) => call[0]).join(''); - - expect(output).toContain('A'); - expect(output).toContain('B'); - - stdoutWriteSpy.mockRestore(); - }); - }); -}); diff --git a/packages/cli/src/commands/auth/interactiveSelector.ts b/packages/cli/src/commands/auth/interactiveSelector.ts deleted file mode 100644 index 84b9c9f0d1a..00000000000 --- a/packages/cli/src/commands/auth/interactiveSelector.ts +++ /dev/null @@ -1,166 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { stdin, stdout } from 'node:process'; -import { t } from '../../i18n/index.js'; - -/** - * Represents an option in the interactive selector - */ -interface Option { - value: T; - label: string; - description?: string; -} - -/** - * Interactive selector that allows users to navigate with arrow keys - */ -export class InteractiveSelector { - private selectedIndex = 0; - private isListening = false; - - constructor( - private options: Array>, - private prompt: string = t('Select an option:'), - ) {} - - /** - * Shows the interactive menu and waits for user selection - */ - async select(): Promise { - return new Promise((resolve, reject) => { - this.isListening = true; - - // Display initial menu - this.renderMenu(); - - // Check if stdin supports raw mode - if (!stdin.setRawMode) { - // Fallback to readline if raw mode is not available (e.g., when piped) - reject( - new Error( - t('Raw mode not available. Please run in an interactive terminal.'), - ), - ); - return; - } - - const wasRaw = stdin.isRaw; - stdin.setRawMode(true); - stdin.resume(); - stdin.setEncoding('utf8'); - - const onData = (chunk: string) => { - if (!this.isListening) return; - - for (const char of chunk) { - switch (char) { - case '\x03': // Ctrl+C - stdin.removeListener('data', onData); - stdin.setRawMode(wasRaw); - reject(new Error('Interrupted')); - return; - case '\r': // Enter - case '\n': // Newline - stdin.removeListener('data', onData); - stdin.setRawMode(wasRaw); - resolve(this.options[this.selectedIndex].value); - return; - case '\x1B': // ESC sequence - // Next character will be [, then A, B, C, or D - break; - default: - // Handle other characters if needed - break; - } - } - - // Handle escape sequences - if (chunk.startsWith('\x1B')) { - if (chunk === '\x1B[A') { - // Arrow up - this.moveUp(); - } else if (chunk === '\x1B[B') { - // Arrow down - this.moveDown(); - } else if (chunk === '\x1B[C') { - // Arrow right - // Do nothing for now - } else if (chunk === '\x1B[D') { - // Arrow left - // Do nothing for now - } - } - }; - - stdin.on('data', onData); - }); - } - - /** - * Renders the menu to stdout - */ - private renderMenu(): void { - // Calculate how many lines we need to clear - const totalLines = this.calculateTotalLines(); - - // Clear the screen area we'll be using - if (totalLines > 0) { - stdout.write(`\x1B[${totalLines}A\x1B[J`); // Move up and clear from cursor down - } - - // Write the prompt - stdout.write(`${this.prompt}\n\n`); - - // Write each option - combine label and description on same line - this.options.forEach((option, index) => { - const isSelected = index === this.selectedIndex; - const indicator = isSelected ? '> ' : ' '; - const color = isSelected ? '\x1B[36m' : '\x1B[0m'; // Cyan for selected, default for others - const reset = '\x1B[0m'; - - // Combine label and description in one line - let line = `${indicator}${color}${option.label}`; - if (option.description) { - line += ` - ${option.description}`; - } - line += `${reset}\n`; - - stdout.write(line); - }); - - // Add instructions - stdout.write( - `\n${t('(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n')}`, - ); - } - - /** - * Calculates the total number of lines to clear - */ - private calculateTotalLines(): number { - // Lines for: prompt (1) + empty line (1) + options (each option takes 1 line) + empty line (1) + instructions (1) - return 4 + this.options.length; - } - - /** - * Moves selection up - */ - private moveUp(): void { - this.selectedIndex = - (this.selectedIndex - 1 + this.options.length) % this.options.length; - this.renderMenu(); - } - - /** - * Moves selection down - */ - private moveDown(): void { - this.selectedIndex = (this.selectedIndex + 1) % this.options.length; - this.renderMenu(); - } -} diff --git a/packages/cli/src/commands/auth/openrouter.test.ts b/packages/cli/src/commands/auth/openrouter.test.ts deleted file mode 100644 index 4d30753bcd7..00000000000 --- a/packages/cli/src/commands/auth/openrouter.test.ts +++ /dev/null @@ -1,310 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { handleQwenAuth } from './handler.js'; -import { AuthType } from '@qwen-code/qwen-code-core'; -import type { LoadedSettings } from '../../config/settings.js'; - -const { - mockRefreshAuth, - mockSetValue, - mockForScope, - mockBackupSettingsFile, - mockLoadCliConfig, - mockReloadModelProvidersConfig, - mockSyncAfterAuthRefresh, -} = vi.hoisted(() => { - const mockRefreshAuth = vi.fn(); - const mockReloadModelProvidersConfig = vi.fn(); - const mockSyncAfterAuthRefresh = vi.fn(); - return { - mockRefreshAuth, - mockSetValue: vi.fn(), - mockForScope: vi.fn(() => ({ path: '/user.json' })), - mockBackupSettingsFile: vi.fn(), - mockReloadModelProvidersConfig, - mockSyncAfterAuthRefresh, - mockLoadCliConfig: vi.fn(async () => ({ - refreshAuth: mockRefreshAuth, - reloadModelProvidersConfig: mockReloadModelProvidersConfig, - getModelsConfig: vi.fn(() => ({ - syncAfterAuthRefresh: mockSyncAfterAuthRefresh, - })), - })), - }; -}); - -vi.mock('../../config/settings.js', () => ({ - loadSettings: vi.fn(), -})); - -vi.mock('../../config/config.js', () => ({ - loadCliConfig: mockLoadCliConfig, -})); - -vi.mock('../../utils/settingsUtils.js', () => ({ - backupSettingsFile: mockBackupSettingsFile, - restoreSettingsFromBackup: vi.fn(), - cleanupSettingsBackup: vi.fn(), -})); - -vi.mock('../../config/modelProvidersScope.js', () => ({ - getPersistScopeForModelSelection: vi.fn(() => 'user'), -})); - -vi.mock('../../auth/providers/oauth/openrouter.js', () => ({ - openRouterProvider: { - id: 'openrouter', - label: 'OpenRouter', - category: 'third-party', - protocol: 'openai', - setupMethods: [{ type: 'oauth' }], - ownsModel: (model: { baseUrl?: string }) => - model.baseUrl === 'https://openrouter.ai/api/v1', - }, - createOpenRouterProviderInstallPlan: vi.fn(async ({ apiKey }) => ({ - providerId: 'openrouter', - authType: 'openai', - env: { - OPENROUTER_API_KEY: apiKey, - }, - modelSelection: { - modelId: 'z-ai/glm-4.5-air:free', - }, - modelProviders: [ - { - authType: 'openai', - models: [ - { - id: 'z-ai/glm-4.5-air:free', - name: 'OpenRouter Ā· GLM 4.5 Air', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - { - id: 'openai/gpt-oss-120b:free', - name: 'OpenRouter Ā· GPT OSS 120B', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - ], - mergeStrategy: 'prepend-and-remove-owned', - ownsModel: (model: { baseUrl?: string }) => - (model.baseUrl ?? '').includes('openrouter.ai'), - }, - ], - })), -})); - -vi.mock('../../utils/stdioHelpers.js', () => ({ - writeStdoutLine: vi.fn(), - writeStderrLine: vi.fn(), -})); - -vi.mock('../../auth/providers/oauth/openrouterOAuth.js', () => ({ - OPENROUTER_ENV_KEY: 'OPENROUTER_API_KEY', - OPENROUTER_BASE_URL: 'https://openrouter.ai/api/v1', - OPENROUTER_OAUTH_CALLBACK_URL: 'http://localhost:3000/openrouter/callback', - createOpenRouterOAuthSession: vi.fn(() => ({ - callbackUrl: 'http://localhost:3000/openrouter/callback', - codeVerifier: 'test-verifier', - authorizationUrl: 'https://openrouter.ai/auth?manual=1', - })), - runOpenRouterOAuthLogin: vi.fn(), -})); - -import { loadSettings } from '../../config/settings.js'; -import { runOpenRouterOAuthLogin } from '../../auth/providers/oauth/openrouterOAuth.js'; - -describe('handleQwenAuth openrouter', () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); - delete process.env['OPENROUTER_API_KEY']; - }); - - afterEach(() => { - vi.restoreAllMocks(); - delete process.env['OPENROUTER_API_KEY']; - }); - - const createMockSettings = ( - merged: Record, - ): LoadedSettings => - ({ - merged, - system: { settings: {}, path: '/system.json' }, - systemDefaults: { settings: {}, path: '/system-defaults.json' }, - user: { settings: {}, path: '/user.json' }, - workspace: { settings: {}, path: '/workspace.json' }, - forScope: mockForScope, - setValue: mockSetValue, - getUserHooks: vi.fn(() => []), - getProjectHooks: vi.fn(() => []), - isTrusted: true, - }) as unknown as LoadedSettings; - - it('stores OpenRouter key and model provider config', async () => { - vi.mocked(loadSettings).mockReturnValue( - createMockSettings({ - modelProviders: { - [AuthType.USE_OPENAI]: [ - { - id: 'gpt-4.1', - name: 'OpenAI GPT-4.1', - baseUrl: 'https://api.openai.com/v1', - envKey: 'OPENAI_API_KEY', - }, - ], - }, - }), - ); - - await handleQwenAuth('openrouter', { key: 'or-key-123' }); - - expect(mockBackupSettingsFile).toHaveBeenCalledWith('/user.json'); - expect(mockSetValue).toHaveBeenCalledWith( - 'user', - 'env.OPENROUTER_API_KEY', - 'or-key-123', - ); - expect(mockSetValue).toHaveBeenCalledWith( - 'user', - 'security.auth.selectedType', - AuthType.USE_OPENAI, - ); - expect(mockSetValue).toHaveBeenCalledWith( - 'user', - 'model.name', - 'z-ai/glm-4.5-air:free', - ); - - const modelProvidersCall = mockSetValue.mock.calls.find( - (call) => call[1] === `modelProviders.${AuthType.USE_OPENAI}`, - ); - expect(modelProvidersCall).toBeDefined(); - expect(modelProvidersCall?.[2]).toEqual([ - { - id: 'z-ai/glm-4.5-air:free', - name: 'OpenRouter Ā· GLM 4.5 Air', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - { - id: 'openai/gpt-oss-120b:free', - name: 'OpenRouter Ā· GPT OSS 120B', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - { - id: 'gpt-4.1', - name: 'OpenAI GPT-4.1', - baseUrl: 'https://api.openai.com/v1', - envKey: 'OPENAI_API_KEY', - }, - ]); - expect(mockReloadModelProvidersConfig).toHaveBeenCalledWith( - expect.objectContaining({ - [AuthType.USE_OPENAI]: expect.arrayContaining([ - expect.objectContaining({ id: 'z-ai/glm-4.5-air:free' }), - ]), - }), - ); - expect(mockSyncAfterAuthRefresh).toHaveBeenCalledWith( - AuthType.USE_OPENAI, - 'z-ai/glm-4.5-air:free', - ); - expect(mockRefreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI); - expect(process.env['OPENROUTER_API_KEY']).toBe('or-key-123'); - }); - - it('replaces existing OpenRouter configs instead of duplicating them', async () => { - vi.mocked(loadSettings).mockReturnValue( - createMockSettings({ - modelProviders: { - [AuthType.USE_OPENAI]: [ - { - id: 'old/model', - name: 'Old OpenRouter Model', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - { - id: 'gpt-4.1', - name: 'OpenAI GPT-4.1', - baseUrl: 'https://api.openai.com/v1', - envKey: 'OPENAI_API_KEY', - }, - ], - }, - }), - ); - - await handleQwenAuth('openrouter', { key: 'or-key-456' }); - - const modelProvidersCall = mockSetValue.mock.calls.find( - (call) => call[1] === `modelProviders.${AuthType.USE_OPENAI}`, - ); - expect(modelProvidersCall?.[2]).toEqual([ - { - id: 'z-ai/glm-4.5-air:free', - name: 'OpenRouter Ā· GLM 4.5 Air', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - { - id: 'openai/gpt-oss-120b:free', - name: 'OpenRouter Ā· GPT OSS 120B', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - { - id: 'gpt-4.1', - name: 'OpenAI GPT-4.1', - baseUrl: 'https://api.openai.com/v1', - envKey: 'OPENAI_API_KEY', - }, - ]); - }); - - it('uses OAuth flow when key is not provided', async () => { - vi.mocked(loadSettings).mockReturnValue(createMockSettings({})); - vi.mocked(runOpenRouterOAuthLogin).mockResolvedValue({ - apiKey: 'oauth-key-123', - userId: 'user-1', - authorizationUrl: 'https://openrouter.ai/auth?manual=1', - }); - - await handleQwenAuth('openrouter', {}); - - expect(runOpenRouterOAuthLogin).toHaveBeenCalledTimes(1); - expect(mockSetValue).toHaveBeenCalledWith( - 'user', - 'env.OPENROUTER_API_KEY', - 'oauth-key-123', - ); - expect(process.env['OPENROUTER_API_KEY']).toBe('oauth-key-123'); - }); - - it('applies OpenRouter provider updates through the shared installer', async () => { - vi.mocked(loadSettings).mockReturnValue(createMockSettings({})); - - await handleQwenAuth('openrouter', { key: 'or-key-dynamic' }); - - expect(mockSetValue).toHaveBeenCalledWith( - 'user', - 'env.OPENROUTER_API_KEY', - 'or-key-dynamic', - ); - expect(mockReloadModelProvidersConfig).toHaveBeenCalled(); - expect(mockSyncAfterAuthRefresh).toHaveBeenCalledWith( - AuthType.USE_OPENAI, - 'z-ai/glm-4.5-air:free', - ); - expect(mockRefreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI); - }); -}); diff --git a/packages/cli/src/commands/auth/status.test.ts b/packages/cli/src/commands/auth/status.test.ts deleted file mode 100644 index 03c9c94f185..00000000000 --- a/packages/cli/src/commands/auth/status.test.ts +++ /dev/null @@ -1,707 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { showAuthStatus } from './handler.js'; -import { AuthType } from '@qwen-code/qwen-code-core'; -import { - CODING_PLAN_ENV_KEY, - CODING_PLAN_CHINA_BASE_URL, - CODING_PLAN_GLOBAL_BASE_URL, - codingPlanProvider, -} from '../../auth/providers/alibaba/codingPlan.js'; -import { buildProviderTemplate } from '../../auth/providerConfig.js'; -import type { LoadedSettings } from '../../config/settings.js'; - -vi.mock('../../config/settings.js', () => ({ - loadSettings: vi.fn(), -})); - -vi.mock('../../utils/stdioHelpers.js', () => ({ - writeStdoutLine: vi.fn(), - writeStderrLine: vi.fn(), -})); - -import { loadSettings } from '../../config/settings.js'; -import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; - -const codingPlanProviders = (baseUrl: string = CODING_PLAN_CHINA_BASE_URL) => ({ - [AuthType.USE_OPENAI]: buildProviderTemplate(codingPlanProvider, baseUrl), -}); - -describe('showAuthStatus', () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); - delete process.env[CODING_PLAN_ENV_KEY]; - delete process.env['OPENAI_API_KEY']; - delete process.env['OPENROUTER_API_KEY']; - }); - - afterEach(() => { - vi.restoreAllMocks(); - delete process.env[CODING_PLAN_ENV_KEY]; - delete process.env['OPENAI_API_KEY']; - delete process.env['OPENROUTER_API_KEY']; - }); - - const createMockSettings = ( - merged: Record, - ): LoadedSettings => - ({ - merged, - system: { settings: {}, path: '/system.json' }, - systemDefaults: { settings: {}, path: '/system-defaults.json' }, - user: { settings: {}, path: '/user.json' }, - workspace: { settings: {}, path: '/workspace.json' }, - forScope: vi.fn(), - setValue: vi.fn(), - isTrusted: true, - }) as unknown as LoadedSettings; - - it('should show message when no authentication is configured', async () => { - vi.mocked(loadSettings).mockReturnValue(createMockSettings({})); - - await showAuthStatus(); - - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('No authentication method configured'), - ); - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('qwen auth openrouter'), - ); - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('qwen auth qwen-oauth'), - ); - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('qwen auth coding-plan'), - ); - expect(process.exit).toHaveBeenCalledWith(0); - }); - - it('should show Qwen OAuth status when configured', async () => { - vi.mocked(loadSettings).mockReturnValue( - createMockSettings({ - security: { - auth: { - selectedType: AuthType.QWEN_OAUTH, - }, - }, - }), - ); - - await showAuthStatus(); - - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('Qwen OAuth'), - ); - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('Free tier (discontinued 2026-04-15)'), - ); - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('No longer available'), - ); - expect(process.exit).toHaveBeenCalledWith(0); - }); - - it('should show Coding Plan status when configured with API key', async () => { - process.env[CODING_PLAN_ENV_KEY] = 'test-api-key'; - - vi.mocked(loadSettings).mockReturnValue( - createMockSettings({ - security: { - auth: { - selectedType: AuthType.USE_OPENAI, - }, - }, - providerMetadata: { - 'coding-plan': { - baseUrl: CODING_PLAN_CHINA_BASE_URL, - version: 'abc123def456', - }, - }, - model: { - name: 'qwen3.5-plus', - }, - modelProviders: codingPlanProviders(), - }), - ); - - await showAuthStatus(); - - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('Coding Plan'), - ); - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('API key configured'), - ); - expect(process.exit).toHaveBeenCalledWith(0); - }); - - it('should show OpenRouter status when configured with API key', async () => { - process.env['OPENROUTER_API_KEY'] = 'test-openrouter-key'; - - vi.mocked(loadSettings).mockReturnValue( - createMockSettings({ - security: { - auth: { - selectedType: AuthType.USE_OPENAI, - }, - }, - model: { - name: 'openai/gpt-4o-mini', - }, - modelProviders: { - [AuthType.USE_OPENAI]: [ - { - id: 'openai/gpt-4o-mini', - name: 'OpenRouter Ā· GPT-4o mini', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - ], - }, - }), - ); - - await showAuthStatus(); - - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('OpenRouter'), - ); - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('openai/gpt-4o-mini'), - ); - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('API key configured'), - ); - expect(process.exit).toHaveBeenCalledWith(0); - }); - - it('should show OpenRouter as incomplete when API key is missing', async () => { - vi.mocked(loadSettings).mockReturnValue( - createMockSettings({ - security: { - auth: { - selectedType: AuthType.USE_OPENAI, - }, - }, - modelProviders: { - [AuthType.USE_OPENAI]: [ - { - id: 'openai/gpt-4o-mini', - name: 'OpenRouter Ā· GPT-4o mini', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - ], - }, - }), - ); - - await showAuthStatus(); - - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('OpenRouter (Incomplete)'), - ); - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('qwen auth openrouter'), - ); - }); - - it('should show Coding Plan as incomplete when API key is missing', async () => { - vi.mocked(loadSettings).mockReturnValue( - createMockSettings({ - security: { - auth: { - selectedType: AuthType.USE_OPENAI, - }, - }, - providerMetadata: { - 'coding-plan': { - baseUrl: CODING_PLAN_GLOBAL_BASE_URL, - }, - }, - modelProviders: codingPlanProviders(CODING_PLAN_GLOBAL_BASE_URL), - }), - ); - - await showAuthStatus(); - - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('Incomplete'), - ); - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('API key not found'), - ); - }); - - it('should show Coding Plan base URL for China endpoint', async () => { - process.env[CODING_PLAN_ENV_KEY] = 'test-api-key'; - - vi.mocked(loadSettings).mockReturnValue( - createMockSettings({ - security: { - auth: { - selectedType: AuthType.USE_OPENAI, - }, - }, - providerMetadata: { - 'coding-plan': { - baseUrl: CODING_PLAN_CHINA_BASE_URL, - }, - }, - model: { - name: 'qwen3.5-plus', - }, - modelProviders: codingPlanProviders(), - }), - ); - - await showAuthStatus(); - - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining(CODING_PLAN_CHINA_BASE_URL), - ); - }); - - it('should show Coding Plan base URL for global endpoint', async () => { - process.env[CODING_PLAN_ENV_KEY] = 'test-api-key'; - - vi.mocked(loadSettings).mockReturnValue( - createMockSettings({ - security: { - auth: { - selectedType: AuthType.USE_OPENAI, - }, - }, - providerMetadata: { - 'coding-plan': { - baseUrl: CODING_PLAN_GLOBAL_BASE_URL, - }, - }, - model: { - name: 'qwen3-coder-plus', - }, - modelProviders: codingPlanProviders(CODING_PLAN_GLOBAL_BASE_URL), - }), - ); - - await showAuthStatus(); - - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining(CODING_PLAN_GLOBAL_BASE_URL), - ); - }); - - it('should show current model name', async () => { - process.env[CODING_PLAN_ENV_KEY] = 'test-api-key'; - - vi.mocked(loadSettings).mockReturnValue( - createMockSettings({ - security: { - auth: { - selectedType: AuthType.USE_OPENAI, - }, - }, - providerMetadata: { - 'coding-plan': { - baseUrl: CODING_PLAN_CHINA_BASE_URL, - }, - }, - model: { - name: 'qwen3.5-plus', - }, - modelProviders: codingPlanProviders(), - }), - ); - - await showAuthStatus(); - - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('qwen3.5-plus'), - ); - }); - - it('should show config version (truncated)', async () => { - process.env[CODING_PLAN_ENV_KEY] = 'test-api-key'; - - vi.mocked(loadSettings).mockReturnValue( - createMockSettings({ - security: { - auth: { - selectedType: AuthType.USE_OPENAI, - }, - }, - providerMetadata: { - 'coding-plan': { - baseUrl: CODING_PLAN_CHINA_BASE_URL, - version: 'abc123def456789', - }, - }, - model: { - name: 'qwen3.5-plus', - }, - modelProviders: codingPlanProviders(), - }), - ); - - await showAuthStatus(); - - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('abc123de...'), - ); - }); - - it('should handle errors and exit with code 1', async () => { - const error = new Error('Settings load failed'); - vi.mocked(loadSettings).mockImplementation(() => { - throw error; - }); - - await showAuthStatus(); - - expect(writeStderrLine).toHaveBeenCalledWith( - expect.stringContaining('Failed to check authentication status'), - ); - expect(process.exit).toHaveBeenCalledWith(1); - }); - - describe('OpenAI-compatible provider (no Coding Plan)', () => { - afterEach(() => { - delete process.env['OPENAI_API_KEY']; - delete process.env['CUSTOM_API_KEY']; - delete process.env['XUNFEI_API_KEY']; - delete process.env[CODING_PLAN_ENV_KEY]; - }); - - it('should show OpenAI-compatible status with OPENAI_API_KEY', async () => { - process.env['OPENAI_API_KEY'] = 'test-key'; - - vi.mocked(loadSettings).mockReturnValue( - createMockSettings({ - security: { - auth: { - selectedType: AuthType.USE_OPENAI, - }, - }, - model: { - name: 'gpt-4o', - }, - }), - ); - - await showAuthStatus(); - - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('OpenAI-compatible Provider'), - ); - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('gpt-4o'), - ); - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('API key configured'), - ); - expect(writeStdoutLine).not.toHaveBeenCalledWith( - expect.stringContaining('Alibaba Cloud Coding Plan'), - ); - expect(process.exit).toHaveBeenCalledWith(0); - }); - - it('should show OpenAI-compatible status with custom envKey from modelProviders', async () => { - process.env['CUSTOM_API_KEY'] = 'test-key'; - - vi.mocked(loadSettings).mockReturnValue( - createMockSettings({ - security: { - auth: { - selectedType: AuthType.USE_OPENAI, - }, - }, - model: { - name: 'custom-model', - }, - modelProviders: { - openai: [ - { - id: 'custom-model', - envKey: 'CUSTOM_API_KEY', - baseUrl: 'https://custom-api.example.com/v1', - }, - ], - }, - }), - ); - - await showAuthStatus(); - - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('OpenAI-compatible Provider'), - ); - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('custom-model'), - ); - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('https://custom-api.example.com/v1'), - ); - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('API key configured'), - ); - expect(process.exit).toHaveBeenCalledWith(0); - }); - - it('should show OpenAI-compatible status with settings.security.auth.apiKey', async () => { - vi.mocked(loadSettings).mockReturnValue( - createMockSettings({ - security: { - auth: { - selectedType: AuthType.USE_OPENAI, - apiKey: 'settings-api-key', - baseUrl: 'https://my-provider.example.com/v1', - }, - }, - model: { - name: 'my-model', - }, - }), - ); - - await showAuthStatus(); - - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('OpenAI-compatible Provider'), - ); - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('https://my-provider.example.com/v1'), - ); - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('API key configured'), - ); - expect(process.exit).toHaveBeenCalledWith(0); - }); - - it('should show incomplete when no API key is found for OpenAI-compatible provider', async () => { - vi.mocked(loadSettings).mockReturnValue( - createMockSettings({ - security: { - auth: { - selectedType: AuthType.USE_OPENAI, - }, - }, - }), - ); - - await showAuthStatus(); - - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('OpenAI-compatible Provider (Incomplete)'), - ); - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('API key not found'), - ); - expect(writeStdoutLine).not.toHaveBeenCalledWith( - expect.stringContaining('Alibaba Cloud Coding Plan'), - ); - }); - - it('should detect API key via default model when model.name is unset', async () => { - process.env['CUSTOM_API_KEY'] = 'test-key'; - - vi.mocked(loadSettings).mockReturnValue( - createMockSettings({ - security: { - auth: { - selectedType: AuthType.USE_OPENAI, - }, - }, - modelProviders: { - openai: [ - { - id: 'default-model', - envKey: 'CUSTOM_API_KEY', - baseUrl: 'https://default-api.example.com/v1', - }, - ], - }, - }), - ); - - await showAuthStatus(); - - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('OpenAI-compatible Provider'), - ); - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('https://default-api.example.com/v1'), - ); - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('API key configured'), - ); - expect(process.exit).toHaveBeenCalledWith(0); - }); - - it('should show Incomplete when explicit envKey is missing even if OPENAI_API_KEY is set', async () => { - process.env['OPENAI_API_KEY'] = 'fallback-key'; - - vi.mocked(loadSettings).mockReturnValue( - createMockSettings({ - security: { - auth: { - selectedType: AuthType.USE_OPENAI, - }, - }, - model: { - name: 'custom-model', - }, - modelProviders: { - openai: [ - { - id: 'custom-model', - envKey: 'CUSTOM_API_KEY', - baseUrl: 'https://custom-api.example.com/v1', - }, - ], - }, - }), - ); - - await showAuthStatus(); - - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('OpenAI-compatible Provider (Incomplete)'), - ); - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('API key not found'), - ); - }); - - it('should not bind to unrelated provider entry when model.name does not match', async () => { - process.env['OPENAI_API_KEY'] = 'test-key'; - - vi.mocked(loadSettings).mockReturnValue( - createMockSettings({ - security: { - auth: { - selectedType: AuthType.USE_OPENAI, - }, - }, - model: { - name: 'manual-model-not-in-providers', - }, - modelProviders: { - openai: [ - { - id: 'other-model', - envKey: 'OTHER_API_KEY', - baseUrl: 'https://other-api.example.com/v1', - }, - ], - }, - }), - ); - - await showAuthStatus(); - - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('OpenAI-compatible Provider'), - ); - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('API key configured'), - ); - // Should NOT show the unrelated provider's base URL - expect(writeStdoutLine).not.toHaveBeenCalledWith( - expect.stringContaining('https://other-api.example.com/v1'), - ); - expect(process.exit).toHaveBeenCalledWith(0); - }); - - it('should show OpenAI-compatible when stale codingPlan.region exists but active model is generic', async () => { - process.env['XUNFEI_API_KEY'] = 'active-key'; - - vi.mocked(loadSettings).mockReturnValue( - createMockSettings({ - security: { - auth: { - selectedType: AuthType.USE_OPENAI, - }, - }, - codingPlan: { - region: 'china', - version: 'stale-version', - }, - model: { - name: 'spark-v4', - }, - modelProviders: { - openai: [ - { - id: 'spark-v4', - envKey: 'XUNFEI_API_KEY', - baseUrl: 'https://spark-api-open.xf-yun.com/v1', - }, - ], - }, - }), - ); - - await showAuthStatus(); - - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('OpenAI-compatible Provider'), - ); - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('API key configured'), - ); - expect(writeStdoutLine).not.toHaveBeenCalledWith( - expect.stringContaining('Alibaba Cloud Coding Plan'), - ); - expect(process.exit).toHaveBeenCalledWith(0); - }); - - it('should show OpenAI-compatible when stale Coding Plan key exists but active model is generic', async () => { - process.env[CODING_PLAN_ENV_KEY] = 'stale-coding-plan-key'; - process.env['XUNFEI_API_KEY'] = 'active-key'; - - vi.mocked(loadSettings).mockReturnValue( - createMockSettings({ - security: { - auth: { - selectedType: AuthType.USE_OPENAI, - }, - }, - model: { - name: 'spark-v4', - }, - modelProviders: { - openai: [ - { - id: 'spark-v4', - envKey: 'XUNFEI_API_KEY', - baseUrl: 'https://spark-api-open.xf-yun.com/v1', - }, - ], - }, - }), - ); - - await showAuthStatus(); - - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('OpenAI-compatible Provider'), - ); - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('spark-v4'), - ); - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('API key configured'), - ); - expect(writeStdoutLine).not.toHaveBeenCalledWith( - expect.stringContaining('Alibaba Cloud Coding Plan'), - ); - expect(process.exit).toHaveBeenCalledWith(0); - }); - }); -}); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 0968ee0b61a..a765a140950 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -38,7 +38,6 @@ import { extensionsCommand } from '../commands/extensions.js'; import { hooksCommand } from '../commands/hooks.js'; import type { Settings } from './settings.js'; import { loadSettings, SettingScope } from './settings.js'; -import { authCommand } from '../commands/auth.js'; import { resolveCliGenerationConfig, getAuthTypeFromEnv, @@ -56,6 +55,7 @@ import { loadSandboxConfig } from './sandboxConfig.js'; import { appEvents } from '../utils/events.js'; import { mcpCommand } from '../commands/mcp.js'; import { channelCommand } from '../commands/channel.js'; +import { authCommand } from '../commands/auth.js'; import { reviewCommand } from '../commands/review.js'; // UUID v4 regex pattern for validation @@ -959,7 +959,6 @@ export async function parseArguments(): Promise { .command(mcpCommand) // Register Extension subcommands .command(extensionsCommand) - // Register Auth subcommands .command(authCommand) // Register Hooks subcommands .command(hooksCommand) @@ -987,11 +986,12 @@ export async function parseArguments(): Promise { result._.length > 0 && (result._[0] === 'mcp' || result._[0] === 'extensions' || + result._[0] === 'auth' || result._[0] === 'hooks' || result._[0] === 'channel' || result._[0] === 'review') ) { - // MCP/Extensions/Hooks/Channel/Review commands handle their own + // MCP/Extensions/Auth/Hooks/Channel/Review commands handle their own // execution and exit. Returning here would let the main interactive // flow run, which would prompt for stdin input despite the user // having already invoked a subcommand. diff --git a/packages/cli/src/ui/commands/authCommand.test.ts b/packages/cli/src/ui/commands/authCommand.test.ts index 8930af62da9..751a4c97ec2 100644 --- a/packages/cli/src/ui/commands/authCommand.test.ts +++ b/packages/cli/src/ui/commands/authCommand.test.ts @@ -29,6 +29,47 @@ describe('authCommand', () => { }); }); + it('should return a dialog action when execution mode is undefined', () => { + if (!authCommand.action) { + throw new Error('The auth command must have an action.'); + } + + mockContext.executionMode = undefined; + const result = authCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'dialog', + dialog: 'auth', + }); + }); + + it.each(['non_interactive', 'acp'] as const)( + 'should return an info message in %s mode', + (executionMode) => { + if (!authCommand.action) { + throw new Error('The auth command must have an action.'); + } + + mockContext.executionMode = executionMode; + const result = authCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: + 'Authentication configuration is only available in interactive mode. To configure authentication, run Qwen Code interactively and use /auth, or set environment variables: OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL.', + }); + }, + ); + + it('should support interactive, non-interactive, and ACP modes', () => { + expect(authCommand.supportedModes).toEqual([ + 'interactive', + 'non_interactive', + 'acp', + ]); + }); + it('should have the correct name and description', () => { expect(authCommand.name).toBe('auth'); expect(authCommand.description).toBe( diff --git a/packages/cli/src/ui/commands/authCommand.ts b/packages/cli/src/ui/commands/authCommand.ts index 73e3e029d17..4328a3c0e38 100644 --- a/packages/cli/src/ui/commands/authCommand.ts +++ b/packages/cli/src/ui/commands/authCommand.ts @@ -4,7 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { OpenDialogActionReturn, SlashCommand } from './types.js'; +import type { + OpenDialogActionReturn, + SlashCommand, + SlashCommandActionReturn, +} from './types.js'; import { CommandKind } from './types.js'; import { t } from '../../i18n/index.js'; @@ -15,9 +19,24 @@ export const authCommand: SlashCommand = { return t('Configure authentication information for login'); }, kind: CommandKind.BUILT_IN, - supportedModes: ['interactive'] as const, - action: (_context, _args): OpenDialogActionReturn => ({ - type: 'dialog', - dialog: 'auth', - }), + supportedModes: ['interactive', 'non_interactive', 'acp'], + action: ( + context, + _args, + ): OpenDialogActionReturn | SlashCommandActionReturn => { + const executionMode = context.executionMode ?? 'interactive'; + if (executionMode !== 'interactive') { + return { + type: 'message', + messageType: 'info', + content: t( + 'Authentication configuration is only available in interactive mode. To configure authentication, run Qwen Code interactively and use /auth, or set environment variables: OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL.', + ), + }; + } + return { + type: 'dialog', + dialog: 'auth', + }; + }, }; diff --git a/packages/cli/src/utils/doctorChecks.test.ts b/packages/cli/src/utils/doctorChecks.test.ts index 262fa63fc32..b3ca12f6d13 100644 --- a/packages/cli/src/utils/doctorChecks.test.ts +++ b/packages/cli/src/utils/doctorChecks.test.ts @@ -10,9 +10,18 @@ import { type CommandContext } from '../ui/commands/types.js'; import { createMockCommandContext } from '../test-utils/mockCommandContext.js'; import * as systemInfoUtils from './systemInfo.js'; import * as authModule from '../config/auth.js'; +import * as allProviders from '../auth/allProviders.js'; vi.mock('./systemInfo.js'); vi.mock('../config/auth.js'); +vi.mock('../auth/allProviders.js', async (importOriginal) => { + const actual = + (await importOriginal()) as typeof import('../auth/allProviders.js'); + return { + ...actual, + findProviderByCredentials: vi.fn(actual.findProviderByCredentials), + }; +}); vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { const actual = (await importOriginal()) as typeof import('@qwen-code/qwen-code-core'); @@ -40,6 +49,11 @@ describe('runDoctorChecks', () => { isInitialized: vi.fn().mockReturnValue(true), }), getModel: vi.fn().mockReturnValue('gpt-4'), + getContentGeneratorConfig: vi.fn().mockReturnValue({ + model: 'openrouter/auto', + baseUrl: 'https://openrouter.ai/api/v1', + apiKeyEnvKey: 'OPENROUTER_API_KEY', + }), getMcpServers: vi.fn().mockReturnValue({}), getToolRegistry: vi.fn().mockReturnValue({ getAllTools: vi.fn().mockReturnValue([{ name: 'tool1' }]), @@ -61,6 +75,7 @@ describe('runDoctorChecks', () => { }); afterEach(() => { + delete process.env['OPENROUTER_API_KEY']; vi.clearAllMocks(); }); @@ -128,6 +143,79 @@ describe('runDoctorChecks', () => { expect(authCheck!.status).toBe('pass'); }); + it('should show provider detail when credentials match a known provider', async () => { + process.env['OPENROUTER_API_KEY'] = 'test-key'; + + const results = await runDoctorChecks(mockContext); + const authCheck = results.find((r) => r.name === 'API key'); + + expect(authCheck!.detail).toContain('Provider: OpenRouter'); + expect(authCheck!.detail).toContain( + 'Base URL: https://openrouter.ai/api/v1', + ); + expect(authCheck!.detail).toContain('Model: openrouter/auto'); + expect(authCheck!.detail).toContain( + 'API key: configured (OPENROUTER_API_KEY)', + ); + }); + + it('should show a missing API key in provider detail', async () => { + delete process.env['OPENROUTER_API_KEY']; + + const results = await runDoctorChecks(mockContext); + const authCheck = results.find((r) => r.name === 'API key'); + + expect(authCheck!.detail).toContain('API key: OPENROUTER_API_KEY not set'); + }); + + it('should treat an empty API key environment variable as not set', async () => { + process.env['OPENROUTER_API_KEY'] = ''; + + const results = await runDoctorChecks(mockContext); + const authCheck = results.find((r) => r.name === 'API key'); + + expect(authCheck!.detail).toContain('API key: OPENROUTER_API_KEY not set'); + }); + + it('should omit provider detail when credentials do not match a known provider', async () => { + vi.mocked(allProviders.findProviderByCredentials).mockReturnValueOnce( + undefined, + ); + + const results = await runDoctorChecks(mockContext); + const authCheck = results.find((r) => r.name === 'API key'); + + expect(authCheck!.detail).not.toContain('Provider:'); + expect(authCheck!.detail).toContain( + 'Base URL: https://openrouter.ai/api/v1', + ); + }); + + it('should fall back to config model when content generator config is missing', async () => { + const config = mockContext.services.config; + vi.mocked(config!.getContentGeneratorConfig).mockImplementationOnce( + () => undefined as never, + ); + + const results = await runDoctorChecks(mockContext); + const authCheck = results.find((r) => r.name === 'API key'); + + expect(authCheck!.detail).toBe('Model: gpt-4'); + }); + + it('should omit detail when no enriched auth fields are available', async () => { + const config = mockContext.services.config; + vi.mocked(config!.getModel).mockReturnValueOnce(undefined as never); + vi.mocked(config!.getContentGeneratorConfig).mockReturnValueOnce( + {} as never, + ); + + const results = await runDoctorChecks(mockContext); + const authCheck = results.find((r) => r.name === 'API key'); + + expect(authCheck!.detail).toBeUndefined(); + }); + it('should pass tool registry check when registry is loaded', async () => { const results = await runDoctorChecks(mockContext); const toolCheck = results.find((r) => r.name === 'Tool registry'); diff --git a/packages/cli/src/utils/doctorChecks.ts b/packages/cli/src/utils/doctorChecks.ts index 6422bb5c171..82c8fcac92f 100644 --- a/packages/cli/src/utils/doctorChecks.ts +++ b/packages/cli/src/utils/doctorChecks.ts @@ -8,6 +8,7 @@ import process from 'node:process'; import os from 'node:os'; import { getNpmVersion, getGitVersion } from './systemInfo.js'; import { validateAuthMethod } from '../config/auth.js'; +import { findProviderByCredentials } from '../auth/allProviders.js'; import type { CommandContext } from '../ui/commands/types.js'; import type { DoctorCheckResult } from '../ui/types.js'; import { @@ -71,7 +72,8 @@ function checkPlatform(): DoctorCheckResult { } function checkAuth(context: CommandContext): DoctorCheckResult { - const authType = context.services.config?.getAuthType(); + const config = context.services.config; + const authType = config?.getAuthType(); if (!authType) { return { category: t('Authentication'), @@ -82,10 +84,7 @@ function checkAuth(context: CommandContext): DoctorCheckResult { }; } - const error = validateAuthMethod( - authType, - context.services.config ?? undefined, - ); + const error = validateAuthMethod(authType, config ?? undefined); if (error) { return { category: t('Authentication'), @@ -96,11 +95,44 @@ function checkAuth(context: CommandContext): DoctorCheckResult { }; } + // Build enriched diagnostic information + const cgConfig = + typeof config?.getContentGeneratorConfig === 'function' + ? config.getContentGeneratorConfig() + : undefined; + const model = cgConfig?.model ?? config?.getModel(); + const baseUrl = cgConfig?.baseUrl; + const envKey = cgConfig?.apiKeyEnvKey; + + const provider = findProviderByCredentials(baseUrl, envKey); + + const detailParts: string[] = []; + if (provider) { + detailParts.push( + t('Provider: {{provider}}', { provider: t(provider.label) }), + ); + } + if (baseUrl) { + detailParts.push(t('Base URL: {{baseUrl}}', { baseUrl })); + } + if (model) { + detailParts.push(t('Model: {{model}}', { model })); + } + if (envKey) { + const hasKey = !!process.env[envKey]; + detailParts.push( + hasKey + ? t('API key: configured ({{envKey}})', { envKey }) + : t('API key: {{envKey}} not set', { envKey }), + ); + } + return { category: t('Authentication'), name: t('API key'), status: 'pass', message: t('configured ({{authType}})', { authType }), + detail: detailParts.length > 0 ? detailParts.join('\n') : undefined, }; } diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index d83117778f7..3840d3e3d84 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1490,10 +1490,7 @@ export class Config { // sidecar that happens to share the outgoing session id — // mirrors kimi-cli PR #2082's "write only when a session is // established for this process" rule. - if ( - this.runtimeStatusEnabled && - previousSessionId !== this.sessionId - ) { + if (this.runtimeStatusEnabled && previousSessionId !== this.sessionId) { const oldPath = this.storage.getRuntimeStatusPath(previousSessionId); const newPath = this.storage.getRuntimeStatusPath(this.sessionId); const cliVersion = this.cliVersion ?? null; diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index 6103565bb24..1ee97592a58 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -251,7 +251,11 @@ export class Storage { * the same directory used for chat history to find live sessions. */ getRuntimeStatusPath(sessionId: string): string { - return path.join(this.getProjectDir(), 'chats', `${sessionId}.runtime.json`); + return path.join( + this.getProjectDir(), + 'chats', + `${sessionId}.runtime.json`, + ); } getProjectTempCheckpointsDir(): string { diff --git a/packages/core/src/telemetry/trace-id-utils.test.ts b/packages/core/src/telemetry/trace-id-utils.test.ts index c95e24557b4..e7b01cb239a 100644 --- a/packages/core/src/telemetry/trace-id-utils.test.ts +++ b/packages/core/src/telemetry/trace-id-utils.test.ts @@ -5,7 +5,11 @@ */ import { describe, it, expect } from 'vitest'; -import { deriveTraceId, randomSpanId, randomHexString } from './trace-id-utils.js'; +import { + deriveTraceId, + randomSpanId, + randomHexString, +} from './trace-id-utils.js'; describe('deriveTraceId', () => { it('returns a 32-char hex string', () => {