diff --git a/.qwen/skills/qwen-code-claw/SKILL.md b/.qwen/skills/qwen-code-claw/SKILL.md index e129b7300..9c080f332 100644 --- a/.qwen/skills/qwen-code-claw/SKILL.md +++ b/.qwen/skills/qwen-code-claw/SKILL.md @@ -1,71 +1,201 @@ --- name: qwen-code-claw -description: 使用QwenCode作为Code Agent完成代码理解、项目生成、feature、fix bug、重构等各种编程相关需求 +description: Use Qwen Code as a Code Agent for code understanding, project generation, features, bug fixes, refactoring, and various programming tasks --- -# qwen-code +# Qwen Code Claw -## When to use this skill +## When to Use This Skill -## What qwen-code is +Use this skill when you need to: + +- Understand codebases or ask questions about source code +- Generate new projects or add new features +- Review pull requests in the codebase +- Fix bugs or refactor existing code +- Execute various programming tasks such as code review, testing, documentation generation, etc. +- Collaborate with other tools and agents to complete complex development tasks ## Install ```bash -npm i -g @qwen-code/qwen-code +npm install -g @qwen-code/qwen-code ``` For normal session reuse, prefer a global install over `npx`. -## acpx +## Authentication -You should always use acpx to run qwen-code over the Agent Client Protocol -(ACP) instead of scraping PTY sessions. +Check if authentication is already configured: -Please do the following to use acpx: +```bash +qwen auth status +``` -1. Install acpx globally (recommended) or use npx: +If authentication exists, skip this section. If not authenticated, check if the `BAILIAN_CODING_PLAN_API_KEY` environment variable exists: + +```bash +echo $BAILIAN_CODING_PLAN_API_KEY +``` + +**If `BAILIAN_CODING_PLAN_API_KEY` exists**, authenticate directly: + +```bash +qwen auth code-plan --region china --key $BAILIAN_CODING_PLAN_API_KEY +``` + +**If the environment variable does not exist**, interrupt and prompt the user to authenticate via `qwen-oauth` or `code-plan`: + +```bash +qwen auth +``` + +Or configure custom API after launching Qwen Code via `/auth`. + +## ACPX Integration + +You should always use `acpx` to run Qwen Code over the Agent Client Protocol (ACP) instead of scraping PTY sessions. + +To use acpx: + +1. **Install acpx globally** (recommended) or use npx: + + ```bash npm install -g acpx@latest + ``` Or without installing: + + ```bash npx acpx@latest + ``` -2. Install the acpx skill so you have the full reference available: +2. **Install the acpx skill** for full reference: + + ```bash npx acpx@latest --skill install acpx + ``` -3. Read the acpx skill reference so you know every command, flag, and - workflow pattern: - https://raw.githubusercontent.com/openclaw/acpx/main/skills/acpx/SKILL.md +3. **Read the acpx skill reference** for every command, flag, and workflow pattern: + See the [ACPX Reference](#acpx-reference) section below for details -4. If you need the full CLI reference with all options and examples: +4. **For complete CLI reference with all options and examples**: https://raw.githubusercontent.com/openclaw/acpx/main/docs/CLI.md -From now on, when you need to delegate work to qwen-code, use acpx -instead of spawning raw terminal sessions. For example: +From now on, when you need to delegate work to Qwen Code, use `acpx` instead of spawning raw terminal sessions. For example: + +```bash npx acpx@latest qwen "fix the failing tests" npx acpx@latest qwen "refactor the auth module" npx acpx@latest qwen exec "one-shot: summarize this repo" +``` -通常情况下,你应该使用approval-all参数来防止任务中断 +> **Tip:** You should typically use the `--approve-all` flag to prevent task interruptions. -## Practical workflows +## Practical Workflows -Persistent repo assistant: +### Persistent Repository Assistant ```bash acpx qwen 'inspect failing tests and propose a fix plan' acpx qwen 'apply the smallest safe fix and run tests' ``` -One-shot script step: +### One-Shot Script Steps ```bash acpx qwen exec 'summarize repo purpose in 3 lines' ``` -Parallel named streams: +### Parallel Named Streams ```bash acpx qwen -s backend 'fix API pagination bug' acpx qwen -s docs 'draft changelog entry for release' ``` + +### Queue Follow-ups Without Waiting + +```bash +acpx qwen 'run full test suite and investigate failures' +acpx qwen --no-wait 'after tests, summarize root causes and next steps' +``` + +### Machine-Readable Output for Orchestration + +```bash +acpx --format json qwen 'review current branch changes' > events.ndjson +``` + +### Repository-Wide Review with Permissive Mode + +```bash +acpx --cwd ~/repos/my-project --approve-all qwen -s pr-123 \ + 'review PR #123 for regressions and propose minimal patch' +``` + +## Approval Modes + +- `--approve-all`: No interactive prompts +- `--approve-reads` (default): Auto-approve reads/searches, prompt for writes +- `--deny-all`: Deny all permission requests + +If every permission request is denied/cancelled and none are approved, `acpx` exits with permission denied. + +## Best Practices + +1. Use **named sessions** for organizing different types of development tasks +2. Use `--no-wait` for long-running tasks to avoid blocking +3. Use `--approve-all` for non-interactive batch operations +4. Use `--format json` for automation and script integration +5. Use `--cwd` to manage context across multiple projects + +## ACPX Reference + +### Built-in Agent Registry + +Well-known agent names resolve to commands: + +- `qwen` → `qwen --acp` + +### Command Syntax + +```bash +# Default (prompt mode, persistent session) +acpx [global options] [prompt text...] +acpx [global options] prompt [options] [prompt text...] + +# One-shot execution +acpx [global options] exec [options] [prompt text...] + +# Session management +acpx [global options] cancel [-s ] +acpx [global options] set-mode [-s ] +acpx [global options] set [-s ] +acpx [global options] status [-s ] +acpx [global options] sessions [list | new [--name ] | close [name] | show [name] | history [name] [--limit ]] +acpx [global options] config [show | init] + +# With explicit agent +acpx [global options] [options] [prompt text...] +acpx [global options] prompt [options] [prompt text...] +acpx [global options] exec [options] [prompt text...] +``` + +> **Note:** If prompt text is omitted and stdin is piped, `acpx` reads prompt from stdin. + +### Global Options + +| Option | Description | +| --------------------- | ------------------------------------------------------------ | +| `--agent ` | Raw ACP agent command (fallback mechanism) | +| `--cwd ` | Session working directory | +| `--approve-all` | Auto-approve all requests | +| `--approve-reads` | Auto-approve reads/searches, prompt for writes (default) | +| `--deny-all` | Deny all requests | +| `--format ` | Output format: `text`, `json`, `quiet` | +| `--timeout ` | Maximum wait time (positive integer) | +| `--ttl ` | Idle TTL for queue owners (default: `300`, `0` disables TTL) | +| `--verbose` | Verbose ACP/debug logs to stderr | + +Flags are mutually exclusive where applicable. diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts new file mode 100644 index 000000000..0e6cfcb80 --- /dev/null +++ b/packages/cli/src/commands/auth.ts @@ -0,0 +1,78 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule , Argv } from 'yargs'; +import { + handleQwenAuth, + runInteractiveAuth, + showAuthStatus, +} from './auth/handler.js'; +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 codePlanCommand = { + command: 'code-plan', + describe: t('Authenticate using Alibaba Cloud Coding Plan'), + builder: (yargs: Argv) => + yargs + .option('region', { + alias: 'r', + describe: t('Region for Coding Plan (china/global)'), + type: 'string', + }) + .option('key', { + alias: 'k', + describe: t('API key for Coding Plan'), + type: 'string', + }), + handler: async (argv: { region?: string; key?: string }) => { + const region = argv['region'] as string | undefined; + const key = argv['key'] as string | undefined; + + // If region and key are provided, use them directly + if (region && key) { + await handleQwenAuth('code-plan', { region, key }); + } else { + // Otherwise, prompt interactively + await handleQwenAuth('code-plan', {}); + } + }, +}; + +const statusCommand = { + command: 'status', + describe: t('Show current authentication status'), + handler: async () => { + await showAuthStatus(); + }, +}; + +export const authCommand: CommandModule = { + command: 'auth', + describe: t( + 'Configure Qwen authentication information with Qwen-OAuth or Alibaba Cloud Coding Plan', + ), + builder: (yargs: Argv) => + yargs + .command(qwenOauthCommand) + .command(codePlanCommand) + .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(); + }, +}; diff --git a/packages/cli/src/commands/auth/handler.ts b/packages/cli/src/commands/auth/handler.ts new file mode 100644 index 000000000..b75f6b208 --- /dev/null +++ b/packages/cli/src/commands/auth/handler.ts @@ -0,0 +1,509 @@ +/** + * @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 { + getCodingPlanConfig, + isCodingPlanConfig, + CodingPlanRegion, + CODING_PLAN_ENV_KEY, +} from '../../constants/codingPlan.js'; +import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; +import { backupSettingsFile } from '../../utils/settingsUtils.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'; + +interface QwenAuthOptions { + region?: string; + key?: string; +} + +interface CodingPlanSettings { + region?: CodingPlanRegion; + version?: string; +} + +interface MergedSettingsWithCodingPlan { + security?: { + auth?: { + selectedType?: string; + }; + }; + codingPlan?: CodingPlanSettings; + model?: { + name?: string; + }; + modelProviders?: Record; + env?: Record; +} + +/** + * Handles the authentication process based on the specified command and options + */ +export async function handleQwenAuth( + command: 'qwen-oauth' | 'code-plan', + options: QwenAuthOptions, +) { + try { + const settings = loadSettings(); + + // Create a minimal argv for config loading + const minimalArgv: CliArgs = { + query: undefined, + model: undefined, + sandbox: undefined, + sandboxImage: undefined, + debug: undefined, + prompt: undefined, + promptInteractive: undefined, + yolo: undefined, + approvalMode: undefined, + telemetry: undefined, + checkpointing: undefined, + telemetryTarget: undefined, + telemetryOtlpEndpoint: undefined, + telemetryOtlpProtocol: undefined, + telemetryLogPrompts: undefined, + telemetryOutfile: undefined, + allowedMcpServerNames: undefined, + allowedTools: undefined, + acp: undefined, + experimentalAcp: undefined, + experimentalLsp: undefined, + experimentalHooks: undefined, + extensions: [], + listExtensions: undefined, + openaiLogging: undefined, + openaiApiKey: undefined, + openaiBaseUrl: undefined, + openaiLoggingDir: undefined, + proxy: undefined, + includeDirectories: undefined, + tavilyApiKey: undefined, + googleApiKey: undefined, + googleSearchEngineId: undefined, + webSearchDefault: undefined, + screenReader: undefined, + inputFormat: undefined, + outputFormat: undefined, + includePartialMessages: undefined, + chatRecording: undefined, + continue: undefined, + resume: undefined, + sessionId: undefined, + maxSessionTurns: undefined, + coreTools: undefined, + excludeTools: undefined, + authType: undefined, + channel: undefined, + }; + + // Create a minimal config to access settings and storage + const config = await loadCliConfig( + settings.merged, + minimalArgv, + process.cwd(), + [], // No extensions for auth command + ); + + if (command === 'qwen-oauth') { + await handleQwenOAuth(config, settings); + } else if (command === 'code-plan') { + await handleCodePlanAuth(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 { region, key } = options; + + let selectedRegion: CodingPlanRegion; + let selectedKey: string; + + // If region and key are provided as options, use them + if (region && key) { + selectedRegion = + region.toLowerCase() === 'global' + ? CodingPlanRegion.GLOBAL + : CodingPlanRegion.CHINA; + selectedKey = key; + } else { + // Otherwise, prompt interactively + selectedRegion = await promptForRegion(); + selectedKey = await promptForKey(); + } + + writeStdoutLine(t('Processing Alibaba Cloud Coding Plan authentication...')); + + try { + // Get configuration based on region + const { template, version } = getCodingPlanConfig(selectedRegion); + + // Get persist scope + const authTypeScope = getPersistScopeForModelSelection(settings); + + // Backup settings file before modification + const settingsFile = settings.forScope(authTypeScope); + backupSettingsFile(settingsFile.path); + + // Store api-key in settings.env (unified env key) + settings.setValue(authTypeScope, `env.${CODING_PLAN_ENV_KEY}`, selectedKey); + + // Sync to process.env immediately so refreshAuth can read the apiKey + process.env[CODING_PLAN_ENV_KEY] = selectedKey; + + // Generate model configs from template + const newConfigs = template.map((templateConfig) => ({ + ...templateConfig, + envKey: CODING_PLAN_ENV_KEY, + })); + + // Get existing configs + const existingConfigs = + (settings.merged.modelProviders as Record)?.[ + AuthType.USE_OPENAI + ] || []; + + // Filter out all existing Coding Plan configs (mutually exclusive) + const nonCodingPlanConfigs = existingConfigs.filter( + (existing) => !isCodingPlanConfig(existing.baseUrl, existing.envKey), + ); + + // Add new Coding Plan configs at the beginning + const updatedConfigs = [...newConfigs, ...nonCodingPlanConfigs]; + + // Persist to modelProviders + settings.setValue( + authTypeScope, + `modelProviders.${AuthType.USE_OPENAI}`, + updatedConfigs, + ); + + // Also persist authType + settings.setValue( + authTypeScope, + 'security.auth.selectedType', + AuthType.USE_OPENAI, + ); + + // Persist coding plan region + settings.setValue(authTypeScope, 'codingPlan.region', selectedRegion); + + // Persist coding plan version (single field for backward compatibility) + settings.setValue(authTypeScope, 'codingPlan.version', version); + + // If there are configs, use the first one as the model + if (updatedConfigs.length > 0 && updatedConfigs[0]?.id) { + settings.setValue( + authTypeScope, + 'model.name', + (updatedConfigs[0] as ModelConfig).id, + ); + } + + // Refresh auth with the new configuration + await config.refreshAuth(AuthType.USE_OPENAI); + + 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); + } +} + +/** + * Prompts the user to select a region using an interactive selector + */ +async function promptForRegion(): Promise { + const selector = new InteractiveSelector( + [ + { + value: CodingPlanRegion.CHINA, + label: t('中国 (China)'), + description: t('阿里云百炼 (aliyun.com)'), + }, + { + value: CodingPlanRegion.GLOBAL, + label: t('Global'), + description: t('Alibaba Cloud (alibabacloud.com)'), + }, + ], + t('Select region for Coding Plan:'), + ); + + return await selector.select(); +} + +/** + * Prompts the user to enter an API key + */ +async function promptForKey(): Promise { + // Create a simple password-style input (without echoing characters) + const stdin = process.stdin; + const stdout = process.stdout; + + stdout.write(t('Enter your Coding Plan API key: ')); + + // Set raw mode to capture keystrokes + 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'); // New line after input + resolve(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: + // Add character to input + input += char; + // Print asterisk instead of the actual character for security + stdout.write('*'); + break; + } + } + }; + + stdin.on('data', onData); + }); +} + +/** + * Runs the interactive authentication flow + */ +export async function runInteractiveAuth() { + const selector = new InteractiveSelector( + [ + { + value: 'qwen-oauth' as const, + label: t('Qwen OAuth'), + description: t('Free · Up to 1,000 requests/day · Qwen latest models'), + }, + { + value: 'code-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', + ), + }, + ], + t('Select authentication method:'), + ); + + const choice = await selector.select(); + + if (choice === 'code-plan') { + await handleQwenAuth('code-plan', {}); + } else { + await handleQwenAuth('qwen-oauth', {}); + } +} + +/** + * 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 qwen-oauth - Authenticate with Qwen OAuth (free tier)', + ), + ); + writeStdoutLine( + t( + ' qwen auth code-plan - Authenticate with Alibaba Cloud Coding Plan\n', + ), + ); + 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')); + writeStdoutLine(t(' Limit: Up to 1,000 requests/day')); + writeStdoutLine(t(' Models: Qwen latest models\n')); + } else if (selectedType === AuthType.USE_OPENAI) { + // Check for Coding Plan configuration + const codingPlanRegion = mergedSettings.codingPlan?.region; + const codingPlanVersion = mergedSettings.codingPlan?.version; + const modelName = mergedSettings.model?.name; + + // Check if API key is set in environment + const hasApiKey = + !!process.env[CODING_PLAN_ENV_KEY] || + !!mergedSettings.env?.[CODING_PLAN_ENV_KEY]; + + if (hasApiKey) { + writeStdoutLine( + t('✓ Authentication Method: Alibaba Cloud Coding Plan'), + ); + + if (codingPlanRegion) { + const regionDisplay = + codingPlanRegion === CodingPlanRegion.CHINA + ? t('中国 (China) - 阿里云百炼') + : t('Global - Alibaba Cloud'); + writeStdoutLine(t(' Region: {{region}}', { region: regionDisplay })); + } + + if (modelName) { + writeStdoutLine( + t(' Current Model: {{model}}', { model: modelName }), + ); + } + + if (codingPlanVersion) { + writeStdoutLine( + t(' Config Version: {{version}}', { + version: codingPlanVersion.substring(0, 8) + '...', + }), + ); + } + + writeStdoutLine(t(' Status: API key configured\n')); + } else { + writeStdoutLine( + t( + '⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)', + ), + ); + writeStdoutLine( + t(' Issue: API key not found in environment or settings\n'), + ); + writeStdoutLine(t(' Run `qwen auth code-plan` to re-configure.\n')); + } + } else { + writeStdoutLine( + t('✓ Authentication Method: {{type}}', { type: selectedType }), + ); + writeStdoutLine(t(' Status: Configured\n')); + } + + // Show available commands + writeStdoutLine(t('---')); + writeStdoutLine(t('Commands:')); + writeStdoutLine( + t(' qwen auth - Change authentication method'), + ); + writeStdoutLine(t(' qwen auth status - Show this status')); + writeStdoutLine(t(' qwen auth qwen-oauth - Switch to Qwen OAuth')); + writeStdoutLine(t(' qwen auth code-plan - Switch to Coding Plan\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 new file mode 100644 index 000000000..e580cb3bf --- /dev/null +++ b/packages/cli/src/commands/auth/interactiveSelector.test.ts @@ -0,0 +1,421 @@ +/** + * @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 new file mode 100644 index 000000000..84b9c9f0d --- /dev/null +++ b/packages/cli/src/commands/auth/interactiveSelector.ts @@ -0,0 +1,166 @@ +/** + * @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/status.test.ts b/packages/cli/src/commands/auth/status.test.ts new file mode 100644 index 000000000..9666d11f3 --- /dev/null +++ b/packages/cli/src/commands/auth/status.test.ts @@ -0,0 +1,287 @@ +/** + * @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 } from '../../constants/codingPlan.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'; + +describe('showAuthStatus', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + delete process.env[CODING_PLAN_ENV_KEY]; + }); + + afterEach(() => { + vi.restoreAllMocks(); + delete process.env[CODING_PLAN_ENV_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 qwen-oauth'), + ); + expect(writeStdoutLine).toHaveBeenCalledWith( + expect.stringContaining('qwen auth code-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'), + ); + expect(writeStdoutLine).toHaveBeenCalledWith( + expect.stringContaining('1,000 requests/day'), + ); + 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, + }, + }, + codingPlan: { + region: 'china', + version: 'abc123def456', + }, + model: { + name: 'qwen3.5-plus', + }, + }), + ); + + await showAuthStatus(); + + expect(writeStdoutLine).toHaveBeenCalledWith( + expect.stringContaining('Alibaba Cloud Coding Plan'), + ); + expect(writeStdoutLine).toHaveBeenCalledWith( + expect.stringContaining('API key configured'), + ); + expect(process.exit).toHaveBeenCalledWith(0); + }); + + it('should show Coding Plan as incomplete when API key is missing', async () => { + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + security: { + auth: { + selectedType: AuthType.USE_OPENAI, + }, + }, + codingPlan: { + region: 'global', + }, + }), + ); + + await showAuthStatus(); + + expect(writeStdoutLine).toHaveBeenCalledWith( + expect.stringContaining('Incomplete'), + ); + expect(writeStdoutLine).toHaveBeenCalledWith( + expect.stringContaining('API key not found'), + ); + }); + + it('should show Coding Plan region for china', async () => { + process.env[CODING_PLAN_ENV_KEY] = 'test-api-key'; + + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + security: { + auth: { + selectedType: AuthType.USE_OPENAI, + }, + }, + codingPlan: { + region: 'china', + }, + model: { + name: 'qwen3.5-plus', + }, + }), + ); + + await showAuthStatus(); + + expect(writeStdoutLine).toHaveBeenCalledWith( + expect.stringContaining('中国 (China)'), + ); + }); + + it('should show Coding Plan region for global', async () => { + process.env[CODING_PLAN_ENV_KEY] = 'test-api-key'; + + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + security: { + auth: { + selectedType: AuthType.USE_OPENAI, + }, + }, + codingPlan: { + region: 'global', + }, + model: { + name: 'qwen3-coder-plus', + }, + }), + ); + + await showAuthStatus(); + + expect(writeStdoutLine).toHaveBeenCalledWith( + expect.stringContaining('Global'), + ); + }); + + 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, + }, + }, + codingPlan: { + region: 'china', + }, + model: { + name: 'qwen3.5-plus', + }, + }), + ); + + 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, + }, + }, + codingPlan: { + region: 'china', + version: 'abc123def456789', + }, + model: { + name: 'qwen3.5-plus', + }, + }), + ); + + await showAuthStatus(); + + expect(writeStdoutLine).toHaveBeenCalledWith( + expect.stringContaining('abc123de...'), + ); + }); + + it('should show available commands at the end', async () => { + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + security: { + auth: { + selectedType: AuthType.QWEN_OAUTH, + }, + }, + }), + ); + + await showAuthStatus(); + + expect(writeStdoutLine).toHaveBeenCalledWith( + expect.stringContaining('Commands:'), + ); + expect(writeStdoutLine).toHaveBeenCalledWith( + expect.stringContaining('qwen auth status'), + ); + expect(process.exit).toHaveBeenCalledWith(0); + }); + + 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); + }); +}); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index eab0470c6..833290609 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -34,6 +34,7 @@ import { } from '@qwen-code/qwen-code-core'; import { extensionsCommand } from '../commands/extensions.js'; import { hooksCommand } from '../commands/hooks.js'; +import { authCommand } from '../commands/auth.js'; import type { Settings } from './settings.js'; import { resolveCliGenerationConfig, @@ -570,6 +571,8 @@ export async function parseArguments(): Promise { .command(mcpCommand) // Register Extension subcommands .command(extensionsCommand) + // Register Auth subcommands + .command(authCommand) // Register Hooks subcommands .command(hooksCommand);