mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 11:41:04 +00:00
feat: add auth command
This commit is contained in:
parent
d4608afc2d
commit
9a3041335f
7 changed files with 1616 additions and 22 deletions
|
|
@ -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.
|
||||
|
|
|
|||
78
packages/cli/src/commands/auth.ts
Normal file
78
packages/cli/src/commands/auth.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
509
packages/cli/src/commands/auth/handler.ts
Normal file
509
packages/cli/src/commands/auth/handler.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
421
packages/cli/src/commands/auth/interactiveSelector.test.ts
Normal file
421
packages/cli/src/commands/auth/interactiveSelector.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
166
packages/cli/src/commands/auth/interactiveSelector.ts
Normal file
166
packages/cli/src/commands/auth/interactiveSelector.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
287
packages/cli/src/commands/auth/status.test.ts
Normal file
287
packages/cli/src/commands/auth/status.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue