Compare commits

..

73 Commits

Author SHA1 Message Date
github-actions[bot]
e3eccb5987 chore(release): sdk-typescript v0.1.3 2026-01-13 12:59:45 +00:00
Mingholy
22916457cd Merge pull request #1482 from QwenLM/mingholy/test/skip-flaky-e2e-test
Skip flaky permission control test
2026-01-13 20:16:35 +08:00
Mingholy
28bc4e6467 Merge pull request #1480 from QwenLM/mingholy/fix/qwen-oauth-fallback
Fix: Improve qwen-oauth fallback message display
2026-01-13 20:15:25 +08:00
mingholy.lmh
50bf65b10b test: skip flaky & ambigous sdk e2e test case 2026-01-13 20:04:19 +08:00
Mingholy
47c8bc5303 Merge pull request #1478 from QwenLM/mingholy/fix/misc-adjustments
Fix auth type switching and model persistence issues
2026-01-13 19:48:57 +08:00
mingholy.lmh
e70ecdf3a8 fix: improve qwen-oauth fallback message display 2026-01-13 19:40:41 +08:00
tanzhenxin
117af05122 Merge pull request #1386 from tt-a1i/fix/error-message-object-display
fix(cli): improve error message display for object errors
2026-01-13 19:18:16 +08:00
tanzhenxin
557e6397bb Merge pull request #1473 from BlockHand/build-sandbox
feat: Customizing the sandbox environment
2026-01-13 19:07:41 +08:00
刘伟光
f762a62a2e feat: Improve the usage documentation 2026-01-13 18:54:26 +08:00
tanzhenxin
ca12772a28 Merge pull request #1469 from QwenLM/fix/1454-shell-timeout
feat(shell): add optional timeout for foreground commands
2026-01-13 18:25:36 +08:00
tanzhenxin
cec4b831b6 Merge pull request #1447 from xuewenjie123/feature/add-defaultHeaders-support
Feature/add custom headers support
2026-01-13 17:51:10 +08:00
tanzhenxin
74bf72877d Merge branch 'main' into fix/1454-shell-timeout 2026-01-13 17:41:09 +08:00
tanzhenxin
b60ae42d10 Merge pull request #1474 from QwenLM/fix/vscode-run
fix(vscode-ide-companion): Fix cross-platform CLI terminal execution
2026-01-13 17:38:28 +08:00
tanzhenxin
54fd4c22a9 Merge pull request #1460 from QwenLM/feat/support-ipynb-select-code
Support Jupyter Notebook (.ipynb) File Code Selection
2026-01-13 17:37:02 +08:00
tanzhenxin
f3b7c63cd1 Merge pull request #1436 from QwenLM/feat/skills-enhancement
feat(skills): add experimental /skills command + hot reload
2026-01-13 17:36:21 +08:00
tanzhenxin
e4dee3a2b2 Implement proper header merging: customHeaders now merge with default headers instead of replacing them in all content generators
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-01-13 17:30:54 +08:00
mingholy.lmh
996b9df947 fix: switch auth won't persist fallback default models for qwen-oauth 2026-01-13 17:19:15 +08:00
mingholy.lmh
64291db926 fix: misc issues in qwen-oauth models, sdk cli path resolving.
1. remove `generationConfig`` of qwen-oauth models
2. fix esm issues when sdk trying to spawn cli
2026-01-13 17:19:15 +08:00
tanzhenxin
a8e3b9ebe7 Merge pull request #1411 from niklas-wortmann/jetbrains-docs
docs: add integration guide for JetBrains IDEs
2026-01-13 16:52:10 +08:00
tanzhenxin
5cfc9f4686 Update skill manager and package dependencies
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-01-13 16:51:36 +08:00
刘伟光
85473210e5 feat: Customizing the sandbox environment 2026-01-13 10:47:08 +08:00
刘伟光
c0c94bd4fc feat: Customizing the sandbox environment 2026-01-13 10:39:32 +08:00
yiliang114
8111511a89 chore(vscode-ide-companion): add comments under window 2026-01-13 10:19:43 +08:00
xwj02155382
a8eb858f99 refactor: rename defaultHeaders to customHeaders
- Rename defaultHeaders field to customHeaders in ContentGeneratorConfig
- Update MODEL_GENERATION_CONFIG_FIELDS constant
- Update ModelGenerationConfig type definition
- Align naming with documentation and usage across the codebase
2026-01-13 10:14:55 +08:00
pomelo
52d6d1ff13 Merge pull request #1472 from QwenLM/update-vscode-extension-docs
docs(vscode-ide-companion): update vscode extension readme
2026-01-13 09:11:04 +08:00
yiliang114
c845049d26 Merge branch 'main' into fix/vscode-run 2026-01-13 00:27:44 +08:00
Jan-Niklas W.
299b7de030 add image for jetbrains acp configuration 2026-01-12 10:17:25 -06:00
yiliang114
b93bb8bff6 docs(vscode-ide-companion): update vscode extension readme 2026-01-13 00:14:57 +08:00
xwj02155382
adb53a6dc6 refactor: change customHeaders to use priority override instead of merge
- Remove special merge handling for customHeaders in modelConfigResolver
- Update all content generators to use priority override logic
- If customHeaders is defined in modelProvider, use it directly
- Otherwise, use customHeaders from global config or default headers
- Update documentation to reflect the new behavior
- Align customHeaders behavior with other config fields (timeout, maxRetries, etc.)
2026-01-12 18:03:02 +08:00
qwen-code-ci-bot
09196c6e19 Merge pull request #1470 from QwenLM/release/sdk-typescript/v0.1.2
chore(release): sdk-typescript v0.1.2
2026-01-12 16:26:57 +08:00
github-actions[bot]
4bd01d592b chore(release): sdk-typescript v0.1.2 2026-01-12 08:25:25 +00:00
tanzhenxin
6917031128 feat(shell): add optional timeout for foreground commands
Adds a timeout parameter (validated and schema-exposed) and improves abort messaging by distinguishing user cancellation from timeout.

Resolves #1454
2026-01-12 16:23:11 +08:00
xwj02155382
b33525183f Merge branch 'main' of github.com:QwenLM/qwen-code into feature/add-defaultHeaders-support 2026-01-12 15:52:23 +08:00
Mingholy
1aed5ce858 Merge pull request #1462 from QwenLM/mingholy/feat/sdk-skills
fix: SDK release workflow and stability improvements
2026-01-12 15:06:55 +08:00
Mingholy
bad5b0485d Merge pull request #1457 from liqiongyu/fix/1118-auth-fetch-failed-diagnostics
fix(core): improve OAuth fetch-failed diagnostics
2026-01-12 15:06:16 +08:00
tanzhenxin
5a6e5bb452 Merge pull request #1427 from liqiongyu/fix/1333-legacy-settings-alias
fix(cli): warn on deprecated/unknown settings keys
2026-01-12 14:43:27 +08:00
tanzhenxin
5f8e1ebc94 chore(settings): update legacy settings alias implementation and tests
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-01-12 14:29:40 +08:00
mingholy.lmh
9670456a56 fix: simplify JavaScript runtime detection to fix powershell spawning process issue 2026-01-12 13:42:24 +08:00
liqoingyu
4c186e7c92 refactor(core): extract fetch error troubleshooting 2026-01-12 12:00:01 +08:00
tanzhenxin
2f6b0b233a Merge pull request #1464 from xuewenjie123/fix/windows-background-terminal-execute-x
fix(shell): prevent console window flash on Windows for foreground tasks
2026-01-12 11:48:10 +08:00
xuewenjie
9a8ce605c5 test: update shellExecutionService test for Windows spawn config changes 2026-01-12 11:22:54 +08:00
tanzhenxin
afc693a4ab Merge pull request #1453 from liqiongyu/fix/1359-sandbox-uidgid-default-linux
fix(cli): default sandbox UID/GID mapping on Linux
2026-01-12 11:08:47 +08:00
xuewenjie
7173cba844 fix(shell): prevent console window flash on Windows for foreground tasks 2026-01-12 11:04:05 +08:00
yiliang114
ec8cccafd7 Merge branch 'main' of https://github.com/QwenLM/qwen-code into fix/vscode-run 2026-01-12 10:57:56 +08:00
liqoingyu
8c56b612fb fix(cli): warn on deprecated/unknown settings keys 2026-01-12 10:49:37 +08:00
mingholy.lmh
7d40e1470c chore: add CODEOWNERS for SDK TypeScript package and remove legacy CLI path alias 2026-01-11 21:24:45 +08:00
yiliang114
b0e561ca73 chore(vscode-ide-companion/open-files-manager): update copyright headers to Qwen Team 2026-01-11 00:25:31 +08:00
yiliang114
563d68ad5b feat(vscode-ide-companion/services): add IPYNB code selection support and refactor OpenFilesManager 2026-01-10 23:51:51 +08:00
yiliang114
82c524f87d fix(vscode-ide-companion): window qwen code run command 2026-01-10 22:30:10 +08:00
yiliang114
df75aa06b6 fix(vscode-ide-companion): window qwen code run command 2026-01-10 22:08:14 +08:00
yiliang114
8ea9871d23 fix(vscode-ide-companion): fix positional argument problem due to special handling for Electron app of yargs
- Remove isNodeAvailable function and related child_process import
- Update command execution logic to properly handle ELECTRON_RUN_AS_NODE
- Add proper quoting mechanisms for different platforms (PowerShell vs POSIX)
- Bump version from 0.6.1 to 0.6.2
2026-01-10 19:52:25 +08:00
liqoingyu
097482910e fix(core): improve OAuth fetch-failed diagnostics 2026-01-10 16:49:56 +08:00
liqoingyu
9b78c17638 fix(cli): default sandbox UID/GID mapping on Linux
Fixes #1359.

Default container sandboxing on Linux to use host UID/GID so qwen runs under a user that matches the mounted home directory and persists auth/settings in ~/.qwen.

Also gate the informational log behind DEBUG/DEBUG_MODE and clarify docs about Linux UID/GID mapping and ~/.qwen persistence.
2026-01-10 14:31:08 +08:00
xwj02155382
2d1934bf2f docs: add defaultHeaders documentation to settings.md
- Add defaultHeaders to model.generationConfig description
- Add defaultHeaders example in model.generationConfig
- Add defaultHeaders example in modelProviders configuration
- Document defaultHeaders merge strategy in generation config layering
- Explain use cases: request tracing, monitoring, API gateway routing
2026-01-09 18:15:21 +08:00
mingholy.lmh
7f15256eba fix: improve release workflow 2026-01-09 18:00:01 +08:00
mingholy.lmh
587fc82fbc chore: update version to 0.1.1 in package.json 2026-01-09 17:54:59 +08:00
xwj02155382
1b7418f91f docs: 添加 defaultHeaders 功能完整实现文档
- 整合当前分支相对于 main 的所有改动(10 个文件)
- 包含两个 commit 的完整改动详情
- 删除测试文件 test-defaultHeaders.cjs 和 verify-defaultHeaders.cjs
- 删除旧的不完整文档
- 新增完整的功能文档,包含代码改动说明、配置示例、使用指南等
2026-01-09 17:31:01 +08:00
yiliang114
b7828ac765 Merge branch 'main' into fix/vscode-run 2026-01-09 16:39:12 +08:00
mingholy.lmh
8705f734d0 fix: improve bundled CLI path finding and support --experimental-skills 2026-01-09 16:32:55 +08:00
xwj02155382
0bd17a2406 feat: 支持从 modelProviders 配置中读取 defaultHeaders
- 修改 ModelConfigSourcesInput 接口,将 modelProvider 类型从 ResolvedModelConfig 改为 ModelProviderConfig
- 在 resolveCliGenerationConfig 中添加从 settings.modelProviders 查找 modelProvider 的逻辑
- 使用类型别名避免与 subagents/types.ts 中的 ModelConfig 冲突
- 修复测试文件中的类型错误
- 现在可以通过 modelProviders 配置为特定模型设置 defaultHeaders
2026-01-09 16:08:59 +08:00
xwj02155382
59be5163fd feat: add defaultHeaders support for all content generators
- Add defaultHeaders field to ContentGeneratorConfig and ModelGenerationConfig
- Implement defaultHeaders merging logic in resolveGenerationConfig
- Support defaultHeaders in OpenAI providers (DefaultOpenAICompatibleProvider, DashScopeOpenAICompatibleProvider)
- Support defaultHeaders in Gemini and Anthropic content generators
- Add defaultHeaders to MODEL_GENERATION_CONFIG_FIELDS
- Update resolveQwenOAuthConfig to support modelProvider.generationConfig

Configuration hierarchy:
- L1: modelProvider.generationConfig.defaultHeaders (high priority)
- L2: settings.model.generationConfig.defaultHeaders (low priority)
- Merge strategy: high priority headers override low priority headers with same name
2026-01-09 15:56:32 +08:00
tanzhenxin
95efe89ac0 fix positional argument problem due to special handling for Electron app of yargs 2026-01-09 14:49:57 +08:00
tanzhenxin
d86903ced5 Update skill tool descriptions 2026-01-08 16:43:04 +08:00
tanzhenxin
a47bdc0b06 fix(cli): guard experimental skills config lookup 2026-01-08 15:54:43 +08:00
tanzhenxin
0e769e100b Added automatic skill hot-reload 2026-01-08 15:43:46 +08:00
tanzhenxin
b5bcc07223 Add skills list display to CLI interface 2026-01-08 14:45:48 +08:00
tanzhenxin
9653dc90d5 Add skills command with completion support 2026-01-08 14:23:13 +08:00
yiliang114
052337861b Fix #1416 2026-01-07 21:05:49 +08:00
tanzhenxin
f8aecb2631 only allow shell execution in current working directory for skills 2026-01-07 19:29:49 +08:00
yiliang114
361492247e fix(vscode-ide-companion): fix cross-platform CLI execution in terminal
- Add platform.ts utility with isWindows constant
- Fix Windows PowerShell execution with & call operator
- Fix macOS Electron helper with ELECTRON_RUN_AS_NODE=1
- Prefer system Node.js, fallback to VS Code runtime
- Refactor platform detection in acpMessageHandler and acpSessionManager

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 01:35:05 +08:00
Jan-Niklas W.
824ca056a4 docs: add integration guide for JetBrains IDEs 2026-01-05 14:07:37 -06:00
Tu Shaokun
4f664d00ac fix: handle edge case where JSON.stringify returns undefined
Add fallback to String() when JSON.stringify returns undefined,
which can happen with objects that have toJSON() returning undefined.
2026-01-01 10:10:24 +08:00
Tu Shaokun
7fdebe8fe6 fix(cli): improve error message display for object errors
Previously, when a tool execution failed with an error object (not an
Error instance), getErrorMessage() would return '[object Object]',
hiding useful error information from users.

This change improves getErrorMessage() to:
1. Extract the 'message' property from error-like objects
2. JSON.stringify plain objects to show their full content
3. Fall back to String() only when JSON.stringify fails

Fixes #1338
2026-01-01 09:56:27 +08:00
85 changed files with 3064 additions and 1281 deletions

3
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1,3 @@
* @tanzhenxin @DennisYu07 @gwinthis @LaZzyMan @pomelo-nwu @Mingholy
# SDK TypeScript package changes require review from Mingholy
packages/sdk-typescript/** @Mingholy

View File

@@ -241,7 +241,7 @@ jobs:
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
id: 'pr'
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
GITHUB_TOKEN: '${{ secrets.CI_BOT_PAT }}'
RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
run: |-
@@ -258,26 +258,15 @@ jobs:
echo "PR_URL=${pr_url}" >> "${GITHUB_OUTPUT}"
- name: 'Wait for CI checks to complete'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
PR_URL: '${{ steps.pr.outputs.PR_URL }}'
run: |-
set -euo pipefail
echo "Waiting for CI checks to complete..."
gh pr checks "${PR_URL}" --watch --interval 30
- name: 'Enable auto-merge for release PR'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
GITHUB_TOKEN: '${{ secrets.CI_BOT_PAT }}'
PR_URL: '${{ steps.pr.outputs.PR_URL }}'
run: |-
set -euo pipefail
gh pr merge "${PR_URL}" --merge --auto
gh pr merge "${PR_URL}" --merge --auto --delete-branch
- name: 'Create Issue on Failure'
if: |-

View File

@@ -25,7 +25,7 @@ Qwen Code is an open-source AI agent for the terminal, optimized for [Qwen3-Code
- **OpenAI-compatible, OAuth free tier**: use an OpenAI-compatible API, or sign in with Qwen OAuth to get 2,000 free requests/day.
- **Open-source, co-evolving**: both the framework and the Qwen3-Coder model are open-source—and they ship and evolve together.
- **Agentic workflow, feature-rich**: rich built-in tools (Skills, SubAgents, Plan Mode) for a full agentic workflow and a Claude Code-like experience.
- **Terminal-first, IDE-friendly**: built for developers who live in the command line, with optional integration for VS Code and Zed.
- **Terminal-first, IDE-friendly**: built for developers who live in the command line, with optional integration for VS Code, Zed, and JetBrains IDEs.
## Installation
@@ -137,10 +137,11 @@ Use `-p` to run Qwen Code without the interactive UI—ideal for scripts, automa
#### IDE integration
Use Qwen Code inside your editor (VS Code and Zed):
Use Qwen Code inside your editor (VS Code, Zed, and JetBrains IDEs):
- [Use in VS Code](https://qwenlm.github.io/qwen-code-docs/en/users/integration-vscode/)
- [Use in Zed](https://qwenlm.github.io/qwen-code-docs/en/users/integration-zed/)
- [Use in JetBrains IDEs](https://qwenlm.github.io/qwen-code-docs/en/users/integration-jetbrains/)
#### TypeScript SDK

View File

@@ -10,4 +10,5 @@ export default {
'web-search': 'Web Search',
memory: 'Memory',
'mcp-server': 'MCP Servers',
sandbox: 'Sandboxing',
};

View File

@@ -0,0 +1,90 @@
## Customizing the sandbox environment (Docker/Podman)
### Currently, the project does not support the use of the BUILD_SANDBOX function after installation through the npm package
1. To build a custom sandbox, you need to access the build scripts (scripts/build_sandbox.js) in the source code repository.
2. These build scripts are not included in the packages released by npm.
3. The code contains hard-coded path checks that explicitly reject build requests from non-source code environments.
If you need extra tools inside the container (e.g., `git`, `python`, `rg`), create a custom Dockerfile, The specific operation is as follows
#### 1、Clone qwen code project first, https://github.com/QwenLM/qwen-code.git
#### 2、Make sure you perform the following operation in the source code repository directory
```bash
# 1. First, install the dependencies of the project
npm install
# 2. Build the Qwen Code project
npm run build
# 3. Verify that the dist directory has been generated
ls -la packages/cli/dist/
# 4. Create a global link in the CLI package directory
cd packages/cli
npm link
# 5. Verification link (it should now point to the source code)
which qwen
# Expected output: /xxx/xxx/.nvm/versions/node/v24.11.1/bin/qwen
# Or similar paths, but it should be a symbolic link
# 6. For details of the symbolic link, you can see the specific source code path
ls -la $(dirname $(which qwen))/../lib/node_modules/@qwen-code/qwen-code
# It should show that this is a symbolic link pointing to your source code directory
# 7.Test the version of qwen
qwen -v
# npm link will overwrite the global qwen. To avoid being unable to distinguish the same version number, you can uninstall the global CLI first
```
#### 3、Create your sandbox Dockerfile under the root directory of your own project
- Path: `.qwen/sandbox.Dockerfile`
- Official mirror image address:https://github.com/QwenLM/qwen-code/pkgs/container/qwen-code
```bash
# Based on the official Qwen sandbox image (It is recommended to explicitly specify the version)
FROM ghcr.io/qwenlm/qwen-code:sha-570ec43
# Add your extra tools here
RUN apt-get update && apt-get install -y \
git \
python3 \
ripgrep
```
#### 4、Create the first sandbox image under the root directory of your project
```bash
GEMINI_SANDBOX=docker BUILD_SANDBOX=1 qwen -s
# Observe whether the sandbox version of the tool you launched is consistent with the version of your custom image. If they are consistent, the startup will be successful
```
This builds a project-specific image based on the default sandbox image.
#### Remove npm link
- If you want to restore the official CLI of qwen, please remove the npm link
```bash
# Method 1: Unlink globally
npm unlink -g @qwen-code/qwen-code
# Method 2: Remove it in the packages/cli directory
cd packages/cli
npm unlink
# Verification has been lifted
which qwen
# It should display "qwen not found"
# Reinstall the global version if necessary
npm install -g @qwen-code/qwen-code
# Verification Recovery
which qwen
qwen --version
```

View File

@@ -12,6 +12,7 @@ export default {
},
'integration-vscode': 'Visual Studio Code',
'integration-zed': 'Zed IDE',
'integration-jetbrains': 'JetBrains IDEs',
'integration-github-action': 'Github Actions',
'Code with Qwen Code': {
type: 'separator',

View File

@@ -104,7 +104,7 @@ Settings are organized into categories. All settings should be placed within the
| `model.name` | string | The Qwen model to use for conversations. | `undefined` |
| `model.maxSessionTurns` | number | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` |
| `model.summarizeToolOutput` | object | Enables or disables the summarization of tool output. You can specify the token budget for the summarization using the `tokenBudget` setting. Note: Currently only the `run_shell_command` tool is supported. For example `{"run_shell_command": {"tokenBudget": 2000}}` | `undefined` |
| `model.generationConfig` | object | Advanced overrides passed to the underlying content generator. Supports request controls such as `timeout`, `maxRetries`, and `disableCacheControl`, along with fine-tuning knobs under `samplingParams` (for example `temperature`, `top_p`, `max_tokens`). Leave unset to rely on provider defaults. | `undefined` |
| `model.generationConfig` | object | Advanced overrides passed to the underlying content generator. Supports request controls such as `timeout`, `maxRetries`, `disableCacheControl`, and `customHeaders` (custom HTTP headers for API requests), along with fine-tuning knobs under `samplingParams` (for example `temperature`, `top_p`, `max_tokens`). Leave unset to rely on provider defaults. | `undefined` |
| `model.chatCompression.contextPercentageThreshold` | number | Sets the threshold for chat history compression as a percentage of the model's total token limit. This is a value between 0 and 1 that applies to both automatic compression and the manual `/compress` command. For example, a value of `0.6` will trigger compression when the chat history exceeds 60% of the token limit. Use `0` to disable compression entirely. | `0.7` |
| `model.skipNextSpeakerCheck` | boolean | Skip the next speaker check. | `false` |
| `model.skipLoopDetection` | boolean | Disables loop detection checks. Loop detection prevents infinite loops in AI responses but can generate false positives that interrupt legitimate workflows. Enable this option if you experience frequent false positive loop detection interruptions. | `false` |
@@ -114,12 +114,16 @@ Settings are organized into categories. All settings should be placed within the
**Example model.generationConfig:**
```
```json
{
"model": {
"generationConfig": {
"timeout": 60000,
"disableCacheControl": false,
"customHeaders": {
"X-Request-ID": "req-123",
"X-User-ID": "user-456"
},
"samplingParams": {
"temperature": 0.2,
"top_p": 0.8,
@@ -130,6 +134,8 @@ Settings are organized into categories. All settings should be placed within the
}
```
The `customHeaders` field allows you to add custom HTTP headers to all API requests. This is useful for request tracing, monitoring, API gateway routing, or when different models require different headers. If `customHeaders` is defined in `modelProviders[].generationConfig.customHeaders`, it will be used directly; otherwise, headers from `model.generationConfig.customHeaders` will be used. No merging occurs between the two levels.
**model.openAILoggingDir examples:**
- `"~/qwen-logs"` - Logs to `~/qwen-logs` directory
@@ -154,6 +160,10 @@ Use `modelProviders` to declare curated model lists per auth type that the `/mod
"generationConfig": {
"timeout": 60000,
"maxRetries": 3,
"customHeaders": {
"X-Model-Version": "v1.0",
"X-Request-Priority": "high"
},
"samplingParams": { "temperature": 0.2 }
}
}
@@ -215,7 +225,7 @@ Per-field precedence for `generationConfig`:
3. `settings.model.generationConfig`
4. Content-generator defaults (`getDefaultGenerationConfig` for OpenAI, `getParameterValue` for Gemini, etc.)
`samplingParams` is treated atomically; provider values replace the entire object. Defaults from the content generator apply last so each provider retains its tuned baseline.
`samplingParams` and `customHeaders` are both treated atomically; provider values replace the entire object. If `modelProviders[].generationConfig` defines these fields, they are used directly; otherwise, values from `model.generationConfig` are used. No merging occurs between provider and global configuration levels. Defaults from the content generator apply last so each provider retains its tuned baseline.
##### Selection persistence and recommendations

View File

@@ -59,6 +59,7 @@ Commands for managing AI tools and models.
| ---------------- | --------------------------------------------- | --------------------------------------------- |
| `/mcp` | List configured MCP servers and tools | `/mcp`, `/mcp desc` |
| `/tools` | Display currently available tool list | `/tools`, `/tools desc` |
| `/skills` | List and run available skills (experimental) | `/skills`, `/skills <name>` |
| `/approval-mode` | Change approval mode for tool usage | `/approval-mode <mode (auto-edit)> --project` |
| →`plan` | Analysis only, no execution | Secure review |
| →`default` | Require approval for edits | Daily use |

View File

@@ -49,6 +49,8 @@ Cross-platform sandboxing with complete process isolation.
By default, Qwen Code uses a published sandbox image (configured in the CLI package) and will pull it as needed.
The container sandbox mounts your workspace and your `~/.qwen` directory into the container so auth and settings persist between runs.
**Best for**: Strong isolation on any OS, consistent tooling inside a known image.
### Choosing a method
@@ -157,22 +159,13 @@ For a working allowlist-style proxy example, see: [Example Proxy Script](/develo
## Linux UID/GID handling
The sandbox automatically handles user permissions on Linux. Override these permissions with:
On Linux, Qwen Code defaults to enabling UID/GID mapping so the sandbox runs as your user (and reuses the mounted `~/.qwen`). Override with:
```bash
export SANDBOX_SET_UID_GID=true # Force host UID/GID
export SANDBOX_SET_UID_GID=false # Disable UID/GID mapping
```
## Customizing the sandbox environment (Docker/Podman)
If you need extra tools inside the container (e.g., `git`, `python`, `rg`), create a custom Dockerfile:
- Path: `.qwen/sandbox.Dockerfile`
- Then run with: `BUILD_SANDBOX=1 qwen -s ...`
This builds a project-specific image based on the default sandbox image.
## Troubleshooting
### Common issues

View File

@@ -27,6 +27,14 @@ Agent Skills package expertise into discoverable capabilities. Each Skill consis
Skills are **model-invoked** — the model autonomously decides when to use them based on your request and the Skills description. This is different from slash commands, which are **user-invoked** (you explicitly type `/command`).
If you want to invoke a Skill explicitly, use the `/skills` slash command:
```bash
/skills <skill-name>
```
The `/skills` command is only available when you run with `--experimental-skills`. Use autocomplete to browse available Skills and descriptions.
### Benefits
- Extend Qwen Code for your workflows

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -0,0 +1,57 @@
# 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.
### Features
- **Native agent experience**: Integrated AI assistant panel within your JetBrains IDE
- **Agent Control 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
### Requirements
- JetBrains IDE with ACP support (IntelliJ IDEA, WebStorm, PyCharm, etc.)
- Qwen Code CLI installed
### Installation
1. Install Qwen Code CLI:
```bash
npm install -g @qwen-code/qwen-code
```
2. Open your JetBrains IDE and navigate to AI Chat tool window.
3. Click the 3-dot menu in the upper-right corner and select **Configure ACP Agent** and configure Qwen Code with the following settings:
```json
{
"agent_servers": {
"qwen": {
"command": "/path/to/qwen",
"args": ["--acp"],
"env": {}
}
}
}
```
4. The Qwen Code agent should now be available in the AI Assistant panel
![Qwen Code in JetBrains AI Chat](./images/jetbrains-acp.png)
## Troubleshooting
### Agent not appearing
- Run `qwen --version` in terminal to verify installation
- Ensure your JetBrains IDE version supports ACP
- Restart your JetBrains IDE
### Qwen Code not responding
- Check your internet connection
- Verify CLI works by running `qwen` in terminal
- [File an issue on GitHub](https://github.com/qwenlm/qwen-code/issues) if the problem persists

View File

@@ -18,7 +18,7 @@
### Requirements
- VS Code 1.98.0 or higher
- VS Code 1.85.0 or higher
### Installation
@@ -34,7 +34,7 @@
### Extension not installing
- Ensure you have VS Code 1.98.0 or higher
- Ensure you have VS Code 1.85.0 or higher
- Check that VS Code has permission to install extensions
- Try installing directly from the Marketplace website

View File

@@ -159,7 +159,7 @@ Qwen Code will:
### Test out other common workflows
There are a number of ways to work with Claude:
There are a number of ways to work with Qwen Code:
**Refactor code**

View File

@@ -9,11 +9,18 @@ This guide provides solutions to common issues and debugging tips, including top
## Authentication or login errors
- **Error: `UNABLE_TO_GET_ISSUER_CERT_LOCALLY` or `unable to get local issuer certificate`**
- **Error: `UNABLE_TO_GET_ISSUER_CERT_LOCALLY`, `UNABLE_TO_VERIFY_LEAF_SIGNATURE`, or `unable to get local issuer certificate`**
- **Cause:** You may be on a corporate network with a firewall that intercepts and inspects SSL/TLS traffic. This often requires a custom root CA certificate to be trusted by Node.js.
- **Solution:** Set the `NODE_EXTRA_CA_CERTS` environment variable to the absolute path of your corporate root CA certificate file.
- Example: `export NODE_EXTRA_CA_CERTS=/path/to/your/corporate-ca.crt`
- **Error: `Device authorization flow failed: fetch failed`**
- **Cause:** Node.js could not reach Qwen OAuth endpoints (often a proxy or SSL/TLS trust issue). When available, Qwen Code will also print the underlying error cause (for example: `UNABLE_TO_VERIFY_LEAF_SIGNATURE`).
- **Solution:**
- Confirm you can access `https://chat.qwen.ai` from the same machine/network.
- If you are behind a proxy, set it via `qwen --proxy <url>` (or the `proxy` setting in `settings.json`).
- If your network uses a corporate TLS inspection CA, set `NODE_EXTRA_CA_CERTS` as described above.
- **Issue: Unable to display UI after authentication failure**
- **Cause:** If authentication fails after selecting an authentication type, the `security.auth.selectedType` setting may be persisted in `settings.json`. On restart, the CLI may get stuck trying to authenticate with the failed auth type and fail to display the UI.
- **Solution:** Clear the `security.auth.selectedType` configuration item in your `settings.json` file:

View File

@@ -831,7 +831,7 @@ describe('Permission Control (E2E)', () => {
TEST_TIMEOUT,
);
it(
it.skip(
'should execute dangerous commands without confirmation',
async () => {
const q = query({

9
package-lock.json generated
View File

@@ -6216,10 +6216,7 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"readdirp": "^4.0.1"
},
@@ -13882,10 +13879,7 @@
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 14.18.0"
},
@@ -17974,6 +17968,7 @@
"ajv-formats": "^3.0.0",
"async-mutex": "^0.5.0",
"chardet": "^2.1.0",
"chokidar": "^4.0.3",
"diff": "^7.0.0",
"dotenv": "^17.1.0",
"fast-levenshtein": "^2.0.6",
@@ -18593,7 +18588,7 @@
},
"packages/sdk-typescript": {
"name": "@qwen-code/sdk",
"version": "0.1.0",
"version": "0.1.3",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.25.1",

View File

@@ -170,7 +170,17 @@ function normalizeOutputFormat(
}
export async function parseArguments(settings: Settings): Promise<CliArgs> {
const rawArgv = hideBin(process.argv);
let rawArgv = hideBin(process.argv);
// hack: if the first argument is the CLI entry point, remove it
if (
rawArgv.length > 0 &&
(rawArgv[0].endsWith('/dist/qwen-cli/cli.js') ||
rawArgv[0].endsWith('/dist/cli.js'))
) {
rawArgv = rawArgv.slice(1);
}
const yargsInstance = yargs(rawArgv)
.locale('en')
.scriptName('qwen')

View File

@@ -55,6 +55,7 @@ import { disableExtension } from './extension.js';
// These imports will get the versions from the vi.mock('./settings.js', ...) factory.
import {
getSettingsWarnings,
loadSettings,
USER_SETTINGS_PATH, // This IS the mocked path.
getSystemSettingsPath,
@@ -418,6 +419,86 @@ describe('Settings Loading and Merging', () => {
});
});
it('should warn about ignored legacy keys in a v2 settings file', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const userSettingsContent = {
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
usageStatisticsEnabled: false,
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(getSettingsWarnings(settings)).toEqual(
expect.arrayContaining([
expect.stringContaining(
"Legacy setting 'usageStatisticsEnabled' will be ignored",
),
]),
);
expect(getSettingsWarnings(settings)).toEqual(
expect.arrayContaining([
expect.stringContaining("'privacy.usageStatisticsEnabled'"),
]),
);
});
it('should warn about unknown top-level keys in a v2 settings file', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const userSettingsContent = {
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
someUnknownKey: 'value',
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(getSettingsWarnings(settings)).toEqual(
expect.arrayContaining([
expect.stringContaining(
"Unknown setting 'someUnknownKey' will be ignored",
),
]),
);
});
it('should not warn for valid v2 container keys', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const userSettingsContent = {
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
model: { name: 'qwen-coder' },
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(getSettingsWarnings(settings)).toEqual([]);
});
it('should rewrite allowedTools to tools.allowed during migration', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,

View File

@@ -344,6 +344,97 @@ const KNOWN_V2_CONTAINERS = new Set(
Object.values(MIGRATION_MAP).map((path) => path.split('.')[0]),
);
function getSettingsFileKeyWarnings(
settings: Record<string, unknown>,
settingsFilePath: string,
): string[] {
const version = settings[SETTINGS_VERSION_KEY];
if (typeof version !== 'number' || version < SETTINGS_VERSION) {
return [];
}
const warnings: string[] = [];
const ignoredLegacyKeys = new Set<string>();
// Ignored legacy keys (V1 top-level keys that moved to a nested V2 path).
for (const [oldKey, newPath] of Object.entries(MIGRATION_MAP)) {
if (oldKey === newPath) {
continue;
}
if (!(oldKey in settings)) {
continue;
}
const oldValue = settings[oldKey];
// If this key is a V2 container (like 'model') and it's already an object,
// it's likely already in V2 format. Don't warn.
if (
KNOWN_V2_CONTAINERS.has(oldKey) &&
typeof oldValue === 'object' &&
oldValue !== null &&
!Array.isArray(oldValue)
) {
continue;
}
ignoredLegacyKeys.add(oldKey);
warnings.push(
`⚠️ Legacy setting '${oldKey}' will be ignored in ${settingsFilePath}. Please use '${newPath}' instead.`,
);
}
// Unknown top-level keys.
const schemaKeys = new Set(Object.keys(getSettingsSchema()));
for (const key of Object.keys(settings)) {
if (key === SETTINGS_VERSION_KEY) {
continue;
}
if (ignoredLegacyKeys.has(key)) {
continue;
}
if (schemaKeys.has(key)) {
continue;
}
warnings.push(
`⚠️ Unknown setting '${key}' will be ignored in ${settingsFilePath}.`,
);
}
return warnings;
}
/**
* Collects warnings for ignored legacy and unknown settings keys.
*
* For `$version: 2` settings files, we do not apply implicit migrations.
* Instead, we surface actionable, de-duplicated warnings in the terminal UI.
*/
export function getSettingsWarnings(loadedSettings: LoadedSettings): string[] {
const warningSet = new Set<string>();
for (const scope of [SettingScope.User, SettingScope.Workspace]) {
const settingsFile = loadedSettings.forScope(scope);
if (settingsFile.rawJson === undefined) {
continue; // File not present / not loaded.
}
const settingsObject = settingsFile.originalSettings as unknown as Record<
string,
unknown
>;
for (const warning of getSettingsFileKeyWarnings(
settingsObject,
settingsFile.path,
)) {
warningSet.add(warning);
}
}
return [...warningSet];
}
export function migrateSettingsToV1(
v2Settings: Record<string, unknown>,
): Record<string, unknown> {

View File

@@ -17,7 +17,11 @@ import * as cliConfig from './config/config.js';
import { loadCliConfig, parseArguments } from './config/config.js';
import { ExtensionStorage, loadExtensions } from './config/extension.js';
import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js';
import { loadSettings, migrateDeprecatedSettings } from './config/settings.js';
import {
getSettingsWarnings,
loadSettings,
migrateDeprecatedSettings,
} from './config/settings.js';
import {
initializeApp,
type InitializationResult,
@@ -342,6 +346,7 @@ export async function main() {
extensionEnablementManager,
argv,
);
registerCleanup(() => config.shutdown());
if (config.getListExtensions()) {
console.log('Installed extensions:');
@@ -400,12 +405,15 @@ export async function main() {
let input = config.getQuestion();
const startupWarnings = [
...(await getStartupWarnings()),
...(await getUserStartupWarnings({
workspaceRoot: process.cwd(),
useRipgrep: settings.merged.tools?.useRipgrep ?? true,
useBuiltinRipgrep: settings.merged.tools?.useBuiltinRipgrep ?? true,
})),
...new Set([
...(await getStartupWarnings()),
...(await getUserStartupWarnings({
workspaceRoot: process.cwd(),
useRipgrep: settings.merged.tools?.useRipgrep ?? true,
useBuiltinRipgrep: settings.merged.tools?.useBuiltinRipgrep ?? true,
})),
...getSettingsWarnings(settings),
]),
];
// Render UI, passing necessary config values. Check that there is no command line question.

View File

@@ -1826,7 +1826,7 @@ describe('runNonInteractive', () => {
);
});
it('should print tool description and output to console in text mode (non-Task tools)', async () => {
it('should print tool output to console in text mode (non-Task tools)', async () => {
// Test that tool output is printed to stdout in text mode
const toolCallEvent: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallRequest,
@@ -1839,21 +1839,6 @@ describe('runNonInteractive', () => {
},
};
// Mock the tool registry to return a tool with displayName and build method
const mockTool = {
displayName: 'Shell',
build: (args: Record<string, unknown>) => {
// @ts-expect-error - accessing indexed property for test mock
const command: string = args.command || '';
return {
getDescription: () => String(command),
};
},
};
vi.mocked(mockToolRegistry.getTool).mockReturnValue(
mockTool as unknown as ReturnType<typeof mockToolRegistry.getTool>,
);
// Mock tool execution with outputUpdateHandler being called
mockCoreExecuteToolCall.mockImplementation(
async (_config, _request, _signal, options) => {
@@ -1916,15 +1901,8 @@ describe('runNonInteractive', () => {
);
// Verify tool output was written to stdout
// First call should be tool description
expect(processStdoutSpy).toHaveBeenCalledWith('Shell: npm outdated');
expect(processStdoutSpy).toHaveBeenCalledWith('\n');
// Then the actual tool output
expect(processStdoutSpy).toHaveBeenCalledWith('Package outdated');
expect(processStdoutSpy).toHaveBeenCalledWith('npm@1.0.0 -> npm@2.0.0');
// Final newline after tool execution
expect(processStdoutSpy).toHaveBeenCalledWith('\n');
// And the model's response
expect(processStdoutSpy).toHaveBeenCalledWith('Package outdated\n');
expect(processStdoutSpy).toHaveBeenCalledWith('npm@1.0.0 -> npm@2.0.0\n');
expect(processStdoutSpy).toHaveBeenCalledWith('Dependencies checked');
});
});

View File

@@ -351,51 +351,19 @@ export async function runNonInteractive(
const taskToolProgressHandler = taskToolProgress?.handler;
// Create output handler for non-Task tools in text mode (for console output)
const toolOutputLines: string[] = [];
const nonTaskOutputHandler =
!isTaskTool && !adapter
? (callId: string, outputChunk: ToolResultDisplay) => {
const toolRegistry = config.getToolRegistry();
const tool = toolRegistry.getTool(finalRequestInfo.name);
if (tool) {
try {
const invocation = tool.build(finalRequestInfo.args);
const description = invocation.getDescription();
toolOutputLines.push(
`${tool.displayName}: ${description}`,
);
toolOutputLines.push('\n');
} catch {
// If we can't build invocation, just show tool name
toolOutputLines.push(`${tool.displayName}`);
toolOutputLines.push('\n');
}
}
// Print tool output to console in text mode
if (typeof outputChunk === 'string') {
// Indent output lines to show they're part of the tool execution
const lines = outputChunk.split('\n');
for (let i = 0; i < lines.length; i++) {
if (i === lines.length - 1 && lines[i] === '') {
// Skip trailing empty line
continue;
}
toolOutputLines.push(lines[i]);
}
process.stdout.write(outputChunk);
} else if (
outputChunk &&
typeof outputChunk === 'object' &&
'ansiOutput' in outputChunk
) {
// Handle ANSI output - indent it similarly
const ansiStr = String(outputChunk.ansiOutput);
const lines = ansiStr.split('\n');
for (let i = 0; i < lines.length; i++) {
if (i === lines.length - 1 && lines[i] === '') {
continue;
}
toolOutputLines.push(lines[i]);
}
// Handle ANSI output - just print as string for now
process.stdout.write(String(outputChunk.ansiOutput));
}
}
: undefined;
@@ -418,11 +386,6 @@ export async function runNonInteractive(
: undefined,
);
if (toolOutputLines.length > 0) {
toolOutputLines.forEach((line) => process.stdout.write(line));
process.stdout.write('\n');
}
// Note: In JSON mode, subagent messages are automatically added to the main
// adapter's messages array and will be output together on emitResult()

View File

@@ -31,6 +31,7 @@ import { quitCommand } from '../ui/commands/quitCommand.js';
import { restoreCommand } from '../ui/commands/restoreCommand.js';
import { resumeCommand } from '../ui/commands/resumeCommand.js';
import { settingsCommand } from '../ui/commands/settingsCommand.js';
import { skillsCommand } from '../ui/commands/skillsCommand.js';
import { statsCommand } from '../ui/commands/statsCommand.js';
import { summaryCommand } from '../ui/commands/summaryCommand.js';
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
@@ -78,6 +79,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
quitCommand,
restoreCommand(this.config),
resumeCommand,
...(this.config?.getExperimentalSkills?.() ? [skillsCommand] : []),
statsCommand,
summaryCommand,
themeCommand,

View File

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

View File

@@ -0,0 +1,132 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import {
CommandKind,
type CommandCompletionItem,
type CommandContext,
type SlashCommand,
} from './types.js';
import { MessageType, type HistoryItemSkillsList } from '../types.js';
import { t } from '../../i18n/index.js';
import { AsyncFzf } from 'fzf';
import type { SkillConfig } from '@qwen-code/qwen-code-core';
export const skillsCommand: SlashCommand = {
name: 'skills',
get description() {
return t('List available skills.');
},
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext, args?: string) => {
const rawArgs = args?.trim() ?? '';
const [skillName = ''] = rawArgs.split(/\s+/);
const skillManager = context.services.config?.getSkillManager();
if (!skillManager) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: t('Could not retrieve skill manager.'),
},
Date.now(),
);
return;
}
const skills = await skillManager.listSkills();
if (skills.length === 0) {
context.ui.addItem(
{
type: MessageType.INFO,
text: t('No skills are currently available.'),
},
Date.now(),
);
return;
}
if (!skillName) {
const sortedSkills = [...skills].sort((left, right) =>
left.name.localeCompare(right.name),
);
const skillsListItem: HistoryItemSkillsList = {
type: MessageType.SKILLS_LIST,
skills: sortedSkills.map((skill) => ({ name: skill.name })),
};
context.ui.addItem(skillsListItem, Date.now());
return;
}
const normalizedName = skillName.toLowerCase();
const hasSkill = skills.some(
(skill) => skill.name.toLowerCase() === normalizedName,
);
if (!hasSkill) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: t('Unknown skill: {{name}}', { name: skillName }),
},
Date.now(),
);
return;
}
const rawInput = context.invocation?.raw ?? `/skills ${rawArgs}`;
return {
type: 'submit_prompt',
content: [{ text: rawInput }],
};
},
completion: async (
context: CommandContext,
partialArg: string,
): Promise<CommandCompletionItem[]> => {
const skillManager = context.services.config?.getSkillManager();
if (!skillManager) {
return [];
}
const skills = await skillManager.listSkills();
const normalizedPartial = partialArg.trim();
const matches = await getSkillMatches(skills, normalizedPartial);
return matches.map((skill) => ({
value: skill.name,
description: skill.description,
}));
},
};
async function getSkillMatches(
skills: SkillConfig[],
query: string,
): Promise<SkillConfig[]> {
if (!query) {
return skills;
}
const names = skills.map((skill) => skill.name);
const skillMap = new Map(skills.map((skill) => [skill.name, skill]));
try {
const fzf = new AsyncFzf(names, {
fuzzy: 'v2',
casing: 'case-insensitive',
});
const results = (await fzf.find(query)) as Array<{ item: string }>;
return results
.map((result) => skillMap.get(result.item))
.filter((skill): skill is SkillConfig => !!skill);
} catch (error) {
console.error('[skillsCommand] Fuzzy match failed:', error);
const lowerQuery = query.toLowerCase();
return skills.filter((skill) =>
skill.name.toLowerCase().startsWith(lowerQuery),
);
}
}

View File

@@ -209,6 +209,12 @@ export enum CommandKind {
MCP_PROMPT = 'mcp-prompt',
}
export interface CommandCompletionItem {
value: string;
label?: string;
description?: string;
}
// The standardized contract for any command in the system.
export interface SlashCommand {
name: string;
@@ -234,7 +240,7 @@ export interface SlashCommand {
completion?: (
context: CommandContext,
partialArg: string,
) => Promise<string[]>;
) => Promise<Array<string | CommandCompletionItem> | null>;
subCommands?: SlashCommand[];
}

View File

@@ -30,6 +30,7 @@ import { Help } from './Help.js';
import type { SlashCommand } from '../commands/types.js';
import { ExtensionsList } from './views/ExtensionsList.js';
import { getMCPServerStatus } from '@qwen-code/qwen-code-core';
import { SkillsList } from './views/SkillsList.js';
import { ToolsList } from './views/ToolsList.js';
import { McpStatus } from './views/McpStatus.js';
@@ -153,6 +154,9 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
showDescriptions={itemForDisplay.showDescriptions}
/>
)}
{itemForDisplay.type === 'skills_list' && (
<SkillsList skills={itemForDisplay.skills} />
)}
{itemForDisplay.type === 'mcp_status' && (
<McpStatus {...itemForDisplay} serverStatus={getMCPServerStatus} />
)}

View File

@@ -106,7 +106,7 @@ export function SuggestionsDisplay({
</Box>
{suggestion.description && (
<Box flexGrow={1} paddingLeft={3}>
<Box flexGrow={1} paddingLeft={2}>
<Text color={textColor} wrap="truncate">
{suggestion.description}
</Text>

View File

@@ -23,7 +23,7 @@ export const InfoMessage: React.FC<InfoMessageProps> = ({ text }) => {
const prefixWidth = prefix.length;
return (
<Box flexDirection="row" marginTop={1}>
<Box flexDirection="row" marginBottom={1}>
<Box width={prefixWidth}>
<Text color={theme.status.warning}>{prefix}</Text>
</Box>

View File

@@ -18,7 +18,7 @@ export const WarningMessage: React.FC<WarningMessageProps> = ({ text }) => {
const prefixWidth = 3;
return (
<Box flexDirection="row" marginTop={1}>
<Box flexDirection="row" marginBottom={1}>
<Box width={prefixWidth}>
<Text color={Colors.AccentYellow}>{prefix}</Text>
</Box>

View File

@@ -0,0 +1,36 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import { type SkillDefinition } from '../../types.js';
import { t } from '../../../i18n/index.js';
interface SkillsListProps {
skills: readonly SkillDefinition[];
}
export const SkillsList: React.FC<SkillsListProps> = ({ skills }) => (
<Box flexDirection="column" marginBottom={1}>
<Text bold color={theme.text.primary}>
{t('Available skills:')}
</Text>
<Box height={1} />
{skills.length > 0 ? (
skills.map((skill) => (
<Box key={skill.name} flexDirection="row">
<Text color={theme.text.primary}>{' '}- </Text>
<Text bold color={theme.text.accent}>
{skill.name}
</Text>
</Box>
))
) : (
<Text color={theme.text.primary}> {t('No skills available')}</Text>
)}
</Box>
);

View File

@@ -573,6 +573,45 @@ describe('useSlashCompletion', () => {
});
});
it('should map completion items with descriptions for argument suggestions', async () => {
const mockCompletionFn = vi.fn().mockResolvedValue([
{ value: 'pdf', description: 'Create PDF documents' },
{ value: 'xlsx', description: 'Work with spreadsheets' },
]);
const slashCommands = [
createTestCommand({
name: 'skills',
description: 'List available skills',
completion: mockCompletionFn,
}),
];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/skills ',
slashCommands,
mockCommandContext,
),
);
await waitFor(() => {
expect(result.current.suggestions).toEqual([
{
label: 'pdf',
value: 'pdf',
description: 'Create PDF documents',
},
{
label: 'xlsx',
value: 'xlsx',
description: 'Work with spreadsheets',
},
]);
});
});
it('should call command.completion with an empty string when args start with a space', async () => {
const mockCompletionFn = vi
.fn()

View File

@@ -9,6 +9,7 @@ import { AsyncFzf } from 'fzf';
import type { Suggestion } from '../components/SuggestionsDisplay.js';
import {
CommandKind,
type CommandCompletionItem,
type CommandContext,
type SlashCommand,
} from '../commands/types.js';
@@ -215,10 +216,9 @@ function useCommandSuggestions(
)) || [];
if (!signal.aborted) {
const finalSuggestions = results.map((s) => ({
label: s,
value: s,
}));
const finalSuggestions = results
.map((item) => toSuggestion(item))
.filter((suggestion): suggestion is Suggestion => !!suggestion);
setSuggestions(finalSuggestions);
setIsLoading(false);
}
@@ -310,6 +310,20 @@ function useCommandSuggestions(
return { suggestions, isLoading };
}
function toSuggestion(item: string | CommandCompletionItem): Suggestion | null {
if (typeof item === 'string') {
return { label: item, value: item };
}
if (!item.value) {
return null;
}
return {
label: item.label ?? item.value,
value: item.value,
description: item.description,
};
}
function useCompletionPositions(
query: string | null,
parserResult: CommandParserResult,

View File

@@ -201,12 +201,21 @@ export interface ToolDefinition {
description?: string;
}
export interface SkillDefinition {
name: string;
}
export type HistoryItemToolsList = HistoryItemBase & {
type: 'tools_list';
tools: ToolDefinition[];
showDescriptions: boolean;
};
export type HistoryItemSkillsList = HistoryItemBase & {
type: 'skills_list';
skills: SkillDefinition[];
};
// JSON-friendly types for using as a simple data model showing info about an
// MCP Server.
export interface JsonMcpTool {
@@ -268,6 +277,7 @@ export type HistoryItemWithoutId =
| HistoryItemCompression
| HistoryItemExtensionsList
| HistoryItemToolsList
| HistoryItemSkillsList
| HistoryItemMcpStatus;
export type HistoryItem = HistoryItemWithoutId & { id: number };
@@ -289,6 +299,7 @@ export enum MessageType {
SUMMARY = 'summary',
EXTENSIONS_LIST = 'extensions_list',
TOOLS_LIST = 'tools_list',
SKILLS_LIST = 'skills_list',
MCP_STATUS = 'mcp_status',
}

View File

@@ -117,8 +117,33 @@ describe('errors', () => {
expect(getErrorMessage(undefined)).toBe('undefined');
});
it('should handle objects', () => {
const obj = { message: 'test' };
it('should extract message from error-like objects', () => {
const obj = { message: 'test error message' };
expect(getErrorMessage(obj)).toBe('test error message');
});
it('should stringify plain objects without message property', () => {
const obj = { code: 500, details: 'internal error' };
expect(getErrorMessage(obj)).toBe(
'{"code":500,"details":"internal error"}',
);
});
it('should handle empty objects', () => {
expect(getErrorMessage({})).toBe('{}');
});
it('should handle objects with non-string message property', () => {
const obj = { message: 123 };
expect(getErrorMessage(obj)).toBe('{"message":123}');
});
it('should fallback to String() when toJSON returns undefined', () => {
const obj = {
toJSON() {
return undefined;
},
};
expect(getErrorMessage(obj)).toBe('[object Object]');
});
});

View File

@@ -18,6 +18,29 @@ export function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
// Handle objects with message property (error-like objects)
if (
error !== null &&
typeof error === 'object' &&
'message' in error &&
typeof (error as { message: unknown }).message === 'string'
) {
return (error as { message: string }).message;
}
// Handle plain objects by stringifying them
if (error !== null && typeof error === 'object') {
try {
const stringified = JSON.stringify(error);
// JSON.stringify can return undefined for objects with toJSON() returning undefined
return stringified ?? String(error);
} catch {
// If JSON.stringify fails (circular reference, etc.), fall back to String
return String(error);
}
}
return String(error);
}

View File

@@ -10,6 +10,7 @@ import {
type ContentGeneratorConfigSources,
resolveModelConfig,
type ModelConfigSourcesInput,
type ProviderModelConfig,
} from '@qwen-code/qwen-code-core';
import type { Settings } from '../config/settings.js';
@@ -81,6 +82,21 @@ export function resolveCliGenerationConfig(
const authType = selectedAuthType;
// Find modelProvider from settings.modelProviders based on authType and model
let modelProvider: ProviderModelConfig | undefined;
if (authType && settings.modelProviders) {
const providers = settings.modelProviders[authType];
if (providers && Array.isArray(providers)) {
// Try to find by requested model (from CLI or settings)
const requestedModel = argv.model || settings.model?.name;
if (requestedModel) {
modelProvider = providers.find((p) => p.id === requestedModel) as
| ProviderModelConfig
| undefined;
}
}
}
const configSources: ModelConfigSourcesInput = {
authType,
cli: {
@@ -96,6 +112,7 @@ export function resolveCliGenerationConfig(
| Partial<ContentGeneratorConfig>
| undefined,
},
modelProvider,
env,
};
@@ -103,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)

View File

@@ -8,7 +8,6 @@ import { exec, execSync, spawn, type ChildProcess } from 'node:child_process';
import os from 'node:os';
import path from 'node:path';
import fs from 'node:fs';
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { quote, parse } from 'shell-quote';
import {
@@ -50,16 +49,16 @@ const BUILTIN_SEATBELT_PROFILES = [
/**
* Determines whether the sandbox container should be run with the current user's UID and GID.
* This is often necessary on Linux systems (especially Debian/Ubuntu based) when using
* rootful Docker without userns-remap configured, to avoid permission issues with
* This is often necessary on Linux systems when using rootful Docker without userns-remap
* configured, to avoid permission issues with
* mounted volumes.
*
* The behavior is controlled by the `SANDBOX_SET_UID_GID` environment variable:
* - If `SANDBOX_SET_UID_GID` is "1" or "true", this function returns `true`.
* - If `SANDBOX_SET_UID_GID` is "0" or "false", this function returns `false`.
* - If `SANDBOX_SET_UID_GID` is not set:
* - On Debian/Ubuntu Linux, it defaults to `true`.
* - On other OSes, or if OS detection fails, it defaults to `false`.
* - On Linux, it defaults to `true`.
* - On other OSes, it defaults to `false`.
*
* For more context on running Docker containers as non-root, see:
* https://medium.com/redbubble/running-a-docker-container-as-a-non-root-user-7d2e00f8ee15
@@ -76,31 +75,20 @@ async function shouldUseCurrentUserInSandbox(): Promise<boolean> {
return false;
}
// If environment variable is not explicitly set, check for Debian/Ubuntu Linux
if (os.platform() === 'linux') {
try {
const osReleaseContent = await readFile('/etc/os-release', 'utf8');
if (
osReleaseContent.includes('ID=debian') ||
osReleaseContent.includes('ID=ubuntu') ||
osReleaseContent.match(/^ID_LIKE=.*debian.*/m) || // Covers derivatives
osReleaseContent.match(/^ID_LIKE=.*ubuntu.*/m) // Covers derivatives
) {
// note here and below we use console.error for informational messages on stderr
console.error(
'INFO: Defaulting to use current user UID/GID for Debian/Ubuntu-based Linux.',
);
return true;
}
} catch (_err) {
// Silently ignore if /etc/os-release is not found or unreadable.
// The default (false) will be applied in this case.
console.warn(
'Warning: Could not read /etc/os-release to auto-detect Debian/Ubuntu for UID/GID default.',
const debugEnv = [process.env['DEBUG'], process.env['DEBUG_MODE']].some(
(v) => v === 'true' || v === '1',
);
if (debugEnv) {
// Use stderr so it doesn't clutter normal STDOUT output (e.g. in `--prompt` runs).
console.error(
'INFO: Using current user UID/GID in Linux sandbox. Set SANDBOX_SET_UID_GID=false to disable.',
);
}
return true;
}
return false; // Default to false if no other condition is met
return false;
}
// docker does not allow container names to contain ':' or '/', so we
@@ -372,10 +360,10 @@ export async function start_sandbox(
//
// note this can only be done with binary linked from gemini-cli repo
if (process.env['BUILD_SANDBOX']) {
if (!gcPath.includes('gemini-cli/packages/')) {
if (!gcPath.includes('qwen-code/packages/')) {
throw new FatalSandboxError(
'Cannot build sandbox using installed gemini binary; ' +
'run `npm link ./packages/cli` under gemini-cli repo to switch to linked binary.',
'Cannot build sandbox using installed Qwen Code binary; ' +
'run `npm link ./packages/cli` under QwenCode-cli repo to switch to linked binary.',
);
} else {
console.error('building sandbox ...');

View File

@@ -27,7 +27,6 @@
"@google/genai": "1.30.0",
"@modelcontextprotocol/sdk": "^1.25.1",
"@opentelemetry/api": "^1.9.0",
"async-mutex": "^0.5.0",
"@opentelemetry/exporter-logs-otlp-grpc": "^0.203.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.203.0",
"@opentelemetry/exporter-metrics-otlp-grpc": "^0.203.0",
@@ -40,7 +39,9 @@
"@xterm/headless": "5.5.0",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.0",
"async-mutex": "^0.5.0",
"chardet": "^2.1.0",
"chokidar": "^4.0.3",
"diff": "^7.0.0",
"dotenv": "^17.1.0",
"fast-levenshtein": "^2.0.6",

View File

@@ -673,6 +673,7 @@ export class Config {
this.promptRegistry = new PromptRegistry();
this.subagentManager = new SubagentManager(this);
this.skillManager = new SkillManager(this);
await this.skillManager.startWatching();
// Load session subagents if they were provided before initialization
if (this.sessionSubagents.length > 0) {
@@ -773,6 +774,13 @@ export class Config {
return this.sessionId;
}
/**
* Releases resources owned by the config instance.
*/
async shutdown(): Promise<void> {
this.skillManager?.stopWatching();
}
/**
* Starts a new session and resets session-scoped services.
*/

View File

@@ -10,6 +10,7 @@ import type {
GenerateContentParameters,
} from '@google/genai';
import { FinishReason, GenerateContentResponse } from '@google/genai';
import type { ContentGeneratorConfig } from '../contentGenerator.js';
// Mock the request tokenizer module BEFORE importing the class that uses it.
const mockTokenizer = {
@@ -127,6 +128,32 @@ describe('AnthropicContentGenerator', () => {
);
});
it('merges customHeaders into defaultHeaders (does not replace defaults)', async () => {
const { AnthropicContentGenerator } = await importGenerator();
void new AnthropicContentGenerator(
{
model: 'claude-test',
apiKey: 'test-key',
baseUrl: 'https://example.invalid',
timeout: 10_000,
maxRetries: 2,
samplingParams: {},
schemaCompliance: 'auto',
reasoning: { effort: 'medium' },
customHeaders: {
'X-Custom': '1',
},
} as unknown as Record<string, unknown> as ContentGeneratorConfig,
mockConfig,
);
const headers = (anthropicState.constructorOptions?.['defaultHeaders'] ||
{}) as Record<string, string>;
expect(headers['User-Agent']).toContain('QwenCode/1.2.3');
expect(headers['anthropic-beta']).toContain('effort-2025-11-24');
expect(headers['X-Custom']).toBe('1');
});
it('adds the effort beta header when reasoning.effort is set', async () => {
const { AnthropicContentGenerator } = await importGenerator();
void new AnthropicContentGenerator(

View File

@@ -141,6 +141,7 @@ export class AnthropicContentGenerator implements ContentGenerator {
private buildHeaders(): Record<string, string> {
const version = this.cliConfig.getCliVersion() || 'unknown';
const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`;
const { customHeaders } = this.contentGeneratorConfig;
const betas: string[] = [];
const reasoning = this.contentGeneratorConfig.reasoning;
@@ -163,7 +164,7 @@ export class AnthropicContentGenerator implements ContentGenerator {
headers['anthropic-beta'] = betas.join(',');
}
return headers;
return customHeaders ? { ...headers, ...customHeaders } : headers;
}
private async buildRequest(

View File

@@ -91,6 +91,8 @@ export type ContentGeneratorConfig = {
userAgent?: string;
// Schema compliance mode for tool definitions
schemaCompliance?: 'auto' | 'openapi_30';
// Custom HTTP headers to be sent with requests
customHeaders?: Record<string, string>;
};
// Keep the public ContentGeneratorConfigSources API, but reuse the generic

View File

@@ -39,6 +39,41 @@ describe('GeminiContentGenerator', () => {
mockGoogleGenAI = vi.mocked(GoogleGenAI).mock.results[0].value;
});
it('should merge customHeaders into existing httpOptions.headers', async () => {
vi.mocked(GoogleGenAI).mockClear();
void new GeminiContentGenerator(
{
apiKey: 'test-api-key',
httpOptions: {
headers: {
'X-Base': 'base',
'X-Override': 'base',
},
},
},
{
customHeaders: {
'X-Custom': 'custom',
'X-Override': 'custom',
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
);
expect(vi.mocked(GoogleGenAI)).toHaveBeenCalledTimes(1);
expect(vi.mocked(GoogleGenAI)).toHaveBeenCalledWith({
apiKey: 'test-api-key',
httpOptions: {
headers: {
'X-Base': 'base',
'X-Custom': 'custom',
'X-Override': 'custom',
},
},
});
});
it('should call generateContent on the underlying model', async () => {
const request = { model: 'gemini-1.5-flash', contents: [] };
const expectedResponse = { responseId: 'test-id' };

View File

@@ -35,7 +35,26 @@ export class GeminiContentGenerator implements ContentGenerator {
},
contentGeneratorConfig?: ContentGeneratorConfig,
) {
this.googleGenAI = new GoogleGenAI(options);
const customHeaders = contentGeneratorConfig?.customHeaders;
const finalOptions = customHeaders
? (() => {
const baseHttpOptions = options.httpOptions;
const baseHeaders = baseHttpOptions?.headers ?? {};
return {
...options,
httpOptions: {
...(baseHttpOptions ?? {}),
headers: {
...baseHeaders,
...customHeaders,
},
},
};
})()
: options;
this.googleGenAI = new GoogleGenAI(finalOptions);
this.contentGeneratorConfig = contentGeneratorConfig;
}

View File

@@ -142,6 +142,27 @@ describe('DashScopeOpenAICompatibleProvider', () => {
});
});
it('should merge custom headers with DashScope defaults', () => {
const providerWithCustomHeaders = new DashScopeOpenAICompatibleProvider(
{
...mockContentGeneratorConfig,
customHeaders: {
'X-Custom': '1',
'X-DashScope-CacheControl': 'disable',
},
} as ContentGeneratorConfig,
mockCliConfig,
);
const headers = providerWithCustomHeaders.buildHeaders();
expect(headers['User-Agent']).toContain('QwenCode/1.0.0');
expect(headers['X-DashScope-UserAgent']).toContain('QwenCode/1.0.0');
expect(headers['X-DashScope-AuthType']).toBe(AuthType.QWEN_OAUTH);
expect(headers['X-Custom']).toBe('1');
expect(headers['X-DashScope-CacheControl']).toBe('disable');
});
it('should handle unknown CLI version', () => {
(
mockCliConfig.getCliVersion as MockedFunction<

View File

@@ -47,13 +47,17 @@ export class DashScopeOpenAICompatibleProvider
buildHeaders(): Record<string, string | undefined> {
const version = this.cliConfig.getCliVersion() || 'unknown';
const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`;
const { authType } = this.contentGeneratorConfig;
return {
const { authType, customHeaders } = this.contentGeneratorConfig;
const defaultHeaders = {
'User-Agent': userAgent,
'X-DashScope-CacheControl': 'enable',
'X-DashScope-UserAgent': userAgent,
'X-DashScope-AuthType': authType,
};
return customHeaders
? { ...defaultHeaders, ...customHeaders }
: defaultHeaders;
}
buildClient(): OpenAI {

View File

@@ -73,6 +73,26 @@ describe('DefaultOpenAICompatibleProvider', () => {
});
});
it('should merge customHeaders with defaults (and allow overrides)', () => {
const providerWithCustomHeaders = new DefaultOpenAICompatibleProvider(
{
...mockContentGeneratorConfig,
customHeaders: {
'X-Custom': '1',
'User-Agent': 'custom-agent',
},
} as ContentGeneratorConfig,
mockCliConfig,
);
const headers = providerWithCustomHeaders.buildHeaders();
expect(headers).toEqual({
'User-Agent': 'custom-agent',
'X-Custom': '1',
});
});
it('should handle unknown CLI version', () => {
(
mockCliConfig.getCliVersion as MockedFunction<

View File

@@ -25,9 +25,14 @@ export class DefaultOpenAICompatibleProvider
buildHeaders(): Record<string, string | undefined> {
const version = this.cliConfig.getCliVersion() || 'unknown';
const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`;
return {
const { customHeaders } = this.contentGeneratorConfig;
const defaultHeaders = {
'User-Agent': userAgent,
};
return customHeaders
? { ...defaultHeaders, ...customHeaders }
: defaultHeaders;
}
buildClient(): OpenAI {

View File

@@ -25,6 +25,7 @@ export const MODEL_GENERATION_CONFIG_FIELDS = [
'disableCacheControl',
'schemaCompliance',
'reasoning',
'customHeaders',
] as const satisfies ReadonlyArray<keyof ContentGeneratorConfig>;
/**
@@ -105,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',
@@ -121,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,
},
},
];

View File

@@ -112,11 +112,9 @@ describe('modelConfigResolver', () => {
modelProvider: {
id: 'provider-model',
name: 'Provider Model',
authType: AuthType.USE_OPENAI,
envKey: 'MY_CUSTOM_KEY',
baseUrl: 'https://provider.example.com',
generationConfig: {},
capabilities: {},
},
});
@@ -249,13 +247,11 @@ describe('modelConfigResolver', () => {
modelProvider: {
id: 'model',
name: 'Model',
authType: AuthType.USE_OPENAI,
envKey: 'MY_KEY',
baseUrl: 'https://api.example.com',
generationConfig: {
timeout: 60000,
},
capabilities: {},
},
});

View File

@@ -41,7 +41,7 @@ import {
QWEN_OAUTH_ALLOWED_MODELS,
MODEL_GENERATION_CONFIG_FIELDS,
} from './constants.js';
import type { ResolvedModelConfig } from './types.js';
import type { ModelConfig as ModelProviderConfig } from './types.js';
export {
validateModelConfig,
type ModelConfigValidationResult,
@@ -86,8 +86,8 @@ export interface ModelConfigSourcesInput {
/** Environment variables (injected for testability) */
env: Record<string, string | undefined>;
/** Resolved model from ModelProviders (explicit selection, highest priority) */
modelProvider?: ResolvedModelConfig;
/** Model from ModelProviders (explicit selection, highest priority) */
modelProvider?: ModelProviderConfig;
/** Proxy URL (computed from Config) */
proxy?: string;
@@ -277,7 +277,7 @@ function resolveQwenOAuthConfig(
input: ModelConfigSourcesInput,
warnings: string[],
): ModelConfigResolutionResult {
const { cli, settings, proxy } = input;
const { cli, settings, proxy, modelProvider } = input;
const sources: ConfigSources = {};
// Qwen OAuth only allows specific models
@@ -311,10 +311,10 @@ function resolveQwenOAuthConfig(
sources['proxy'] = computedSource('Config.getProxy()');
}
// Resolve generation config from settings
// Resolve generation config from settings and modelProvider
const generationConfig = resolveGenerationConfig(
settings?.generationConfig,
undefined,
modelProvider?.generationConfig,
AuthType.QWEN_OAUTH,
resolvedModel,
sources,
@@ -344,7 +344,7 @@ function resolveGenerationConfig(
const result: Partial<ContentGeneratorConfig> = {};
for (const field of MODEL_GENERATION_CONFIG_FIELDS) {
// ModelProvider config takes priority
// ModelProvider config takes priority over settings config
if (authType && modelProviderConfig && field in modelProviderConfig) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(result as any)[field] = modelProviderConfig[field];

View File

@@ -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: [

View File

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

View File

@@ -31,6 +31,7 @@ export type ModelGenerationConfig = Pick<
| 'disableCacheControl'
| 'schemaCompliance'
| 'reasoning'
| 'customHeaders'
>;
/**

View File

@@ -16,6 +16,8 @@ import {
isDeviceTokenPending,
isDeviceTokenSuccess,
isErrorResponse,
qwenOAuth2Events,
QwenOAuth2Event,
QwenOAuth2Client,
type DeviceAuthorizationResponse,
type DeviceTokenResponse,
@@ -845,6 +847,58 @@ describe('getQwenOAuthClient', () => {
SharedTokenManager.getInstance = originalGetInstance;
});
it('should include troubleshooting hints when device auth fetch fails', async () => {
// Make SharedTokenManager fail so we hit the fallback device-flow path
const mockTokenManager = {
getValidCredentials: vi
.fn()
.mockRejectedValue(new Error('Token refresh failed')),
};
const originalGetInstance = SharedTokenManager.getInstance;
SharedTokenManager.getInstance = vi.fn().mockReturnValue(mockTokenManager);
const tlsCause = new Error('unable to verify the first certificate');
(tlsCause as Error & { code?: string }).code =
'UNABLE_TO_VERIFY_LEAF_SIGNATURE';
const fetchError = new TypeError('fetch failed') as TypeError & {
cause?: unknown;
};
fetchError.cause = tlsCause;
vi.mocked(global.fetch).mockRejectedValue(fetchError);
const emitSpy = vi.spyOn(qwenOAuth2Events, 'emit');
let thrownError: unknown;
try {
const { getQwenOAuthClient } = await import('./qwenOAuth2.js');
await getQwenOAuthClient(mockConfig);
} catch (error: unknown) {
thrownError = error;
}
expect(thrownError).toBeInstanceOf(Error);
expect((thrownError as Error).message).toContain(
'Device authorization flow failed: fetch failed',
);
expect((thrownError as Error).message).toContain(
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
);
expect((thrownError as Error).message).toContain('NODE_EXTRA_CA_CERTS');
expect((thrownError as Error).message).toContain('--proxy');
expect(emitSpy).toHaveBeenCalledWith(
QwenOAuth2Event.AuthProgress,
'error',
expect.stringContaining('NODE_EXTRA_CA_CERTS'),
);
emitSpy.mockRestore();
SharedTokenManager.getInstance = originalGetInstance;
});
});
describe('CredentialsClearRequiredError', () => {

View File

@@ -13,6 +13,7 @@ import open from 'open';
import { EventEmitter } from 'events';
import type { Config } from '../config/config.js';
import { randomUUID } from 'node:crypto';
import { formatFetchErrorForUser } from '../utils/fetch.js';
import {
SharedTokenManager,
TokenManagerError,
@@ -558,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,
@@ -570,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<void> => {
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();
@@ -592,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
@@ -652,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 {
@@ -699,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.',
);
@@ -724,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})`,
);
@@ -756,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;
@@ -792,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);
@@ -824,31 +924,23 @@ 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) {
const errorMessage = error instanceof Error ? error.message : String(error);
const message = `Device authorization flow failed: ${errorMessage}`;
const fullErrorMessage = formatFetchErrorForUser(error, {
url: QWEN_OAUTH_BASE_URL,
});
const message = `Device authorization flow failed: ${fullErrorMessage}`;
emitAuthProgress('error', message);
console.error(message);
return { success: false, reason: 'error', message };
} finally {

View File

@@ -818,7 +818,7 @@ describe('ShellExecutionService child_process fallback', () => {
});
describe('Platform-Specific Behavior', () => {
it('should use cmd.exe on Windows', async () => {
it('should use cmd.exe and hide window on Windows', async () => {
mockPlatform.mockReturnValue('win32');
await simulateExecution('dir "foo bar"', (cp) =>
cp.emit('exit', 0, null),
@@ -829,7 +829,8 @@ describe('ShellExecutionService child_process fallback', () => {
[],
expect.objectContaining({
shell: true,
detached: true,
detached: false,
windowsHide: true,
}),
);
});

View File

@@ -229,7 +229,8 @@ export class ShellExecutionService {
stdio: ['ignore', 'pipe', 'pipe'],
windowsVerbatimArguments: true,
shell: isWindows ? true : 'bash',
detached: true,
detached: !isWindows,
windowsHide: isWindows,
env: {
...process.env,
QWEN_CODE: '1',

View File

@@ -5,8 +5,10 @@
*/
import * as fs from 'fs/promises';
import * as fsSync from 'fs';
import * as path from 'path';
import * as os from 'os';
import { watch as watchFs, type FSWatcher } from 'chokidar';
import { parse as parseYaml } from '../utils/yaml-parser.js';
import type {
SkillConfig,
@@ -29,6 +31,9 @@ export class SkillManager {
private skillsCache: Map<SkillLevel, SkillConfig[]> | null = null;
private readonly changeListeners: Set<() => void> = new Set();
private parseErrors: Map<string, SkillError> = new Map();
private readonly watchers: Map<string, FSWatcher> = new Map();
private watchStarted = false;
private refreshTimer: NodeJS.Timeout | null = null;
constructor(private readonly config: Config) {}
@@ -221,6 +226,36 @@ export class SkillManager {
this.notifyChangeListeners();
}
/**
* Starts watching skill directories for changes.
*/
async startWatching(): Promise<void> {
if (this.watchStarted) {
return;
}
this.watchStarted = true;
await this.refreshCache();
this.updateWatchersFromCache();
}
/**
* Stops watching skill directories for changes.
*/
stopWatching(): void {
for (const watcher of this.watchers.values()) {
void watcher.close().catch((error) => {
console.warn('Failed to close skills watcher:', error);
});
}
this.watchers.clear();
this.watchStarted = false;
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
}
}
/**
* Parses a SKILL.md file and returns the configuration.
*
@@ -449,4 +484,77 @@ export class SkillManager {
this.skillsCache.set(level, levelSkills);
}
}
private updateWatchersFromCache(): void {
const desiredPaths = new Set<string>();
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);
}
}
}
for (const existingPath of this.watchers.keys()) {
if (!desiredPaths.has(existingPath)) {
void this.watchers
.get(existingPath)
?.close()
.catch((error) => {
console.warn(
`Failed to close skills watcher for ${existingPath}:`,
error,
);
});
this.watchers.delete(existingPath);
}
}
for (const watchPath of desiredPaths) {
if (this.watchers.has(watchPath)) {
continue;
}
try {
const watcher = watchFs(watchPath, {
ignoreInitial: true,
})
.on('all', () => {
this.scheduleRefresh();
})
.on('error', (error) => {
console.warn(`Skills watcher error for ${watchPath}:`, error);
});
this.watchers.set(watchPath, watcher);
} catch (error) {
console.warn(
`Failed to watch skills directory at ${watchPath}:`,
error,
);
}
}
}
private scheduleRefresh(): void {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
}
this.refreshTimer = setTimeout(() => {
this.refreshTimer = null;
void this.refreshCache().then(() => this.updateWatchersFromCache());
}, 150);
}
}

View File

@@ -1,67 +1,98 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ShellTool > getDescription > should return the non-windows description when not on windows 1`] = `
"This tool executes a given shell command as \`bash -c <command>\`. Command can start background processes using \`&\`. Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`.
"Executes a given shell command (as \`bash -c <command>\`) in a persistent shell session with optional timeout, ensuring proper handling and security measures.
**Background vs Foreground Execution:**
You should decide whether commands should run in background or foreground based on their nature:
**Use background execution (is_background: true) for:**
- Long-running development servers: \`npm run start\`, \`npm run dev\`, \`yarn dev\`, \`bun run start\`
- Build watchers: \`npm run watch\`, \`webpack --watch\`
- Database servers: \`mongod\`, \`mysql\`, \`redis-server\`
- Web servers: \`python -m http.server\`, \`php -S localhost:8000\`
- Any command expected to run indefinitely until manually stopped
**Use foreground execution (is_background: false) for:**
- One-time commands: \`ls\`, \`cat\`, \`grep\`
- Build commands: \`npm run build\`, \`make\`
- Installation commands: \`npm install\`, \`pip install\`
- Git operations: \`git commit\`, \`git push\`
- Test runs: \`npm test\`, \`pytest\`
The following information is returned:
IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.
Command: Executed command.
Directory: Directory where command was executed, or \`(root)\`.
Stdout: Output on stdout stream. Can be \`(empty)\` or partial on error and for any unwaited background processes.
Stderr: Output on stderr stream. Can be \`(empty)\` or partial on error and for any unwaited background processes.
Error: Error or \`(none)\` if no error was reported for the subprocess.
Exit Code: Exit code or \`(none)\` if terminated by signal.
Signal: Signal number or \`(none)\` if no signal was received.
Background PIDs: List of background processes started or \`(none)\`.
Process Group PGID: Process group started or \`(none)\`"
**Usage notes**:
- The command argument is required.
- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 120000ms (2 minutes).
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
- Avoid using run_shell_command with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
- File search: Use glob (NOT find or ls)
- Content search: Use grep_search (NOT grep or rg)
- Read files: Use read_file (NOT cat/head/tail)
- Edit files: Use edit (NOT sed/awk)
- Write files: Use write_file (NOT echo >/cat <<EOF)
- Communication: Output text directly (NOT echo/printf)
- When issuing multiple commands:
- If the commands are independent and can run in parallel, make multiple run_shell_command tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two run_shell_command tool calls in parallel.
- If the commands depend on each other and must run sequentially, use a single run_shell_command call with '&&' to chain them together (e.g., \`git add . && git commit -m "message" && git push\`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before run_shell_command for git operations, or git add before git commit), run these operations sequentially instead.
- Use ';' only when you need to run commands sequentially but don't care if earlier commands fail
- DO NOT use newlines to separate commands (newlines are ok in quoted strings)
- Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of \`cd\`. You may use \`cd\` if the User explicitly requests it.
<good-example>
pytest /foo/bar/tests
</good-example>
<bad-example>
cd /foo/bar && pytest tests
</bad-example>
**Background vs Foreground Execution:**
- You should decide whether commands should run in background or foreground based on their nature:
- Use background execution (is_background: true) for:
- Long-running development servers: \`npm run start\`, \`npm run dev\`, \`yarn dev\`, \`bun run start\`
- Build watchers: \`npm run watch\`, \`webpack --watch\`
- Database servers: \`mongod\`, \`mysql\`, \`redis-server\`
- Web servers: \`python -m http.server\`, \`php -S localhost:8000\`
- Any command expected to run indefinitely until manually stopped
- Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`.
- Use foreground execution (is_background: false) for:
- One-time commands: \`ls\`, \`cat\`, \`grep\`
- Build commands: \`npm run build\`, \`make\`
- Installation commands: \`npm install\`, \`pip install\`
- Git operations: \`git commit\`, \`git push\`
- Test runs: \`npm test\`, \`pytest\`
"
`;
exports[`ShellTool > getDescription > should return the windows description when on windows 1`] = `
"This tool executes a given shell command as \`cmd.exe /c <command>\`. Command can start background processes using \`start /b\`.
"Executes a given shell command (as \`cmd.exe /c <command>\`) in a persistent shell session with optional timeout, ensuring proper handling and security measures.
**Background vs Foreground Execution:**
You should decide whether commands should run in background or foreground based on their nature:
**Use background execution (is_background: true) for:**
- Long-running development servers: \`npm run start\`, \`npm run dev\`, \`yarn dev\`, \`bun run start\`
- Build watchers: \`npm run watch\`, \`webpack --watch\`
- Database servers: \`mongod\`, \`mysql\`, \`redis-server\`
- Web servers: \`python -m http.server\`, \`php -S localhost:8000\`
- Any command expected to run indefinitely until manually stopped
**Use foreground execution (is_background: false) for:**
- One-time commands: \`ls\`, \`cat\`, \`grep\`
- Build commands: \`npm run build\`, \`make\`
- Installation commands: \`npm install\`, \`pip install\`
- Git operations: \`git commit\`, \`git push\`
- Test runs: \`npm test\`, \`pytest\`
The following information is returned:
IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.
Command: Executed command.
Directory: Directory where command was executed, or \`(root)\`.
Stdout: Output on stdout stream. Can be \`(empty)\` or partial on error and for any unwaited background processes.
Stderr: Output on stderr stream. Can be \`(empty)\` or partial on error and for any unwaited background processes.
Error: Error or \`(none)\` if no error was reported for the subprocess.
Exit Code: Exit code or \`(none)\` if terminated by signal.
Signal: Signal number or \`(none)\` if no signal was received.
Background PIDs: List of background processes started or \`(none)\`.
Process Group PGID: Process group started or \`(none)\`"
**Usage notes**:
- The command argument is required.
- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 120000ms (2 minutes).
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
- Avoid using run_shell_command with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
- File search: Use glob (NOT find or ls)
- Content search: Use grep_search (NOT grep or rg)
- Read files: Use read_file (NOT cat/head/tail)
- Edit files: Use edit (NOT sed/awk)
- Write files: Use write_file (NOT echo >/cat <<EOF)
- Communication: Output text directly (NOT echo/printf)
- When issuing multiple commands:
- If the commands are independent and can run in parallel, make multiple run_shell_command tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two run_shell_command tool calls in parallel.
- If the commands depend on each other and must run sequentially, use a single run_shell_command call with '&&' to chain them together (e.g., \`git add . && git commit -m "message" && git push\`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before run_shell_command for git operations, or git add before git commit), run these operations sequentially instead.
- Use ';' only when you need to run commands sequentially but don't care if earlier commands fail
- DO NOT use newlines to separate commands (newlines are ok in quoted strings)
- Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of \`cd\`. You may use \`cd\` if the User explicitly requests it.
<good-example>
pytest /foo/bar/tests
</good-example>
<bad-example>
cd /foo/bar && pytest tests
</bad-example>
**Background vs Foreground Execution:**
- You should decide whether commands should run in background or foreground based on their nature:
- Use background execution (is_background: true) for:
- Long-running development servers: \`npm run start\`, \`npm run dev\`, \`yarn dev\`, \`bun run start\`
- Build watchers: \`npm run watch\`, \`webpack --watch\`
- Database servers: \`mongod\`, \`mysql\`, \`redis-server\`
- Web servers: \`python -m http.server\`, \`php -S localhost:8000\`
- Any command expected to run indefinitely until manually stopped
- Use foreground execution (is_background: false) for:
- One-time commands: \`ls\`, \`cat\`, \`grep\`
- Build commands: \`npm run build\`, \`make\`
- Installation commands: \`npm install\`, \`pip install\`
- Git operations: \`git commit\`, \`git push\`
- Test runs: \`npm test\`, \`pytest\`
"
`;

View File

@@ -59,6 +59,9 @@ describe('ShellTool', () => {
getWorkspaceContext: vi
.fn()
.mockReturnValue(createMockWorkspaceContext('/test/dir')),
storage: {
getUserSkillsDir: vi.fn().mockReturnValue('/test/dir/.qwen/skills'),
},
getGeminiClient: vi.fn(),
getGitCoAuthor: vi.fn().mockReturnValue({
enabled: true,
@@ -142,6 +145,42 @@ describe('ShellTool', () => {
);
});
it('should throw an error for a directory within the user skills directory', () => {
expect(() =>
shellTool.build({
command: 'ls',
directory: '/test/dir/.qwen/skills/my-skill',
is_background: false,
}),
).toThrow(
'Explicitly running shell commands from within the user skills directory is not allowed. Please use absolute paths for command parameter instead.',
);
});
it('should throw an error for the user skills directory itself', () => {
expect(() =>
shellTool.build({
command: 'ls',
directory: '/test/dir/.qwen/skills',
is_background: false,
}),
).toThrow(
'Explicitly running shell commands from within the user skills directory is not allowed. Please use absolute paths for command parameter instead.',
);
});
it('should resolve directory path before checking user skills directory', () => {
expect(() =>
shellTool.build({
command: 'ls',
directory: '/test/dir/.qwen/skills/../skills/my-skill',
is_background: false,
}),
).toThrow(
'Explicitly running shell commands from within the user skills directory is not allowed. Please use absolute paths for command parameter instead.',
);
});
it('should return an invocation for a valid absolute directory path', () => {
(mockConfig.getWorkspaceContext as Mock).mockReturnValue(
createMockWorkspaceContext('/test/dir', ['/another/workspace']),
@@ -670,7 +709,7 @@ describe('ShellTool', () => {
),
expect.any(String),
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
@@ -861,7 +900,7 @@ describe('ShellTool', () => {
),
expect.any(String),
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
@@ -870,8 +909,8 @@ describe('ShellTool', () => {
it('should add co-author to git commit with multi-line message', async () => {
const command = `git commit -m "Fix bug
This is a detailed description
spanning multiple lines"`;
This is a detailed description
spanning multiple lines"`;
const invocation = shellTool.build({ command, is_background: false });
const promise = invocation.execute(mockAbortSignal);
@@ -894,7 +933,7 @@ spanning multiple lines"`;
),
expect.any(String),
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
@@ -999,4 +1038,248 @@ spanning multiple lines"`;
);
});
});
describe('timeout parameter', () => {
it('should validate timeout parameter correctly', () => {
// Valid timeout
expect(() => {
shellTool.build({
command: 'echo test',
is_background: false,
timeout: 5000,
});
}).not.toThrow();
// Valid small timeout
expect(() => {
shellTool.build({
command: 'echo test',
is_background: false,
timeout: 500,
});
}).not.toThrow();
// Zero timeout
expect(() => {
shellTool.build({
command: 'echo test',
is_background: false,
timeout: 0,
});
}).toThrow('Timeout must be a positive number.');
// Negative timeout
expect(() => {
shellTool.build({
command: 'echo test',
is_background: false,
timeout: -1000,
});
}).toThrow('Timeout must be a positive number.');
// Timeout too large
expect(() => {
shellTool.build({
command: 'echo test',
is_background: false,
timeout: 700000,
});
}).toThrow('Timeout cannot exceed 600000ms (10 minutes).');
// Non-integer timeout
expect(() => {
shellTool.build({
command: 'echo test',
is_background: false,
timeout: 5000.5,
});
}).toThrow('Timeout must be an integer number of milliseconds.');
// Non-number timeout (schema validation catches this first)
expect(() => {
shellTool.build({
command: 'echo test',
is_background: false,
timeout: 'invalid' as unknown as number,
});
}).toThrow('params/timeout must be number');
});
it('should include timeout in description for foreground commands', () => {
const invocation = shellTool.build({
command: 'npm test',
is_background: false,
timeout: 30000,
});
expect(invocation.getDescription()).toBe('npm test [timeout: 30000ms]');
});
it('should not include timeout in description for background commands', () => {
const invocation = shellTool.build({
command: 'npm start',
is_background: true,
timeout: 30000,
});
expect(invocation.getDescription()).toBe('npm start [background]');
});
it('should create combined signal with timeout for foreground execution', async () => {
const mockAbortSignal = new AbortController().signal;
const invocation = shellTool.build({
command: 'sleep 1',
is_background: false,
timeout: 5000,
});
const promise = invocation.execute(mockAbortSignal);
resolveExecutionPromise({
rawOutput: Buffer.from(''),
output: '',
exitCode: 0,
signal: null,
error: null,
aborted: false,
pid: 12345,
executionMethod: 'child_process',
});
await promise;
// Verify that ShellExecutionService was called with a combined signal
expect(mockShellExecutionService).toHaveBeenCalledWith(
expect.any(String),
expect.any(String),
expect.any(Function),
expect.any(AbortSignal),
false,
{},
);
// The signal passed should be different from the original signal
const calledSignal = mockShellExecutionService.mock.calls[0][3];
expect(calledSignal).not.toBe(mockAbortSignal);
});
it('should not create timeout signal for background execution', async () => {
const mockAbortSignal = new AbortController().signal;
const invocation = shellTool.build({
command: 'npm start',
is_background: true,
timeout: 5000,
});
const promise = invocation.execute(mockAbortSignal);
resolveExecutionPromise({
rawOutput: Buffer.from(''),
output: 'Background command started. PID: 12345',
exitCode: 0,
signal: null,
error: null,
aborted: false,
pid: 12345,
executionMethod: 'child_process',
});
await promise;
// For background execution, the original signal should be used
expect(mockShellExecutionService).toHaveBeenCalledWith(
expect.any(String),
expect.any(String),
expect.any(Function),
mockAbortSignal,
false,
{},
);
});
it('should handle timeout vs user cancellation correctly', async () => {
const userAbortController = new AbortController();
const invocation = shellTool.build({
command: 'sleep 10',
is_background: false,
timeout: 5000,
});
// Mock AbortSignal.timeout and AbortSignal.any
const mockTimeoutSignal = {
aborted: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
} as unknown as AbortSignal;
const mockCombinedSignal = {
aborted: true,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
} as unknown as AbortSignal;
const originalAbortSignal = globalThis.AbortSignal;
vi.stubGlobal('AbortSignal', {
...originalAbortSignal,
timeout: vi.fn().mockReturnValue(mockTimeoutSignal),
any: vi.fn().mockReturnValue(mockCombinedSignal),
});
const promise = invocation.execute(userAbortController.signal);
resolveExecutionPromise({
rawOutput: Buffer.from('partial output'),
output: 'partial output',
exitCode: null,
signal: null,
error: null,
aborted: true,
pid: 12345,
executionMethod: 'child_process',
});
const result = await promise;
// Restore original AbortSignal
vi.stubGlobal('AbortSignal', originalAbortSignal);
expect(result.llmContent).toContain('Command timed out after 5000ms');
expect(result.llmContent).toContain(
'Below is the output before it timed out',
);
});
it('should use default timeout behavior when timeout is not specified', async () => {
const mockAbortSignal = new AbortController().signal;
const invocation = shellTool.build({
command: 'echo test',
is_background: false,
});
const promise = invocation.execute(mockAbortSignal);
resolveExecutionPromise({
rawOutput: Buffer.from('test'),
output: 'test',
exitCode: 0,
signal: null,
error: null,
aborted: false,
pid: 12345,
executionMethod: 'child_process',
});
await promise;
// Should create a combined signal with the default timeout when no timeout is specified
expect(mockShellExecutionService).toHaveBeenCalledWith(
expect.any(String),
expect.any(String),
expect.any(Function),
expect.any(AbortSignal),
false,
{},
);
});
});
});

View File

@@ -34,6 +34,7 @@ import type {
import { ShellExecutionService } from '../services/shellExecutionService.js';
import { formatMemoryUsage } from '../utils/formatters.js';
import type { AnsiOutput } from '../utils/terminalSerializer.js';
import { isSubpath } from '../utils/paths.js';
import {
getCommandRoots,
isCommandAllowed,
@@ -42,10 +43,12 @@ import {
} from '../utils/shell-utils.js';
export const OUTPUT_UPDATE_INTERVAL_MS = 1000;
const DEFAULT_FOREGROUND_TIMEOUT_MS = 120000;
export interface ShellToolParams {
command: string;
is_background: boolean;
timeout?: number;
description?: string;
directory?: string;
}
@@ -72,6 +75,9 @@ export class ShellToolInvocation extends BaseToolInvocation<
// append background indicator
if (this.params.is_background) {
description += ` [background]`;
} else if (this.params.timeout) {
// append timeout for foreground commands
description += ` [timeout: ${this.params.timeout}ms]`;
}
// append optional (description), replacing any line breaks with spaces
if (this.params.description) {
@@ -130,6 +136,17 @@ export class ShellToolInvocation extends BaseToolInvocation<
};
}
const effectiveTimeout = this.params.is_background
? undefined
: (this.params.timeout ?? DEFAULT_FOREGROUND_TIMEOUT_MS);
// Create combined signal with timeout for foreground execution
let combinedSignal = signal;
if (effectiveTimeout) {
const timeoutSignal = AbortSignal.timeout(effectiveTimeout);
combinedSignal = AbortSignal.any([signal, timeoutSignal]);
}
const isWindows = os.platform() === 'win32';
const tempFileName = `shell_pgrep_${crypto
.randomBytes(6)
@@ -219,7 +236,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
lastUpdateTime = Date.now();
}
},
signal,
combinedSignal,
this.config.getShouldUseNodePtyShell(),
shellExecutionConfig ?? {},
);
@@ -270,11 +287,28 @@ export class ShellToolInvocation extends BaseToolInvocation<
let llmContent = '';
if (result.aborted) {
llmContent = 'Command was cancelled by user before it could complete.';
if (result.output.trim()) {
llmContent += ` Below is the output before it was cancelled:\n${result.output}`;
// Check if it was a timeout or user cancellation
const wasTimeout =
!this.params.is_background &&
effectiveTimeout &&
combinedSignal.aborted &&
!signal.aborted;
if (wasTimeout) {
llmContent = `Command timed out after ${effectiveTimeout}ms before it could complete.`;
if (result.output.trim()) {
llmContent += ` Below is the output before it timed out:\n${result.output}`;
} else {
llmContent += ' There was no output before it timed out.';
}
} else {
llmContent += ' There was no output before it was cancelled.';
llmContent =
'Command was cancelled by user before it could complete.';
if (result.output.trim()) {
llmContent += ` Below is the output before it was cancelled:\n${result.output}`;
} else {
llmContent += ' There was no output before it was cancelled.';
}
}
} else {
// Create a formatted error string for display, replacing the wrapper command
@@ -305,7 +339,16 @@ export class ShellToolInvocation extends BaseToolInvocation<
returnDisplayMessage = result.output;
} else {
if (result.aborted) {
returnDisplayMessage = 'Command cancelled by user.';
// Check if it was a timeout or user cancellation
const wasTimeout =
!this.params.is_background &&
effectiveTimeout &&
combinedSignal.aborted &&
!signal.aborted;
returnDisplayMessage = wasTimeout
? `Command timed out after ${effectiveTimeout}ms.`
: 'Command cancelled by user.';
} else if (result.signal) {
returnDisplayMessage = `Command terminated by signal: ${result.signal}`;
} else if (result.error) {
@@ -406,42 +449,59 @@ Co-authored-by: ${gitCoAuthorSettings.name} <${gitCoAuthorSettings.email}>`;
}
function getShellToolDescription(): string {
const toolDescription = `
const isWindows = os.platform() === 'win32';
const executionWrapper = isWindows
? 'cmd.exe /c <command>'
: 'bash -c <command>';
const processGroupNote = isWindows
? ''
: '\n - Command is executed as a subprocess that leads its own process group. Command process group can be terminated as `kill -- -PGID` or signaled as `kill -s SIGNAL -- -PGID`.';
**Background vs Foreground Execution:**
You should decide whether commands should run in background or foreground based on their nature:
**Use background execution (is_background: true) for:**
- Long-running development servers: \`npm run start\`, \`npm run dev\`, \`yarn dev\`, \`bun run start\`
- Build watchers: \`npm run watch\`, \`webpack --watch\`
- Database servers: \`mongod\`, \`mysql\`, \`redis-server\`
- Web servers: \`python -m http.server\`, \`php -S localhost:8000\`
- Any command expected to run indefinitely until manually stopped
**Use foreground execution (is_background: false) for:**
- One-time commands: \`ls\`, \`cat\`, \`grep\`
- Build commands: \`npm run build\`, \`make\`
- Installation commands: \`npm install\`, \`pip install\`
- Git operations: \`git commit\`, \`git push\`
- Test runs: \`npm test\`, \`pytest\`
The following information is returned:
return `Executes a given shell command (as \`${executionWrapper}\`) in a persistent shell session with optional timeout, ensuring proper handling and security measures.
Command: Executed command.
Directory: Directory where command was executed, or \`(root)\`.
Stdout: Output on stdout stream. Can be \`(empty)\` or partial on error and for any unwaited background processes.
Stderr: Output on stderr stream. Can be \`(empty)\` or partial on error and for any unwaited background processes.
Error: Error or \`(none)\` if no error was reported for the subprocess.
Exit Code: Exit code or \`(none)\` if terminated by signal.
Signal: Signal number or \`(none)\` if no signal was received.
Background PIDs: List of background processes started or \`(none)\`.
Process Group PGID: Process group started or \`(none)\``;
IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.
if (os.platform() === 'win32') {
return `This tool executes a given shell command as \`cmd.exe /c <command>\`. Command can start background processes using \`start /b\`.${toolDescription}`;
} else {
return `This tool executes a given shell command as \`bash -c <command>\`. Command can start background processes using \`&\`. Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`.${toolDescription}`;
}
**Usage notes**:
- The command argument is required.
- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 120000ms (2 minutes).
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
- Avoid using run_shell_command with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
- File search: Use ${ToolNames.GLOB} (NOT find or ls)
- Content search: Use ${ToolNames.GREP} (NOT grep or rg)
- Read files: Use ${ToolNames.READ_FILE} (NOT cat/head/tail)
- Edit files: Use ${ToolNames.EDIT} (NOT sed/awk)
- Write files: Use ${ToolNames.WRITE_FILE} (NOT echo >/cat <<EOF)
- Communication: Output text directly (NOT echo/printf)
- When issuing multiple commands:
- If the commands are independent and can run in parallel, make multiple run_shell_command tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two run_shell_command tool calls in parallel.
- If the commands depend on each other and must run sequentially, use a single run_shell_command call with '&&' to chain them together (e.g., \`git add . && git commit -m "message" && git push\`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before run_shell_command for git operations, or git add before git commit), run these operations sequentially instead.
- Use ';' only when you need to run commands sequentially but don't care if earlier commands fail
- DO NOT use newlines to separate commands (newlines are ok in quoted strings)
- Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of \`cd\`. You may use \`cd\` if the User explicitly requests it.
<good-example>
pytest /foo/bar/tests
</good-example>
<bad-example>
cd /foo/bar && pytest tests
</bad-example>
**Background vs Foreground Execution:**
- You should decide whether commands should run in background or foreground based on their nature:
- Use background execution (is_background: true) for:
- Long-running development servers: \`npm run start\`, \`npm run dev\`, \`yarn dev\`, \`bun run start\`
- Build watchers: \`npm run watch\`, \`webpack --watch\`
- Database servers: \`mongod\`, \`mysql\`, \`redis-server\`
- Web servers: \`python -m http.server\`, \`php -S localhost:8000\`
- Any command expected to run indefinitely until manually stopped
${processGroupNote}
- Use foreground execution (is_background: false) for:
- One-time commands: \`ls\`, \`cat\`, \`grep\`
- Build commands: \`npm run build\`, \`make\`
- Installation commands: \`npm install\`, \`pip install\`
- Git operations: \`git commit\`, \`git push\`
- Test runs: \`npm test\`, \`pytest\`
`;
}
function getCommandDescription(): string {
@@ -485,6 +545,10 @@ export class ShellTool extends BaseDeclarativeTool<
description:
'Whether to run the command in background. Default is false. Set to true for long-running processes like development servers, watchers, or daemons that should continue running without blocking further commands.',
},
timeout: {
type: 'number',
description: 'Optional timeout in milliseconds (max 600000)',
},
description: {
type: 'string',
description:
@@ -522,10 +586,35 @@ export class ShellTool extends BaseDeclarativeTool<
if (getCommandRoots(params.command).length === 0) {
return 'Could not identify command root to obtain permission from user.';
}
if (params.timeout !== undefined) {
if (
typeof params.timeout !== 'number' ||
!Number.isInteger(params.timeout)
) {
return 'Timeout must be an integer number of milliseconds.';
}
if (params.timeout <= 0) {
return 'Timeout must be a positive number.';
}
if (params.timeout > 600000) {
return 'Timeout cannot exceed 600000ms (10 minutes).';
}
}
if (params.directory) {
if (!path.isAbsolute(params.directory)) {
return 'Directory must be an absolute path.';
}
const userSkillsDir = this.config.storage.getUserSkillsDir();
const resolvedDirectoryPath = path.resolve(params.directory);
const isWithinUserSkills = isSubpath(
userSkillsDir,
resolvedDirectoryPath,
);
if (isWithinUserSkills) {
return `Explicitly running shell commands from within the user skills directory is not allowed. Please use absolute paths for command parameter instead.`;
}
const workspaceDirs = this.config.getWorkspaceContext().getDirectories();
const isWithinWorkspace = workspaceDirs.some((wsDir) =>
params.directory!.startsWith(wsDir),

View File

@@ -324,7 +324,9 @@ describe('SkillTool', () => {
'Review code for quality and best practices.',
);
expect(result.returnDisplay).toBe('Launching skill: code-review');
expect(result.returnDisplay).toBe(
'Specialized skill for reviewing code quality',
);
});
it('should include allowedTools in result when present', async () => {
@@ -349,7 +351,7 @@ describe('SkillTool', () => {
// Base description is omitted from llmContent; ensure body is present.
expect(llmText).toContain('Help write comprehensive tests.');
expect(result.returnDisplay).toBe('Launching skill: testing');
expect(result.returnDisplay).toBe('Skill for writing and running tests');
});
it('should handle skill not found error', async () => {
@@ -416,7 +418,7 @@ describe('SkillTool', () => {
).createInvocation(params);
const description = invocation.getDescription();
expect(description).toBe('Launching skill: "code-review"');
expect(description).toBe('Use skill: "code-review"');
});
it('should handle skill without additional files', async () => {
@@ -436,7 +438,9 @@ describe('SkillTool', () => {
const llmText = partToString(result.llmContent);
expect(llmText).not.toContain('## Additional Files');
expect(result.returnDisplay).toBe('Launching skill: code-review');
expect(result.returnDisplay).toBe(
'Specialized skill for reviewing code quality',
);
});
});
});

View File

@@ -49,7 +49,7 @@ export class SkillTool extends BaseDeclarativeTool<SkillParams, ToolResult> {
'Execute a skill within the main conversation. Loading available skills...', // Initial description
Kind.Read,
initialSchema,
true, // isOutputMarkdown
false, // isOutputMarkdown
false, // canUpdateOutput
);
@@ -128,6 +128,10 @@ Important:
- Only use skills listed in <available_skills> below
- Do not invoke a skill that is already running
- Do not use this tool for built-in CLI commands (like /help, /clear, etc.)
- When executing scripts or loading referenced files, ALWAYS resolve absolute paths from skill's base directory. Examples:
- \`bash scripts/init.sh\` -> \`bash /path/to/skill/scripts/init.sh\`
- \`python scripts/helper.py\` -> \`python /path/to/skill/scripts/helper.py\`
- \`reference.md\` -> \`/path/to/skill/reference.md\`
</skills_instructions>
<available_skills>
@@ -183,7 +187,7 @@ class SkillToolInvocation extends BaseToolInvocation<SkillParams, ToolResult> {
}
getDescription(): string {
return `Launching skill: "${this.params.skill}"`;
return `Use skill: "${this.params.skill}"`;
}
override async shouldConfirmExecute(): Promise<false> {
@@ -238,16 +242,16 @@ class SkillToolInvocation extends BaseToolInvocation<SkillParams, ToolResult> {
const baseDir = path.dirname(skill.filePath);
// Build markdown content for LLM (show base dir, then body)
const llmContent = `Base directory for this skill: ${baseDir}\n\n${skill.body}\n`;
const llmContent = `Base directory for this skill: ${baseDir}\nImportant: ALWAYS resolve absolute paths from this base directory when working with skills.\n\n${skill.body}\n`;
return {
llmContent: [{ text: llmContent }],
returnDisplay: `Launching skill: ${skill.name}`,
returnDisplay: skill.description,
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error(`[SkillsTool] Error launching skill: ${errorMessage}`);
console.error(`[SkillsTool] Error using skill: ${errorMessage}`);
// Log failed skill launch
logSkillLaunch(

View File

@@ -0,0 +1,52 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { FetchError, formatFetchErrorForUser } from './fetch.js';
describe('formatFetchErrorForUser', () => {
it('includes troubleshooting hints for TLS errors', () => {
const tlsCause = new Error('unable to verify the first certificate');
(tlsCause as Error & { code?: string }).code =
'UNABLE_TO_VERIFY_LEAF_SIGNATURE';
const fetchError = new TypeError('fetch failed') as TypeError & {
cause?: unknown;
};
fetchError.cause = tlsCause;
const message = formatFetchErrorForUser(fetchError, {
url: 'https://chat.qwen.ai',
});
expect(message).toContain('fetch failed');
expect(message).toContain('UNABLE_TO_VERIFY_LEAF_SIGNATURE');
expect(message).toContain('Troubleshooting:');
expect(message).toContain('Confirm you can reach https://chat.qwen.ai');
expect(message).toContain('--proxy');
expect(message).toContain('NODE_EXTRA_CA_CERTS');
});
it('includes troubleshooting hints for network codes', () => {
const fetchError = new FetchError(
'Request timed out after 100ms',
'ETIMEDOUT',
);
const message = formatFetchErrorForUser(fetchError, {
url: 'https://example.com',
});
expect(message).toContain('Request timed out after 100ms');
expect(message).toContain('Troubleshooting:');
expect(message).toContain('Confirm you can reach https://example.com');
expect(message).toContain('--proxy');
expect(message).not.toContain('NODE_EXTRA_CA_CERTS');
});
it('does not include troubleshooting for non-fetch errors', () => {
expect(formatFetchErrorForUser(new Error('boom'))).toBe('boom');
});
});

View File

@@ -17,6 +17,26 @@ const PRIVATE_IP_RANGES = [
/^fe80:/,
];
const TLS_ERROR_CODES = new Set([
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
'SELF_SIGNED_CERT_IN_CHAIN',
'DEPTH_ZERO_SELF_SIGNED_CERT',
'CERT_HAS_EXPIRED',
'ERR_TLS_CERT_ALTNAME_INVALID',
]);
const FETCH_TROUBLESHOOTING_ERROR_CODES = new Set([
...TLS_ERROR_CODES,
'ECONNRESET',
'ETIMEDOUT',
'ECONNREFUSED',
'ENOTFOUND',
'EAI_AGAIN',
'EHOSTUNREACH',
'ENETUNREACH',
]);
export class FetchError extends Error {
constructor(
message: string,
@@ -55,3 +75,118 @@ export async function fetchWithTimeout(
clearTimeout(timeoutId);
}
}
function getErrorCode(error: unknown): string | undefined {
if (!error || typeof error !== 'object') {
return undefined;
}
if (
'code' in error &&
typeof (error as Record<string, unknown>)['code'] === 'string'
) {
return (error as Record<string, string>)['code'];
}
return undefined;
}
function formatUnknownErrorMessage(error: unknown): string | undefined {
if (typeof error === 'string') {
return error;
}
if (
typeof error === 'number' ||
typeof error === 'boolean' ||
typeof error === 'bigint'
) {
return String(error);
}
if (error instanceof Error) {
return error.message;
}
if (!error || typeof error !== 'object') {
return undefined;
}
const message = (error as Record<string, unknown>)['message'];
if (typeof message === 'string') {
return message;
}
return undefined;
}
function formatErrorCause(error: unknown): string | undefined {
if (!(error instanceof Error)) {
return undefined;
}
const cause = (error as Error & { cause?: unknown }).cause;
if (!cause) {
return undefined;
}
const causeCode = getErrorCode(cause);
const causeMessage = formatUnknownErrorMessage(cause);
if (!causeCode && !causeMessage) {
return undefined;
}
if (causeCode && causeMessage && !causeMessage.includes(causeCode)) {
return `${causeCode}: ${causeMessage}`;
}
return causeMessage ?? causeCode;
}
export function formatFetchErrorForUser(
error: unknown,
options: { url?: string } = {},
): string {
const errorMessage = getErrorMessage(error);
const code =
error instanceof Error
? (getErrorCode((error as Error & { cause?: unknown }).cause) ??
getErrorCode(error))
: getErrorCode(error);
const cause = formatErrorCause(error);
const fullErrorMessage = [
errorMessage,
cause ? `(cause: ${cause})` : undefined,
]
.filter(Boolean)
.join(' ');
const shouldShowFetchHints =
errorMessage.toLowerCase().includes('fetch failed') ||
(code != null && FETCH_TROUBLESHOOTING_ERROR_CODES.has(code));
const shouldShowTlsHint = code != null && TLS_ERROR_CODES.has(code);
if (!shouldShowFetchHints) {
return fullErrorMessage;
}
const hintLines = [
'',
'Troubleshooting:',
...(options.url
? [`- Confirm you can reach ${options.url} from this machine.`]
: []),
'- If you are behind a proxy, pass `--proxy <url>` (or set `proxy` in settings).',
...(shouldShowTlsHint
? [
'- If your network uses a corporate TLS inspection CA, set `NODE_EXTRA_CA_CERTS` to your CA bundle.',
]
: []),
];
return `${fullErrorMessage}${hintLines.join('\n')}`;
}

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/sdk",
"version": "0.1.0",
"version": "0.1.3",
"description": "TypeScript SDK for programmatic access to qwen-code CLI",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",

View File

@@ -5,7 +5,7 @@
import type { SDKUserMessage } from '../types/protocol.js';
import { serializeJsonLine } from '../utils/jsonLines.js';
import { ProcessTransport } from '../transport/ProcessTransport.js';
import { parseExecutableSpec } from '../utils/cliPath.js';
import { prepareSpawnInfo, type SpawnInfo } from '../utils/cliPath.js';
import { Query } from './Query.js';
import type { QueryOptions } from '../types/types.js';
import { QueryOptionsSchema } from '../types/queryOptionsSchema.js';
@@ -32,17 +32,17 @@ export function query({
*/
options?: QueryOptions;
}): Query {
const parsedExecutable = validateOptions(options);
const spawnInfo = validateOptions(options);
const isSingleTurn = typeof prompt === 'string';
const pathToQwenExecutable =
options.pathToQwenExecutable ?? parsedExecutable.executablePath;
const pathToQwenExecutable = options.pathToQwenExecutable;
const abortController = options.abortController ?? new AbortController();
const transport = new ProcessTransport({
pathToQwenExecutable,
spawnInfo,
cwd: options.cwd,
model: options.model,
permissionMode: options.permissionMode,
@@ -97,9 +97,7 @@ export function query({
return queryInstance;
}
function validateOptions(
options: QueryOptions,
): ReturnType<typeof parseExecutableSpec> {
function validateOptions(options: QueryOptions): SpawnInfo | undefined {
const validationResult = QueryOptionsSchema.safeParse(options);
if (!validationResult.success) {
const errors = validationResult.error.errors
@@ -108,13 +106,10 @@ function validateOptions(
throw new Error(`Invalid QueryOptions: ${errors}`);
}
let parsedExecutable: ReturnType<typeof parseExecutableSpec>;
try {
parsedExecutable = parseExecutableSpec(options.pathToQwenExecutable);
return prepareSpawnInfo(options.pathToQwenExecutable);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(`Invalid pathToQwenExecutable: ${errorMessage}`);
}
return parsedExecutable;
}

View File

@@ -44,7 +44,9 @@ export class ProcessTransport implements Transport {
const cwd = this.options.cwd ?? process.cwd();
const env = { ...process.env, ...this.options.env };
const spawnInfo = prepareSpawnInfo(this.options.pathToQwenExecutable);
const spawnInfo =
this.options.spawnInfo ??
prepareSpawnInfo(this.options.pathToQwenExecutable);
const stderrMode =
this.options.debug || this.options.stderr ? 'pipe' : 'ignore';
@@ -140,6 +142,7 @@ export class ProcessTransport implements Transport {
'--output-format',
'stream-json',
'--channel=SDK',
'--experimental-skills',
];
if (this.options.model) {

View File

@@ -4,11 +4,13 @@ import type {
SubagentConfig,
SDKMcpServerConfig,
} from './protocol.js';
import type { SpawnInfo } from '../utils/cliPath.js';
export type { PermissionMode };
export type TransportOptions = {
pathToQwenExecutable: string;
pathToQwenExecutable?: string;
spawnInfo?: SpawnInfo;
cwd?: string;
model?: string;
permissionMode?: PermissionMode;
@@ -177,32 +179,25 @@ export interface QueryOptions {
model?: string;
/**
* Path to the Qwen CLI executable or runtime specification.
* Path to the Qwen CLI executable.
*
* If not provided, the SDK automatically uses the bundled CLI included in the package.
*
* Supports multiple formats:
* - 'qwen' -> native binary (auto-detected from PATH)
* - '/path/to/qwen' -> native binary (explicit path)
* - '/path/to/cli.js' -> Node.js bundle (default for .js files)
* - '/path/to/index.ts' -> TypeScript source (requires tsx)
* - 'bun:/path/to/cli.js' -> Force Bun runtime
* - 'node:/path/to/cli.js' -> Force Node.js runtime
* - 'tsx:/path/to/index.ts' -> Force tsx runtime
* - 'deno:/path/to/cli.ts' -> Force Deno runtime
* - Command name (no path separators): `'qwen'` -> executes from PATH
* - JavaScript file: `'/path/to/cli.js'` -> uses Node.js (or Bun if running under Bun)
* - TypeScript file: `'/path/to/index.ts'` -> uses tsx if available (silent support for dev/debug)
* - Native binary: `'/path/to/qwen'` -> executes directly
*
* If not provided, the SDK will auto-detect the native binary in this order:
* 1. QWEN_CODE_CLI_PATH environment variable
* 2. ~/.volta/bin/qwen
* 3. ~/.npm-global/bin/qwen
* 4. /usr/local/bin/qwen
* 5. ~/.local/bin/qwen
* 6. ~/node_modules/.bin/qwen
* 7. ~/.yarn/bin/qwen
*
* The .ts files are only supported for debugging purposes.
* Runtime detection:
* - `.js/.mjs/.cjs` files: Node.js (or Bun if running under Bun)
* - `.ts/.tsx` files: tsx if available, otherwise treated as native
* - Command names: executed directly from PATH
* - Other files: executed as native binaries
*
* @example '/path/to/cli.js'
* @example 'qwen'
* @example '/usr/local/bin/qwen'
* @example 'tsx:/path/to/packages/cli/src/index.ts'
* @example './packages/cli/index.ts'
*/
pathToQwenExecutable?: string;

View File

@@ -1,28 +1,29 @@
/**
* CLI path auto-detection and subprocess spawning utilities
*
* Supports multiple execution modes:
* 1. Bundled CLI: Node.js bundle included in the SDK package (default)
* 2. Node.js bundle: 'node /path/to/cli.js' (custom path)
* 3. Bun bundle: 'bun /path/to/cli.js' (alternative runtime)
* 4. TypeScript source: 'tsx /path/to/index.ts' (development)
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* CLI path resolution and subprocess spawning utilities
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { execSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
/**
* Executable types supported by the SDK
*/
export type ExecutableType = 'native' | 'node' | 'bun' | 'tsx' | 'deno';
export type ExecutableType = 'node' | 'bun' | 'tsx' | 'native';
/**
* Spawn information for CLI process
*/
export type SpawnInfo = {
/** Command to execute (e.g., 'qwen', 'node', 'bun', 'tsx') */
/** Command to execute (e.g., 'node', 'bun', 'tsx', or native binary path) */
command: string;
/** Arguments to pass to command */
args: string[];
@@ -32,49 +33,244 @@ export type SpawnInfo = {
originalInput: string;
};
function getBundledCliPath(): string | null {
/**
* Get the directory containing the current module (ESM or CJS)
*/
function getCurrentModuleDir(): string {
let moduleDir: string | null = null;
try {
const currentFile =
typeof __filename !== 'undefined'
? __filename
: fileURLToPath(import.meta.url);
const currentDir = path.dirname(currentFile);
const bundledCliPath = path.join(currentDir, 'cli', 'cli.js');
if (fs.existsSync(bundledCliPath)) {
return bundledCliPath;
if (typeof import.meta !== 'undefined' && import.meta.url) {
moduleDir = path.dirname(fileURLToPath(import.meta.url));
}
return null;
} catch {
return null;
// Fall through to CJS
}
if (!moduleDir) {
try {
if (typeof __dirname !== 'undefined') {
moduleDir = __dirname;
}
} catch {
// Fall through
}
}
if (moduleDir) {
return path.normalize(moduleDir);
}
throw new Error('Cannot find module directory.');
}
export function findNativeCliPath(): string {
/**
* Find the SDK package root directory
*/
function findSdkPackageRoot(): string | null {
try {
const require = createRequire(import.meta.url);
const packageJsonPath = require.resolve('@qwen-code/sdk/package.json');
const packageRoot = path.dirname(packageJsonPath);
const cliPath = path.join(packageRoot, 'dist', 'cli', 'cli.js');
if (fs.existsSync(cliPath)) {
return packageRoot;
}
} catch {
// Continue to fallback strategy
}
const currentDir = getCurrentModuleDir();
let dir = currentDir;
const root = path.parse(dir).root;
let bestMatch: string | null = null;
while (dir !== root) {
const packageJsonPath = path.join(dir, 'package.json');
if (fs.existsSync(packageJsonPath)) {
const cliPath = path.join(dir, 'dist', 'cli', 'cli.js');
if (fs.existsSync(cliPath)) {
try {
const packageJson = JSON.parse(
fs.readFileSync(packageJsonPath, 'utf-8'),
);
if (packageJson.name === '@qwen-code/sdk') {
return dir;
}
if (!bestMatch) {
bestMatch = dir;
}
} catch {
if (!bestMatch) {
bestMatch = dir;
}
}
}
}
dir = path.dirname(dir);
}
return bestMatch;
}
/**
* Normalize path separators for regex matching
*/
function normalizeForRegex(dirPath: string): string {
return dirPath.replace(/\\/g, '/');
}
/**
* Resolve bundled CLI using import.meta.url relative path
*/
function tryResolveCliFromImportMeta(): string | null {
try {
if (typeof import.meta !== 'undefined' && import.meta.url) {
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;
}
}
} catch {
// Ignore errors
}
return null;
}
/**
* Get all candidate paths for the bundled CLI
*/
function getBundledCliCandidatePaths(): string[] {
const candidates: string[] = [];
const importMetaResolved = tryResolveCliFromImportMeta();
if (importMetaResolved) {
candidates.push(importMetaResolved);
}
try {
const currentDir = getCurrentModuleDir();
const normalizedDir = normalizeForRegex(currentDir);
candidates.push(path.join(currentDir, 'cli', 'cli.js'));
if (/\/src\/utils$/.test(normalizedDir)) {
const packageRoot = path.dirname(path.dirname(currentDir));
candidates.push(path.join(packageRoot, 'dist', 'cli', 'cli.js'));
}
const packageRoot = findSdkPackageRoot();
if (packageRoot) {
candidates.push(path.join(packageRoot, 'dist', 'cli', 'cli.js'));
}
const monorepoMatch = normalizedDir.match(
/^(.+?)\/packages\/sdk-typescript/,
);
if (monorepoMatch && monorepoMatch[1]) {
const monorepoRoot =
process.platform === 'win32'
? monorepoMatch[1].replace(/\//g, '\\')
: monorepoMatch[1];
candidates.push(path.join(monorepoRoot, 'dist', 'cli.js'));
}
} catch {
const packageRoot = findSdkPackageRoot();
if (packageRoot) {
candidates.push(path.join(packageRoot, 'dist', 'cli', 'cli.js'));
}
}
return candidates;
}
/**
* Find the bundled CLI path
*/
function getBundledCliPath(): string | null {
const candidates = getBundledCliCandidatePaths();
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return null;
}
/**
* Find the bundled CLI path or throw error
*/
export function findBundledCliPath(): string {
const bundledCli = getBundledCliPath();
if (bundledCli) {
return bundledCli;
}
const candidates = getBundledCliCandidatePaths();
throw new Error(
'Bundled qwen CLI not found. The CLI should be included in the SDK package.\n' +
'If you need to use a custom CLI, provide explicit executable:\n' +
' • query({ pathToQwenExecutable: "/path/to/cli.js" })\n' +
' • TypeScript source: query({ pathToQwenExecutable: "/path/to/index.ts" })\n' +
' • Force specific runtime: query({ pathToQwenExecutable: "bun:/path/to/cli.js" })',
'Searched locations:\n' +
candidates.map((c) => ` - ${c}`).join('\n') +
'\n\nIf you need to use a custom CLI, provide explicit path:\n' +
' • query({ pathToQwenExecutable: "/path/to/cli.js" })',
);
}
/**
* Validate file exists and is a file
*/
function validateFilePath(filePath: string): void {
if (!fs.existsSync(filePath)) {
throw new Error(
`Executable file not found at '${filePath}'. ` +
'Please check the file path and ensure the file exists.',
);
}
const stats = fs.statSync(filePath);
if (!stats.isFile()) {
throw new Error(
`Path '${filePath}' exists but is not a file. ` +
'Please provide a path to an executable file.',
);
}
}
/**
* Check if path contains separators (file path vs command name)
*/
function isFilePath(spec: string): boolean {
return spec.includes('/') || spec.includes('\\');
}
/**
* Check if file is JavaScript
*/
function isJavaScriptFile(filePath: string): boolean {
const ext = path.extname(filePath).toLowerCase();
return ['.js', '.mjs', '.cjs'].includes(ext);
}
/**
* Check if file is TypeScript
*/
function isTypeScriptFile(filePath: string): boolean {
const ext = path.extname(filePath).toLowerCase();
return ['.ts', '.tsx'].includes(ext);
}
/**
* Check if command is available in PATH
*/
function isCommandAvailable(command: string): boolean {
try {
// Use 'which' on Unix-like systems, 'where' on Windows
const whichCommand = process.platform === 'win32' ? 'where' : 'which';
execSync(`${whichCommand} ${command}`, {
stdio: 'ignore',
timeout: 5000, // 5 second timeout
timeout: 1000,
});
return true;
} catch {
@@ -82,245 +278,87 @@ function isCommandAvailable(command: string): boolean {
}
}
function validateRuntimeAvailability(runtime: string): boolean {
// Node.js is always available since we're running in Node.js
if (runtime === 'node') {
return true;
}
// Check if the runtime command is available in PATH
return isCommandAvailable(runtime);
}
function validateFileExtensionForRuntime(
filePath: string,
runtime: string,
): boolean {
const ext = path.extname(filePath).toLowerCase();
switch (runtime) {
case 'node':
case 'bun':
return ['.js', '.mjs', '.cjs'].includes(ext);
case 'tsx':
return ['.ts', '.tsx'].includes(ext);
case 'deno':
return ['.ts', '.tsx', '.js', '.mjs'].includes(ext);
default:
return true; // Unknown runtime, let it pass
}
/**
* Check if tsx is available
*/
function isTsxAvailable(): boolean {
return isCommandAvailable('tsx');
}
/**
* Parse executable specification into components with comprehensive validation
*
* Supports multiple formats:
* - 'qwen' -> native binary (auto-detected)
* - '/path/to/qwen' -> native binary (explicit path)
* - '/path/to/cli.js' -> Node.js bundle (default for .js files)
* - '/path/to/index.ts' -> TypeScript source (requires tsx)
*
* Advanced runtime specification (for overriding defaults):
* - 'bun:/path/to/cli.js' -> Force Bun runtime
* - 'node:/path/to/cli.js' -> Force Node.js runtime
* - 'tsx:/path/to/index.ts' -> Force tsx runtime
* - 'deno:/path/to/cli.ts' -> Force Deno runtime
*
* @param executableSpec - Executable specification
* @returns Parsed executable information
* @throws Error if specification is invalid or files don't exist
* Get JavaScript runtime type (bun if running under bun, otherwise node)
*/
export function parseExecutableSpec(executableSpec?: string): {
runtime?: string;
executablePath: string;
isExplicitRuntime: boolean;
} {
function getJsRuntimeType(): 'bun' | 'node' {
if (
executableSpec === '' ||
(executableSpec && executableSpec.trim() === '')
typeof process !== 'undefined' &&
'versions' in process &&
'bun' in process.versions
) {
throw new Error('Command name cannot be empty');
return 'bun';
}
return 'node';
}
/**
* Prepare spawn information for CLI process
*/
export function prepareSpawnInfo(executableSpec?: string): SpawnInfo {
if (executableSpec !== undefined && executableSpec.trim() === '') {
throw new Error('Executable path cannot be empty');
}
if (!executableSpec) {
if (executableSpec === undefined) {
const bundledCliPath = findBundledCliPath();
return {
executablePath: findNativeCliPath(),
isExplicitRuntime: false,
command: process.execPath,
args: [bundledCliPath],
type: getJsRuntimeType(),
originalInput: '',
};
}
// Check for runtime prefix (e.g., 'bun:/path/to/cli.js')
// Use whitelist mechanism: only treat as runtime spec if prefix matches supported runtimes
const supportedRuntimes = ['node', 'bun', 'tsx', 'deno'];
const runtimeMatch = executableSpec.match(/^([^:]+):(.+)$/);
if (runtimeMatch) {
const [, runtime, filePath] = runtimeMatch;
// Only process as runtime specification if it matches a supported runtime
if (runtime && supportedRuntimes.includes(runtime)) {
if (!filePath) {
throw new Error(`Invalid runtime specification: '${executableSpec}'`);
}
if (!validateRuntimeAvailability(runtime)) {
throw new Error(
`Runtime '${runtime}' is not available on this system. Please install it first.`,
);
}
const resolvedPath = path.resolve(filePath);
if (!fs.existsSync(resolvedPath)) {
throw new Error(
`Executable file not found at '${resolvedPath}' for runtime '${runtime}'. ` +
'Please check the file path and ensure the file exists.',
);
}
if (!validateFileExtensionForRuntime(resolvedPath, runtime)) {
const ext = path.extname(resolvedPath);
throw new Error(
`File extension '${ext}' is not compatible with runtime '${runtime}'. ` +
`Expected extensions for ${runtime}: ${getExpectedExtensions(runtime).join(', ')}`,
);
}
return {
runtime,
executablePath: resolvedPath,
isExplicitRuntime: true,
};
}
// If not a supported runtime, fall through to treat as file path (e.g., Windows paths like 'D:\path\to\cli.js')
}
// Check if it's a command name (no path separators) or a file path
const isCommandName =
!executableSpec.includes('/') && !executableSpec.includes('\\');
if (isCommandName) {
// It's a command name like 'qwen' - validate it's a reasonable command name
if (!executableSpec || executableSpec.trim() === '') {
throw new Error('Command name cannot be empty');
}
// Basic validation for command names
if (!isFilePath(executableSpec)) {
if (!/^[a-zA-Z0-9._-]+$/.test(executableSpec)) {
throw new Error(
`Invalid command name '${executableSpec}'. Command names should only contain letters, numbers, dots, hyphens, and underscores.`,
`Invalid command name '${executableSpec}'. ` +
'Command names should only contain letters, numbers, dots, hyphens, and underscores.',
);
}
return {
executablePath: executableSpec,
isExplicitRuntime: false,
command: executableSpec,
args: [],
type: 'native',
originalInput: executableSpec,
};
}
// It's a file path - validate and resolve
const resolvedPath = path.resolve(executableSpec);
validateFilePath(resolvedPath);
if (!fs.existsSync(resolvedPath)) {
throw new Error(
`Executable file not found at '${resolvedPath}'. ` +
'Please check the file path and ensure the file exists. ' +
'You can also:\n' +
' • Set QWEN_CODE_CLI_PATH environment variable\n' +
' • Install qwen globally: npm install -g qwen\n' +
' • For TypeScript files, ensure tsx is installed: npm install -g tsx\n' +
' • Force specific runtime: bun:/path/to/cli.js or tsx:/path/to/index.ts',
);
if (isJavaScriptFile(resolvedPath)) {
return {
command: process.execPath,
args: [resolvedPath],
type: getJsRuntimeType(),
originalInput: executableSpec,
};
}
// Additional validation for file paths
const stats = fs.statSync(resolvedPath);
if (!stats.isFile()) {
throw new Error(
`Path '${resolvedPath}' exists but is not a file. Please provide a path to an executable file.`,
);
}
return {
executablePath: resolvedPath,
isExplicitRuntime: false,
};
}
function getExpectedExtensions(runtime: string): string[] {
switch (runtime) {
case 'node':
case 'bun':
return ['.js', '.mjs', '.cjs'];
case 'tsx':
return ['.ts', '.tsx'];
case 'deno':
return ['.ts', '.tsx', '.js', '.mjs'];
default:
return [];
}
}
function detectRuntimeFromExtension(filePath: string): string | undefined {
const ext = path.extname(filePath).toLowerCase();
if (['.js', '.mjs', '.cjs'].includes(ext)) {
// Default to Node.js for JavaScript files
return 'node';
}
if (['.ts', '.tsx'].includes(ext)) {
// Check if tsx is available for TypeScript files
if (isCommandAvailable('tsx')) {
return 'tsx';
if (isTypeScriptFile(resolvedPath)) {
if (isTsxAvailable()) {
return {
command: 'tsx',
args: [resolvedPath],
type: 'tsx',
originalInput: executableSpec,
};
}
// If tsx is not available, suggest it in error message
throw new Error(
`TypeScript file '${filePath}' requires 'tsx' runtime, but it's not available. ` +
'Please install tsx: npm install -g tsx, or use explicit runtime: tsx:/path/to/file.ts',
);
}
// Native executable or unknown extension
return undefined;
}
export function prepareSpawnInfo(executableSpec?: string): SpawnInfo {
const parsed = parseExecutableSpec(executableSpec);
const { runtime, executablePath, isExplicitRuntime } = parsed;
// If runtime is explicitly specified, use it
if (isExplicitRuntime && runtime) {
const runtimeCommand = runtime === 'node' ? process.execPath : runtime;
return {
command: runtimeCommand,
args: [executablePath],
type: runtime as ExecutableType,
originalInput: executableSpec || '',
};
}
// If no explicit runtime, try to detect from file extension
const detectedRuntime = detectRuntimeFromExtension(executablePath);
if (detectedRuntime) {
const runtimeCommand =
detectedRuntime === 'node' ? process.execPath : detectedRuntime;
return {
command: runtimeCommand,
args: [executablePath],
type: detectedRuntime as ExecutableType,
originalInput: executableSpec || '',
};
}
// Native executable or command name - use it directly
return {
command: executablePath,
command: resolvedPath,
args: [],
type: 'native',
originalInput: executableSpec || '',
originalInput: executableSpec,
};
}

View File

@@ -1,16 +1,21 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Unit tests for CLI path utilities
* Tests executable detection, parsing, and spawn info preparation
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { execSync } from 'node:child_process';
import {
parseExecutableSpec,
prepareSpawnInfo,
findNativeCliPath,
findBundledCliPath,
} from '../../src/utils/cliPath.js';
// Mock fs module
@@ -21,36 +26,43 @@ const mockFs = vi.mocked(fs);
vi.mock('node:child_process');
const mockExecSync = vi.mocked(execSync);
// Mock process.versions for bun detection
const originalVersions = process.versions;
describe('CLI Path Utilities', () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset process.versions
Object.defineProperty(process, 'versions', {
value: { ...originalVersions },
writable: true,
});
// Default: tsx is available (can be overridden in specific tests)
mockExecSync.mockReturnValue(Buffer.from(''));
// Default: mock statSync to return a proper file stat object
mockFs.statSync.mockReturnValue({
isFile: () => true,
} as ReturnType<typeof import('fs').statSync>);
// Default: return true for existsSync (can be overridden in specific tests)
mockFs.existsSync.mockReturnValue(true);
// Default: tsx is available (can be overridden in specific tests)
mockExecSync.mockReturnValue(Buffer.from(''));
});
afterEach(() => {
// Restore original process.versions
Object.defineProperty(process, 'versions', {
value: originalVersions,
writable: true,
describe('findBundledCliPath', () => {
it('should find bundled CLI when it exists', () => {
// Mock existsSync to return true for bundled CLI
mockFs.existsSync.mockImplementation((p) => {
const pathStr = p.toString();
return (
pathStr.includes('cli/cli.js') || pathStr.includes('cli\\cli.js')
);
});
const result = findBundledCliPath();
expect(result).toContain('cli.js');
});
it('should throw descriptive error when bundled CLI not found', () => {
mockFs.existsSync.mockReturnValue(false);
expect(() => findBundledCliPath()).toThrow('Bundled qwen CLI not found');
expect(() => findBundledCliPath()).toThrow('Searched locations:');
});
});
describe('parseExecutableSpec', () => {
describe('prepareSpawnInfo', () => {
describe('auto-detection (no spec provided)', () => {
it('should auto-detect bundled CLI when no spec provided', () => {
// Mock existsSync to return true for bundled CLI
@@ -61,176 +73,23 @@ describe('CLI Path Utilities', () => {
);
});
const result = parseExecutableSpec();
const result = prepareSpawnInfo();
expect(result.executablePath).toContain('cli.js');
expect(result.isExplicitRuntime).toBe(false);
expect(result.command).toBe(process.execPath);
expect(result.args[0]).toContain('cli.js');
expect(result.type).toBe('node');
expect(result.originalInput).toBe('');
});
it('should throw when bundled CLI not found', () => {
mockFs.existsSync.mockReturnValue(false);
expect(() => parseExecutableSpec()).toThrow(
'Bundled qwen CLI not found',
);
});
});
describe('runtime prefix parsing', () => {
it('should parse node runtime prefix', () => {
mockFs.existsSync.mockReturnValue(true);
const result = parseExecutableSpec('node:/path/to/cli.js');
expect(result).toEqual({
runtime: 'node',
executablePath: path.resolve('/path/to/cli.js'),
isExplicitRuntime: true,
});
});
it('should parse bun runtime prefix', () => {
mockFs.existsSync.mockReturnValue(true);
const result = parseExecutableSpec('bun:/path/to/cli.js');
expect(result).toEqual({
runtime: 'bun',
executablePath: path.resolve('/path/to/cli.js'),
isExplicitRuntime: true,
});
});
it('should parse tsx runtime prefix', () => {
mockFs.existsSync.mockReturnValue(true);
const result = parseExecutableSpec('tsx:/path/to/index.ts');
expect(result).toEqual({
runtime: 'tsx',
executablePath: path.resolve('/path/to/index.ts'),
isExplicitRuntime: true,
});
});
it('should parse deno runtime prefix', () => {
mockFs.existsSync.mockReturnValue(true);
const result = parseExecutableSpec('deno:/path/to/cli.ts');
expect(result).toEqual({
runtime: 'deno',
executablePath: path.resolve('/path/to/cli.ts'),
isExplicitRuntime: true,
});
});
it('should treat non-whitelisted runtime prefixes as command names', () => {
// With whitelist approach, 'invalid:format' is not recognized as a runtime spec
// so it's treated as a command name, which fails validation due to the colon
expect(() => parseExecutableSpec('invalid:format')).toThrow(
'Invalid command name',
);
});
it('should treat Windows drive letters as file paths, not runtime specs', () => {
mockFs.existsSync.mockReturnValue(true);
// Test various Windows drive letters
const windowsPaths = [
'C:\\path\\to\\cli.js',
'D:\\path\\to\\cli.js',
'E:\\Users\\dev\\qwen\\cli.js',
];
for (const winPath of windowsPaths) {
const result = parseExecutableSpec(winPath);
expect(result.isExplicitRuntime).toBe(false);
expect(result.runtime).toBeUndefined();
expect(result.executablePath).toBe(path.resolve(winPath));
}
});
it('should handle Windows paths with forward slashes', () => {
mockFs.existsSync.mockReturnValue(true);
const result = parseExecutableSpec('C:/path/to/cli.js');
expect(result.isExplicitRuntime).toBe(false);
expect(result.runtime).toBeUndefined();
expect(result.executablePath).toBe(path.resolve('C:/path/to/cli.js'));
});
it('should throw when runtime-prefixed file does not exist', () => {
mockFs.existsSync.mockReturnValue(false);
expect(() => parseExecutableSpec('node:/nonexistent/cli.js')).toThrow(
'Executable file not found at',
);
expect(() => prepareSpawnInfo()).toThrow('Bundled qwen CLI not found');
});
});
describe('command name detection', () => {
it('should detect command names without path separators', () => {
const result = parseExecutableSpec('qwen');
expect(result).toEqual({
executablePath: 'qwen',
isExplicitRuntime: false,
});
});
it('should detect command names on Windows', () => {
const result = parseExecutableSpec('qwen.exe');
expect(result).toEqual({
executablePath: 'qwen.exe',
isExplicitRuntime: false,
});
});
});
describe('file path resolution', () => {
it('should resolve absolute file paths', () => {
mockFs.existsSync.mockReturnValue(true);
const result = parseExecutableSpec('/absolute/path/to/qwen');
expect(result).toEqual({
executablePath: path.resolve('/absolute/path/to/qwen'),
isExplicitRuntime: false,
});
});
it('should resolve relative file paths', () => {
mockFs.existsSync.mockReturnValue(true);
const result = parseExecutableSpec('./relative/path/to/qwen');
expect(result).toEqual({
executablePath: path.resolve('./relative/path/to/qwen'),
isExplicitRuntime: false,
});
});
it('should throw when file path does not exist', () => {
mockFs.existsSync.mockReturnValue(false);
expect(() => parseExecutableSpec('/nonexistent/path')).toThrow(
'Executable file not found at',
);
});
});
});
describe('prepareSpawnInfo', () => {
beforeEach(() => {
mockFs.existsSync.mockReturnValue(true);
});
describe('native executables', () => {
it('should prepare spawn info for native binary command', () => {
const result = prepareSpawnInfo('qwen');
expect(result).toEqual({
@@ -241,37 +100,38 @@ describe('CLI Path Utilities', () => {
});
});
it('should prepare spawn info for native binary path', () => {
const result = prepareSpawnInfo('/usr/local/bin/qwen');
it('should detect command names on Windows', () => {
const result = prepareSpawnInfo('qwen.exe');
expect(result).toEqual({
command: path.resolve('/usr/local/bin/qwen'),
command: 'qwen.exe',
args: [],
type: 'native',
originalInput: '/usr/local/bin/qwen',
originalInput: 'qwen.exe',
});
});
it('should reject invalid command name characters', () => {
expect(() => prepareSpawnInfo('qwen@invalid')).toThrow(
"Invalid command name 'qwen@invalid'. Command names should only contain letters, numbers, dots, hyphens, and underscores.",
);
});
it('should accept valid command names', () => {
expect(() => prepareSpawnInfo('qwen')).not.toThrow();
expect(() => prepareSpawnInfo('qwen-code')).not.toThrow();
expect(() => prepareSpawnInfo('qwen_code')).not.toThrow();
expect(() => prepareSpawnInfo('qwen.exe')).not.toThrow();
expect(() => prepareSpawnInfo('qwen123')).not.toThrow();
});
});
describe('JavaScript files', () => {
it('should use node for .js files', () => {
const result = prepareSpawnInfo('/path/to/cli.js');
expect(result).toEqual({
command: process.execPath,
args: [path.resolve('/path/to/cli.js')],
type: 'node',
originalInput: '/path/to/cli.js',
});
beforeEach(() => {
mockFs.existsSync.mockReturnValue(true);
});
it('should default to node for .js files (not auto-detect bun)', () => {
// Even when running under bun, default to node for .js files
Object.defineProperty(process, 'versions', {
value: { ...originalVersions, bun: '1.0.0' },
writable: true,
});
it('should use node for .js files', () => {
const result = prepareSpawnInfo('/path/to/cli.js');
expect(result).toEqual({
@@ -306,6 +166,10 @@ describe('CLI Path Utilities', () => {
});
describe('TypeScript files', () => {
beforeEach(() => {
mockFs.existsSync.mockReturnValue(true);
});
it('should use tsx for .ts files when tsx is available', () => {
// tsx is available by default in beforeEach
const result = prepareSpawnInfo('/path/to/index.ts');
@@ -329,107 +193,178 @@ describe('CLI Path Utilities', () => {
});
});
it('should throw helpful error when tsx is not available', () => {
it('should fallback to native when tsx is not available', () => {
// Mock tsx not being available
mockExecSync.mockImplementation(() => {
throw new Error('Command not found');
});
const resolvedPath = path.resolve('/path/to/index.ts');
expect(() => prepareSpawnInfo('/path/to/index.ts')).toThrow(
`TypeScript file '${resolvedPath}' requires 'tsx' runtime, but it's not available`,
);
expect(() => prepareSpawnInfo('/path/to/index.ts')).toThrow(
'Please install tsx: npm install -g tsx',
);
});
});
describe('explicit runtime specifications', () => {
it('should use explicit node runtime', () => {
const result = prepareSpawnInfo('node:/path/to/cli.js');
const result = prepareSpawnInfo('/path/to/index.ts');
expect(result).toEqual({
command: process.execPath,
args: [path.resolve('/path/to/cli.js')],
type: 'node',
originalInput: 'node:/path/to/cli.js',
});
});
it('should use explicit bun runtime', () => {
const result = prepareSpawnInfo('bun:/path/to/cli.js');
expect(result).toEqual({
command: 'bun',
args: [path.resolve('/path/to/cli.js')],
type: 'bun',
originalInput: 'bun:/path/to/cli.js',
});
});
it('should use explicit tsx runtime', () => {
const result = prepareSpawnInfo('tsx:/path/to/index.ts');
expect(result).toEqual({
command: 'tsx',
args: [path.resolve('/path/to/index.ts')],
type: 'tsx',
originalInput: 'tsx:/path/to/index.ts',
});
});
it('should use explicit deno runtime', () => {
const result = prepareSpawnInfo('deno:/path/to/cli.ts');
expect(result).toEqual({
command: 'deno',
args: [path.resolve('/path/to/cli.ts')],
type: 'deno',
originalInput: 'deno:/path/to/cli.ts',
command: path.resolve('/path/to/index.ts'),
args: [],
type: 'native',
originalInput: '/path/to/index.ts',
});
});
});
describe('auto-detection fallback', () => {
it('should auto-detect bundled CLI when no spec provided', () => {
// Mock existsSync to return true for bundled CLI
mockFs.existsSync.mockImplementation((p) => {
const pathStr = p.toString();
return (
pathStr.includes('cli/cli.js') || pathStr.includes('cli\\cli.js')
);
});
describe('native executables', () => {
beforeEach(() => {
mockFs.existsSync.mockReturnValue(true);
});
const result = prepareSpawnInfo();
it('should prepare spawn info for native binary path', () => {
const result = prepareSpawnInfo('/usr/local/bin/qwen');
expect(result).toEqual({
command: path.resolve('/usr/local/bin/qwen'),
args: [],
type: 'native',
originalInput: '/usr/local/bin/qwen',
});
});
});
describe('file path resolution', () => {
it('should resolve absolute file paths', () => {
mockFs.existsSync.mockReturnValue(true);
const result = prepareSpawnInfo('/absolute/path/to/qwen');
expect(result.command).toBe(path.resolve('/absolute/path/to/qwen'));
expect(result.type).toBe('native');
});
it('should resolve relative file paths', () => {
mockFs.existsSync.mockReturnValue(true);
const result = prepareSpawnInfo('./relative/path/to/cli.js');
expect(result.command).toBe(process.execPath);
expect(result.args[0]).toContain('cli.js');
expect(result.args[0]).toBe(path.resolve('./relative/path/to/cli.js'));
expect(result.type).toBe('node');
expect(result.originalInput).toBe('');
});
it('should throw when file path does not exist', () => {
mockFs.existsSync.mockReturnValue(false);
expect(() => prepareSpawnInfo('/nonexistent/path')).toThrow(
'Executable file not found at',
);
});
it('should throw when path is a directory', () => {
mockFs.existsSync.mockReturnValue(true);
mockFs.statSync.mockReturnValue({
isFile: () => false,
} as ReturnType<typeof import('fs').statSync>);
expect(() => prepareSpawnInfo('/path/to/directory')).toThrow(
'exists but is not a file',
);
});
});
});
describe('findNativeCliPath', () => {
it('should find bundled CLI', () => {
// Mock existsSync to return true for bundled CLI
mockFs.existsSync.mockImplementation((p) => {
const pathStr = p.toString();
return (
pathStr.includes('cli/cli.js') || pathStr.includes('cli\\cli.js')
);
});
const result = findNativeCliPath();
expect(result).toContain('cli.js');
describe('Windows path handling', () => {
beforeEach(() => {
mockFs.existsSync.mockReturnValue(true);
});
it('should throw descriptive error when bundled CLI not found', () => {
it('should handle Windows paths with drive letters', () => {
const windowsPath = 'D:\\path\\to\\cli.js';
const result = prepareSpawnInfo(windowsPath);
expect(result).toEqual({
command: process.execPath,
args: [path.resolve(windowsPath)],
type: 'node',
originalInput: windowsPath,
});
});
it('should handle Windows paths with forward slashes', () => {
const windowsPath = 'C:/path/to/cli.js';
const result = prepareSpawnInfo(windowsPath);
expect(result.command).toBe(process.execPath);
expect(result.args[0]).toBe(path.resolve(windowsPath));
expect(result.type).toBe('node');
});
it('should not confuse Windows drive letters with invalid syntax', () => {
const windowsPath = 'D:\\workspace\\project\\cli.js';
const result = prepareSpawnInfo(windowsPath);
// Should use node runtime based on .js extension
expect(result.type).toBe('node');
expect(result.command).toBe(process.execPath);
});
it('should handle Windows paths when file is missing', () => {
mockFs.existsSync.mockReturnValue(false);
expect(() => findNativeCliPath()).toThrow('Bundled qwen CLI not found');
expect(() => prepareSpawnInfo('D:\\missing\\cli.js')).toThrow(
'Executable file not found at',
);
});
it('should handle mixed path separators', () => {
// Users might paste paths with mixed separators
const mixedPath = 'C:\\Users/project\\cli.js';
const result = prepareSpawnInfo(mixedPath);
expect(result.command).toBe(process.execPath);
expect(result.type).toBe('node');
// path.resolve normalizes the separators
expect(result.args[0]).toBe(path.resolve(mixedPath));
});
it('should handle UNC paths', () => {
// Windows network paths: \\server\share\path
const uncPath = '\\\\server\\share\\path\\cli.js';
const result = prepareSpawnInfo(uncPath);
expect(result.command).toBe(process.execPath);
expect(result.type).toBe('node');
expect(result.args[0]).toBe(path.resolve(uncPath));
});
it('should handle Windows native executables', () => {
const windowsPath = 'C:\\Program Files\\qwen\\qwen.exe';
const result = prepareSpawnInfo(windowsPath);
// .exe files without .js extension should be treated as native
expect(result.type).toBe('native');
expect(result.command).toBe(path.resolve(windowsPath));
expect(result.args).toEqual([]);
});
});
describe('error cases', () => {
it('should throw for empty string', () => {
expect(() => prepareSpawnInfo('')).toThrow(
'Executable path cannot be empty',
);
});
it('should throw for whitespace-only string', () => {
expect(() => prepareSpawnInfo(' ')).toThrow(
'Executable path cannot be empty',
);
});
it('should provide helpful error for missing file', () => {
mockFs.existsSync.mockReturnValue(false);
expect(() => prepareSpawnInfo('/missing/file')).toThrow(
'Executable file not found at',
);
expect(() => prepareSpawnInfo('/missing/file')).toThrow(
'Please check the file path and ensure the file exists',
);
});
});
@@ -438,18 +373,6 @@ describe('CLI Path Utilities', () => {
mockFs.existsSync.mockReturnValue(true);
});
it('should handle development with TypeScript source', () => {
const devPath = '/Users/dev/qwen-code/packages/cli/index.ts';
const result = prepareSpawnInfo(devPath);
expect(result).toEqual({
command: 'tsx',
args: [path.resolve(devPath)],
type: 'tsx',
originalInput: devPath,
});
});
it('should handle production bundle validation', () => {
const bundlePath = '/path/to/bundled/cli.js';
const result = prepareSpawnInfo(bundlePath);
@@ -473,235 +396,27 @@ describe('CLI Path Utilities', () => {
});
});
it('should handle bun runtime with bundle', () => {
const bundlePath = '/path/to/cli.js';
const result = prepareSpawnInfo(`bun:${bundlePath}`);
expect(result).toEqual({
command: 'bun',
args: [path.resolve(bundlePath)],
type: 'bun',
originalInput: `bun:${bundlePath}`,
});
});
it('should handle Windows paths with drive letters', () => {
const windowsPath = 'D:\\path\\to\\cli.js';
const result = prepareSpawnInfo(windowsPath);
it('should handle ESM bundle', () => {
const bundlePath = '/path/to/cli.mjs';
const result = prepareSpawnInfo(bundlePath);
expect(result).toEqual({
command: process.execPath,
args: [path.resolve(windowsPath)],
args: [path.resolve(bundlePath)],
type: 'node',
originalInput: windowsPath,
originalInput: bundlePath,
});
});
it('should handle Windows paths with TypeScript files', () => {
const windowsPath = 'C:\\Users\\dev\\qwen\\index.ts';
const result = prepareSpawnInfo(windowsPath);
it('should handle CJS bundle', () => {
const bundlePath = '/path/to/cli.cjs';
const result = prepareSpawnInfo(bundlePath);
expect(result).toEqual({
command: 'tsx',
args: [path.resolve(windowsPath)],
type: 'tsx',
originalInput: windowsPath,
});
});
it('should not confuse Windows drive letters with runtime prefixes', () => {
// Ensure 'D:' is not treated as a runtime specification
const windowsPath = 'D:\\workspace\\project\\cli.js';
const result = prepareSpawnInfo(windowsPath);
// Should use node runtime based on .js extension, not treat 'D' as runtime
expect(result.type).toBe('node');
expect(result.command).toBe(process.execPath);
expect(result.args).toEqual([path.resolve(windowsPath)]);
});
});
describe('error cases', () => {
it('should provide helpful error for missing TypeScript file', () => {
mockFs.existsSync.mockReturnValue(false);
expect(() => prepareSpawnInfo('/missing/index.ts')).toThrow(
'Executable file not found at',
);
});
it('should provide helpful error for missing JavaScript file', () => {
mockFs.existsSync.mockReturnValue(false);
expect(() => prepareSpawnInfo('/missing/cli.js')).toThrow(
'Executable file not found at',
);
});
it('should treat non-whitelisted runtime prefixes as command names', () => {
// With whitelist approach, 'invalid:spec' is not recognized as a runtime spec
// so it's treated as a command name, which fails validation due to the colon
expect(() => prepareSpawnInfo('invalid:spec')).toThrow(
'Invalid command name',
);
});
it('should handle Windows paths correctly even when file is missing', () => {
mockFs.existsSync.mockReturnValue(false);
expect(() => prepareSpawnInfo('D:\\missing\\cli.js')).toThrow(
'Executable file not found at',
);
// Should not throw 'Invalid command name' error (which would happen if 'D:' was treated as invalid command)
expect(() => prepareSpawnInfo('D:\\missing\\cli.js')).not.toThrow(
'Invalid command name',
);
});
});
describe('comprehensive validation', () => {
describe('runtime validation', () => {
it('should treat unsupported runtime prefixes as file paths', () => {
mockFs.existsSync.mockReturnValue(true);
// With whitelist approach, 'unsupported:' is not recognized as a runtime spec
// so 'unsupported:/path/to/file.js' is treated as a file path
const result = parseExecutableSpec('unsupported:/path/to/file.js');
// Should be treated as a file path, not a runtime specification
expect(result.isExplicitRuntime).toBe(false);
expect(result.runtime).toBeUndefined();
});
it('should validate runtime availability for explicit runtime specs', () => {
mockFs.existsSync.mockReturnValue(true);
// Mock bun not being available
mockExecSync.mockImplementation((command) => {
if (command.includes('bun')) {
throw new Error('Command not found');
}
return Buffer.from('');
});
expect(() => parseExecutableSpec('bun:/path/to/cli.js')).toThrow(
"Runtime 'bun' is not available on this system. Please install it first.",
);
});
it('should allow node runtime (always available)', () => {
mockFs.existsSync.mockReturnValue(true);
expect(() => parseExecutableSpec('node:/path/to/cli.js')).not.toThrow();
});
it('should validate file extension matches runtime', () => {
mockFs.existsSync.mockReturnValue(true);
expect(() => parseExecutableSpec('tsx:/path/to/file.js')).toThrow(
"File extension '.js' is not compatible with runtime 'tsx'",
);
});
it('should validate node runtime with JavaScript files', () => {
mockFs.existsSync.mockReturnValue(true);
expect(() => parseExecutableSpec('node:/path/to/file.ts')).toThrow(
"File extension '.ts' is not compatible with runtime 'node'",
);
});
it('should accept valid runtime-file combinations', () => {
mockFs.existsSync.mockReturnValue(true);
expect(() => parseExecutableSpec('tsx:/path/to/file.ts')).not.toThrow();
expect(() =>
parseExecutableSpec('node:/path/to/file.js'),
).not.toThrow();
expect(() =>
parseExecutableSpec('bun:/path/to/file.mjs'),
).not.toThrow();
});
});
describe('command name validation', () => {
it('should reject empty command names', () => {
expect(() => parseExecutableSpec('')).toThrow(
'Command name cannot be empty',
);
expect(() => parseExecutableSpec(' ')).toThrow(
'Command name cannot be empty',
);
});
it('should reject invalid command name characters', () => {
expect(() => parseExecutableSpec('qwen@invalid')).toThrow(
"Invalid command name 'qwen@invalid'. Command names should only contain letters, numbers, dots, hyphens, and underscores.",
);
expect(() => parseExecutableSpec('qwen/invalid')).not.toThrow(); // This is treated as a path
});
it('should accept valid command names', () => {
expect(() => parseExecutableSpec('qwen')).not.toThrow();
expect(() => parseExecutableSpec('qwen-code')).not.toThrow();
expect(() => parseExecutableSpec('qwen_code')).not.toThrow();
expect(() => parseExecutableSpec('qwen.exe')).not.toThrow();
expect(() => parseExecutableSpec('qwen123')).not.toThrow();
});
});
describe('file path validation', () => {
it('should validate file exists', () => {
mockFs.existsSync.mockReturnValue(false);
expect(() => parseExecutableSpec('/nonexistent/path')).toThrow(
'Executable file not found at',
);
});
it('should validate path points to a file, not directory', () => {
mockFs.existsSync.mockReturnValue(true);
mockFs.statSync.mockReturnValue({
isFile: () => false,
} as ReturnType<typeof import('fs').statSync>);
expect(() => parseExecutableSpec('/path/to/directory')).toThrow(
'exists but is not a file',
);
});
it('should accept valid file paths', () => {
mockFs.existsSync.mockReturnValue(true);
mockFs.statSync.mockReturnValue({
isFile: () => true,
} as ReturnType<typeof import('fs').statSync>);
expect(() => parseExecutableSpec('/path/to/qwen')).not.toThrow();
expect(() => parseExecutableSpec('./relative/path')).not.toThrow();
});
});
describe('error message quality', () => {
it('should provide helpful error for missing runtime-prefixed file', () => {
mockFs.existsSync.mockReturnValue(false);
expect(() => parseExecutableSpec('tsx:/missing/file.ts')).toThrow(
'Executable file not found at',
);
expect(() => parseExecutableSpec('tsx:/missing/file.ts')).toThrow(
'Please check the file path and ensure the file exists',
);
});
it('should provide helpful error for missing regular file', () => {
mockFs.existsSync.mockReturnValue(false);
expect(() => parseExecutableSpec('/missing/file')).toThrow(
'Executable file not found at',
);
expect(() => parseExecutableSpec('/missing/file')).toThrow(
'Please check the file path and ensure the file exists',
);
command: process.execPath,
args: [path.resolve(bundlePath)],
type: 'node',
originalInput: bundlePath,
});
});
});

View File

@@ -1,25 +1,36 @@
# Qwen Code Companion
The Qwen Code Companion extension seamlessly integrates [Qwen Code](https://github.com/QwenLM/qwen-code). This extension is compatible with both VS Code and VS Code forks.
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.
# Features
## Demo
- Open Editor File Context: Qwen Code gains awareness of the files you have open in your editor, providing it with a richer understanding of your project's structure and content.
<video src="https://cloud.video.taobao.com/vod/IKKwfM-kqNI3OJjM_U8uMCSMAoeEcJhs6VNCQmZxUfk.mp4" controls width="800">
Your browser does not support the video tag. You can open the video directly:
https://cloud.video.taobao.com/vod/IKKwfM-kqNI3OJjM_U8uMCSMAoeEcJhs6VNCQmZxUfk.mp4
</video>
- Selection Context: Qwen Code can easily access your cursor's position and selected text within the editor, giving it valuable context directly from your current work.
## Features
- Native Diffing: Seamlessly view, modify, and accept code changes suggested by Qwen Code directly within the editor.
- **Native IDE experience**: Dedicated Qwen Code sidebar panel accessed via the Qwen icon
- **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
- **Conversation history & multiple sessions**: Access past conversations and run multiple sessions simultaneously
- **Open file & selection context**: Share active files, cursor position, and selections for more precise help
- Launch Qwen Code: Quickly start a new Qwen Code session from the Command Palette (Cmd+Shift+P or Ctrl+Shift+P) by running the "Qwen Code: Run" command.
## Requirements
# Requirements
- Visual Studio Code 1.85.0 or newer
To use this extension, you'll need:
## Installation
- VS Code version 1.101.0 or newer
- Qwen Code (installed separately) running within the VS Code integrated terminal
1. Install from the VS Code Marketplace: https://marketplace.visualstudio.com/items?itemName=qwenlm.qwen-code-vscode-ide-companion
# Development and Debugging
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).
## Development and Debugging
To debug and develop this extension locally:
@@ -76,6 +87,6 @@ npx vsce package
pnpm vsce package
```
# Terms of Service and Privacy Notice
## 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).

View File

@@ -17,6 +17,7 @@ import {
import { WebViewProvider } from './webview/WebViewProvider.js';
import { registerNewCommands } from './commands/index.js';
import { ReadonlyFileSystemProvider } from './services/readonlyFileSystemProvider.js';
import { isWindows } from './utils/platform.js';
const CLI_IDE_COMPANION_IDENTIFIER = 'qwenlm.qwen-code-vscode-ide-companion';
const INFO_MESSAGE_SHOWN_KEY = 'qwenCodeInfoMessageShown';
@@ -312,13 +313,38 @@ export async function activate(context: vscode.ExtensionContext) {
'qwen-cli',
'cli.js',
).fsPath;
const quote = (s: string) => `"${s.replaceAll('"', '\\"')}"`;
const qwenCmd = `${quote(process.execPath)} ${quote(cliEntry)}`;
const terminal = vscode.window.createTerminal({
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,
});
};
if (isWindows) {
// Use system Node via cmd.exe; avoid PowerShell parsing issues
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 {
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;
}
}
const terminal = vscode.window.createTerminal(terminalOptions);
terminal.show();
terminal.sendText(qwenCmd);
}

View File

@@ -6,7 +6,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as vscode from 'vscode';
import { OpenFilesManager, MAX_FILES } from './open-files-manager.js';
import { OpenFilesManager } from './open-files-manager.js';
import { MAX_FILES } from './services/open-files-manager/constants.js';
vi.mock('vscode', () => ({
EventEmitter: vi.fn(() => {

View File

@@ -9,9 +9,23 @@ import type {
File,
IdeContext,
} from '@qwen-code/qwen-code-core/src/ide/types.js';
export const MAX_FILES = 10;
const MAX_SELECTED_TEXT_LENGTH = 16384; // 16 KiB limit
import {
isFileUri,
isNotebookFileUri,
isNotebookCellUri,
removeFile,
renameFile,
getNotebookUriFromCellUri,
} from './services/open-files-manager/utils.js';
import {
addOrMoveToFront,
updateActiveContext,
} from './services/open-files-manager/text-handler.js';
import {
addOrMoveToFrontNotebook,
updateNotebookActiveContext,
updateNotebookCellSelection,
} from './services/open-files-manager/notebook-handler.js';
/**
* Keeps track of the workspace state, including open files, cursor position, and selected text.
@@ -25,33 +39,102 @@ export class OpenFilesManager {
constructor(private readonly context: vscode.ExtensionContext) {
const editorWatcher = vscode.window.onDidChangeActiveTextEditor(
(editor) => {
if (editor && this.isFileUri(editor.document.uri)) {
this.addOrMoveToFront(editor);
if (editor && isFileUri(editor.document.uri)) {
addOrMoveToFront(this.openFiles, editor);
this.fireWithDebounce();
} else if (editor && isNotebookCellUri(editor.document.uri)) {
// Handle when a notebook cell becomes active (which indicates the notebook is active)
const notebookUri = getNotebookUriFromCellUri(editor.document.uri);
if (notebookUri && isNotebookFileUri(notebookUri)) {
// Find the notebook editor for this cell
const notebookEditor = vscode.window.visibleNotebookEditors.find(
(nbEditor) =>
nbEditor.notebook.uri.toString() === notebookUri.toString(),
);
if (notebookEditor) {
addOrMoveToFrontNotebook(this.openFiles, notebookEditor);
this.fireWithDebounce();
}
}
}
},
);
// Watch for when notebook editors gain focus by monitoring focus changes
// Since VS Code doesn't have a direct onDidChangeActiveNotebookEditor event,
// we monitor when visible notebook editors change and assume the last one shown is active
let notebookFocusWatcher: vscode.Disposable | undefined;
if (vscode.window.onDidChangeVisibleNotebookEditors) {
notebookFocusWatcher = vscode.window.onDidChangeVisibleNotebookEditors(
() => {
// When visible notebook editors change, the currently focused one is likely the active one
const activeNotebookEditor = vscode.window.activeNotebookEditor;
if (
activeNotebookEditor &&
isNotebookFileUri(activeNotebookEditor.notebook.uri)
) {
addOrMoveToFrontNotebook(this.openFiles, activeNotebookEditor);
this.fireWithDebounce();
}
},
);
}
const selectionWatcher = vscode.window.onDidChangeTextEditorSelection(
(event) => {
if (this.isFileUri(event.textEditor.document.uri)) {
this.updateActiveContext(event.textEditor);
if (isFileUri(event.textEditor.document.uri)) {
updateActiveContext(this.openFiles, event.textEditor);
this.fireWithDebounce();
} else if (isNotebookCellUri(event.textEditor.document.uri)) {
// Handle text selections within notebook cells
updateNotebookCellSelection(
this.openFiles,
event.textEditor,
event.selections,
);
this.fireWithDebounce();
}
},
);
// Add notebook cell selection watcher for .ipynb files if the API is available
let notebookCellSelectionWatcher: vscode.Disposable | undefined;
if (vscode.window.onDidChangeNotebookEditorSelection) {
notebookCellSelectionWatcher =
vscode.window.onDidChangeNotebookEditorSelection((event) => {
if (isNotebookFileUri(event.notebookEditor.notebook.uri)) {
// Ensure the notebook is added to the active list if selected
addOrMoveToFrontNotebook(this.openFiles, event.notebookEditor);
updateNotebookActiveContext(this.openFiles, event.notebookEditor);
this.fireWithDebounce();
}
});
}
const closeWatcher = vscode.workspace.onDidCloseTextDocument((document) => {
if (this.isFileUri(document.uri)) {
this.remove(document.uri);
if (isFileUri(document.uri)) {
removeFile(this.openFiles, document.uri);
this.fireWithDebounce();
}
});
// Add notebook close watcher if the API is available
let notebookCloseWatcher: vscode.Disposable | undefined;
if (vscode.workspace.onDidCloseNotebookDocument) {
notebookCloseWatcher = vscode.workspace.onDidCloseNotebookDocument(
(document) => {
if (isNotebookFileUri(document.uri)) {
removeFile(this.openFiles, document.uri);
this.fireWithDebounce();
}
},
);
}
const deleteWatcher = vscode.workspace.onDidDeleteFiles((event) => {
for (const uri of event.files) {
if (this.isFileUri(uri)) {
this.remove(uri);
if (isFileUri(uri) || isNotebookFileUri(uri)) {
removeFile(this.openFiles, uri);
}
}
this.fireWithDebounce();
@@ -59,12 +142,12 @@ export class OpenFilesManager {
const renameWatcher = vscode.workspace.onDidRenameFiles((event) => {
for (const { oldUri, newUri } of event.files) {
if (this.isFileUri(oldUri)) {
if (this.isFileUri(newUri)) {
this.rename(oldUri, newUri);
if (isFileUri(oldUri) || isNotebookFileUri(oldUri)) {
if (isFileUri(newUri) || isNotebookFileUri(newUri)) {
renameFile(this.openFiles, oldUri, newUri);
} else {
// The file was renamed to a non-file URI, so we should remove it.
this.remove(oldUri);
removeFile(this.openFiles, oldUri);
}
}
}
@@ -79,87 +162,37 @@ export class OpenFilesManager {
renameWatcher,
);
// Conditionally add notebook-specific watchers if they were created
if (notebookCellSelectionWatcher) {
context.subscriptions.push(notebookCellSelectionWatcher);
}
if (notebookCloseWatcher) {
context.subscriptions.push(notebookCloseWatcher);
}
if (notebookFocusWatcher) {
context.subscriptions.push(notebookFocusWatcher);
}
// Just add current active file on start-up.
if (
vscode.window.activeTextEditor &&
this.isFileUri(vscode.window.activeTextEditor.document.uri)
isFileUri(vscode.window.activeTextEditor.document.uri)
) {
this.addOrMoveToFront(vscode.window.activeTextEditor);
}
}
private isFileUri(uri: vscode.Uri): boolean {
return uri.scheme === 'file';
}
private addOrMoveToFront(editor: vscode.TextEditor) {
// Deactivate previous active file
const currentActive = this.openFiles.find((f) => f.isActive);
if (currentActive) {
currentActive.isActive = false;
currentActive.cursor = undefined;
currentActive.selectedText = undefined;
addOrMoveToFront(this.openFiles, vscode.window.activeTextEditor);
}
// Remove if it exists
const index = this.openFiles.findIndex(
(f) => f.path === editor.document.uri.fsPath,
);
if (index !== -1) {
this.openFiles.splice(index, 1);
// Also add current active notebook if applicable and the API is available
if (
vscode.window.activeNotebookEditor &&
isNotebookFileUri(vscode.window.activeNotebookEditor.notebook.uri)
) {
addOrMoveToFrontNotebook(
this.openFiles,
vscode.window.activeNotebookEditor,
);
}
// Add to the front as active
this.openFiles.unshift({
path: editor.document.uri.fsPath,
timestamp: Date.now(),
isActive: true,
});
// Enforce max length
if (this.openFiles.length > MAX_FILES) {
this.openFiles.pop();
}
this.updateActiveContext(editor);
}
private remove(uri: vscode.Uri) {
const index = this.openFiles.findIndex((f) => f.path === uri.fsPath);
if (index !== -1) {
this.openFiles.splice(index, 1);
}
}
private rename(oldUri: vscode.Uri, newUri: vscode.Uri) {
const index = this.openFiles.findIndex((f) => f.path === oldUri.fsPath);
if (index !== -1) {
this.openFiles[index].path = newUri.fsPath;
}
}
private updateActiveContext(editor: vscode.TextEditor) {
const file = this.openFiles.find(
(f) => f.path === editor.document.uri.fsPath,
);
if (!file || !file.isActive) {
return;
}
file.cursor = editor.selection.active
? {
line: editor.selection.active.line + 1,
character: editor.selection.active.character,
}
: undefined;
let selectedText: string | undefined =
editor.document.getText(editor.selection) || undefined;
if (selectedText && selectedText.length > MAX_SELECTED_TEXT_LENGTH) {
selectedText =
selectedText.substring(0, MAX_SELECTED_TEXT_LENGTH) + '... [TRUNCATED]';
}
file.selectedText = selectedText;
}
private fireWithDebounce() {

View File

@@ -26,6 +26,7 @@ import type {
} from '../types/connectionTypes.js';
import { AcpFileHandler } from '../services/acpFileHandler.js';
import type { ChildProcess } from 'child_process';
import { isWindows } from '../utils/platform.js';
/**
* ACP Message Handler Class
@@ -47,7 +48,7 @@ export class AcpMessageHandler {
sendResponseMessage(child: ChildProcess | null, response: AcpResponse): void {
if (child?.stdin) {
const jsonString = JSON.stringify(response);
const lineEnding = process.platform === 'win32' ? '\r\n' : '\n';
const lineEnding = isWindows ? '\r\n' : '\n';
child.stdin.write(jsonString + lineEnding);
}
}

View File

@@ -19,6 +19,7 @@ import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
import { AGENT_METHODS } from '../constants/acpSchema.js';
import type { PendingRequest } from '../types/connectionTypes.js';
import type { ChildProcess } from 'child_process';
import { isWindows } from '../utils/platform.js';
/**
* ACP Session Manager Class
@@ -102,7 +103,7 @@ export class AcpSessionManager {
): void {
if (child?.stdin) {
const jsonString = JSON.stringify(message);
const lineEnding = process.platform === 'win32' ? '\r\n' : '\n';
const lineEnding = isWindows ? '\r\n' : '\n';
child.stdin.write(jsonString + lineEnding);
}
}

View File

@@ -0,0 +1,8 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
export const MAX_FILES = 10;
export const MAX_SELECTED_TEXT_LENGTH = 16384; // 16 KiB limit

View File

@@ -0,0 +1,119 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as vscode from 'vscode';
import type { File } from '@qwen-code/qwen-code-core/src/ide/types.js';
import { MAX_FILES, MAX_SELECTED_TEXT_LENGTH } from './constants.js';
import {
deactivateCurrentActiveFile,
enforceMaxFiles,
truncateSelectedText,
getNotebookUriFromCellUri,
} from './utils.js';
export function addOrMoveToFrontNotebook(
openFiles: File[],
notebookEditor: vscode.NotebookEditor,
) {
// Deactivate previous active file
deactivateCurrentActiveFile(openFiles);
// Remove if it exists
const index = openFiles.findIndex(
(f) => f.path === notebookEditor.notebook.uri.fsPath,
);
if (index !== -1) {
openFiles.splice(index, 1);
}
// Add to the front as active
openFiles.unshift({
path: notebookEditor.notebook.uri.fsPath,
timestamp: Date.now(),
isActive: true,
});
// Enforce max length
enforceMaxFiles(openFiles, MAX_FILES);
updateNotebookActiveContext(openFiles, notebookEditor);
}
export function updateNotebookActiveContext(
openFiles: File[],
notebookEditor: vscode.NotebookEditor,
) {
const file = openFiles.find(
(f) => f.path === notebookEditor.notebook.uri.fsPath,
);
if (!file || !file.isActive) {
return;
}
// For notebook editors, selections may span multiple cells
// We'll gather selected text from all selected cells
const selections = notebookEditor.selections;
let combinedSelectedText = '';
for (const selection of selections) {
// Process each selected cell range
for (let i = selection.start; i < selection.end; i++) {
const cell = notebookEditor.notebook.cellAt(i);
if (cell && cell.kind === vscode.NotebookCellKind.Code) {
// For now, we'll get the full cell content if it's in a selection
// TODO: Implement per-cell cursor position and finer-grained selection if needed
combinedSelectedText += cell.document.getText() + '\n';
}
}
}
if (combinedSelectedText) {
combinedSelectedText = combinedSelectedText.trim();
file.selectedText = truncateSelectedText(
combinedSelectedText,
MAX_SELECTED_TEXT_LENGTH,
);
} else {
file.selectedText = undefined;
}
}
export function updateNotebookCellSelection(
openFiles: File[],
cellEditor: vscode.TextEditor,
selections: readonly vscode.Selection[],
) {
// Find the parent notebook by traversing the URI
const notebookUri = getNotebookUriFromCellUri(cellEditor.document.uri);
if (!notebookUri) {
return;
}
// Find the corresponding file entry for this notebook
const file = openFiles.find((f) => f.path === notebookUri.fsPath);
if (!file || !file.isActive) {
return;
}
// Extract the selected text from the cell editor
let selectedText = '';
for (const selection of selections) {
const text = cellEditor.document.getText(selection);
if (text) {
selectedText += text + '\n';
}
}
if (selectedText) {
selectedText = selectedText.trim();
file.selectedText = truncateSelectedText(
selectedText,
MAX_SELECTED_TEXT_LENGTH,
);
} else {
file.selectedText = undefined;
}
}

View File

@@ -0,0 +1,61 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type * as vscode from 'vscode';
import type { File } from '@qwen-code/qwen-code-core/src/ide/types.js';
import { MAX_FILES, MAX_SELECTED_TEXT_LENGTH } from './constants.js';
import {
deactivateCurrentActiveFile,
enforceMaxFiles,
truncateSelectedText,
} from './utils.js';
export function addOrMoveToFront(openFiles: File[], editor: vscode.TextEditor) {
// Deactivate previous active file
deactivateCurrentActiveFile(openFiles);
// Remove if it exists
const index = openFiles.findIndex(
(f) => f.path === editor.document.uri.fsPath,
);
if (index !== -1) {
openFiles.splice(index, 1);
}
// Add to the front as active
openFiles.unshift({
path: editor.document.uri.fsPath,
timestamp: Date.now(),
isActive: true,
});
// Enforce max length
enforceMaxFiles(openFiles, MAX_FILES);
updateActiveContext(openFiles, editor);
}
export function updateActiveContext(
openFiles: File[],
editor: vscode.TextEditor,
) {
const file = openFiles.find((f) => f.path === editor.document.uri.fsPath);
if (!file || !file.isActive) {
return;
}
file.cursor = editor.selection.active
? {
line: editor.selection.active.line + 1,
character: editor.selection.active.character,
}
: undefined;
let selectedText: string | undefined =
editor.document.getText(editor.selection) || undefined;
selectedText = truncateSelectedText(selectedText, MAX_SELECTED_TEXT_LENGTH);
file.selectedText = selectedText;
}

View File

@@ -0,0 +1,101 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as vscode from 'vscode';
import type { File } from '@qwen-code/qwen-code-core/src/ide/types.js';
export function isFileUri(uri: vscode.Uri): boolean {
return uri.scheme === 'file';
}
export function isNotebookFileUri(uri: vscode.Uri): boolean {
return uri.scheme === 'file' && uri.path.toLowerCase().endsWith('.ipynb');
}
export function isNotebookCellUri(uri: vscode.Uri): boolean {
// Notebook cell URIs have the scheme 'vscode-notebook-cell'
return uri.scheme === 'vscode-notebook-cell';
}
export function removeFile(openFiles: File[], uri: vscode.Uri): void {
const index = openFiles.findIndex((f) => f.path === uri.fsPath);
if (index !== -1) {
openFiles.splice(index, 1);
}
}
export function renameFile(
openFiles: File[],
oldUri: vscode.Uri,
newUri: vscode.Uri,
): void {
const index = openFiles.findIndex((f) => f.path === oldUri.fsPath);
if (index !== -1) {
openFiles[index].path = newUri.fsPath;
}
}
export function deactivateCurrentActiveFile(openFiles: File[]): void {
const currentActive = openFiles.find((f) => f.isActive);
if (currentActive) {
currentActive.isActive = false;
currentActive.cursor = undefined;
currentActive.selectedText = undefined;
}
}
export function enforceMaxFiles(openFiles: File[], maxFiles: number): void {
if (openFiles.length > maxFiles) {
openFiles.pop();
}
}
export function truncateSelectedText(
selectedText: string | undefined,
maxLength: number,
): string | undefined {
if (!selectedText) {
return undefined;
}
if (selectedText.length > maxLength) {
return selectedText.substring(0, maxLength) + '... [TRUNCATED]';
}
return selectedText;
}
export function getNotebookUriFromCellUri(
cellUri: vscode.Uri,
): vscode.Uri | null {
// Most efficient approach: Check if the currently active notebook editor contains this cell
const activeNotebookEditor = vscode.window.activeNotebookEditor;
if (
activeNotebookEditor &&
isNotebookFileUri(activeNotebookEditor.notebook.uri)
) {
for (let i = 0; i < activeNotebookEditor.notebook.cellCount; i++) {
const cell = activeNotebookEditor.notebook.cellAt(i);
if (cell.document.uri.toString() === cellUri.toString()) {
return activeNotebookEditor.notebook.uri;
}
}
}
// If not in the active editor, check all visible notebook editors
for (const editor of vscode.window.visibleNotebookEditors) {
if (
editor !== activeNotebookEditor &&
isNotebookFileUri(editor.notebook.uri)
) {
for (let i = 0; i < editor.notebook.cellCount; i++) {
const cell = editor.notebook.cellAt(i);
if (cell.document.uri.toString() === cellUri.toString()) {
return editor.notebook.uri;
}
}
}
}
return null;
}

View File

@@ -0,0 +1,8 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/** Whether the current platform is Windows */
export const isWindows = process.platform === 'win32';