diff --git a/README.md b/README.md index 099d55504..75997c715 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,11 @@ If you encounter issues, check the [troubleshooting guide](https://qwenlm.github To report a bug from within the CLI, run `/bug` and include a short title and repro steps. +## Connect with Us + +- Discord: https://discord.gg/ycKBjdNd +- Dingtalk: https://qr.dingtalk.com/action/joingroup?code=v1,k1,+FX6Gf/ZDlTahTIRi8AEQhIaBlqykA0j+eBKKdhLeAE=&_dt_no_comment=1&origin=1 + ## Acknowledgments This project is based on [Google Gemini CLI](https://github.com/google-gemini/gemini-cli). We acknowledge and appreciate the excellent work of the Gemini CLI team. Our main contribution focuses on parser-level adaptations to better support Qwen-Coder models. diff --git a/docs/users/configuration/auth.md b/docs/users/configuration/auth.md index 82bc66b26..ca62f2918 100644 --- a/docs/users/configuration/auth.md +++ b/docs/users/configuration/auth.md @@ -5,11 +5,13 @@ Qwen Code supports two authentication methods. Pick the one that matches how you - **Qwen OAuth (recommended)**: sign in with your `qwen.ai` account in a browser. - **OpenAI-compatible API**: use an API key (OpenAI or any OpenAI-compatible provider / endpoint). +![](https://img.alicdn.com/imgextra/i2/O1CN01IxI1bt1sNO543AVTT_!!6000000005754-0-tps-1958-822.jpg) + ## Option 1: Qwen OAuth (recommended & free) 👍 -Use this if you want the simplest setup and you’re using Qwen models. +Use this if you want the simplest setup and you're using Qwen models. -- **How it works**: on first start, Qwen Code opens a browser login page. After you finish, credentials are cached locally so you usually won’t need to log in again. +- **How it works**: on first start, Qwen Code opens a browser login page. After you finish, credentials are cached locally so you usually won't need to log in again. - **Requirements**: a `qwen.ai` account + internet access (at least for the first login). - **Benefits**: no API key management, automatic credential refresh. - **Cost & quota**: free, with a quota of **60 requests/minute** and **2,000 requests/day**. @@ -24,15 +26,54 @@ qwen Use this if you want to use OpenAI models or any provider that exposes an OpenAI-compatible API (e.g. OpenAI, Azure OpenAI, OpenRouter, ModelScope, Alibaba Cloud Bailian, or a self-hosted compatible endpoint). -### Quick start (interactive, recommended for local use) +### Recommended: Coding Plan (subscription-based) 🚀 -When you choose the OpenAI-compatible option in the CLI, it will prompt you for: +Use this if you want predictable costs with higher usage quotas for the qwen3-coder-plus model. -- **API key** -- **Base URL** (default: `https://api.openai.com/v1`) -- **Model** (default: `gpt-4o`) +> [!IMPORTANT] +> +> Coding Plan is only available for users in China mainland (Beijing region). -> **Note:** the CLI may display the key in plain text for verification. Make sure your terminal is not being recorded or shared. +- **How it works**: subscribe to the Coding Plan with a fixed monthly fee, then configure Qwen Code to use the dedicated endpoint and your subscription API key. +- **Requirements**: an active Coding Plan subscription from [Alibaba Cloud Bailian](https://bailian.console.aliyun.com/cn-beijing/?tab=globalset#/efm/coding_plan). +- **Benefits**: higher usage quotas, predictable monthly costs, access to latest qwen3-coder-plus model. +- **Cost & quota**: varies by plan (see table below). + +#### Coding Plan Pricing & Quotas + +| Feature | Lite Basic Plan | Pro Advanced Plan | +| :------------------ | :-------------------- | :-------------------- | +| **Price** | ¥40/month | ¥200/month | +| **5-Hour Limit** | Up to 1,200 requests | Up to 6,000 requests | +| **Weekly Limit** | Up to 9,000 requests | Up to 45,000 requests | +| **Monthly Limit** | Up to 18,000 requests | Up to 90,000 requests | +| **Supported Model** | qwen3-coder-plus | qwen3-coder-plus | + +#### Quick Setup for Coding Plan + +When you select the OpenAI-compatible option in the CLI, enter these values: + +- **API key**: `sk-sp-xxxxx` +- **Base URL**: `https://coding.dashscope.aliyuncs.com/v1` +- **Model**: `qwen3-coder-plus` + +> **Note**: Coding Plan API keys have the format `sk-sp-xxxxx`, which is different from standard Alibaba Cloud API keys. + +#### Configure via Environment Variables + +Set these environment variables to use Coding Plan: + +```bash +export OPENAI_API_KEY="your-coding-plan-api-key" # Format: sk-sp-xxxxx +export OPENAI_BASE_URL="https://coding.dashscope.aliyuncs.com/v1" +export OPENAI_MODEL="qwen3-coder-plus" +``` + +For more details about Coding Plan, including subscription options and troubleshooting, see the [full Coding Plan documentation](https://bailian.console.aliyun.com/cn-beijing/?tab=doc#/doc/?type=model&url=3005961). + +### Other OpenAI-compatible Providers + +If you are using other providers (OpenAI, Azure, local LLMs, etc.), use the following configuration methods. ### Configure via command-line arguments diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 97ee91598..added7dec 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -275,6 +275,7 @@ If you are experiencing performance issues with file searching (e.g., with `@` c | `tools.truncateToolOutputThreshold` | number | Truncate tool output if it is larger than this many characters. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools. | `25000` | Requires restart: Yes | | `tools.truncateToolOutputLines` | number | Maximum lines or entries kept when truncating tool output. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools. | `1000` | Requires restart: Yes | | `tools.autoAccept` | boolean | Controls whether the CLI automatically accepts and executes tool calls that are considered safe (e.g., read-only operations) without explicit user confirmation. If set to `true`, the CLI will bypass the confirmation prompt for tools deemed safe. | `false` | | +| `tools.experimental.skills` | boolean | Enable experimental Agent Skills feature | `false` | | #### mcp @@ -480,7 +481,7 @@ Arguments passed directly when running the CLI can override other configurations | `--telemetry-otlp-protocol` | | Sets the OTLP protocol for telemetry (`grpc` or `http`). | | Defaults to `grpc`. See [telemetry](../../developers/development/telemetry) for more information. | | `--telemetry-log-prompts` | | Enables logging of prompts for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. | | `--checkpointing` | | Enables [checkpointing](../features/checkpointing). | | | -| `--acp` | | Enables ACP mode (Agent Control Protocol). Useful for IDE/editor integrations like [Zed](../integration-zed). | | Stable. Replaces the deprecated `--experimental-acp` flag. | +| `--acp` | | Enables ACP mode (Agent Client Protocol). Useful for IDE/editor integrations like [Zed](../integration-zed). | | Stable. Replaces the deprecated `--experimental-acp` flag. | | `--experimental-skills` | | Enables experimental [Agent Skills](../features/skills) (registers the `skill` tool and loads Skills from `.qwen/skills/` and `~/.qwen/skills/`). | | Experimental. | | `--extensions` | `-e` | Specifies a list of extensions to use for the session. | Extension names | If not provided, all available extensions are used. Use the special term `qwen -e none` to disable all extensions. Example: `qwen -e my-extension -e my-other-extension` | | `--list-extensions` | `-l` | Lists all available extensions and exits. | | | diff --git a/docs/users/features/skills.md b/docs/users/features/skills.md index 0387ff389..0e55644ab 100644 --- a/docs/users/features/skills.md +++ b/docs/users/features/skills.md @@ -11,12 +11,29 @@ This guide shows you how to create, use, and manage Agent Skills in **Qwen Code* ## Prerequisites - Qwen Code (recent version) -- Run with the experimental flag enabled: + +## How to enable + +### Via CLI flag ```bash qwen --experimental-skills ``` +### Via settings.json + +Add to your `~/.qwen/settings.json` or project's `.qwen/settings.json`: + +```json +{ + "tools": { + "experimental": { + "skills": true + } + } +} +``` + - Basic familiarity with Qwen Code ([Quickstart](../quickstart.md)) ## What are Agent Skills? diff --git a/docs/users/images/jetbrains-acp.png b/docs/users/images/jetbrains-acp.png deleted file mode 100644 index b49ef3547..000000000 Binary files a/docs/users/images/jetbrains-acp.png and /dev/null differ diff --git a/docs/users/integration-jetbrains.md b/docs/users/integration-jetbrains.md index f4d6cfa02..3f4739eab 100644 --- a/docs/users/integration-jetbrains.md +++ b/docs/users/integration-jetbrains.md @@ -1,11 +1,11 @@ # JetBrains IDEs -> JetBrains IDEs provide native support for AI coding assistants through the Agent Control Protocol (ACP). This integration allows you to use Qwen Code directly within your JetBrains IDE with real-time code suggestions. +> JetBrains IDEs provide native support for AI coding assistants through the Agent Client Protocol (ACP). This integration allows you to use Qwen Code directly within your JetBrains IDE with real-time code suggestions. ### Features - **Native agent experience**: Integrated AI assistant panel within your JetBrains IDE -- **Agent Control Protocol**: Full support for ACP enabling advanced IDE interactions +- **Agent Client Protocol**: Full support for ACP enabling advanced IDE interactions - **Symbol management**: #-mention files to add them to the conversation context - **Conversation history**: Access to past conversations within the IDE @@ -40,7 +40,7 @@ 4. The Qwen Code agent should now be available in the AI Assistant panel -![Qwen Code in JetBrains AI Chat](./images/jetbrains-acp.png) +![Qwen Code in JetBrains AI Chat](https://img.alicdn.com/imgextra/i3/O1CN01ZxYel21y433Ci6eg0_!!6000000006524-2-tps-2774-1494.png) ## Troubleshooting diff --git a/docs/users/integration-vscode.md b/docs/users/integration-vscode.md index 836e9ee99..eb08b6678 100644 --- a/docs/users/integration-vscode.md +++ b/docs/users/integration-vscode.md @@ -22,13 +22,7 @@ ### Installation -1. Install Qwen Code CLI: - - ```bash - npm install -g qwen-code - ``` - -2. Download and install the extension from the [Visual Studio Code Extension Marketplace](https://marketplace.visualstudio.com/items?itemName=qwenlm.qwen-code-vscode-ide-companion). +Download and install the extension from the [Visual Studio Code Extension Marketplace](https://marketplace.visualstudio.com/items?itemName=qwenlm.qwen-code-vscode-ide-companion). ## Troubleshooting diff --git a/docs/users/integration-zed.md b/docs/users/integration-zed.md index 663e23e80..7379bf69b 100644 --- a/docs/users/integration-zed.md +++ b/docs/users/integration-zed.md @@ -1,6 +1,6 @@ # Zed Editor -> Zed Editor provides native support for AI coding assistants through the Agent Control Protocol (ACP). This integration allows you to use Qwen Code directly within Zed's interface with real-time code suggestions. +> Zed Editor provides native support for AI coding assistants through the Agent Client Protocol (ACP). This integration allows you to use Qwen Code directly within Zed's interface with real-time code suggestions. ![Zed Editor Overview](https://img.alicdn.com/imgextra/i1/O1CN01aAhU311GwEoNh27FP_!!6000000000686-2-tps-3024-1898.png) @@ -20,9 +20,9 @@ 1. Install Qwen Code CLI: - ```bash - npm install -g qwen-code - ``` +```bash +npm install -g @qwen-code/qwen-code +``` 2. Download and install [Zed Editor](https://zed.dev/) diff --git a/integration-tests/sdk-typescript/permission-control.test.ts b/integration-tests/sdk-typescript/permission-control.test.ts index 974f72b37..eee344755 100644 --- a/integration-tests/sdk-typescript/permission-control.test.ts +++ b/integration-tests/sdk-typescript/permission-control.test.ts @@ -831,7 +831,7 @@ describe('Permission Control (E2E)', () => { TEST_TIMEOUT, ); - it( + it.skip( 'should execute dangerous commands without confirmation', async () => { const q = query({ diff --git a/package-lock.json b/package-lock.json index 0fcc6e905..8bb011064 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qwen-code/qwen-code", - "version": "0.7.0", + "version": "0.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qwen-code/qwen-code", - "version": "0.7.0", + "version": "0.7.1", "workspaces": [ "packages/*" ], @@ -17349,7 +17349,7 @@ }, "packages/cli": { "name": "@qwen-code/qwen-code", - "version": "0.7.0", + "version": "0.7.1", "dependencies": { "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", @@ -17988,7 +17988,7 @@ }, "packages/core": { "name": "@qwen-code/qwen-code-core", - "version": "0.7.0", + "version": "0.7.1", "hasInstallScript": true, "dependencies": { "@anthropic-ai/sdk": "^0.36.1", @@ -18629,7 +18629,7 @@ }, "packages/sdk-typescript": { "name": "@qwen-code/sdk", - "version": "0.1.2", + "version": "0.1.3", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.25.1", @@ -21449,7 +21449,7 @@ }, "packages/test-utils": { "name": "@qwen-code/qwen-code-test-utils", - "version": "0.7.0", + "version": "0.7.1", "dev": true, "license": "Apache-2.0", "devDependencies": { @@ -21461,7 +21461,7 @@ }, "packages/vscode-ide-companion": { "name": "qwen-code-vscode-ide-companion", - "version": "0.7.0", + "version": "0.7.1", "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.25.1", diff --git a/package.json b/package.json index 81c788628..eb9c9a75f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.7.0", + "version": "0.7.1", "engines": { "node": ">=20.0.0" }, @@ -13,7 +13,7 @@ "url": "git+https://github.com/QwenLM/qwen-code.git" }, "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.0" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.1" }, "scripts": { "start": "cross-env node scripts/start.js", diff --git a/packages/cli/package.json b/packages/cli/package.json index 618325c07..aabc0db45 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.7.0", + "version": "0.7.1", "description": "Qwen Code", "repository": { "type": "git", @@ -33,7 +33,7 @@ "dist" ], "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.0" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.1" }, "dependencies": { "@google/genai": "1.30.0", diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index e5cee9987..dd3b5a993 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -329,7 +329,7 @@ export async function parseArguments(): Promise { .option('experimental-skills', { type: 'boolean', description: 'Enable experimental Skills feature', - default: false, + default: settings.tools?.experimental?.skills ?? false, }) .option('channel', { type: 'string', @@ -830,11 +830,10 @@ export async function loadCliConfig( } }; - if ( - !interactive && - !argv.experimentalAcp && - inputFormat !== InputFormat.STREAM_JSON - ) { + // ACP mode check: must include both --acp (current) and --experimental-acp (deprecated). + // Without this check, edit, write_file, run_shell_command would be excluded in ACP mode. + const isAcpMode = argv.acp || argv.experimentalAcp; + if (!interactive && !isAcpMode && inputFormat !== InputFormat.STREAM_JSON) { switch (approvalMode) { case ApprovalMode.PLAN: case ApprovalMode.DEFAULT: diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 07c3f675a..0a0eb3317 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -981,6 +981,27 @@ const SETTINGS_SCHEMA = { description: 'The number of lines to keep when truncating tool output.', showInDialog: true, }, + experimental: { + type: 'object', + label: 'Experimental', + category: 'Tools', + requiresRestart: true, + default: {}, + description: 'Experimental tool features.', + showInDialog: false, + properties: { + skills: { + type: 'boolean', + label: 'Skills', + category: 'Tools', + requiresRestart: true, + default: false, + description: + 'Enable experimental Agent Skills feature. When enabled, Qwen Code can use Skills from .qwen/skills/ and ~/.qwen/skills/.', + showInDialog: true, + }, + }, + }, }, }, diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 883ebb994..b1381cf83 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -905,11 +905,11 @@ export default { 'Session Stats': '会话统计', 'Model Usage': '模型使用情况', Reqs: '请求数', - 'Input Tokens': '输入令牌', - 'Output Tokens': '输出令牌', + 'Input Tokens': '输入 token 数', + 'Output Tokens': '输出 token 数', 'Savings Highlight:': '节省亮点:', 'of input tokens were served from the cache, reducing costs.': - '的输入令牌来自缓存,降低了成本', + '从缓存载入 token ,降低了成本', 'Tip: For a full token breakdown, run `/stats model`.': '提示:要查看完整的令牌明细,请运行 `/stats model`', 'Model Stats For Nerds': '模型统计(技术细节)', diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index 7237ac33d..05f811741 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -83,12 +83,26 @@ export const useAuthCommand = ( async (authType: AuthType, credentials?: OpenAICredentials) => { try { const authTypeScope = getPersistScopeForModelSelection(settings); + + // Persist authType settings.setValue( authTypeScope, 'security.auth.selectedType', authType, ); + // Persist model from ContentGenerator config (handles fallback cases) + // This ensures that when syncAfterAuthRefresh falls back to default model, + // it gets persisted to settings.json + const contentGeneratorConfig = config.getContentGeneratorConfig(); + if (contentGeneratorConfig?.model) { + settings.setValue( + authTypeScope, + 'model.name', + contentGeneratorConfig.model, + ); + } + // Only update credentials if not switching to QWEN_OAUTH, // so that OpenAI credentials are preserved when switching to QWEN_OAUTH. if (authType !== AuthType.QWEN_OAUTH && credentials) { @@ -106,9 +120,6 @@ export const useAuthCommand = ( credentials.baseUrl, ); } - if (credentials?.model != null) { - settings.setValue(authTypeScope, 'model.name', credentials.model); - } } } catch (error) { handleAuthFailure(error); diff --git a/packages/cli/src/utils/commentJson.test.ts b/packages/cli/src/utils/commentJson.test.ts index 4957b7497..fcf2501cd 100644 --- a/packages/cli/src/utils/commentJson.test.ts +++ b/packages/cli/src/utils/commentJson.test.ts @@ -8,7 +8,10 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; -import { updateSettingsFilePreservingFormat } from './commentJson.js'; +import { + updateSettingsFilePreservingFormat, + applyUpdates, +} from './commentJson.js'; describe('commentJson', () => { let tempDir: string; @@ -180,3 +183,18 @@ describe('commentJson', () => { }); }); }); + +describe('applyUpdates', () => { + it('should apply updates correctly', () => { + const original = { a: 1, b: { c: 2 } }; + const updates = { b: { c: 3 } }; + const result = applyUpdates(original, updates); + expect(result).toEqual({ a: 1, b: { c: 3 } }); + }); + it('should apply updates correctly when empty', () => { + const original = { a: 1, b: { c: 2 } }; + const updates = { b: {} }; + const result = applyUpdates(original, updates); + expect(result).toEqual({ a: 1, b: {} }); + }); +}); diff --git a/packages/cli/src/utils/commentJson.ts b/packages/cli/src/utils/commentJson.ts index 9ea4d3f80..bf325d9af 100644 --- a/packages/cli/src/utils/commentJson.ts +++ b/packages/cli/src/utils/commentJson.ts @@ -38,7 +38,7 @@ export function updateSettingsFilePreservingFormat( fs.writeFileSync(filePath, updatedContent, 'utf-8'); } -function applyUpdates( +export function applyUpdates( current: Record, updates: Record, ): Record { @@ -50,6 +50,7 @@ function applyUpdates( typeof value === 'object' && value !== null && !Array.isArray(value) && + Object.keys(value).length > 0 && typeof result[key] === 'object' && result[key] !== null && !Array.isArray(result[key]) diff --git a/packages/cli/src/utils/modelConfigUtils.ts b/packages/cli/src/utils/modelConfigUtils.ts index 4a025ed1f..305e50d15 100644 --- a/packages/cli/src/utils/modelConfigUtils.ts +++ b/packages/cli/src/utils/modelConfigUtils.ts @@ -120,7 +120,7 @@ export function resolveCliGenerationConfig( // Log warnings if any for (const warning of resolved.warnings) { - console.warn(`[modelProviderUtils] ${warning}`); + console.warn(warning); } // Resolve OpenAI logging config (CLI-specific, not part of core resolver) diff --git a/packages/core/package.json b/packages/core/package.json index f163b81c0..6c29d02d3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code-core", - "version": "0.7.0", + "version": "0.7.1", "description": "Qwen Code Core", "repository": { "type": "git", diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index c74449fb9..f9b3fb1c2 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -409,7 +409,7 @@ export class Config { private promptRegistry!: PromptRegistry; private subagentManager!: SubagentManager; private extensionManager!: ExtensionManager; - private skillManager!: SkillManager; + private skillManager: SkillManager | null = null; private fileSystemService: FileSystemService; private contentGeneratorConfig!: ContentGeneratorConfig; private contentGeneratorConfigSources: ContentGeneratorConfigSources = {}; @@ -687,8 +687,10 @@ export class Config { await this.extensionManager.refreshCache(); this.subagentManager = new SubagentManager(this); - this.skillManager = new SkillManager(this); - await this.skillManager.startWatching(); + if (this.getExperimentalSkills()) { + this.skillManager = new SkillManager(this); + await this.skillManager.startWatching(); + } // Load session subagents if they were provided before initialization if (this.sessionSubagents.length > 0) { @@ -1565,7 +1567,7 @@ export class Config { return this.subagentManager; } - getSkillManager(): SkillManager { + getSkillManager(): SkillManager | null { return this.skillManager; } diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index 476776cb6..8849400a5 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -270,28 +270,28 @@ export function createContentGeneratorConfig( } export async function createContentGenerator( - config: ContentGeneratorConfig, - gcConfig: Config, + generatorConfig: ContentGeneratorConfig, + config: Config, isInitialAuth?: boolean, ): Promise { - const validation = validateModelConfig(config, false); + const validation = validateModelConfig(generatorConfig, false); if (!validation.valid) { throw new Error(validation.errors.map((e) => e.message).join('\n')); } - if (config.authType === AuthType.USE_OPENAI) { - // Import OpenAIContentGenerator dynamically to avoid circular dependencies + const authType = generatorConfig.authType; + if (!authType) { + throw new Error('ContentGeneratorConfig must have an authType'); + } + + let baseGenerator: ContentGenerator; + + if (authType === AuthType.USE_OPENAI) { const { createOpenAIContentGenerator } = await import( './openaiContentGenerator/index.js' ); - - // Always use OpenAIContentGenerator, logging is controlled by enableOpenAILogging flag - const generator = createOpenAIContentGenerator(config, gcConfig); - return new LoggingContentGenerator(generator, gcConfig); - } - - if (config.authType === AuthType.QWEN_OAUTH) { - // Import required classes dynamically + baseGenerator = createOpenAIContentGenerator(generatorConfig, config); + } else if (authType === AuthType.QWEN_OAUTH) { const { getQwenOAuthClient: getQwenOauthClient } = await import( '../qwen/qwenOAuth2.js' ); @@ -300,44 +300,38 @@ export async function createContentGenerator( ); try { - // Get the Qwen OAuth client (now includes integrated token management) - // If this is initial auth, require cached credentials to detect missing credentials const qwenClient = await getQwenOauthClient( - gcConfig, + config, isInitialAuth ? { requireCachedCredentials: true } : undefined, ); - - // Create the content generator with dynamic token management - const generator = new QwenContentGenerator(qwenClient, config, gcConfig); - return new LoggingContentGenerator(generator, gcConfig); + baseGenerator = new QwenContentGenerator( + qwenClient, + generatorConfig, + config, + ); } catch (error) { throw new Error( `${error instanceof Error ? error.message : String(error)}`, ); } - } - - if (config.authType === AuthType.USE_ANTHROPIC) { + } else if (authType === AuthType.USE_ANTHROPIC) { const { createAnthropicContentGenerator } = await import( './anthropicContentGenerator/index.js' ); - - const generator = createAnthropicContentGenerator(config, gcConfig); - return new LoggingContentGenerator(generator, gcConfig); - } - - if ( - config.authType === AuthType.USE_GEMINI || - config.authType === AuthType.USE_VERTEX_AI + baseGenerator = createAnthropicContentGenerator(generatorConfig, config); + } else if ( + authType === AuthType.USE_GEMINI || + authType === AuthType.USE_VERTEX_AI ) { const { createGeminiContentGenerator } = await import( './geminiContentGenerator/index.js' ); - const generator = createGeminiContentGenerator(config, gcConfig); - return new LoggingContentGenerator(generator, gcConfig); + baseGenerator = createGeminiContentGenerator(generatorConfig, config); + } else { + throw new Error( + `Error creating contentGenerator: Unsupported authType: ${authType}`, + ); } - throw new Error( - `Error creating contentGenerator: Unsupported authType: ${config.authType}`, - ); + return new LoggingContentGenerator(baseGenerator, config, generatorConfig); } diff --git a/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.test.ts b/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.test.ts index 0a61e5573..156b75a01 100644 --- a/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.test.ts +++ b/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.test.ts @@ -12,6 +12,7 @@ import type { import { GenerateContentResponse } from '@google/genai'; import type { Config } from '../../config/config.js'; import type { ContentGenerator } from '../contentGenerator.js'; +import { AuthType } from '../contentGenerator.js'; import { LoggingContentGenerator } from './index.js'; import { OpenAIContentConverter } from '../openaiContentGenerator/converter.js'; import { @@ -50,14 +51,17 @@ const convertGeminiResponseToOpenAISpy = vi choices: [], } as OpenAI.Chat.ChatCompletion); -const createConfig = (overrides: Record = {}): Config => - ({ - getContentGeneratorConfig: () => ({ - authType: 'openai', - enableOpenAILogging: false, - ...overrides, - }), - }) as Config; +const createConfig = (overrides: Record = {}): Config => { + const configContent = { + authType: 'openai', + enableOpenAILogging: false, + ...overrides, + }; + return { + getContentGeneratorConfig: () => configContent, + getAuthType: () => configContent.authType as AuthType | undefined, + } as Config; +}; const createWrappedGenerator = ( generateContent: ContentGenerator['generateContent'], @@ -124,13 +128,17 @@ describe('LoggingContentGenerator', () => { ), vi.fn(), ); + const generatorConfig = { + model: 'test-model', + authType: AuthType.USE_OPENAI, + enableOpenAILogging: true, + openAILoggingDir: 'logs', + schemaCompliance: 'openapi_30' as const, + }; const generator = new LoggingContentGenerator( wrapped, - createConfig({ - enableOpenAILogging: true, - openAILoggingDir: 'logs', - schemaCompliance: 'openapi_30', - }), + createConfig(), + generatorConfig, ); const request = { @@ -225,9 +233,15 @@ describe('LoggingContentGenerator', () => { vi.fn().mockRejectedValue(error), vi.fn(), ); + const generatorConfig = { + model: 'test-model', + authType: AuthType.USE_OPENAI, + enableOpenAILogging: true, + }; const generator = new LoggingContentGenerator( wrapped, - createConfig({ enableOpenAILogging: true }), + createConfig(), + generatorConfig, ); const request = { @@ -293,9 +307,15 @@ describe('LoggingContentGenerator', () => { })(), ), ); + const generatorConfig = { + model: 'test-model', + authType: AuthType.USE_OPENAI, + enableOpenAILogging: true, + }; const generator = new LoggingContentGenerator( wrapped, - createConfig({ enableOpenAILogging: true }), + createConfig(), + generatorConfig, ); const request = { @@ -345,9 +365,15 @@ describe('LoggingContentGenerator', () => { })(), ), ); + const generatorConfig = { + model: 'test-model', + authType: AuthType.USE_OPENAI, + enableOpenAILogging: true, + }; const generator = new LoggingContentGenerator( wrapped, - createConfig({ enableOpenAILogging: true }), + createConfig(), + generatorConfig, ); const request = { diff --git a/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.ts b/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.ts index 34e9128a8..88e9e2c87 100644 --- a/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.ts +++ b/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.ts @@ -31,7 +31,10 @@ import { logApiRequest, logApiResponse, } from '../../telemetry/loggers.js'; -import type { ContentGenerator } from '../contentGenerator.js'; +import type { + ContentGenerator, + ContentGeneratorConfig, +} from '../contentGenerator.js'; import { isStructuredError } from '../../utils/quotaErrorDetection.js'; import { OpenAIContentConverter } from '../openaiContentGenerator/converter.js'; import { OpenAILogger } from '../../utils/openaiLogger.js'; @@ -50,9 +53,11 @@ export class LoggingContentGenerator implements ContentGenerator { constructor( private readonly wrapped: ContentGenerator, private readonly config: Config, + generatorConfig: ContentGeneratorConfig, ) { - const generatorConfig = this.config.getContentGeneratorConfig(); - if (generatorConfig?.enableOpenAILogging) { + // Extract fields needed for initialization from passed config + // (config.getContentGeneratorConfig() may not be available yet during refreshAuth) + if (generatorConfig.enableOpenAILogging) { this.openaiLogger = new OpenAILogger(generatorConfig.openAILoggingDir); this.schemaCompliance = generatorConfig.schemaCompliance; } @@ -89,7 +94,7 @@ export class LoggingContentGenerator implements ContentGenerator { model, durationMs, prompt_id, - this.config.getContentGeneratorConfig()?.authType, + this.config.getAuthType(), usageMetadata, responseText, ), @@ -126,7 +131,7 @@ export class LoggingContentGenerator implements ContentGenerator { errorMessage, durationMs, prompt_id, - this.config.getContentGeneratorConfig()?.authType, + this.config.getAuthType(), errorType, errorStatus, ), diff --git a/packages/core/src/models/constants.ts b/packages/core/src/models/constants.ts index fcb1be985..1ef9919a2 100644 --- a/packages/core/src/models/constants.ts +++ b/packages/core/src/models/constants.ts @@ -106,15 +106,6 @@ export const QWEN_OAUTH_MODELS: ModelConfig[] = [ description: 'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)', capabilities: { vision: false }, - generationConfig: { - samplingParams: { - temperature: 0.7, - top_p: 0.9, - max_tokens: 8192, - }, - timeout: 60000, - maxRetries: 3, - }, }, { id: 'vision-model', @@ -122,14 +113,5 @@ export const QWEN_OAUTH_MODELS: ModelConfig[] = [ description: 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)', capabilities: { vision: true }, - generationConfig: { - samplingParams: { - temperature: 0.7, - top_p: 0.9, - max_tokens: 8192, - }, - timeout: 60000, - maxRetries: 3, - }, }, ]; diff --git a/packages/core/src/models/modelsConfig.test.ts b/packages/core/src/models/modelsConfig.test.ts index 1b220294e..06896a382 100644 --- a/packages/core/src/models/modelsConfig.test.ts +++ b/packages/core/src/models/modelsConfig.test.ts @@ -480,6 +480,91 @@ describe('ModelsConfig', () => { expect(gc.apiKeyEnvKey).toBeUndefined(); }); + it('should use default model for new authType when switching from different authType with env vars', () => { + // Simulate cold start with OPENAI env vars (OPENAI_MODEL and OPENAI_API_KEY) + // This sets the model in generationConfig but no authType is selected yet + const modelsConfig = new ModelsConfig({ + generationConfig: { + model: 'gpt-4o', // From OPENAI_MODEL env var + apiKey: 'openai-key-from-env', + }, + }); + + // User switches to qwen-oauth via AuthDialog + // refreshAuth calls syncAfterAuthRefresh with the current model (gpt-4o) + // which doesn't exist in qwen-oauth registry, so it should use default + modelsConfig.syncAfterAuthRefresh(AuthType.QWEN_OAUTH, 'gpt-4o'); + + const gc = currentGenerationConfig(modelsConfig); + // Should use default qwen-oauth model (coder-model), not the OPENAI model + expect(gc.model).toBe('coder-model'); + expect(gc.apiKey).toBe('QWEN_OAUTH_DYNAMIC_TOKEN'); + expect(gc.apiKeyEnvKey).toBeUndefined(); + }); + + it('should clear manual credentials when switching from USE_OPENAI to QWEN_OAUTH', () => { + // User manually set credentials for OpenAI + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + generationConfig: { + model: 'gpt-4o', + apiKey: 'manual-openai-key', + baseUrl: 'https://manual.example.com/v1', + }, + }); + + // Manually set credentials via updateCredentials + modelsConfig.updateCredentials({ + apiKey: 'manual-openai-key', + baseUrl: 'https://manual.example.com/v1', + model: 'gpt-4o', + }); + + // User switches to qwen-oauth + // Since authType is not USE_OPENAI, manual credentials should be cleared + // and default qwen-oauth model should be applied + modelsConfig.syncAfterAuthRefresh(AuthType.QWEN_OAUTH, 'gpt-4o'); + + const gc = currentGenerationConfig(modelsConfig); + // Should use default qwen-oauth model, not preserve manual OpenAI credentials + expect(gc.model).toBe('coder-model'); + expect(gc.apiKey).toBe('QWEN_OAUTH_DYNAMIC_TOKEN'); + // baseUrl should be set to qwen-oauth default, not preserved from manual OpenAI config + expect(gc.baseUrl).toBe('DYNAMIC_QWEN_OAUTH_BASE_URL'); + expect(gc.apiKeyEnvKey).toBeUndefined(); + }); + + it('should preserve manual credentials when switching to USE_OPENAI', () => { + // User manually set credentials + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + generationConfig: { + model: 'gpt-4o', + apiKey: 'manual-openai-key', + baseUrl: 'https://manual.example.com/v1', + samplingParams: { temperature: 0.9 }, + }, + }); + + // Manually set credentials via updateCredentials + modelsConfig.updateCredentials({ + apiKey: 'manual-openai-key', + baseUrl: 'https://manual.example.com/v1', + model: 'gpt-4o', + }); + + // User switches to USE_OPENAI (same or different model) + // Since authType is USE_OPENAI, manual credentials should be preserved + modelsConfig.syncAfterAuthRefresh(AuthType.USE_OPENAI, 'gpt-4o'); + + const gc = currentGenerationConfig(modelsConfig); + // Should preserve manual credentials + expect(gc.model).toBe('gpt-4o'); + expect(gc.apiKey).toBe('manual-openai-key'); + expect(gc.baseUrl).toBe('https://manual.example.com/v1'); + expect(gc.samplingParams?.temperature).toBe(0.9); // Preserved from initial config + }); + it('should maintain consistency between currentModelId and _generationConfig.model after initialization', () => { const modelProvidersConfig: ModelProvidersConfig = { openai: [ diff --git a/packages/core/src/models/modelsConfig.ts b/packages/core/src/models/modelsConfig.ts index 1c88903c2..36435143f 100644 --- a/packages/core/src/models/modelsConfig.ts +++ b/packages/core/src/models/modelsConfig.ts @@ -600,7 +600,7 @@ export class ModelsConfig { // If credentials were manually set, don't apply modelProvider defaults // Just update the authType and preserve the manually set credentials - if (preserveManualCredentials) { + if (preserveManualCredentials && authType === AuthType.USE_OPENAI) { this.strictModelProviderSelection = false; this.currentAuthType = authType; if (modelId) { @@ -621,7 +621,17 @@ export class ModelsConfig { this.applyResolvedModelDefaults(resolved); } } else { + // If the provided modelId doesn't exist in the registry for the new authType, + // use the default model for that authType instead of keeping the old model. + // This handles the case where switching from one authType (e.g., OPENAI with + // env vars) to another (e.g., qwen-oauth) - we should use the default model + // for the new authType, not the old model. this.currentAuthType = authType; + const defaultModel = + this.modelRegistry.getDefaultModelForAuthType(authType); + if (defaultModel) { + this.applyResolvedModelDefaults(defaultModel); + } } } diff --git a/packages/core/src/qwen/qwenOAuth2.ts b/packages/core/src/qwen/qwenOAuth2.ts index 74c334006..ab89cdfcf 100644 --- a/packages/core/src/qwen/qwenOAuth2.ts +++ b/packages/core/src/qwen/qwenOAuth2.ts @@ -559,6 +559,109 @@ export async function getQwenOAuthClient( } } +/** + * Displays a formatted box with OAuth device authorization URL. + * Uses process.stderr.write() to bypass ConsolePatcher and ensure the auth URL + * is always visible to users, especially in non-interactive mode. + * Using stderr prevents corruption of structured JSON output (which goes to stdout) + * and follows the standard Unix convention of user-facing messages to stderr. + */ +function showFallbackMessage(verificationUriComplete: string): void { + const title = 'Qwen OAuth Device Authorization'; + const url = verificationUriComplete; + const minWidth = 70; + const maxWidth = 80; + const boxWidth = Math.min(Math.max(title.length + 4, minWidth), maxWidth); + + // Calculate the width needed for the box (account for padding) + const contentWidth = boxWidth - 4; // Subtract 2 spaces and 2 border chars + + // Helper to wrap text to fit within box width + const wrapText = (text: string, width: number): string[] => { + // For URLs, break at any character if too long + if (text.startsWith('http://') || text.startsWith('https://')) { + const lines: string[] = []; + for (let i = 0; i < text.length; i += width) { + lines.push(text.substring(i, i + width)); + } + return lines; + } + + // For regular text, break at word boundaries + const words = text.split(' '); + const lines: string[] = []; + let currentLine = ''; + + for (const word of words) { + if (currentLine.length + word.length + 1 <= width) { + currentLine += (currentLine ? ' ' : '') + word; + } else { + if (currentLine) { + lines.push(currentLine); + } + currentLine = word.length > width ? word.substring(0, width) : word; + } + } + if (currentLine) { + lines.push(currentLine); + } + return lines; + }; + + // Build the box borders with title centered in top border + // Format: +--- Title ---+ + const titleWithSpaces = ' ' + title + ' '; + const totalDashes = boxWidth - 2 - titleWithSpaces.length; // Subtract corners and title + const leftDashes = Math.floor(totalDashes / 2); + const rightDashes = totalDashes - leftDashes; + const topBorder = + '+' + + '-'.repeat(leftDashes) + + titleWithSpaces + + '-'.repeat(rightDashes) + + '+'; + const emptyLine = '|' + ' '.repeat(boxWidth - 2) + '|'; + const bottomBorder = '+' + '-'.repeat(boxWidth - 2) + '+'; + + // Build content lines + const instructionLines = wrapText( + 'Please visit the following URL in your browser to authorize:', + contentWidth, + ); + const urlLines = wrapText(url, contentWidth); + const waitingLine = 'Waiting for authorization to complete...'; + + // Write the box + process.stderr.write('\n' + topBorder + '\n'); + process.stderr.write(emptyLine + '\n'); + + // Write instructions + for (const line of instructionLines) { + process.stderr.write( + '| ' + line + ' '.repeat(contentWidth - line.length) + ' |\n', + ); + } + + process.stderr.write(emptyLine + '\n'); + + // Write URL + for (const line of urlLines) { + process.stderr.write( + '| ' + line + ' '.repeat(contentWidth - line.length) + ' |\n', + ); + } + + process.stderr.write(emptyLine + '\n'); + + // Write waiting message + process.stderr.write( + '| ' + waitingLine + ' '.repeat(contentWidth - waitingLine.length) + ' |\n', + ); + + process.stderr.write(emptyLine + '\n'); + process.stderr.write(bottomBorder + '\n\n'); +} + async function authWithQwenDeviceFlow( client: QwenOAuth2Client, config: Config, @@ -571,6 +674,50 @@ async function authWithQwenDeviceFlow( }; qwenOAuth2Events.once(QwenOAuth2Event.AuthCancel, cancelHandler); + // Helper to check cancellation and return appropriate result + const checkCancellation = (): AuthResult | null => { + if (!isCancelled) { + return null; + } + const message = 'Authentication cancelled by user.'; + console.debug('\n' + message); + qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message); + return { success: false, reason: 'cancelled', message }; + }; + + // Helper to emit auth progress events + const emitAuthProgress = ( + status: 'polling' | 'success' | 'error' | 'timeout' | 'rate_limit', + message: string, + ): void => { + qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, status, message); + }; + + // Helper to handle browser launch with error handling + const launchBrowser = async (url: string): Promise => { + try { + const childProcess = await open(url); + + // IMPORTANT: Attach an error handler to the returned child process. + // Without this, if `open` fails to spawn a process (e.g., `xdg-open` is not found + // in a minimal Docker container), it will emit an unhandled 'error' event, + // causing the entire Node.js process to crash. + if (childProcess) { + childProcess.on('error', (err) => { + console.debug( + 'Browser launch failed:', + err.message || 'Unknown error', + ); + }); + } + } catch (err) { + console.debug( + 'Failed to open browser:', + err instanceof Error ? err.message : 'Unknown error', + ); + } + }; + try { // Generate PKCE code verifier and challenge const { code_verifier, code_challenge } = generatePKCEPair(); @@ -593,56 +740,18 @@ async function authWithQwenDeviceFlow( // Emit device authorization event for UI integration immediately qwenOAuth2Events.emit(QwenOAuth2Event.AuthUri, deviceAuth); - const showFallbackMessage = () => { - console.log('\n=== Qwen OAuth Device Authorization ==='); - console.log( - 'Please visit the following URL in your browser to authorize:', - ); - console.log(`\n${deviceAuth.verification_uri_complete}\n`); - console.log('Waiting for authorization to complete...\n'); - }; - // Always show the fallback message in non-interactive environments to ensure // users can see the authorization URL even if browser launching is attempted. // This is critical for headless/remote environments where browser launching // may silently fail without throwing an error. - if (config.isBrowserLaunchSuppressed()) { - // Browser launch is suppressed, show fallback message - showFallbackMessage(); - } else { - // Try to open the URL in browser, but always show the URL as fallback - // to handle cases where browser launch silently fails (e.g., headless servers) - showFallbackMessage(); - try { - const childProcess = await open(deviceAuth.verification_uri_complete); + showFallbackMessage(deviceAuth.verification_uri_complete); - // IMPORTANT: Attach an error handler to the returned child process. - // Without this, if `open` fails to spawn a process (e.g., `xdg-open` is not found - // in a minimal Docker container), it will emit an unhandled 'error' event, - // causing the entire Node.js process to crash. - if (childProcess) { - childProcess.on('error', (err) => { - console.debug( - 'Browser launch failed:', - err.message || 'Unknown error', - ); - }); - } - } catch (err) { - console.debug( - 'Failed to open browser:', - err instanceof Error ? err.message : 'Unknown error', - ); - } + // Try to open browser if not suppressed + if (!config.isBrowserLaunchSuppressed()) { + await launchBrowser(deviceAuth.verification_uri_complete); } - // Emit auth progress event - qwenOAuth2Events.emit( - QwenOAuth2Event.AuthProgress, - 'polling', - 'Waiting for authorization...', - ); - + emitAuthProgress('polling', 'Waiting for authorization...'); console.debug('Waiting for authorization...\n'); // Poll for the token @@ -653,11 +762,9 @@ async function authWithQwenDeviceFlow( for (let attempt = 0; attempt < maxAttempts; attempt++) { // Check if authentication was cancelled - if (isCancelled) { - const message = 'Authentication cancelled by user.'; - console.debug('\n' + message); - qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message); - return { success: false, reason: 'cancelled', message }; + const cancellationResult = checkCancellation(); + if (cancellationResult) { + return cancellationResult; } try { @@ -700,9 +807,7 @@ async function authWithQwenDeviceFlow( // minimal stub; cache invalidation is best-effort and should not break auth. } - // Emit auth progress success event - qwenOAuth2Events.emit( - QwenOAuth2Event.AuthProgress, + emitAuthProgress( 'success', 'Authentication successful! Access token obtained.', ); @@ -725,9 +830,7 @@ async function authWithQwenDeviceFlow( pollInterval = 2000; // Reset to default interval } - // Emit polling progress event - qwenOAuth2Events.emit( - QwenOAuth2Event.AuthProgress, + emitAuthProgress( 'polling', `Polling... (attempt ${attempt + 1}/${maxAttempts})`, ); @@ -757,15 +860,9 @@ async function authWithQwenDeviceFlow( }); // Check for cancellation after waiting - if (isCancelled) { - const message = 'Authentication cancelled by user.'; - console.debug('\n' + message); - qwenOAuth2Events.emit( - QwenOAuth2Event.AuthProgress, - 'error', - message, - ); - return { success: false, reason: 'cancelled', message }; + const cancellationResult = checkCancellation(); + if (cancellationResult) { + return cancellationResult; } continue; @@ -793,15 +890,17 @@ async function authWithQwenDeviceFlow( message: string, eventType: 'error' | 'rate_limit' = 'error', ): AuthResult => { - qwenOAuth2Events.emit( - QwenOAuth2Event.AuthProgress, - eventType, - message, - ); + emitAuthProgress(eventType, message); console.error('\n' + message); return { success: false, reason, message }; }; + // Check for cancellation first + const cancellationResult = checkCancellation(); + if (cancellationResult) { + return cancellationResult; + } + // Handle credential caching failures - stop polling immediately if (errorMessage.includes('Failed to cache credentials')) { return handleError('error', errorMessage); @@ -825,26 +924,14 @@ async function authWithQwenDeviceFlow( } const message = `Error polling for token: ${errorMessage}`; - qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message); - - if (isCancelled) { - const message = 'Authentication cancelled by user.'; - return { success: false, reason: 'cancelled', message }; - } + emitAuthProgress('error', message); await new Promise((resolve) => setTimeout(resolve, pollInterval)); } } const timeoutMessage = 'Authorization timeout, please restart the process.'; - - // Emit timeout error event - qwenOAuth2Events.emit( - QwenOAuth2Event.AuthProgress, - 'timeout', - timeoutMessage, - ); - + emitAuthProgress('timeout', timeoutMessage); console.error('\n' + timeoutMessage); return { success: false, reason: 'timeout', message: timeoutMessage }; } catch (error: unknown) { @@ -853,7 +940,7 @@ async function authWithQwenDeviceFlow( }); const message = `Device authorization flow failed: ${fullErrorMessage}`; - qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message); + emitAuthProgress('error', message); console.error(message); return { success: false, reason: 'error', message }; } finally { diff --git a/packages/core/src/skills/skill-manager.ts b/packages/core/src/skills/skill-manager.ts index 3919e8cea..b3ecd49cd 100644 --- a/packages/core/src/skills/skill-manager.ts +++ b/packages/core/src/skills/skill-manager.ts @@ -203,6 +203,7 @@ export class SkillManager { } this.watchStarted = true; + await this.ensureUserSkillsDir(); await this.refreshCache(); this.updateWatchersFromCache(); } @@ -471,29 +472,14 @@ export class SkillManager { } private updateWatchersFromCache(): void { - const desiredPaths = new Set(); - - for (const level of ['project', 'user'] as const) { - const baseDir = this.getSkillsBaseDir(level); - const parentDir = path.dirname(baseDir); - if (fsSync.existsSync(parentDir)) { - desiredPaths.add(parentDir); - } - if (fsSync.existsSync(baseDir)) { - desiredPaths.add(baseDir); - } - - const levelSkills = this.skillsCache?.get(level) || []; - for (const skill of levelSkills) { - const skillDir = path.dirname(skill.filePath); - if (fsSync.existsSync(skillDir)) { - desiredPaths.add(skillDir); - } - } - } + const watchTargets = new Set( + (['project', 'user'] as const) + .map((level) => this.getSkillsBaseDir(level)) + .filter((baseDir) => fsSync.existsSync(baseDir)), + ); for (const existingPath of this.watchers.keys()) { - if (!desiredPaths.has(existingPath)) { + if (!watchTargets.has(existingPath)) { void this.watchers .get(existingPath) ?.close() @@ -507,7 +493,7 @@ export class SkillManager { } } - for (const watchPath of desiredPaths) { + for (const watchPath of watchTargets) { if (this.watchers.has(watchPath)) { continue; } @@ -542,4 +528,16 @@ export class SkillManager { void this.refreshCache().then(() => this.updateWatchersFromCache()); }, 150); } + + private async ensureUserSkillsDir(): Promise { + const baseDir = this.getSkillsBaseDir('user'); + try { + await fs.mkdir(baseDir, { recursive: true }); + } catch (error) { + console.warn( + `Failed to create user skills directory at ${baseDir}:`, + error, + ); + } + } } diff --git a/packages/core/src/tools/skill.ts b/packages/core/src/tools/skill.ts index 93a382fef..f1dc1596b 100644 --- a/packages/core/src/tools/skill.ts +++ b/packages/core/src/tools/skill.ts @@ -53,7 +53,7 @@ export class SkillTool extends BaseDeclarativeTool { false, // canUpdateOutput ); - this.skillManager = config.getSkillManager(); + this.skillManager = config.getSkillManager()!; this.skillManager.addChangeListener(() => { void this.refreshSkills(); }); diff --git a/packages/sdk-typescript/package.json b/packages/sdk-typescript/package.json index e6af67427..ce8fd6162 100644 --- a/packages/sdk-typescript/package.json +++ b/packages/sdk-typescript/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/sdk", - "version": "0.1.2", + "version": "0.1.3", "description": "TypeScript SDK for programmatic access to qwen-code CLI", "main": "./dist/index.cjs", "module": "./dist/index.mjs", diff --git a/packages/sdk-typescript/src/utils/cliPath.ts b/packages/sdk-typescript/src/utils/cliPath.ts index e4a7924bc..fb795ec80 100644 --- a/packages/sdk-typescript/src/utils/cliPath.ts +++ b/packages/sdk-typescript/src/utils/cliPath.ts @@ -125,8 +125,9 @@ function normalizeForRegex(dirPath: string): string { function tryResolveCliFromImportMeta(): string | null { try { if (typeof import.meta !== 'undefined' && import.meta.url) { - const cliUrl = new URL('./cli/cli.js', import.meta.url); - const cliPath = fileURLToPath(cliUrl); + const currentFilePath = fileURLToPath(import.meta.url); + const currentDir = path.dirname(currentFilePath); + const cliPath = path.join(currentDir, 'cli', 'cli.js'); if (fs.existsSync(cliPath)) { return cliPath; } diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 1dd551fd3..28ed2171d 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code-test-utils", - "version": "0.7.0", + "version": "0.7.1", "private": true, "main": "src/index.ts", "license": "Apache-2.0", diff --git a/packages/vscode-ide-companion/README.md b/packages/vscode-ide-companion/README.md index 55f7da323..92eb830a6 100644 --- a/packages/vscode-ide-companion/README.md +++ b/packages/vscode-ide-companion/README.md @@ -1,6 +1,11 @@ # Qwen Code Companion -Seamlessly integrate [Qwen Code](https://github.com/QwenLM/qwen-code) into Visual Studio Code with native IDE features and an intuitive interface. This extension bundles everything you need to get started immediately. +[![Version](https://img.shields.io/visual-studio-marketplace/v/qwenlm.qwen-code-vscode-ide-companion)](https://marketplace.visualstudio.com/items?itemName=qwenlm.qwen-code-vscode-ide-companion) +[![VS Code Installs](https://img.shields.io/visual-studio-marketplace/i/qwenlm.qwen-code-vscode-ide-companion)](https://marketplace.visualstudio.com/items?itemName=qwenlm.qwen-code-vscode-ide-companion) +[![Open VSX Downloads](https://img.shields.io/open-vsx/dt/qwenlm/qwen-code-vscode-ide-companion)](https://open-vsx.org/extension/qwenlm/qwen-code-vscode-ide-companion) +[![Rating](https://img.shields.io/visual-studio-marketplace/r/qwenlm.qwen-code-vscode-ide-companion)](https://marketplace.visualstudio.com/items?itemName=qwenlm.qwen-code-vscode-ide-companion) + +Seamlessly integrate [Qwen Code](https://github.com/QwenLM/qwen-code) into Visual Studio Code with native IDE features and an intuitive chat interface. This extension bundles everything you need — no additional installation required. ## Demo @@ -11,7 +16,7 @@ Seamlessly integrate [Qwen Code](https://github.com/QwenLM/qwen-code) into Visua ## Features -- **Native IDE experience**: Dedicated Qwen Code sidebar panel accessed via the Qwen icon +- **Native IDE experience**: Dedicated Qwen Code Chat panel accessed via the Qwen icon in the editor title bar - **Native diffing**: Review, edit, and accept changes in VS Code's diff view - **Auto-accept edits mode**: Automatically apply Qwen's changes as they're made - **File management**: @-mention files or attach files and images using the system file picker @@ -20,73 +25,46 @@ Seamlessly integrate [Qwen Code](https://github.com/QwenLM/qwen-code) into Visua ## Requirements -- Visual Studio Code 1.85.0 or newer +- Visual Studio Code 1.85.0 or newer (also works with Cursor, Windsurf, and other VS Code-based editors) -## Installation +## Quick Start -1. Install from the VS Code Marketplace: https://marketplace.visualstudio.com/items?itemName=qwenlm.qwen-code-vscode-ide-companion +1. **Install** from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=qwenlm.qwen-code-vscode-ide-companion) or [Open VSX Registry](https://open-vsx.org/extension/qwenlm/qwen-code-vscode-ide-companion) -2. Two ways to use - - Chat panel: Click the Qwen icon in the Activity Bar, or run `Qwen Code: Open` from the Command Palette (`Cmd+Shift+P` / `Ctrl+Shift+P`). - - Terminal session (classic): Run `Qwen Code: Run` to launch a session in the integrated terminal (bundled CLI). +2. **Open the Chat panel** using one of these methods: + - Click the **Qwen icon** in the top-right corner of the editor + - Run `Qwen Code: Open` from the Command Palette (`Cmd+Shift+P` / `Ctrl+Shift+P`) -## Development and Debugging +3. **Start chatting** — Ask Qwen to help with coding tasks, explain code, fix bugs, or write new features -To debug and develop this extension locally: +## Commands -1. **Clone the repository** +| Command | Description | +| -------------------------------- | ------------------------------------------------------ | +| `Qwen Code: Open` | Open the Qwen Code Chat panel | +| `Qwen Code: Run` | Launch a classic terminal session with the bundled CLI | +| `Qwen Code: Accept Current Diff` | Accept the currently displayed diff | +| `Qwen Code: Close Diff Editor` | Close/reject the current diff | - ```bash - git clone https://github.com/QwenLM/qwen-code.git - cd qwen-code - ``` +## Feedback & Issues -2. **Install dependencies** +- 🐛 [Report bugs](https://github.com/QwenLM/qwen-code/issues/new?template=bug_report.yml&labels=bug,vscode-ide-companion) +- 💡 [Request features](https://github.com/QwenLM/qwen-code/issues/new?template=feature_request.yml&labels=enhancement,vscode-ide-companion) +- 📖 [Documentation](https://qwenlm.github.io/qwen-code-docs/) +- 📋 [Changelog](https://github.com/QwenLM/qwen-code/releases) - ```bash - npm install - # or if using pnpm - pnpm install - ``` +## Contributing -3. **Start debugging** +We welcome contributions! See our [Contributing Guide](https://github.com/QwenLM/qwen-code/blob/main/CONTRIBUTING.md) for details on: - ```bash - code . # Open the project root in VS Code - ``` - - Open the `packages/vscode-ide-companion/src/extension.ts` file - - Open Debug panel (`Ctrl+Shift+D` or `Cmd+Shift+D`) - - Select **"Launch Companion VS Code Extension"** from the debug dropdown - - Press `F5` to launch Extension Development Host - -4. **Make changes and reload** - - Edit the source code in the original VS Code window - - To see your changes, reload the Extension Development Host window by: - - Pressing `Ctrl+R` (Windows/Linux) or `Cmd+R` (macOS) - - Or clicking the "Reload" button in the debug toolbar - -5. **View logs and debug output** - - Open the Debug Console in the original VS Code window to see extension logs - - In the Extension Development Host window, open Developer Tools with `Help > Toggle Developer Tools` to see webview logs - -## Build for Production - -To build the extension for distribution: - -```bash -npm run compile -# or -pnpm run compile -``` - -To package the extension as a VSIX file: - -```bash -npx vsce package -# or -pnpm vsce package -``` +- Setting up the development environment +- Building and debugging the extension locally +- Submitting pull requests ## Terms of Service and Privacy Notice By installing this extension, you agree to the [Terms of Service](https://github.com/QwenLM/qwen-code/blob/main/docs/tos-privacy.md). + +## License + +[Apache-2.0](https://github.com/QwenLM/qwen-code/blob/main/LICENSE) diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index b7c50f57c..6c5be6727 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -2,7 +2,7 @@ "name": "qwen-code-vscode-ide-companion", "displayName": "Qwen Code Companion", "description": "Enable Qwen Code with direct access to your VS Code workspace.", - "version": "0.7.0", + "version": "0.7.1", "publisher": "qwenlm", "icon": "assets/icon.png", "repository": { diff --git a/packages/vscode-ide-companion/src/extension.ts b/packages/vscode-ide-companion/src/extension.ts index be0f669e6..14ff4bcae 100644 --- a/packages/vscode-ide-companion/src/extension.ts +++ b/packages/vscode-ide-companion/src/extension.ts @@ -314,34 +314,32 @@ export async function activate(context: vscode.ExtensionContext) { 'cli.js', ).fsPath; const execPath = process.execPath; - const lowerExecPath = execPath.toLowerCase(); - const needsElectronRunAsNode = - lowerExecPath.includes('code') || - lowerExecPath.includes('electron'); - let qwenCmd: string; const terminalOptions: vscode.TerminalOptions = { name: `Qwen Code (${selectedFolder.name})`, cwd: selectedFolder.uri.fsPath, location, }; + let qwenCmd: string; + if (isWindows) { - // Use system Node via cmd.exe; avoid PowerShell parsing issues + // On Windows, try multiple strategies to find a Node.js runtime: + // 1. Check if VSCode ships a standalone node.exe alongside Code.exe + // 2. Check VSCode's internal Node.js in resources directory + // 3. Fall back to using Code.exe with ELECTRON_RUN_AS_NODE=1 const quoteCmd = (s: string) => `"${s.replace(/"/g, '""')}"`; const cliQuoted = quoteCmd(cliEntry); // TODO: @yiliang114, temporarily run through node, and later hope to decouple from the local node qwenCmd = `node ${cliQuoted}`; terminalOptions.shellPath = process.env.ComSpec; } else { + // macOS/Linux: All VSCode-like IDEs (VSCode, Cursor, Windsurf, etc.) + // are Electron-based, so we always need ELECTRON_RUN_AS_NODE=1 + // to run Node.js scripts using the IDE's bundled runtime. const quotePosix = (s: string) => `"${s.replace(/"/g, '\\"')}"`; const baseCmd = `${quotePosix(execPath)} ${quotePosix(cliEntry)}`; - if (needsElectronRunAsNode) { - // macOS Electron helper needs ELECTRON_RUN_AS_NODE=1; - qwenCmd = `ELECTRON_RUN_AS_NODE=1 ${baseCmd}`; - } else { - qwenCmd = baseCmd; - } + qwenCmd = `ELECTRON_RUN_AS_NODE=1 ${baseCmd}`; } const terminal = vscode.window.createTerminal(terminalOptions);