feat: add auth command

This commit is contained in:
LaZzyMan 2026-03-17 18:11:22 +08:00
parent d4608afc2d
commit 9a3041335f
7 changed files with 1616 additions and 22 deletions

View file

@ -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 <name>]
acpx [global options] set-mode <mode> [-s <name>]
acpx [global options] set <key> <value> [-s <name>]
acpx [global options] status [-s <name>]
acpx [global options] sessions [list | new [--name <name>] | close [name] | show [name] | history [name] [--limit <count>]]
acpx [global options] config [show | init]
# With explicit agent
acpx [global options] <agent> [options] [prompt text...]
acpx [global options] <agent> prompt [options] [prompt text...]
acpx [global options] <agent> exec [options] [prompt text...]
```
> **Note:** If prompt text is omitted and stdin is piped, `acpx` reads prompt from stdin.
### Global Options
| Option | Description |
| --------------------- | ------------------------------------------------------------ |
| `--agent <command>` | Raw ACP agent command (fallback mechanism) |
| `--cwd <directory>` | 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 <format>` | Output format: `text`, `json`, `quiet` |
| `--timeout <seconds>` | Maximum wait time (positive integer) |
| `--ttl <seconds>` | Idle TTL for queue owners (default: `300`, `0` disables TTL) |
| `--verbose` | Verbose ACP/debug logs to stderr |
Flags are mutually exclusive where applicable.

View file

@ -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();
},
};

View file

@ -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<string, ModelConfig[]>;
env?: Record<string, string>;
}
/**
* 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<void> {
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<void> {
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<string, ModelConfig[]>)?.[
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<CodingPlanRegion> {
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<string> {
// 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<string>((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<void> {
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);
}
}

View file

@ -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();
});
});
});

View file

@ -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<T> {
value: T;
label: string;
description?: string;
}
/**
* Interactive selector that allows users to navigate with arrow keys
*/
export class InteractiveSelector<T> {
private selectedIndex = 0;
private isListening = false;
constructor(
private options: Array<Option<T>>,
private prompt: string = t('Select an option:'),
) {}
/**
* Shows the interactive menu and waits for user selection
*/
async select(): Promise<T> {
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();
}
}

View file

@ -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<string, unknown>,
): 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);
});
});

View file

@ -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<CliArgs> {
.command(mcpCommand)
// Register Extension subcommands
.command(extensionsCommand)
// Register Auth subcommands
.command(authCommand)
// Register Hooks subcommands
.command(hooksCommand);