mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-01-22 08:46:19 +00:00
Compare commits
358 Commits
chore/rele
...
v0.8.0-pre
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50226a2eb8 | ||
|
|
2aa681f610 | ||
|
|
3b6849be94 | ||
|
|
a7e55ccf43 | ||
|
|
c0d78a8f3c | ||
|
|
64eea4889d | ||
|
|
aa80e738fb | ||
|
|
06b64b07e7 | ||
|
|
9af348d6ad | ||
|
|
bc7e586028 | ||
|
|
21b26a400a | ||
|
|
5f3f81b666 | ||
|
|
ae9ba8be18 | ||
|
|
ed12c50693 | ||
|
|
1f5206cd54 | ||
|
|
daea673058 | ||
|
|
19bbd22109 | ||
|
|
3f3c5ff255 | ||
|
|
3ece0e3c3c | ||
|
|
fb3a95e874 | ||
|
|
1562780393 | ||
|
|
1e41965a7e | ||
|
|
140e8c926d | ||
|
|
b91878200c | ||
|
|
fd6c7364e8 | ||
|
|
f467054372 | ||
|
|
7d9917b2c9 | ||
|
|
e87376e06c | ||
|
|
4ae8584c81 | ||
|
|
ba14e9e531 | ||
|
|
7dd56d0861 | ||
|
|
6eb16c0bcf | ||
|
|
7fa1dcb0e6 | ||
|
|
3c68a9a5f6 | ||
|
|
2c22961f92 | ||
|
|
bdfeec24fb | ||
|
|
03f12bfa3f | ||
|
|
452bbc1939 | ||
|
|
2e80f7ffbc | ||
|
|
b0c3e5d884 | ||
|
|
0c960add8d | ||
|
|
55a5df46ba | ||
|
|
eb7dc53d2e | ||
|
|
143beb51ed | ||
|
|
a61a3c5680 | ||
|
|
046559408b | ||
|
|
8b4626a2be | ||
|
|
de47c4e98b | ||
|
|
eed46447da | ||
|
|
8de81b6299 | ||
|
|
b13c5bf090 | ||
|
|
0a64fa78f5 | ||
|
|
f99295462d | ||
|
|
1145045a5a | ||
|
|
95c551c1b4 | ||
|
|
f8e41fb7fa | ||
|
|
6e641b8def | ||
|
|
ec2aa6d86d | ||
|
|
66ad936c31 | ||
|
|
8b5f198e3c | ||
|
|
e8356c5f9e | ||
|
|
dc067697dc | ||
|
|
79cce84280 | ||
|
|
b9207c5884 | ||
|
|
bd314cb7b2 | ||
|
|
a546e84887 | ||
|
|
baf848a4d9 | ||
|
|
f0b2a7ef98 | ||
|
|
706cdb2ac1 | ||
|
|
df33029589 | ||
|
|
c8b0efa4d9 | ||
|
|
d0104dc487 | ||
|
|
c87197d420 | ||
|
|
531062aeaf | ||
|
|
28f6c161da | ||
|
|
d5683886c6 | ||
|
|
c14ddab6fe | ||
|
|
35c865968f | ||
|
|
fa8b5a7762 | ||
|
|
9adad2f369 | ||
|
|
a8ccd7b6fb | ||
|
|
5d20848577 | ||
|
|
ced1b1db80 | ||
|
|
cf140b1b9d | ||
|
|
1f1e78aa3b | ||
|
|
511269446f | ||
|
|
0901b228a7 | ||
|
|
592bf2bad1 | ||
|
|
f10fcc8dc9 | ||
|
|
0681c71894 | ||
|
|
f7fb624af9 | ||
|
|
155c4b9728 | ||
|
|
57ca2823b3 | ||
|
|
620341eeae | ||
|
|
758e5c0992 | ||
|
|
881e7d038b | ||
|
|
5c6c3b2cf6 | ||
|
|
f4d4844364 | ||
|
|
b804b1f48a | ||
|
|
da8c49cb9d | ||
|
|
2852f48a4a | ||
|
|
f00f76456c | ||
|
|
d7d3371ddf | ||
|
|
c6c33233c5 | ||
|
|
4213d06ab9 | ||
|
|
106b69e5c0 | ||
|
|
6afe0f8c29 | ||
|
|
0b3be1a82c | ||
|
|
8af43e3ac3 | ||
|
|
04a11aa111 | ||
|
|
45236b6ec5 | ||
|
|
9e8724a749 | ||
|
|
d91e372c72 | ||
|
|
9325721811 | ||
|
|
56391b11ad | ||
|
|
e748532e6d | ||
|
|
d095a8b3f1 | ||
|
|
f7585153b7 | ||
|
|
d5ad3aebe4 | ||
|
|
98c680642f | ||
|
|
e4efd3a15d | ||
|
|
886f914fb3 | ||
|
|
90365af2f8 | ||
|
|
cbef5ffd89 | ||
|
|
63406b4ba4 | ||
|
|
52db3a766d | ||
|
|
5e80e80387 | ||
|
|
985f65f8fa | ||
|
|
9b9c5fadd5 | ||
|
|
372c67cad4 | ||
|
|
af3864b5de | ||
|
|
4c7605d900 | ||
|
|
1e3791f30a | ||
|
|
b37ede07e8 | ||
|
|
0a88dd7861 | ||
|
|
9bf626d051 | ||
|
|
6f33d92b2c | ||
|
|
a35af6550f | ||
|
|
70991e474f | ||
|
|
d6607e134e | ||
|
|
551e546974 | ||
|
|
9024a41723 | ||
|
|
bde056b62e | ||
|
|
ff5ea3c6d7 | ||
|
|
0faaac8fa4 | ||
|
|
b923acd278 | ||
|
|
c2e62b9122 | ||
|
|
f54b62cda3 | ||
|
|
9521987a09 | ||
|
|
d20f2a41a2 | ||
|
|
e3eccb5987 | ||
|
|
22916457cd | ||
|
|
28bc4e6467 | ||
|
|
50bf65b10b | ||
|
|
47c8bc5303 | ||
|
|
e70ecdf3a8 | ||
|
|
117af05122 | ||
|
|
557e6397bb | ||
|
|
f762a62a2e | ||
|
|
ca12772a28 | ||
|
|
cec4b831b6 | ||
|
|
74bf72877d | ||
|
|
b60ae42d10 | ||
|
|
54fd4c22a9 | ||
|
|
f3b7c63cd1 | ||
|
|
e4dee3a2b2 | ||
|
|
996b9df947 | ||
|
|
64291db926 | ||
|
|
a8e3b9ebe7 | ||
|
|
5cfc9f4686 | ||
|
|
97497457a8 | ||
|
|
85473210e5 | ||
|
|
c0c94bd4fc | ||
|
|
8111511a89 | ||
|
|
a8eb858f99 | ||
|
|
52d6d1ff13 | ||
|
|
c845049d26 | ||
|
|
299b7de030 | ||
|
|
b93bb8bff6 | ||
|
|
adb53a6dc6 | ||
|
|
09196c6e19 | ||
|
|
4bd01d592b | ||
|
|
6917031128 | ||
|
|
b33525183f | ||
|
|
1aed5ce858 | ||
|
|
bad5b0485d | ||
|
|
5a6e5bb452 | ||
|
|
5f8e1ebc94 | ||
|
|
9670456a56 | ||
|
|
4c186e7c92 | ||
|
|
2f6b0b233a | ||
|
|
9a8ce605c5 | ||
|
|
afc693a4ab | ||
|
|
7173cba844 | ||
|
|
ec8cccafd7 | ||
|
|
8c56b612fb | ||
|
|
7d40e1470c | ||
|
|
b0e561ca73 | ||
|
|
563d68ad5b | ||
|
|
82c524f87d | ||
|
|
df75aa06b6 | ||
|
|
8ea9871d23 | ||
|
|
097482910e | ||
|
|
9b78c17638 | ||
|
|
bde31d1261 | ||
|
|
2d1934bf2f | ||
|
|
7f15256eba | ||
|
|
587fc82fbc | ||
|
|
cba9c424eb | ||
|
|
1b7418f91f | ||
|
|
b7828ac765 | ||
|
|
8705f734d0 | ||
|
|
0bd17a2406 | ||
|
|
59be5163fd | ||
|
|
95efe89ac0 | ||
|
|
6714f9ce3c | ||
|
|
155d1f9518 | ||
|
|
f776075aa8 | ||
|
|
36c142951a | ||
|
|
2b511d0b83 | ||
|
|
85bc0833b4 | ||
|
|
2662639280 | ||
|
|
d86903ced5 | ||
|
|
b7ac94ecf6 | ||
|
|
be8259b218 | ||
|
|
ca4c36f233 | ||
|
|
f41308f34c | ||
|
|
a47bdc0b06 | ||
|
|
0a33510304 | ||
|
|
0e769e100b | ||
|
|
82cbdee3b4 | ||
|
|
b5bcc07223 | ||
|
|
9653dc90d5 | ||
|
|
74013bd8b2 | ||
|
|
81de79c899 | ||
|
|
f6a753cf78 | ||
|
|
509d304742 | ||
|
|
6319a6ed56 | ||
|
|
ab07c2d89c | ||
|
|
5ea841dd02 | ||
|
|
ded1ebcdff | ||
|
|
afe6ba255e | ||
|
|
fe2ed889b9 | ||
|
|
8da376637a | ||
|
|
15f4c1ebd6 | ||
|
|
492da0c8c0 | ||
|
|
90855c93d1 | ||
|
|
db12796df5 | ||
|
|
aa9cdf2a3c | ||
|
|
052337861b | ||
|
|
0a0ab64da0 | ||
|
|
8a15017593 | ||
|
|
4d54a231b3 | ||
|
|
f8aecb2631 | ||
|
|
18713ef2b0 | ||
|
|
570ec432af | ||
|
|
bfc3bbfa9c | ||
|
|
91af9bf6c8 | ||
|
|
f6771c0858 | ||
|
|
2c8be05029 | ||
|
|
4744af1ea8 | ||
|
|
2c285394c7 | ||
|
|
0f1cb162c9 | ||
|
|
3d059b71de | ||
|
|
f2d941e469 | ||
|
|
9b2dfe1e06 | ||
|
|
3e695cd82b | ||
|
|
177a91f1d5 | ||
|
|
50dac93c80 | ||
|
|
22504b0a5b | ||
|
|
870d207f18 | ||
|
|
3f512528cb | ||
|
|
361492247e | ||
|
|
0878ee4cbd | ||
|
|
bfe7298858 | ||
|
|
2f2937aafe | ||
|
|
8fcdd86b91 | ||
|
|
d7d7bf0c39 | ||
|
|
b95d9a8d2d | ||
|
|
6f39ae120c | ||
|
|
627857621a | ||
|
|
65c7cf5d8f | ||
|
|
7a823060ac | ||
|
|
2c88ea6dc1 | ||
|
|
ad3086f7dd | ||
|
|
8f3bbef575 | ||
|
|
e2d6ab9b7e | ||
|
|
35bf5ef4d0 | ||
|
|
1d16513e27 | ||
|
|
731fd99800 | ||
|
|
c6ae0a8be7 | ||
|
|
87dc618a21 | ||
|
|
94a5d828bd | ||
|
|
49892a8e17 | ||
|
|
d1a3e828b7 | ||
|
|
824ca056a4 | ||
|
|
b19bb6cb20 | ||
|
|
e8625658ba | ||
|
|
19f8f631b4 | ||
|
|
a4eb3adea8 | ||
|
|
7dc7c6380d | ||
|
|
d2d2b845c5 | ||
|
|
96080f84a6 | ||
|
|
2b6218e564 | ||
|
|
24edf32da8 | ||
|
|
51b08f700c | ||
|
|
58eac7f595 | ||
|
|
32e8b01cf0 | ||
|
|
db9d5cb45d | ||
|
|
473cb7b951 | ||
|
|
e5cced8813 | ||
|
|
4f664d00ac | ||
|
|
7fdebe8fe6 | ||
|
|
73848d3867 | ||
|
|
6a62167f79 | ||
|
|
6ff437671e | ||
|
|
30f9e9c782 | ||
|
|
e4caa7a856 | ||
|
|
aaa66b3172 | ||
|
|
0ae59b900c | ||
|
|
5a5dae1987 | ||
|
|
ac7ba95d65 | ||
|
|
15912892f2 | ||
|
|
e3c20b03bd | ||
|
|
4db50d4158 | ||
|
|
4154493640 | ||
|
|
61aad5a162 | ||
|
|
98c043bf50 | ||
|
|
f610133660 | ||
|
|
fe7ff5b148 | ||
|
|
fd41309ed2 | ||
|
|
48bc0f35d7 | ||
|
|
e30c2dbe23 | ||
|
|
e9204ecba9 | ||
|
|
f24bda3d7b | ||
|
|
5417de4219 | ||
|
|
422998d7f0 | ||
|
|
68628bf952 | ||
|
|
e5efad89e0 | ||
|
|
e09bb5f5c0 | ||
|
|
24d11179d8 | ||
|
|
2ef8b6f350 | ||
|
|
5779f7ab1d | ||
|
|
43e0815def | ||
|
|
0c14f4ce08 | ||
|
|
34d8dbf9b2 | ||
|
|
b3b2bc6ad5 | ||
|
|
6ca54beba2 | ||
|
|
8673426d5c | ||
|
|
b272ac0119 | ||
|
|
574d89da14 | ||
|
|
16939c0bc8 | ||
|
|
6fc09a82fb | ||
|
|
d622f8d1bf | ||
|
|
28d178b5c1 | ||
|
|
4c69d536ac | ||
|
|
403fd06117 | ||
|
|
d9928eab66 |
3
.github/CODEOWNERS
vendored
Normal file
3
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
* @tanzhenxin @DennisYu07 @gwinthis @LaZzyMan @pomelo-nwu @Mingholy
|
||||
# SDK TypeScript package changes require review from Mingholy
|
||||
packages/sdk-typescript/** @Mingholy
|
||||
17
.github/workflows/release-sdk.yml
vendored
17
.github/workflows/release-sdk.yml
vendored
@@ -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: |-
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -23,6 +23,7 @@ package-lock.json
|
||||
.idea
|
||||
*.iml
|
||||
.cursor
|
||||
.qoder
|
||||
|
||||
# OS metadata
|
||||
.DS_Store
|
||||
|
||||
10
README.md
10
README.md
@@ -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
|
||||
|
||||
@@ -200,6 +201,11 @@ If you encounter issues, check the [troubleshooting guide](https://qwenlm.github
|
||||
|
||||
To report a bug from within the CLI, run `/bug` and include a short title and repro steps.
|
||||
|
||||
## Connect with Us
|
||||
|
||||
- Discord: https://discord.gg/ycKBjdNd
|
||||
- Dingtalk: https://qr.dingtalk.com/action/joingroup?code=v1,k1,+FX6Gf/ZDlTahTIRi8AEQhIaBlqykA0j+eBKKdhLeAE=&_dt_no_comment=1&origin=1
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
This project is based on [Google Gemini CLI](https://github.com/google-gemini/gemini-cli). We acknowledge and appreciate the excellent work of the Gemini CLI team. Our main contribution focuses on parser-level adaptations to better support Qwen-Coder models.
|
||||
|
||||
@@ -11,6 +11,7 @@ export default {
|
||||
type: 'separator',
|
||||
},
|
||||
'sdk-typescript': 'Typescript SDK',
|
||||
'sdk-java': 'Java SDK(alpha)',
|
||||
'Dive Into Qwen Code': {
|
||||
title: 'Dive Into Qwen Code',
|
||||
type: 'separator',
|
||||
@@ -18,9 +19,6 @@ export default {
|
||||
|
||||
tools: 'Tools',
|
||||
|
||||
extensions: {
|
||||
display: 'hidden',
|
||||
},
|
||||
examples: {
|
||||
display: 'hidden',
|
||||
},
|
||||
|
||||
@@ -202,7 +202,7 @@ This is the most critical stage where files are moved and transformed into their
|
||||
- Copies README.md and LICENSE to dist/
|
||||
- Copies locales folder for internationalization
|
||||
- Creates a clean package.json for distribution with only necessary dependencies
|
||||
- Includes runtime dependencies like tiktoken
|
||||
- Keeps distribution dependencies minimal (no bundled runtime deps)
|
||||
- Maintains optional dependencies for node-pty
|
||||
|
||||
2. The JavaScript Bundle is Created:
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
# Qwen Code Extensions
|
||||
|
||||
Qwen Code extensions package prompts, MCP servers, and custom commands into a familiar and user-friendly format. With extensions, you can expand the capabilities of Qwen Code and share those capabilities with others. They are designed to be easily installable and shareable.
|
||||
|
||||
## Extension management
|
||||
|
||||
We offer a suite of extension management tools using `qwen extensions` commands.
|
||||
|
||||
Note that these commands are not supported from within the CLI, although you can list installed extensions using the `/extensions list` subcommand.
|
||||
|
||||
Note that all of these commands will only be reflected in active CLI sessions on restart.
|
||||
|
||||
### Installing an extension
|
||||
|
||||
You can install an extension using `qwen extensions install` with either a GitHub URL or a local path`.
|
||||
|
||||
Note that we create a copy of the installed extension, so you will need to run `qwen extensions update` to pull in changes from both locally-defined extensions and those on GitHub.
|
||||
|
||||
```
|
||||
qwen extensions install https://github.com/qwen-cli-extensions/security
|
||||
```
|
||||
|
||||
This will install the Qwen Code Security extension, which offers support for a `/security:analyze` command.
|
||||
|
||||
### Uninstalling an extension
|
||||
|
||||
To uninstall, run `qwen extensions uninstall extension-name`, so, in the case of the install example:
|
||||
|
||||
```
|
||||
qwen extensions uninstall qwen-cli-security
|
||||
```
|
||||
|
||||
### Disabling an extension
|
||||
|
||||
Extensions are, by default, enabled across all workspaces. You can disable an extension entirely or for specific workspace.
|
||||
|
||||
For example, `qwen extensions disable extension-name` will disable the extension at the user level, so it will be disabled everywhere. `qwen extensions disable extension-name --scope=workspace` will only disable the extension in the current workspace.
|
||||
|
||||
### Enabling an extension
|
||||
|
||||
You can enable extensions using `qwen extensions enable extension-name`. You can also enable an extension for a specific workspace using `qwen extensions enable extension-name --scope=workspace` from within that workspace.
|
||||
|
||||
This is useful if you have an extension disabled at the top-level and only enabled in specific places.
|
||||
|
||||
### Updating an extension
|
||||
|
||||
For extensions installed from a local path or a git repository, you can explicitly update to the latest version (as reflected in the `qwen-extension.json` `version` field) with `qwen extensions update extension-name`.
|
||||
|
||||
You can update all extensions with:
|
||||
|
||||
```
|
||||
qwen extensions update --all
|
||||
```
|
||||
|
||||
## Extension creation
|
||||
|
||||
We offer commands to make extension development easier.
|
||||
|
||||
### Create a boilerplate extension
|
||||
|
||||
We offer several example extensions `context`, `custom-commands`, `exclude-tools` and `mcp-server`. You can view these examples [here](https://github.com/QwenLM/qwen-code/tree/main/packages/cli/src/commands/extensions/examples).
|
||||
|
||||
To copy one of these examples into a development directory using the type of your choosing, run:
|
||||
|
||||
```
|
||||
qwen extensions new path/to/directory custom-commands
|
||||
```
|
||||
|
||||
### Link a local extension
|
||||
|
||||
The `qwen extensions link` command will create a symbolic link from the extension installation directory to the development path.
|
||||
|
||||
This is useful so you don't have to run `qwen extensions update` every time you make changes you'd like to test.
|
||||
|
||||
```
|
||||
qwen extensions link path/to/directory
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
On startup, Qwen Code looks for extensions in `<home>/.qwen/extensions`
|
||||
|
||||
Extensions exist as a directory that contains a `qwen-extension.json` file. For example:
|
||||
|
||||
`<home>/.qwen/extensions/my-extension/qwen-extension.json`
|
||||
|
||||
### `qwen-extension.json`
|
||||
|
||||
The `qwen-extension.json` file contains the configuration for the extension. The file has the following structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-extension",
|
||||
"version": "1.0.0",
|
||||
"mcpServers": {
|
||||
"my-server": {
|
||||
"command": "node my-server.js"
|
||||
}
|
||||
},
|
||||
"contextFileName": "QWEN.md",
|
||||
"excludeTools": ["run_shell_command"]
|
||||
}
|
||||
```
|
||||
|
||||
- `name`: The name of the extension. This is used to uniquely identify the extension and for conflict resolution when extension commands have the same name as user or project commands. The name should be lowercase or numbers and use dashes instead of underscores or spaces. This is how users will refer to your extension in the CLI. Note that we expect this name to match the extension directory name.
|
||||
- `version`: The version of the extension.
|
||||
- `mcpServers`: A map of MCP servers to configure. The key is the name of the server, and the value is the server configuration. These servers will be loaded on startup just like MCP servers configured in a [`settings.json` file](./cli/configuration.md). If both an extension and a `settings.json` file configure an MCP server with the same name, the server defined in the `settings.json` file takes precedence.
|
||||
- Note that all MCP server configuration options are supported except for `trust`.
|
||||
- `contextFileName`: The name of the file that contains the context for the extension. This will be used to load the context from the extension directory. If this property is not used but a `QWEN.md` file is present in your extension directory, then that file will be loaded.
|
||||
- `excludeTools`: An array of tool names to exclude from the model. You can also specify command-specific restrictions for tools that support it, like the `run_shell_command` tool. For example, `"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf` command. Note that this differs from the MCP server `excludeTools` functionality, which can be listed in the MCP server config. **Important:** Tools specified in `excludeTools` will be disabled for the entire conversation context and will affect all subsequent queries in the current session.
|
||||
|
||||
When Qwen Code starts, it loads all the extensions and merges their configurations. If there are any conflicts, the workspace configuration takes precedence.
|
||||
|
||||
### Custom commands
|
||||
|
||||
Extensions can provide [custom commands](./cli/commands.md#custom-commands) by placing TOML files in a `commands/` subdirectory within the extension directory. These commands follow the same format as user and project custom commands and use standard naming conventions.
|
||||
|
||||
**Example**
|
||||
|
||||
An extension named `gcp` with the following structure:
|
||||
|
||||
```
|
||||
.qwen/extensions/gcp/
|
||||
├── qwen-extension.json
|
||||
└── commands/
|
||||
├── deploy.toml
|
||||
└── gcs/
|
||||
└── sync.toml
|
||||
```
|
||||
|
||||
Would provide these commands:
|
||||
|
||||
- `/deploy` - Shows as `[gcp] Custom command from deploy.toml` in help
|
||||
- `/gcs:sync` - Shows as `[gcp] Custom command from sync.toml` in help
|
||||
|
||||
### Conflict resolution
|
||||
|
||||
Extension commands have the lowest precedence. When a conflict occurs with user or project commands:
|
||||
|
||||
1. **No conflict**: Extension command uses its natural name (e.g., `/deploy`)
|
||||
2. **With conflict**: Extension command is renamed with the extension prefix (e.g., `/gcp.deploy`)
|
||||
|
||||
For example, if both a user and the `gcp` extension define a `deploy` command:
|
||||
|
||||
- `/deploy` - Executes the user's deploy command
|
||||
- `/gcp.deploy` - Executes the extension's deploy command (marked with `[gcp]` tag)
|
||||
|
||||
## Variables
|
||||
|
||||
Qwen Code extensions allow variable substitution in `qwen-extension.json`. This can be useful if e.g., you need the current directory to run an MCP server using `"cwd": "${extensionPath}${/}run.ts"`.
|
||||
|
||||
**Supported variables:**
|
||||
|
||||
| variable | description |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `${extensionPath}` | The fully-qualified path of the extension in the user's filesystem e.g., '/Users/username/.qwen/extensions/example-extension'. This will not unwrap symlinks. |
|
||||
| `${workspacePath}` | The fully-qualified path of the current workspace. |
|
||||
| `${/} or ${pathSeparator}` | The path separator (differs per OS). |
|
||||
312
docs/developers/sdk-java.md
Normal file
312
docs/developers/sdk-java.md
Normal file
@@ -0,0 +1,312 @@
|
||||
# Qwen Code Java SDK
|
||||
|
||||
The Qwen Code Java SDK is a minimum experimental SDK for programmatic access to Qwen Code functionality. It provides a Java interface to interact with the Qwen Code CLI, allowing developers to integrate Qwen Code capabilities into their Java applications.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Java >= 1.8
|
||||
- Maven >= 3.6.0 (for building from source)
|
||||
- qwen-code >= 0.5.0
|
||||
|
||||
### Dependencies
|
||||
|
||||
- **Logging**: ch.qos.logback:logback-classic
|
||||
- **Utilities**: org.apache.commons:commons-lang3
|
||||
- **JSON Processing**: com.alibaba.fastjson2:fastjson2
|
||||
- **Testing**: JUnit 5 (org.junit.jupiter:junit-jupiter)
|
||||
|
||||
## Installation
|
||||
|
||||
Add the following dependency to your Maven `pom.xml`:
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>com.alibaba</groupId>
|
||||
<artifactId>qwencode-sdk</artifactId>
|
||||
<version>{$version}</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
Or if using Gradle, add to your `build.gradle`:
|
||||
|
||||
```gradle
|
||||
implementation 'com.alibaba:qwencode-sdk:{$version}'
|
||||
```
|
||||
|
||||
## Building and Running
|
||||
|
||||
### Build Commands
|
||||
|
||||
```bash
|
||||
# Compile the project
|
||||
mvn compile
|
||||
|
||||
# Run tests
|
||||
mvn test
|
||||
|
||||
# Package the JAR
|
||||
mvn package
|
||||
|
||||
# Install to local repository
|
||||
mvn install
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
The simplest way to use the SDK is through the `QwenCodeCli.simpleQuery()` method:
|
||||
|
||||
```java
|
||||
public static void runSimpleExample() {
|
||||
List<String> result = QwenCodeCli.simpleQuery("hello world");
|
||||
result.forEach(logger::info);
|
||||
}
|
||||
```
|
||||
|
||||
For more advanced usage with custom transport options:
|
||||
|
||||
```java
|
||||
public static void runTransportOptionsExample() {
|
||||
TransportOptions options = new TransportOptions()
|
||||
.setModel("qwen3-coder-flash")
|
||||
.setPermissionMode(PermissionMode.AUTO_EDIT)
|
||||
.setCwd("./")
|
||||
.setEnv(new HashMap<String, String>() {{put("CUSTOM_VAR", "value");}})
|
||||
.setIncludePartialMessages(true)
|
||||
.setTurnTimeout(new Timeout(120L, TimeUnit.SECONDS))
|
||||
.setMessageTimeout(new Timeout(90L, TimeUnit.SECONDS))
|
||||
.setAllowedTools(Arrays.asList("read_file", "write_file", "list_directory"));
|
||||
|
||||
List<String> result = QwenCodeCli.simpleQuery("who are you, what are your capabilities?", options);
|
||||
result.forEach(logger::info);
|
||||
}
|
||||
```
|
||||
|
||||
For streaming content handling with custom content consumers:
|
||||
|
||||
```java
|
||||
public static void runStreamingExample() {
|
||||
QwenCodeCli.simpleQuery("who are you, what are your capabilities?",
|
||||
new TransportOptions().setMessageTimeout(new Timeout(10L, TimeUnit.SECONDS)), new AssistantContentSimpleConsumers() {
|
||||
|
||||
@Override
|
||||
public void onText(Session session, TextAssistantContent textAssistantContent) {
|
||||
logger.info("Text content received: {}", textAssistantContent.getText());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onThinking(Session session, ThingkingAssistantContent thingkingAssistantContent) {
|
||||
logger.info("Thinking content received: {}", thingkingAssistantContent.getThinking());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onToolUse(Session session, ToolUseAssistantContent toolUseContent) {
|
||||
logger.info("Tool use content received: {} with arguments: {}",
|
||||
toolUseContent, toolUseContent.getInput());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onToolResult(Session session, ToolResultAssistantContent toolResultContent) {
|
||||
logger.info("Tool result content received: {}", toolResultContent.getContent());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onOtherContent(Session session, AssistantContent<?> other) {
|
||||
logger.info("Other content received: {}", other);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUsage(Session session, AssistantUsage assistantUsage) {
|
||||
logger.info("Usage information received: Input tokens: {}, Output tokens: {}",
|
||||
assistantUsage.getUsage().getInputTokens(), assistantUsage.getUsage().getOutputTokens());
|
||||
}
|
||||
}.setDefaultPermissionOperation(Operation.allow));
|
||||
logger.info("Streaming example completed.");
|
||||
}
|
||||
```
|
||||
|
||||
other examples see src/test/java/com/alibaba/qwen/code/cli/example
|
||||
|
||||
## Architecture
|
||||
|
||||
The SDK follows a layered architecture:
|
||||
|
||||
- **API Layer**: Provides the main entry points through `QwenCodeCli` class with simple static methods for basic usage
|
||||
- **Session Layer**: Manages communication sessions with the Qwen Code CLI through the `Session` class
|
||||
- **Transport Layer**: Handles the communication mechanism between the SDK and CLI process (currently using process transport via `ProcessTransport`)
|
||||
- **Protocol Layer**: Defines data structures for communication based on the CLI protocol
|
||||
- **Utils**: Common utilities for concurrent execution, timeout handling, and error management
|
||||
|
||||
## Key Features
|
||||
|
||||
### Permission Modes
|
||||
|
||||
The SDK supports different permission modes for controlling tool execution:
|
||||
|
||||
- **`default`**: Write tools are denied unless approved via `canUseTool` callback or in `allowedTools`. Read-only tools execute without confirmation.
|
||||
- **`plan`**: Blocks all write tools, instructing AI to present a plan first.
|
||||
- **`auto-edit`**: Auto-approve edit tools (edit, write_file) while other tools require confirmation.
|
||||
- **`yolo`**: All tools execute automatically without confirmation.
|
||||
|
||||
### Session Event Consumers and Assistant Content Consumers
|
||||
|
||||
The SDK provides two key interfaces for handling events and content from the CLI:
|
||||
|
||||
#### SessionEventConsumers Interface
|
||||
|
||||
The `SessionEventConsumers` interface provides callbacks for different types of messages during a session:
|
||||
|
||||
- `onSystemMessage`: Handles system messages from the CLI (receives Session and SDKSystemMessage)
|
||||
- `onResultMessage`: Handles result messages from the CLI (receives Session and SDKResultMessage)
|
||||
- `onAssistantMessage`: Handles assistant messages (AI responses) (receives Session and SDKAssistantMessage)
|
||||
- `onPartialAssistantMessage`: Handles partial assistant messages during streaming (receives Session and SDKPartialAssistantMessage)
|
||||
- `onUserMessage`: Handles user messages (receives Session and SDKUserMessage)
|
||||
- `onOtherMessage`: Handles other types of messages (receives Session and String message)
|
||||
- `onControlResponse`: Handles control responses (receives Session and CLIControlResponse)
|
||||
- `onControlRequest`: Handles control requests (receives Session and CLIControlRequest, returns CLIControlResponse)
|
||||
- `onPermissionRequest`: Handles permission requests (receives Session and CLIControlRequest<CLIControlPermissionRequest>, returns Behavior)
|
||||
|
||||
#### AssistantContentConsumers Interface
|
||||
|
||||
The `AssistantContentConsumers` interface handles different types of content within assistant messages:
|
||||
|
||||
- `onText`: Handles text content (receives Session and TextAssistantContent)
|
||||
- `onThinking`: Handles thinking content (receives Session and ThingkingAssistantContent)
|
||||
- `onToolUse`: Handles tool use content (receives Session and ToolUseAssistantContent)
|
||||
- `onToolResult`: Handles tool result content (receives Session and ToolResultAssistantContent)
|
||||
- `onOtherContent`: Handles other content types (receives Session and AssistantContent)
|
||||
- `onUsage`: Handles usage information (receives Session and AssistantUsage)
|
||||
- `onPermissionRequest`: Handles permission requests (receives Session and CLIControlPermissionRequest, returns Behavior)
|
||||
- `onOtherControlRequest`: Handles other control requests (receives Session and ControlRequestPayload, returns ControlResponsePayload)
|
||||
|
||||
#### Relationship Between the Interfaces
|
||||
|
||||
**Important Note on Event Hierarchy:**
|
||||
|
||||
- `SessionEventConsumers` is the **high-level** event processor that handles different message types (system, assistant, user, etc.)
|
||||
- `AssistantContentConsumers` is the **low-level** content processor that handles different types of content within assistant messages (text, tools, thinking, etc.)
|
||||
|
||||
**Processor Relationship:**
|
||||
|
||||
- `SessionEventConsumers` → `AssistantContentConsumers` (SessionEventConsumers uses AssistantContentConsumers to process content within assistant messages)
|
||||
|
||||
**Event Derivation Relationships:**
|
||||
|
||||
- `onAssistantMessage` → `onText`, `onThinking`, `onToolUse`, `onToolResult`, `onOtherContent`, `onUsage`
|
||||
- `onPartialAssistantMessage` → `onText`, `onThinking`, `onToolUse`, `onToolResult`, `onOtherContent`
|
||||
- `onControlRequest` → `onPermissionRequest`, `onOtherControlRequest`
|
||||
|
||||
**Event Timeout Relationships:**
|
||||
|
||||
Each event handler method has a corresponding timeout method that allows customizing the timeout behavior for that specific event:
|
||||
|
||||
- `onSystemMessage` ↔ `onSystemMessageTimeout`
|
||||
- `onResultMessage` ↔ `onResultMessageTimeout`
|
||||
- `onAssistantMessage` ↔ `onAssistantMessageTimeout`
|
||||
- `onPartialAssistantMessage` ↔ `onPartialAssistantMessageTimeout`
|
||||
- `onUserMessage` ↔ `onUserMessageTimeout`
|
||||
- `onOtherMessage` ↔ `onOtherMessageTimeout`
|
||||
- `onControlResponse` ↔ `onControlResponseTimeout`
|
||||
- `onControlRequest` ↔ `onControlRequestTimeout`
|
||||
|
||||
For AssistantContentConsumers timeout methods:
|
||||
|
||||
- `onText` ↔ `onTextTimeout`
|
||||
- `onThinking` ↔ `onThinkingTimeout`
|
||||
- `onToolUse` ↔ `onToolUseTimeout`
|
||||
- `onToolResult` ↔ `onToolResultTimeout`
|
||||
- `onOtherContent` ↔ `onOtherContentTimeout`
|
||||
- `onPermissionRequest` ↔ `onPermissionRequestTimeout`
|
||||
- `onOtherControlRequest` ↔ `onOtherControlRequestTimeout`
|
||||
|
||||
**Default Timeout Values:**
|
||||
|
||||
- `SessionEventSimpleConsumers` default timeout: 180 seconds (Timeout.TIMEOUT_180_SECONDS)
|
||||
- `AssistantContentSimpleConsumers` default timeout: 60 seconds (Timeout.TIMEOUT_60_SECONDS)
|
||||
|
||||
**Timeout Hierarchy Requirements:**
|
||||
|
||||
For proper operation, the following timeout relationships should be maintained:
|
||||
|
||||
- `onAssistantMessageTimeout` return value should be greater than `onTextTimeout`, `onThinkingTimeout`, `onToolUseTimeout`, `onToolResultTimeout`, and `onOtherContentTimeout` return values
|
||||
- `onControlRequestTimeout` return value should be greater than `onPermissionRequestTimeout` and `onOtherControlRequestTimeout` return values
|
||||
|
||||
### Transport Options
|
||||
|
||||
The `TransportOptions` class allows configuration of how the SDK communicates with the Qwen Code CLI:
|
||||
|
||||
- `pathToQwenExecutable`: Path to the Qwen Code CLI executable
|
||||
- `cwd`: Working directory for the CLI process
|
||||
- `model`: AI model to use for the session
|
||||
- `permissionMode`: Permission mode that controls tool execution
|
||||
- `env`: Environment variables to pass to the CLI process
|
||||
- `maxSessionTurns`: Limits the number of conversation turns in a session
|
||||
- `coreTools`: List of core tools that should be available to the AI
|
||||
- `excludeTools`: List of tools to exclude from being available to the AI
|
||||
- `allowedTools`: List of tools that are pre-approved for use without additional confirmation
|
||||
- `authType`: Authentication type to use for the session
|
||||
- `includePartialMessages`: Enables receiving partial messages during streaming responses
|
||||
- `skillsEnable`: Enables or disables skills functionality for the session
|
||||
- `turnTimeout`: Timeout for a complete turn of conversation
|
||||
- `messageTimeout`: Timeout for individual messages within a turn
|
||||
- `resumeSessionId`: ID of a previous session to resume
|
||||
- `otherOptions`: Additional command-line options to pass to the CLI
|
||||
|
||||
### Session Control Features
|
||||
|
||||
- **Session creation**: Use `QwenCodeCli.newSession()` to create a new session with custom options
|
||||
- **Session management**: The `Session` class provides methods to send prompts, handle responses, and manage session state
|
||||
- **Session cleanup**: Always close sessions using `session.close()` to properly terminate the CLI process
|
||||
- **Session resumption**: Use `setResumeSessionId()` in `TransportOptions` to resume a previous session
|
||||
- **Session interruption**: Use `session.interrupt()` to interrupt a currently running prompt
|
||||
- **Dynamic model switching**: Use `session.setModel()` to change the model during a session
|
||||
- **Dynamic permission mode switching**: Use `session.setPermissionMode()` to change the permission mode during a session
|
||||
|
||||
### Thread Pool Configuration
|
||||
|
||||
The SDK uses a thread pool for managing concurrent operations with the following default configuration:
|
||||
|
||||
- **Core Pool Size**: 30 threads
|
||||
- **Maximum Pool Size**: 100 threads
|
||||
- **Keep-Alive Time**: 60 seconds
|
||||
- **Queue Capacity**: 300 tasks (using LinkedBlockingQueue)
|
||||
- **Thread Naming**: "qwen_code_cli-pool-{number}"
|
||||
- **Daemon Threads**: false
|
||||
- **Rejected Execution Handler**: CallerRunsPolicy
|
||||
|
||||
## Error Handling
|
||||
|
||||
The SDK provides specific exception types for different error scenarios:
|
||||
|
||||
- `SessionControlException`: Thrown when there's an issue with session control (creation, initialization, etc.)
|
||||
- `SessionSendPromptException`: Thrown when there's an issue sending a prompt or receiving a response
|
||||
- `SessionClosedException`: Thrown when attempting to use a closed session
|
||||
|
||||
## FAQ / Troubleshooting
|
||||
|
||||
### Q: Do I need to install the Qwen CLI separately?
|
||||
|
||||
A: yes, requires Qwen CLI 0.5.5 or higher.
|
||||
|
||||
### Q: What Java versions are supported?
|
||||
|
||||
A: The SDK requires Java 1.8 or higher.
|
||||
|
||||
### Q: How do I handle long-running requests?
|
||||
|
||||
A: The SDK includes timeout utilities. You can configure timeouts using the `Timeout` class in `TransportOptions`.
|
||||
|
||||
### Q: Why are some tools not executing?
|
||||
|
||||
A: This is likely due to permission modes. Check your permission mode settings and consider using `allowedTools` to pre-approve certain tools.
|
||||
|
||||
### Q: How do I resume a previous session?
|
||||
|
||||
A: Use the `setResumeSessionId()` method in `TransportOptions` to resume a previous session.
|
||||
|
||||
### Q: Can I customize the environment for the CLI process?
|
||||
|
||||
A: Yes, use the `setEnv()` method in `TransportOptions` to pass environment variables to the CLI process.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0 - see [LICENSE](./LICENSE) for details.
|
||||
@@ -10,4 +10,5 @@ export default {
|
||||
'web-search': 'Web Search',
|
||||
memory: 'Memory',
|
||||
'mcp-server': 'MCP Servers',
|
||||
sandbox: 'Sandboxing',
|
||||
};
|
||||
|
||||
90
docs/developers/tools/sandbox.md
Normal file
90
docs/developers/tools/sandbox.md
Normal 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
|
||||
```
|
||||
@@ -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',
|
||||
@@ -19,6 +20,7 @@ export default {
|
||||
},
|
||||
features: 'Features',
|
||||
configuration: 'Configuration',
|
||||
extension: 'Extension',
|
||||
reference: 'Reference',
|
||||
support: 'Support',
|
||||
// need refine
|
||||
|
||||
@@ -5,11 +5,13 @@ Qwen Code supports two authentication methods. Pick the one that matches how you
|
||||
- **Qwen OAuth (recommended)**: sign in with your `qwen.ai` account in a browser.
|
||||
- **OpenAI-compatible API**: use an API key (OpenAI or any OpenAI-compatible provider / endpoint).
|
||||
|
||||

|
||||
|
||||
## Option 1: Qwen OAuth (recommended & free) 👍
|
||||
|
||||
Use this if you want the simplest setup and you’re using Qwen models.
|
||||
Use this if you want the simplest setup and you're using Qwen models.
|
||||
|
||||
- **How it works**: on first start, Qwen Code opens a browser login page. After you finish, credentials are cached locally so you usually won’t need to log in again.
|
||||
- **How it works**: on first start, Qwen Code opens a browser login page. After you finish, credentials are cached locally so you usually won't need to log in again.
|
||||
- **Requirements**: a `qwen.ai` account + internet access (at least for the first login).
|
||||
- **Benefits**: no API key management, automatic credential refresh.
|
||||
- **Cost & quota**: free, with a quota of **60 requests/minute** and **2,000 requests/day**.
|
||||
@@ -24,15 +26,54 @@ qwen
|
||||
|
||||
Use this if you want to use OpenAI models or any provider that exposes an OpenAI-compatible API (e.g. OpenAI, Azure OpenAI, OpenRouter, ModelScope, Alibaba Cloud Bailian, or a self-hosted compatible endpoint).
|
||||
|
||||
### Quick start (interactive, recommended for local use)
|
||||
### Recommended: Coding Plan (subscription-based) 🚀
|
||||
|
||||
When you choose the OpenAI-compatible option in the CLI, it will prompt you for:
|
||||
Use this if you want predictable costs with higher usage quotas for the qwen3-coder-plus model.
|
||||
|
||||
- **API key**
|
||||
- **Base URL** (default: `https://api.openai.com/v1`)
|
||||
- **Model** (default: `gpt-4o`)
|
||||
> [!IMPORTANT]
|
||||
>
|
||||
> Coding Plan is only available for users in China mainland (Beijing region).
|
||||
|
||||
> **Note:** the CLI may display the key in plain text for verification. Make sure your terminal is not being recorded or shared.
|
||||
- **How it works**: subscribe to the Coding Plan with a fixed monthly fee, then configure Qwen Code to use the dedicated endpoint and your subscription API key.
|
||||
- **Requirements**: an active Coding Plan subscription from [Alibaba Cloud Bailian](https://bailian.console.aliyun.com/cn-beijing/?tab=globalset#/efm/coding_plan).
|
||||
- **Benefits**: higher usage quotas, predictable monthly costs, access to latest qwen3-coder-plus model.
|
||||
- **Cost & quota**: varies by plan (see table below).
|
||||
|
||||
#### Coding Plan Pricing & Quotas
|
||||
|
||||
| Feature | Lite Basic Plan | Pro Advanced Plan |
|
||||
| :------------------ | :-------------------- | :-------------------- |
|
||||
| **Price** | ¥40/month | ¥200/month |
|
||||
| **5-Hour Limit** | Up to 1,200 requests | Up to 6,000 requests |
|
||||
| **Weekly Limit** | Up to 9,000 requests | Up to 45,000 requests |
|
||||
| **Monthly Limit** | Up to 18,000 requests | Up to 90,000 requests |
|
||||
| **Supported Model** | qwen3-coder-plus | qwen3-coder-plus |
|
||||
|
||||
#### Quick Setup for Coding Plan
|
||||
|
||||
When you select the OpenAI-compatible option in the CLI, enter these values:
|
||||
|
||||
- **API key**: `sk-sp-xxxxx`
|
||||
- **Base URL**: `https://coding.dashscope.aliyuncs.com/v1`
|
||||
- **Model**: `qwen3-coder-plus`
|
||||
|
||||
> **Note**: Coding Plan API keys have the format `sk-sp-xxxxx`, which is different from standard Alibaba Cloud API keys.
|
||||
|
||||
#### Configure via Environment Variables
|
||||
|
||||
Set these environment variables to use Coding Plan:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="your-coding-plan-api-key" # Format: sk-sp-xxxxx
|
||||
export OPENAI_BASE_URL="https://coding.dashscope.aliyuncs.com/v1"
|
||||
export OPENAI_MODEL="qwen3-coder-plus"
|
||||
```
|
||||
|
||||
For more details about Coding Plan, including subscription options and troubleshooting, see the [full Coding Plan documentation](https://bailian.console.aliyun.com/cn-beijing/?tab=doc#/doc/?type=model&url=3005961).
|
||||
|
||||
### Other OpenAI-compatible Providers
|
||||
|
||||
If you are using other providers (OpenAI, Azure, local LLMs, etc.), use the following configuration methods.
|
||||
|
||||
### Configure via command-line arguments
|
||||
|
||||
|
||||
@@ -74,9 +74,6 @@ Settings are organized into categories. All settings should be placed within the
|
||||
| `ui.customThemes` | object | Custom theme definitions. | `{}` |
|
||||
| `ui.hideWindowTitle` | boolean | Hide the window title bar. | `false` |
|
||||
| `ui.hideTips` | boolean | Hide helpful tips in the UI. | `false` |
|
||||
| `ui.hideBanner` | boolean | Hide the application banner. | `false` |
|
||||
| `ui.hideFooter` | boolean | Hide the footer from the UI. | `false` |
|
||||
| `ui.showMemoryUsage` | boolean | Display memory usage information in the UI. | `false` |
|
||||
| `ui.showLineNumbers` | boolean | Show line numbers in code blocks in the CLI output. | `true` |
|
||||
| `ui.showCitations` | boolean | Show citations for generated text in the chat. | `true` |
|
||||
| `enableWelcomeBack` | boolean | Show welcome back dialog when returning to a project with conversation history. When enabled, Qwen Code will automatically detect if you're returning to a project with a previously generated project summary (`.qwen/PROJECT_SUMMARY.md`) and show a dialog allowing you to continue your previous conversation or start fresh. This feature integrates with the `/summary` command and quit confirmation dialog. | `true` |
|
||||
@@ -104,7 +101,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 +111,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,19 +131,113 @@ 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
|
||||
- `"./custom-logs"` - Logs to `./custom-logs` relative to current directory
|
||||
- `"/tmp/openai-logs"` - Logs to absolute path `/tmp/openai-logs`
|
||||
|
||||
#### modelProviders
|
||||
|
||||
Use `modelProviders` to declare curated model lists per auth type that the `/model` picker can switch between. Keys must be valid auth types (`openai`, `anthropic`, `gemini`, `vertex-ai`, etc.). Each entry requires an `id` and **must include `envKey`**, with optional `name`, `description`, `baseUrl`, and `generationConfig`. Credentials are never persisted in settings; the runtime reads them from `process.env[envKey]`. Qwen OAuth models remain hard-coded and cannot be overridden.
|
||||
|
||||
##### Example
|
||||
|
||||
```json
|
||||
{
|
||||
"modelProviders": {
|
||||
"openai": [
|
||||
{
|
||||
"id": "gpt-4o",
|
||||
"name": "GPT-4o",
|
||||
"envKey": "OPENAI_API_KEY",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"generationConfig": {
|
||||
"timeout": 60000,
|
||||
"maxRetries": 3,
|
||||
"customHeaders": {
|
||||
"X-Model-Version": "v1.0",
|
||||
"X-Request-Priority": "high"
|
||||
},
|
||||
"samplingParams": { "temperature": 0.2 }
|
||||
}
|
||||
}
|
||||
],
|
||||
"anthropic": [
|
||||
{
|
||||
"id": "claude-3-5-sonnet",
|
||||
"envKey": "ANTHROPIC_API_KEY",
|
||||
"baseUrl": "https://api.anthropic.com/v1"
|
||||
}
|
||||
],
|
||||
"gemini": [
|
||||
{
|
||||
"id": "gemini-2.0-flash",
|
||||
"name": "Gemini 2.0 Flash",
|
||||
"envKey": "GEMINI_API_KEY",
|
||||
"baseUrl": "https://generativelanguage.googleapis.com"
|
||||
}
|
||||
],
|
||||
"vertex-ai": [
|
||||
{
|
||||
"id": "gemini-1.5-pro-vertex",
|
||||
"envKey": "GOOGLE_API_KEY",
|
||||
"baseUrl": "https://generativelanguage.googleapis.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> [!note]
|
||||
> Only the `/model` command exposes non-default auth types. Anthropic, Gemini, Vertex AI, etc., must be defined via `modelProviders`. The `/auth` command intentionally lists only the built-in Qwen OAuth and OpenAI flows.
|
||||
|
||||
##### Resolution layers and atomicity
|
||||
|
||||
The effective auth/model/credential values are chosen per field using the following precedence (first present wins). You can combine `--auth-type` with `--model` to point directly at a provider entry; these CLI flags run before other layers.
|
||||
|
||||
| Layer (highest → lowest) | authType | model | apiKey | baseUrl | apiKeyEnvKey | proxy |
|
||||
| -------------------------- | ----------------------------------- | ----------------------------------------------- | --------------------------------------------------- | ---------------------------------------------------- | ---------------------- | --------------------------------- |
|
||||
| Programmatic overrides | `/auth ` | `/auth` input | `/auth` input | `/auth` input | — | — |
|
||||
| Model provider selection | — | `modelProvider.id` | `env[modelProvider.envKey]` | `modelProvider.baseUrl` | `modelProvider.envKey` | — |
|
||||
| CLI arguments | `--auth-type` | `--model` | `--openaiApiKey` (or provider-specific equivalents) | `--openaiBaseUrl` (or provider-specific equivalents) | — | — |
|
||||
| Environment variables | — | Provider-specific mapping (e.g. `OPENAI_MODEL`) | Provider-specific mapping (e.g. `OPENAI_API_KEY`) | Provider-specific mapping (e.g. `OPENAI_BASE_URL`) | — | — |
|
||||
| Settings (`settings.json`) | `security.auth.selectedType` | `model.name` | `security.auth.apiKey` | `security.auth.baseUrl` | — | — |
|
||||
| Default / computed | Falls back to `AuthType.QWEN_OAUTH` | Built-in default (OpenAI ⇒ `qwen3-coder-plus`) | — | — | — | `Config.getProxy()` if configured |
|
||||
|
||||
\*When present, CLI auth flags override settings. Otherwise, `security.auth.selectedType` or the implicit default determine the auth type. Qwen OAuth and OpenAI are the only auth types surfaced without extra configuration.
|
||||
|
||||
Model-provider sourced values are applied atomically: once a provider model is active, every field it defines is protected from lower layers until you manually clear credentials via `/auth`. The final `generationConfig` is the projection across all layers—lower layers only fill gaps left by higher ones, and the provider layer remains impenetrable.
|
||||
|
||||
The merge strategy for `modelProviders` is REPLACE: the entire `modelProviders` from project settings will override the corresponding section in user settings, rather than merging the two.
|
||||
|
||||
##### Generation config layering
|
||||
|
||||
Per-field precedence for `generationConfig`:
|
||||
|
||||
1. Programmatic overrides (e.g. runtime `/model`, `/auth` changes)
|
||||
2. `modelProviders[authType][].generationConfig`
|
||||
3. `settings.model.generationConfig`
|
||||
4. Content-generator defaults (`getDefaultGenerationConfig` for OpenAI, `getParameterValue` for Gemini, etc.)
|
||||
|
||||
`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
|
||||
|
||||
> [!important]
|
||||
> Define `modelProviders` in the user-scope `~/.qwen/settings.json` whenever possible and avoid persisting credential overrides in any scope. Keeping the provider catalog in user settings prevents merge/override conflicts between project and user scopes and ensures `/auth` and `/model` updates always write back to a consistent scope.
|
||||
|
||||
- `/model` and `/auth` persist `model.name` (where applicable) and `security.auth.selectedType` to the closest writable scope that already defines `modelProviders`; otherwise they fall back to the user scope. This keeps workspace/user files in sync with the active provider catalog.
|
||||
- Without `modelProviders`, the resolver mixes CLI/env/settings layers, which is fine for single-provider setups but cumbersome when frequently switching. Define provider catalogs whenever multi-model workflows are common so that switches stay atomic, source-attributed, and debuggable.
|
||||
|
||||
#### context
|
||||
|
||||
| Setting | Type | Description | Default |
|
||||
| ------------------------------------------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
|
||||
| `context.fileName` | string or array of strings | The name of the context file(s). | `undefined` |
|
||||
| `context.importFormat` | string | The format to use when importing memory. | `undefined` |
|
||||
| `context.discoveryMaxDirs` | number | Maximum number of directories to search for memory. | `200` |
|
||||
| `context.includeDirectories` | array | Additional directories to include in the workspace context. Specifies an array of additional absolute or relative paths to include in the workspace context. Missing directories will be skipped with a warning by default. Paths can use `~` to refer to the user's home directory. This setting can be combined with the `--include-directories` command-line flag. | `[]` |
|
||||
| `context.loadFromIncludeDirectories` | boolean | Controls the behavior of the `/memory refresh` command. If set to `true`, `QWEN.md` files should be loaded from all directories that are added. If set to `false`, `QWEN.md` should only be loaded from the current directory. | `false` |
|
||||
| `context.fileFiltering.respectGitIgnore` | boolean | Respect .gitignore files when searching. | `true` |
|
||||
@@ -175,7 +270,6 @@ If you are experiencing performance issues with file searching (e.g., with `@` c
|
||||
| `tools.enableToolOutputTruncation` | boolean | Enable truncation of large tool outputs. | `true` | Requires restart: Yes |
|
||||
| `tools.truncateToolOutputThreshold` | number | Truncate tool output if it is larger than this many characters. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools. | `25000` | Requires restart: Yes |
|
||||
| `tools.truncateToolOutputLines` | number | Maximum lines or entries kept when truncating tool output. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools. | `1000` | Requires restart: Yes |
|
||||
| `tools.autoAccept` | boolean | Controls whether the CLI automatically accepts and executes tool calls that are considered safe (e.g., read-only operations) without explicit user confirmation. If set to `true`, the CLI will bypass the confirmation prompt for tools deemed safe. | `false` | |
|
||||
|
||||
#### mcp
|
||||
|
||||
@@ -212,6 +306,12 @@ If you are experiencing performance issues with file searching (e.g., with `@` c
|
||||
>
|
||||
> **Note about advanced.tavilyApiKey:** This is a legacy configuration format. For Qwen OAuth users, DashScope provider is automatically available without any configuration. For other authentication types, configure Tavily or Google providers using the new `webSearch` configuration format.
|
||||
|
||||
#### experimental
|
||||
|
||||
| Setting | Type | Description | Default |
|
||||
| --------------------- | ------- | -------------------------------- | ------- |
|
||||
| `experimental.skills` | boolean | Enable experimental Agent Skills | `false` |
|
||||
|
||||
#### mcpServers
|
||||
|
||||
Configures connections to one or more Model-Context Protocol (MCP) servers for discovering and using custom tools. Qwen Code attempts to connect to each configured MCP server to discover available tools. If multiple MCP servers expose a tool with the same name, the tool names will be prefixed with the server alias you defined in the configuration (e.g., `serverAlias__actualToolName`) to avoid conflicts. Note that the system might strip certain schema properties from MCP tool definitions for compatibility. At least one of `command`, `url`, or `httpUrl` must be provided. If multiple are specified, the order of precedence is `httpUrl`, then `url`, then `command`.
|
||||
@@ -257,7 +357,6 @@ Here is an example of a `settings.json` file with the nested structure, new as o
|
||||
},
|
||||
"ui": {
|
||||
"theme": "GitHub",
|
||||
"hideBanner": true,
|
||||
"hideTips": false,
|
||||
"customWittyPhrases": [
|
||||
"You forget a thousand things every day. Make sure this is one of 'em",
|
||||
@@ -381,7 +480,7 @@ Arguments passed directly when running the CLI can override other configurations
|
||||
| `--telemetry-otlp-protocol` | | Sets the OTLP protocol for telemetry (`grpc` or `http`). | | Defaults to `grpc`. See [telemetry](../../developers/development/telemetry) for more information. |
|
||||
| `--telemetry-log-prompts` | | Enables logging of prompts for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. |
|
||||
| `--checkpointing` | | Enables [checkpointing](../features/checkpointing). | | |
|
||||
| `--experimental-acp` | | Enables ACP mode (Agent Control Protocol). Useful for IDE/editor integrations like [Zed](../integration-zed). | | Experimental. |
|
||||
| `--acp` | | Enables ACP mode (Agent Client Protocol). Useful for IDE/editor integrations like [Zed](../integration-zed). | | Stable. Replaces the deprecated `--experimental-acp` flag. |
|
||||
| `--experimental-skills` | | Enables experimental [Agent Skills](../features/skills) (registers the `skill` tool and loads Skills from `.qwen/skills/` and `~/.qwen/skills/`). | | Experimental. |
|
||||
| `--extensions` | `-e` | Specifies a list of extensions to use for the session. | Extension names | If not provided, all available extensions are used. Use the special term `qwen -e none` to disable all extensions. Example: `qwen -e my-extension -e my-other-extension` |
|
||||
| `--list-extensions` | `-l` | Lists all available extensions and exits. | | |
|
||||
@@ -430,16 +529,13 @@ Here's a conceptual example of what a context file at the root of a TypeScript p
|
||||
|
||||
This example demonstrates how you can provide general project context, specific coding conventions, and even notes about particular files or components. The more relevant and precise your context files are, the better the AI can assist you. Project-specific context files are highly encouraged to establish conventions and context.
|
||||
|
||||
- **Hierarchical Loading and Precedence:** The CLI implements a sophisticated hierarchical memory system by loading context files (e.g., `QWEN.md`) from several locations. Content from files lower in this list (more specific) typically overrides or supplements content from files higher up (more general). The exact concatenation order and final context can be inspected using the `/memory show` command. The typical loading order is:
|
||||
- **Hierarchical Loading and Precedence:** The CLI implements a hierarchical memory system by loading context files (e.g., `QWEN.md`) from several locations. Content from files lower in this list (more specific) typically overrides or supplements content from files higher up (more general). The exact concatenation order and final context can be inspected using the `/memory show` command. The typical loading order is:
|
||||
1. **Global Context File:**
|
||||
- Location: `~/.qwen/<configured-context-filename>` (e.g., `~/.qwen/QWEN.md` in your user home directory).
|
||||
- Scope: Provides default instructions for all your projects.
|
||||
2. **Project Root & Ancestors Context Files:**
|
||||
- Location: The CLI searches for the configured context file in the current working directory and then in each parent directory up to either the project root (identified by a `.git` folder) or your home directory.
|
||||
- Scope: Provides context relevant to the entire project or a significant portion of it.
|
||||
3. **Sub-directory Context Files (Contextual/Local):**
|
||||
- Location: The CLI also scans for the configured context file in subdirectories _below_ the current working directory (respecting common ignore patterns like `node_modules`, `.git`, etc.). The breadth of this search is limited to 200 directories by default, but can be configured with the `context.discoveryMaxDirs` setting in your `settings.json` file.
|
||||
- Scope: Allows for highly specific instructions relevant to a particular component, module, or subsection of your project.
|
||||
- **Concatenation & UI Indication:** The contents of all found context files are concatenated (with separators indicating their origin and path) and provided as part of the system prompt. The CLI footer displays the count of loaded context files, giving you a quick visual cue about the active instructional context.
|
||||
- **Importing Content:** You can modularize your context files by importing other Markdown files using the `@path/to/file.md` syntax. For more details, see the [Memory Import Processor documentation](../configuration/memory).
|
||||
- **Commands for Memory Management:**
|
||||
|
||||
9
docs/users/extension/_meta.ts
Normal file
9
docs/users/extension/_meta.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export default {
|
||||
introduction: 'Introduction',
|
||||
'getting-start-extensions': {
|
||||
display: 'hidden',
|
||||
},
|
||||
'extension-releasing': {
|
||||
display: 'hidden',
|
||||
},
|
||||
};
|
||||
@@ -148,22 +148,107 @@ Custom commands provide a way to create shortcuts for complex prompts. Let's add
|
||||
mkdir -p commands/fs
|
||||
```
|
||||
|
||||
2. Create a file named `commands/fs/grep-code.toml`:
|
||||
2. Create a file named `commands/fs/grep-code.md`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: Search for a pattern in code and summarize findings
|
||||
---
|
||||
|
||||
```toml
|
||||
prompt = """
|
||||
Please summarize the findings for the pattern `{{args}}`.
|
||||
|
||||
Search Results:
|
||||
!{grep -r {{args}} .}
|
||||
"""
|
||||
```
|
||||
|
||||
This command, `/fs:grep-code`, will take an argument, run the `grep` shell command with it, and pipe the results into a prompt for summarization.
|
||||
|
||||
> **Note:** Commands use Markdown format with optional YAML frontmatter. TOML format is deprecated but still supported for backwards compatibility.
|
||||
|
||||
After saving the file, restart the Qwen Code. You can now run `/fs:grep-code "some pattern"` to use your new command.
|
||||
|
||||
## Step 5: Add a Custom `QWEN.md`
|
||||
## Step 5: Add Custom Skills and Subagents (Optional)
|
||||
|
||||
Extensions can also provide custom skills and subagents to extend Qwen Code's capabilities.
|
||||
|
||||
### Adding a Custom Skill
|
||||
|
||||
Skills are model-invoked capabilities that the AI can automatically use when relevant.
|
||||
|
||||
1. Create a `skills` directory with a skill subdirectory:
|
||||
|
||||
```bash
|
||||
mkdir -p skills/code-analyzer
|
||||
```
|
||||
|
||||
2. Create a `skills/code-analyzer/SKILL.md` file:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: code-analyzer
|
||||
description: Analyzes code structure and provides insights about complexity, dependencies, and potential improvements
|
||||
---
|
||||
|
||||
# Code Analyzer
|
||||
|
||||
## Instructions
|
||||
|
||||
When analyzing code, focus on:
|
||||
|
||||
- Code complexity and maintainability
|
||||
- Dependencies and coupling
|
||||
- Potential performance issues
|
||||
- Suggestions for improvements
|
||||
|
||||
## Examples
|
||||
|
||||
- "Analyze the complexity of this function"
|
||||
- "What are the dependencies of this module?"
|
||||
```
|
||||
|
||||
### Adding a Custom Subagent
|
||||
|
||||
Subagents are specialized AI assistants for specific tasks.
|
||||
|
||||
1. Create an `agents` directory:
|
||||
|
||||
```bash
|
||||
mkdir -p agents
|
||||
```
|
||||
|
||||
2. Create an `agents/refactoring-expert.md` file:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: refactoring-expert
|
||||
description: Specialized in code refactoring, improving code structure and maintainability
|
||||
tools:
|
||||
- read_file
|
||||
- write_file
|
||||
- read_many_files
|
||||
---
|
||||
|
||||
You are a refactoring specialist focused on improving code quality.
|
||||
|
||||
Your expertise includes:
|
||||
|
||||
- Identifying code smells and anti-patterns
|
||||
- Applying SOLID principles
|
||||
- Improving code readability and maintainability
|
||||
- Safe refactoring with minimal risk
|
||||
|
||||
For each refactoring task:
|
||||
|
||||
1. Analyze the current code structure
|
||||
2. Identify areas for improvement
|
||||
3. Propose refactoring steps
|
||||
4. Implement changes incrementally
|
||||
5. Verify functionality is preserved
|
||||
```
|
||||
|
||||
After restarting Qwen Code, your custom skills will be available via `/skills` and subagents via `/agents manage`.
|
||||
|
||||
## Step 6: Add a Custom `QWEN.md`
|
||||
|
||||
You can provide persistent context to the model by adding a `QWEN.md` file to your extension. This is useful for giving the model instructions on how to behave or information about your extension's tools. Note that you may not always need this for extensions built to expose commands and prompts.
|
||||
|
||||
@@ -194,7 +279,7 @@ You can provide persistent context to the model by adding a `QWEN.md` file to yo
|
||||
|
||||
Restart the CLI again. The model will now have the context from your `QWEN.md` file in every session where the extension is active.
|
||||
|
||||
## Step 6: Releasing Your Extension
|
||||
## Step 7: Releasing Your Extension
|
||||
|
||||
Once you are happy with your extension, you can share it with others. The two primary ways of releasing extensions are via a Git repository or through GitHub Releases. Using a public Git repository is the simplest method.
|
||||
|
||||
@@ -207,6 +292,7 @@ You've successfully created a Qwen Code extension! You learned how to:
|
||||
- Bootstrap a new extension from a template.
|
||||
- Add custom tools with an MCP server.
|
||||
- Create convenient custom commands.
|
||||
- Add custom skills and subagents.
|
||||
- Provide persistent context to the model.
|
||||
- Link your extension for local development.
|
||||
|
||||
290
docs/users/extension/introduction.md
Normal file
290
docs/users/extension/introduction.md
Normal file
@@ -0,0 +1,290 @@
|
||||
# Qwen Code Extensions
|
||||
|
||||
Qwen Code extensions package prompts, MCP servers, and custom commands into a familiar and user-friendly format. With extensions, you can expand the capabilities of Qwen Code and share those capabilities with others. They are designed to be easily installable and shareable.
|
||||
|
||||
This cross-platform compatibility gives you access to a rich ecosystem of extensions and plugins, dramatically expanding Qwen Code's capabilities without requiring extension authors to maintain separate versions.
|
||||
|
||||
## Extension management
|
||||
|
||||
We offer a suite of extension management tools using both `qwen extensions` CLI commands and `/extensions` slash commands within the interactive CLI.
|
||||
|
||||
### Runtime Extension Management (Slash Commands)
|
||||
|
||||
You can manage extensions at runtime within the interactive CLI using `/extensions` slash commands. These commands support hot-reloading, meaning changes take effect immediately without restarting the application.
|
||||
|
||||
| Command | Description |
|
||||
| ------------------------------------------------------ | ----------------------------------------------------------------- |
|
||||
| `/extensions` or `/extensions list` | List all installed extensions with their status |
|
||||
| `/extensions install <source>` | Install an extension from a git URL, local path, or marketplace |
|
||||
| `/extensions uninstall <name>` | Uninstall an extension |
|
||||
| `/extensions enable <name> --scope <user\|workspace>` | Enable an extension |
|
||||
| `/extensions disable <name> --scope <user\|workspace>` | Disable an extension |
|
||||
| `/extensions update <name>` | Update a specific extension |
|
||||
| `/extensions update --all` | Update all extensions with available updates |
|
||||
| `/extensions explore [source]` | Open extensions source page(Gemini or ClaudeCode) in your browser |
|
||||
|
||||
### CLI Extension Management
|
||||
|
||||
You can also manage extensions using `qwen extensions` CLI commands. Note that changes made via CLI commands will be reflected in active CLI sessions on restart.
|
||||
|
||||
### Installing an extension
|
||||
|
||||
You can install an extension using `qwen extensions install` from multiple sources:
|
||||
|
||||
#### From Gemini CLI Extensions Marketplace
|
||||
|
||||
Qwen Code fully supports extensions from the [Gemini CLI Extensions Marketplace](https://geminicli.com/extensions/). Simply install them using the git URL:
|
||||
|
||||
```bash
|
||||
qwen extensions install <gemini-cli-extension-url>
|
||||
```
|
||||
|
||||
Gemini extensions are automatically converted to Qwen Code format during installation:
|
||||
|
||||
- `gemini-extension.json` is converted to `qwen-extension.json`
|
||||
- TOML command files are automatically migrated to Markdown format
|
||||
- MCP servers, context files, and settings are preserved
|
||||
|
||||
#### From Claude Code Marketplace
|
||||
|
||||
Qwen Code also supports plugins from the [Claude Code Marketplace](https://claudemarketplaces.com/). Install them using the marketplace URL format:
|
||||
|
||||
```bash
|
||||
qwen extensions install <claude-code-marketplace-url>:<plugin-name>
|
||||
```
|
||||
|
||||
Claude plugins are automatically converted to Qwen Code format during installation:
|
||||
|
||||
- `claude-plugin.json` is converted to `qwen-extension.json`
|
||||
- Agent configurations are converted to Qwen subagent format
|
||||
- Skill configurations are converted to Qwen skill format
|
||||
- Tool mappings are automatically handled
|
||||
|
||||
> **Cross-Platform Compatibility**: This allows you to leverage the rich extension ecosystems from both Gemini CLI and Claude Code, dramatically expanding the available functionality for Qwen Code users.
|
||||
|
||||
#### From Git Repository
|
||||
|
||||
```bash
|
||||
qwen extensions install https://github.com/github/github-mcp-server
|
||||
```
|
||||
|
||||
This will install the github mcp server extension.
|
||||
|
||||
#### From Local Path
|
||||
|
||||
```bash
|
||||
qwen extensions install /path/to/your/extension
|
||||
```
|
||||
|
||||
Note that we create a copy of the installed extension, so you will need to run `qwen extensions update` to pull in changes from both locally-defined extensions and those on GitHub.
|
||||
|
||||
### Uninstalling an extension
|
||||
|
||||
To uninstall, run `qwen extensions uninstall extension-name`, so, in the case of the install example:
|
||||
|
||||
```
|
||||
qwen extensions uninstall qwen-cli-security
|
||||
```
|
||||
|
||||
### Disabling an extension
|
||||
|
||||
Extensions are, by default, enabled across all workspaces. You can disable an extension entirely or for specific workspace.
|
||||
|
||||
For example, `qwen extensions disable extension-name` will disable the extension at the user level, so it will be disabled everywhere. `qwen extensions disable extension-name --scope=workspace` will only disable the extension in the current workspace.
|
||||
|
||||
### Enabling an extension
|
||||
|
||||
You can enable extensions using `qwen extensions enable extension-name`. You can also enable an extension for a specific workspace using `qwen extensions enable extension-name --scope=workspace` from within that workspace.
|
||||
|
||||
This is useful if you have an extension disabled at the top-level and only enabled in specific places.
|
||||
|
||||
### Updating an extension
|
||||
|
||||
For extensions installed from a local path or a git repository, you can explicitly update to the latest version (as reflected in the `qwen-extension.json` `version` field) with `qwen extensions update extension-name`.
|
||||
|
||||
You can update all extensions with:
|
||||
|
||||
```
|
||||
qwen extensions update --all
|
||||
```
|
||||
|
||||
### Exploring Extension Marketplaces
|
||||
|
||||
You can quickly browse available extensions from different marketplaces using the `/extensions explore` command:
|
||||
|
||||
```bash
|
||||
# Open Gemini CLI Extensions marketplace
|
||||
/extensions explore Gemini
|
||||
|
||||
# Open Claude Code marketplace
|
||||
/extensions explore ClaudeCode
|
||||
```
|
||||
|
||||
This command opens the respective marketplace in your default browser, allowing you to discover new extensions to enhance your Qwen Code experience.
|
||||
|
||||
## How it works
|
||||
|
||||
On startup, Qwen Code looks for extensions in `<home>/.qwen/extensions`
|
||||
|
||||
Extensions exist as a directory that contains a `qwen-extension.json` file. For example:
|
||||
|
||||
`<home>/.qwen/extensions/my-extension/qwen-extension.json`
|
||||
|
||||
### `qwen-extension.json`
|
||||
|
||||
The `qwen-extension.json` file contains the configuration for the extension. The file has the following structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-extension",
|
||||
"version": "1.0.0",
|
||||
"mcpServers": {
|
||||
"my-server": {
|
||||
"command": "node my-server.js"
|
||||
}
|
||||
},
|
||||
"contextFileName": "QWEN.md",
|
||||
"commands": "commands",
|
||||
"skills": "skills",
|
||||
"agents": "agents",
|
||||
"settings": [
|
||||
{
|
||||
"name": "API Key",
|
||||
"description": "Your API key for the service",
|
||||
"envVar": "MY_API_KEY",
|
||||
"sensitive": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `name`: The name of the extension. This is used to uniquely identify the extension and for conflict resolution when extension commands have the same name as user or project commands. The name should be lowercase or numbers and use dashes instead of underscores or spaces. This is how users will refer to your extension in the CLI. Note that we expect this name to match the extension directory name.
|
||||
- `version`: The version of the extension.
|
||||
- `mcpServers`: A map of MCP servers to configure. The key is the name of the server, and the value is the server configuration. These servers will be loaded on startup just like MCP servers configured in a [`settings.json` file](./cli/configuration.md). If both an extension and a `settings.json` file configure an MCP server with the same name, the server defined in the `settings.json` file takes precedence.
|
||||
- Note that all MCP server configuration options are supported except for `trust`.
|
||||
- `contextFileName`: The name of the file that contains the context for the extension. This will be used to load the context from the extension directory. If this property is not used but a `QWEN.md` file is present in your extension directory, then that file will be loaded.
|
||||
- `commands`: The directory containing custom commands (default: `commands`). Commands are `.md` files that define prompts.
|
||||
- `skills`: The directory containing custom skills (default: `skills`). Skills are discovered automatically and become available via the `/skills` command.
|
||||
- `agents`: The directory containing custom subagents (default: `agents`). Subagents are `.yaml` or `.md` files that define specialized AI assistants.
|
||||
- `settings`: An array of settings that the extension requires. When installing, users will be prompted to provide values for these settings. The values are stored securely and passed to MCP servers as environment variables.
|
||||
- Each setting has the following properties:
|
||||
- `name`: Display name for the setting
|
||||
- `description`: A description of what this setting is used for
|
||||
- `envVar`: The environment variable name that will be set
|
||||
- `sensitive`: Boolean indicating if the value should be hidden (e.g., API keys, passwords)
|
||||
|
||||
### Managing Extension Settings
|
||||
|
||||
Extensions can require configuration through settings (such as API keys or credentials). These settings can be managed using the `qwen extensions settings` CLI command:
|
||||
|
||||
**Set a setting value:**
|
||||
|
||||
```bash
|
||||
qwen extensions settings set <extension-name> <setting-name> [--scope user|workspace]
|
||||
```
|
||||
|
||||
**List all settings for an extension:**
|
||||
|
||||
```bash
|
||||
qwen extensions settings list <extension-name>
|
||||
```
|
||||
|
||||
**View current values (user and workspace):**
|
||||
|
||||
```bash
|
||||
qwen extensions settings show <extension-name> <setting-name>
|
||||
```
|
||||
|
||||
**Remove a setting value:**
|
||||
|
||||
```bash
|
||||
qwen extensions settings unset <extension-name> <setting-name> [--scope user|workspace]
|
||||
```
|
||||
|
||||
Settings can be configured at two levels:
|
||||
|
||||
- **User level** (default): Settings apply across all projects (`~/.qwen/.env`)
|
||||
- **Workspace level**: Settings apply only to the current project (`.qwen/.env`)
|
||||
|
||||
Workspace settings take precedence over user settings. Sensitive settings are stored securely and never displayed in plain text.
|
||||
|
||||
When Qwen Code starts, it loads all the extensions and merges their configurations. If there are any conflicts, the workspace configuration takes precedence.
|
||||
|
||||
### Custom commands
|
||||
|
||||
Extensions can provide [custom commands](./cli/commands.md#custom-commands) by placing Markdown files in a `commands/` subdirectory within the extension directory. These commands follow the same format as user and project custom commands and use standard naming conventions.
|
||||
|
||||
> **Note:** The command format has been updated from TOML to Markdown. TOML files are deprecated but still supported. You can migrate existing TOML commands using the automatic migration prompt that appears when TOML files are detected.
|
||||
|
||||
**Example**
|
||||
|
||||
An extension named `gcp` with the following structure:
|
||||
|
||||
```
|
||||
.qwen/extensions/gcp/
|
||||
├── qwen-extension.json
|
||||
└── commands/
|
||||
├── deploy.md
|
||||
└── gcs/
|
||||
└── sync.md
|
||||
```
|
||||
|
||||
Would provide these commands:
|
||||
|
||||
- `/deploy` - Shows as `[gcp] Custom command from deploy.md` in help
|
||||
- `/gcs:sync` - Shows as `[gcp] Custom command from sync.md` in help
|
||||
|
||||
### Custom skills
|
||||
|
||||
Extensions can provide custom skills by placing skill files in a `skills/` subdirectory within the extension directory. Each skill should have a `SKILL.md` file with YAML frontmatter defining the skill's name and description.
|
||||
|
||||
**Example**
|
||||
|
||||
```
|
||||
.qwen/extensions/my-extension/
|
||||
├── qwen-extension.json
|
||||
└── skills/
|
||||
└── pdf-processor/
|
||||
└── SKILL.md
|
||||
```
|
||||
|
||||
The skill will be available via the `/skills` command when the extension is active.
|
||||
|
||||
### Custom subagents
|
||||
|
||||
Extensions can provide custom subagents by placing agent configuration files in an `agents/` subdirectory within the extension directory. Agents are defined using YAML or Markdown files.
|
||||
|
||||
**Example**
|
||||
|
||||
```
|
||||
.qwen/extensions/my-extension/
|
||||
├── qwen-extension.json
|
||||
└── agents/
|
||||
└── testing-expert.yaml
|
||||
```
|
||||
|
||||
Extension subagents appear in the subagent manager dialog under "Extension Agents" section.
|
||||
|
||||
### Conflict resolution
|
||||
|
||||
Extension commands have the lowest precedence. When a conflict occurs with user or project commands:
|
||||
|
||||
1. **No conflict**: Extension command uses its natural name (e.g., `/deploy`)
|
||||
2. **With conflict**: Extension command is renamed with the extension prefix (e.g., `/gcp.deploy`)
|
||||
|
||||
For example, if both a user and the `gcp` extension define a `deploy` command:
|
||||
|
||||
- `/deploy` - Executes the user's deploy command
|
||||
- `/gcp.deploy` - Executes the extension's deploy command (marked with `[gcp]` tag)
|
||||
|
||||
## Variables
|
||||
|
||||
Qwen Code extensions allow variable substitution in `qwen-extension.json`. This can be useful if e.g., you need the current directory to run an MCP server using `"cwd": "${extensionPath}${/}run.ts"`.
|
||||
|
||||
**Supported variables:**
|
||||
|
||||
| variable | description |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `${extensionPath}` | The fully-qualified path of the extension in the user's filesystem e.g., '/Users/username/.qwen/extensions/example-extension'. This will not unwrap symlinks. |
|
||||
| `${workspacePath}` | The fully-qualified path of the current workspace. |
|
||||
| `${/} or ${pathSeparator}` | The path separator (differs per OS). |
|
||||
@@ -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 |
|
||||
@@ -120,6 +121,8 @@ Environment Variables: Commands executed via `!` will set the `QWEN_CODE=1` envi
|
||||
|
||||
Save frequently used prompts as shortcut commands to improve work efficiency and ensure consistency.
|
||||
|
||||
> **Note:** Custom commands now use Markdown format with optional YAML frontmatter. TOML format is deprecated but still supported for backwards compatibility. When TOML files are detected, an automatic migration prompt will be displayed.
|
||||
|
||||
### Quick Overview
|
||||
|
||||
| Function | Description | Advantages | Priority | Applicable Scenarios |
|
||||
@@ -134,14 +137,34 @@ Priority Rules: Project commands > User commands (project command used when name
|
||||
|
||||
#### File Path to Command Name Mapping Table
|
||||
|
||||
| File Location | Generated Command | Example Call |
|
||||
| ---------------------------- | ----------------- | --------------------- |
|
||||
| `~/.qwen/commands/test.toml` | `/test` | `/test Parameter` |
|
||||
| `<project>/git/commit.toml` | `/git:commit` | `/git:commit Message` |
|
||||
| File Location | Generated Command | Example Call |
|
||||
| -------------------------- | ----------------- | --------------------- |
|
||||
| `~/.qwen/commands/test.md` | `/test` | `/test Parameter` |
|
||||
| `<project>/git/commit.md` | `/git:commit` | `/git:commit Message` |
|
||||
|
||||
Naming Rules: Path separator (`/` or `\`) converted to colon (`:`)
|
||||
|
||||
### TOML File Format Specification
|
||||
### Markdown File Format Specification (Recommended)
|
||||
|
||||
Custom commands use Markdown files with optional YAML frontmatter:
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: Optional description (displayed in /help)
|
||||
---
|
||||
|
||||
Your prompt content here.
|
||||
Use {{args}} for parameter injection.
|
||||
```
|
||||
|
||||
| Field | Required | Description | Example |
|
||||
| ------------- | -------- | ---------------------------------------- | ------------------------------------------ |
|
||||
| `description` | Optional | Command description (displayed in /help) | `description: Code analysis tool` |
|
||||
| Prompt body | Required | Prompt content sent to model | Any Markdown content after the frontmatter |
|
||||
|
||||
### TOML File Format (Deprecated)
|
||||
|
||||
> **Deprecated:** TOML format is still supported but will be removed in a future version. Please migrate to Markdown format.
|
||||
|
||||
| Field | Required | Description | Example |
|
||||
| ------------- | -------- | ---------------------------------------- | ------------------------------------------ |
|
||||
@@ -190,15 +213,19 @@ Naming Rules: Path separator (`/` or `\`) converted to colon (`:`)
|
||||
|
||||
Example: Git Commit Message Generation
|
||||
|
||||
```
|
||||
# git/commit.toml
|
||||
description = "Generate Commit message based on staged changes"
|
||||
prompt = """
|
||||
````markdown
|
||||
---
|
||||
description: Generate Commit message based on staged changes
|
||||
---
|
||||
|
||||
Please generate a Commit message based on the following diff:
|
||||
diff
|
||||
|
||||
```diff
|
||||
!{git diff --staged}
|
||||
"""
|
||||
```
|
||||
````
|
||||
|
||||
````
|
||||
|
||||
#### 4. File Content Injection (`@{...}`)
|
||||
|
||||
@@ -211,36 +238,38 @@ diff
|
||||
|
||||
Example: Code Review Command
|
||||
|
||||
```
|
||||
# review.toml
|
||||
description = "Code review based on best practices"
|
||||
prompt = """
|
||||
```markdown
|
||||
---
|
||||
description: Code review based on best practices
|
||||
---
|
||||
|
||||
Review {{args}}, reference standards:
|
||||
|
||||
@{docs/code-standards.md}
|
||||
"""
|
||||
```
|
||||
````
|
||||
|
||||
### Practical Creation Example
|
||||
|
||||
#### "Pure Function Refactoring" Command Creation Steps Table
|
||||
|
||||
| Operation | Command/Code |
|
||||
| ----------------------------- | ------------------------------------------- |
|
||||
| 1. Create directory structure | `mkdir -p ~/.qwen/commands/refactor` |
|
||||
| 2. Create command file | `touch ~/.qwen/commands/refactor/pure.toml` |
|
||||
| 3. Edit command content | Refer to the complete code below. |
|
||||
| 4. Test command | `@file.js` → `/refactor:pure` |
|
||||
| Operation | Command/Code |
|
||||
| ----------------------------- | ----------------------------------------- |
|
||||
| 1. Create directory structure | `mkdir -p ~/.qwen/commands/refactor` |
|
||||
| 2. Create command file | `touch ~/.qwen/commands/refactor/pure.md` |
|
||||
| 3. Edit command content | Refer to the complete code below. |
|
||||
| 4. Test command | `@file.js` → `/refactor:pure` |
|
||||
|
||||
```# ~/.qwen/commands/refactor/pure.toml
|
||||
description = "Refactor code to pure function"
|
||||
prompt = """
|
||||
Please analyze code in current context, refactor to pure function.
|
||||
Requirements:
|
||||
1. Provide refactored code
|
||||
2. Explain key changes and pure function characteristic implementation
|
||||
3. Maintain function unchanged
|
||||
"""
|
||||
```markdown
|
||||
---
|
||||
description: Refactor code to pure function
|
||||
---
|
||||
|
||||
Please analyze code in current context, refactor to pure function.
|
||||
Requirements:
|
||||
|
||||
1. Provide refactored code
|
||||
2. Explain key changes and pure function characteristic implementation
|
||||
3. Maintain function unchanged
|
||||
```
|
||||
|
||||
### Custom Command Best Practices Summary
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,12 +11,29 @@ This guide shows you how to create, use, and manage Agent Skills in **Qwen Code*
|
||||
## Prerequisites
|
||||
|
||||
- Qwen Code (recent version)
|
||||
- Run with the experimental flag enabled:
|
||||
|
||||
## How to enable
|
||||
|
||||
### Via CLI flag
|
||||
|
||||
```bash
|
||||
qwen --experimental-skills
|
||||
```
|
||||
|
||||
### Via settings.json
|
||||
|
||||
Add to your `~/.qwen/settings.json` or project's `.qwen/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"experimental": {
|
||||
"skills": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- Basic familiarity with Qwen Code ([Quickstart](../quickstart.md))
|
||||
|
||||
## What are Agent Skills?
|
||||
@@ -27,6 +44,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 Skill’s 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
|
||||
@@ -132,6 +157,18 @@ When `--experimental-skills` is enabled, Qwen Code discovers Skills from:
|
||||
|
||||
- Personal Skills: `~/.qwen/skills/`
|
||||
- Project Skills: `.qwen/skills/`
|
||||
- Extension Skills: Skills provided by installed extensions
|
||||
|
||||
### Extension Skills
|
||||
|
||||
Extensions can provide custom skills that become available when the extension is enabled. These skills are stored in the extension's `skills/` directory and follow the same format as personal and project skills.
|
||||
|
||||
Extension skills are automatically discovered and loaded when:
|
||||
|
||||
- The extension is installed and enabled
|
||||
- The `--experimental-skills` flag is enabled
|
||||
|
||||
To see which extensions provide skills, check the extension's `qwen-extension.json` file for a `skills` field.
|
||||
|
||||
To view available Skills, ask Qwen Code directly:
|
||||
|
||||
|
||||
@@ -6,11 +6,11 @@ Subagents are specialized AI assistants that handle specific types of tasks with
|
||||
|
||||
Subagents are independent AI assistants that:
|
||||
|
||||
- **Specialize in specific tasks** - Each Subagent is configured with a focused system prompt for particular types of work
|
||||
- **Have separate context** - They maintain their own conversation history, separate from your main chat
|
||||
- **Use controlled tools** - You can configure which tools each Subagent has access to
|
||||
- **Work autonomously** - Once given a task, they work independently until completion or failure
|
||||
- **Provide detailed feedback** - You can see their progress, tool usage, and execution statistics in real-time
|
||||
- **Specialize in specific tasks** - Each Subagent is configured with a focused system prompt for particular types of work
|
||||
- **Have separate context** - They maintain their own conversation history, separate from your main chat
|
||||
- **Use controlled tools** - You can configure which tools each Subagent has access to
|
||||
- **Work autonomously** - Once given a task, they work independently until completion or failure
|
||||
- **Provide detailed feedback** - You can see their progress, tool usage, and execution statistics in real-time
|
||||
|
||||
## Key Benefits
|
||||
|
||||
@@ -59,7 +59,7 @@ AI: I'll delegate this to your testing specialist Subagents.
|
||||
|
||||
### CLI Commands
|
||||
|
||||
Subagents are managed through the `/agents` slash command and its subcommands:
|
||||
Subagents are managed through the `/agents` slash command and its subcommands:
|
||||
|
||||
**Usage:**:`/agents create`。Creates a new Subagent through a guided step wizard.
|
||||
|
||||
@@ -67,12 +67,26 @@ Subagents are managed through the `/agents` slash command and its subcommands:
|
||||
|
||||
### Storage Locations
|
||||
|
||||
Subagents are stored as Markdown files in two locations:
|
||||
Subagents are stored as Markdown files in multiple locations:
|
||||
|
||||
- **Project-level**: `.qwen/agents/` (takes precedence)
|
||||
- **User-level**: `~/.qwen/agents/` (fallback)
|
||||
- **Project-level**: `.qwen/agents/` (highest precedence)
|
||||
- **User-level**: `~/.qwen/agents/` (fallback)
|
||||
- **Extension-level**: Provided by installed extensions
|
||||
|
||||
This allows you to have both project-specific agents and personal agents that work across all projects.
|
||||
This allows you to have project-specific agents, personal agents that work across all projects, and extension-provided agents that add specialized capabilities.
|
||||
|
||||
### Extension Subagents
|
||||
|
||||
Extensions can provide custom subagents that become available when the extension is enabled. These agents are stored in the extension's `agents/` directory and follow the same format as personal and project agents.
|
||||
|
||||
Extension subagents:
|
||||
|
||||
- Are automatically discovered when the extension is enabled
|
||||
- Appear in the `/agents manage` dialog under "Extension Agents" section
|
||||
- Cannot be edited directly (edit the extension source instead)
|
||||
- Follow the same configuration format as user-defined agents
|
||||
|
||||
To see which extensions provide subagents, check the extension's `qwen-extension.json` file for an `agents` field.
|
||||
|
||||
### File Format
|
||||
|
||||
@@ -398,7 +412,7 @@ description: Helps with testing, documentation, code review, and deployment
|
||||
---
|
||||
```
|
||||
|
||||
**Why:** Focused agents produce better results and are easier to maintain.
|
||||
**Why:** Focused agents produce better results and are easier to maintain.
|
||||
|
||||
#### Clear Specialization
|
||||
|
||||
@@ -422,7 +436,7 @@ description: Works on frontend development tasks
|
||||
---
|
||||
```
|
||||
|
||||
**Why:** Specific expertise leads to more targeted and effective assistance.
|
||||
**Why:** Specific expertise leads to more targeted and effective assistance.
|
||||
|
||||
#### Actionable Descriptions
|
||||
|
||||
@@ -440,7 +454,7 @@ description: Reviews code for security vulnerabilities, performance issues, and
|
||||
description: A helpful code reviewer
|
||||
```
|
||||
|
||||
**Why:** Clear descriptions help the main AI choose the right agent for each task.
|
||||
**Why:** Clear descriptions help the main AI choose the right agent for each task.
|
||||
|
||||
### Configuration Best Practices
|
||||
|
||||
|
||||
57
docs/users/integration-jetbrains.md
Normal file
57
docs/users/integration-jetbrains.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# JetBrains IDEs
|
||||
|
||||
> JetBrains IDEs provide native support for AI coding assistants through the Agent Client Protocol (ACP). This integration allows you to use Qwen Code directly within your JetBrains IDE with real-time code suggestions.
|
||||
|
||||
### Features
|
||||
|
||||
- **Native agent experience**: Integrated AI assistant panel within your JetBrains IDE
|
||||
- **Agent Client Protocol**: Full support for ACP enabling advanced IDE interactions
|
||||
- **Symbol management**: #-mention files to add them to the conversation context
|
||||
- **Conversation history**: Access to past conversations within the IDE
|
||||
|
||||
### 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
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
@@ -18,23 +18,17 @@
|
||||
|
||||
### Requirements
|
||||
|
||||
- VS Code 1.98.0 or higher
|
||||
- VS Code 1.85.0 or higher
|
||||
|
||||
### Installation
|
||||
|
||||
1. Install Qwen Code CLI:
|
||||
|
||||
```bash
|
||||
npm install -g qwen-code
|
||||
```
|
||||
|
||||
2. Download and install the extension from the [Visual Studio Code Extension Marketplace](https://marketplace.visualstudio.com/items?itemName=qwenlm.qwen-code-vscode-ide-companion).
|
||||
Download and install the extension from the [Visual Studio Code Extension Marketplace](https://marketplace.visualstudio.com/items?itemName=qwenlm.qwen-code-vscode-ide-companion).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Zed Editor
|
||||
|
||||
> Zed Editor provides native support for AI coding assistants through the Agent Control Protocol (ACP). This integration allows you to use Qwen Code directly within Zed's interface with real-time code suggestions.
|
||||
> Zed Editor provides native support for AI coding assistants through the Agent Client Protocol (ACP). This integration allows you to use Qwen Code directly within Zed's interface with real-time code suggestions.
|
||||
|
||||

|
||||
|
||||
@@ -20,9 +20,9 @@
|
||||
|
||||
1. Install Qwen Code CLI:
|
||||
|
||||
```bash
|
||||
npm install -g qwen-code
|
||||
```
|
||||
```bash
|
||||
npm install -g @qwen-code/qwen-code
|
||||
```
|
||||
|
||||
2. Download and install [Zed Editor](https://zed.dev/)
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"Qwen Code": {
|
||||
"type": "custom",
|
||||
"command": "qwen",
|
||||
"args": ["--experimental-acp"],
|
||||
"args": ["--acp"],
|
||||
"env": {}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Qwen Code overview
|
||||
[](https://npm-compare.com/@qwen-code/qwen-code)
|
||||
|
||||
[](https://npm-compare.com/@qwen-code/qwen-code)
|
||||
[](https://www.npmjs.com/package/@qwen-code/qwen-code)
|
||||
|
||||
> Learn about Qwen Code, Qwen's agentic coding tool that lives in your terminal and helps you turn ideas into code faster than ever before.
|
||||
|
||||
@@ -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**
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ This document lists the available keyboard shortcuts in Qwen Code.
|
||||
| Shortcut | Description |
|
||||
| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `!` | Toggle shell mode when the input is empty. |
|
||||
| `?` | Toggle keyboard shortcuts display when the input is empty. |
|
||||
| `\` (at end of line) + `Enter` | Insert a newline. |
|
||||
| `Down Arrow` | Navigate down through the input history. |
|
||||
| `Enter` | Submit the current prompt. |
|
||||
@@ -38,6 +39,7 @@ This document lists the available keyboard shortcuts in Qwen Code.
|
||||
| `Ctrl+Left Arrow` / `Meta+Left Arrow` / `Meta+B` | Move the cursor one word to the left. |
|
||||
| `Ctrl+N` | Navigate down through the input history. |
|
||||
| `Ctrl+P` | Navigate up through the input history. |
|
||||
| `Ctrl+R` | Reverse search through input/shell history. |
|
||||
| `Ctrl+Right Arrow` / `Meta+Right Arrow` / `Meta+F` | Move the cursor one word to the right. |
|
||||
| `Ctrl+U` | Delete from the cursor to the beginning of the line. |
|
||||
| `Ctrl+V` | Paste clipboard content. If the clipboard contains an image, it will be saved and a reference to it will be inserted in the prompt. |
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -33,7 +33,6 @@ const external = [
|
||||
'@lydell/node-pty-linux-x64',
|
||||
'@lydell/node-pty-win32-arm64',
|
||||
'@lydell/node-pty-win32-x64',
|
||||
'tiktoken',
|
||||
];
|
||||
|
||||
esbuild
|
||||
|
||||
@@ -80,10 +80,11 @@ type PermissionHandler = (
|
||||
|
||||
/**
|
||||
* Sets up an ACP test environment with all necessary utilities.
|
||||
* @param useNewFlag - If true, uses --acp; if false, uses --experimental-acp (for backward compatibility testing)
|
||||
*/
|
||||
function setupAcpTest(
|
||||
rig: TestRig,
|
||||
options?: { permissionHandler?: PermissionHandler },
|
||||
options?: { permissionHandler?: PermissionHandler; useNewFlag?: boolean },
|
||||
) {
|
||||
const pending = new Map<number, PendingRequest>();
|
||||
let nextRequestId = 1;
|
||||
@@ -95,9 +96,13 @@ function setupAcpTest(
|
||||
const permissionHandler =
|
||||
options?.permissionHandler ?? (() => ({ optionId: 'proceed_once' }));
|
||||
|
||||
// Use --acp by default, but allow testing with --experimental-acp for backward compatibility
|
||||
const acpFlag =
|
||||
options?.useNewFlag !== false ? '--acp' : '--experimental-acp';
|
||||
|
||||
const agent = spawn(
|
||||
'node',
|
||||
[rig.bundlePath, '--experimental-acp', '--no-chat-recording'],
|
||||
[rig.bundlePath, acpFlag, '--no-chat-recording'],
|
||||
{
|
||||
cwd: rig.testDir!,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
@@ -306,9 +311,9 @@ function setupAcpTest(
|
||||
}
|
||||
});
|
||||
|
||||
it('returns modes on initialize and allows setting approval mode', async () => {
|
||||
it('returns modes on initialize and allows setting mode and model', async () => {
|
||||
const rig = new TestRig();
|
||||
rig.setup('acp approval mode');
|
||||
rig.setup('acp mode and model');
|
||||
|
||||
const { sendRequest, cleanup, stderr } = setupAcpTest(rig);
|
||||
|
||||
@@ -361,8 +366,14 @@ function setupAcpTest(
|
||||
const newSession = (await sendRequest('session/new', {
|
||||
cwd: rig.testDir!,
|
||||
mcpServers: [],
|
||||
})) as { sessionId: string };
|
||||
})) as {
|
||||
sessionId: string;
|
||||
models: {
|
||||
availableModels: Array<{ modelId: string }>;
|
||||
};
|
||||
};
|
||||
expect(newSession.sessionId).toBeTruthy();
|
||||
expect(newSession.models.availableModels.length).toBeGreaterThan(0);
|
||||
|
||||
// Test 4: Set approval mode to 'yolo'
|
||||
const setModeResult = (await sendRequest('session/set_mode', {
|
||||
@@ -387,6 +398,15 @@ function setupAcpTest(
|
||||
})) as { modeId: string };
|
||||
expect(setModeResult3).toBeDefined();
|
||||
expect(setModeResult3.modeId).toBe('default');
|
||||
|
||||
// Test 7: Set model using first available model
|
||||
const firstModel = newSession.models.availableModels[0];
|
||||
const setModelResult = (await sendRequest('session/set_model', {
|
||||
sessionId: newSession.sessionId,
|
||||
modelId: firstModel.modelId,
|
||||
})) as { modelId: string };
|
||||
expect(setModelResult).toBeDefined();
|
||||
expect(setModelResult.modelId).toBeTruthy();
|
||||
} catch (e) {
|
||||
if (stderr.length) {
|
||||
console.error('Agent stderr:', stderr.join(''));
|
||||
@@ -621,3 +641,99 @@ function setupAcpTest(
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
(IS_SANDBOX ? describe.skip : describe)(
|
||||
'acp flag backward compatibility',
|
||||
() => {
|
||||
it('should work with deprecated --experimental-acp flag and show warning', async () => {
|
||||
const rig = new TestRig();
|
||||
rig.setup('acp backward compatibility');
|
||||
|
||||
const { sendRequest, cleanup, stderr } = setupAcpTest(rig, {
|
||||
useNewFlag: false,
|
||||
});
|
||||
|
||||
try {
|
||||
const initResult = await sendRequest('initialize', {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
fs: { readTextFile: true, writeTextFile: true },
|
||||
},
|
||||
});
|
||||
expect(initResult).toBeDefined();
|
||||
|
||||
// Verify deprecation warning is shown
|
||||
const stderrOutput = stderr.join('');
|
||||
expect(stderrOutput).toContain('--experimental-acp is deprecated');
|
||||
expect(stderrOutput).toContain('Please use --acp instead');
|
||||
|
||||
await sendRequest('authenticate', { methodId: 'openai' });
|
||||
|
||||
const newSession = (await sendRequest('session/new', {
|
||||
cwd: rig.testDir!,
|
||||
mcpServers: [],
|
||||
})) as { sessionId: string };
|
||||
expect(newSession.sessionId).toBeTruthy();
|
||||
|
||||
// Verify functionality still works
|
||||
const promptResult = await sendRequest('session/prompt', {
|
||||
sessionId: newSession.sessionId,
|
||||
prompt: [{ type: 'text', text: 'Say hello.' }],
|
||||
});
|
||||
expect(promptResult).toBeDefined();
|
||||
} catch (e) {
|
||||
if (stderr.length) {
|
||||
console.error('Agent stderr:', stderr.join(''));
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('should work with new --acp flag without warnings', async () => {
|
||||
const rig = new TestRig();
|
||||
rig.setup('acp new flag');
|
||||
|
||||
const { sendRequest, cleanup, stderr } = setupAcpTest(rig, {
|
||||
useNewFlag: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const initResult = await sendRequest('initialize', {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
fs: { readTextFile: true, writeTextFile: true },
|
||||
},
|
||||
});
|
||||
expect(initResult).toBeDefined();
|
||||
|
||||
// Verify no deprecation warning is shown
|
||||
const stderrOutput = stderr.join('');
|
||||
expect(stderrOutput).not.toContain('--experimental-acp is deprecated');
|
||||
|
||||
await sendRequest('authenticate', { methodId: 'openai' });
|
||||
|
||||
const newSession = (await sendRequest('session/new', {
|
||||
cwd: rig.testDir!,
|
||||
mcpServers: [],
|
||||
})) as { sessionId: string };
|
||||
expect(newSession.sessionId).toBeTruthy();
|
||||
|
||||
// Verify functionality works
|
||||
const promptResult = await sendRequest('session/prompt', {
|
||||
sessionId: newSession.sessionId,
|
||||
prompt: [{ type: 'text', text: 'Say hello.' }],
|
||||
});
|
||||
expect(promptResult).toBeDefined();
|
||||
} catch (e) {
|
||||
if (stderr.length) {
|
||||
console.error('Agent stderr:', stderr.join(''));
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -831,7 +831,7 @@ describe('Permission Control (E2E)', () => {
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
it.skip(
|
||||
'should execute dangerous commands without confirmation',
|
||||
async () => {
|
||||
const q = query({
|
||||
|
||||
77
package-lock.json
generated
77
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.0-preview.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.0-preview.1",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -3875,6 +3875,17 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/prompts": {
|
||||
"version": "2.4.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/prompts/-/prompts-2.4.9.tgz",
|
||||
"integrity": "sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"kleur": "^3.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
@@ -6216,10 +6227,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"
|
||||
},
|
||||
@@ -10984,6 +10992,15 @@
|
||||
"json-buffer": "3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/kleur": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
|
||||
"integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/ky": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/ky/-/ky-1.8.1.tgz",
|
||||
@@ -13393,6 +13410,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prompts": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
|
||||
"integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"kleur": "^3.0.3",
|
||||
"sisteransi": "^1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
@@ -13882,10 +13912,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"
|
||||
},
|
||||
@@ -14753,6 +14780,12 @@
|
||||
"url": "https://github.com/steveukx/git-js?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/sisteransi": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
|
||||
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/slash": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz",
|
||||
@@ -15688,12 +15721,6 @@
|
||||
"tslib": "^2"
|
||||
}
|
||||
},
|
||||
"node_modules/tiktoken": {
|
||||
"version": "1.0.22",
|
||||
"resolved": "https://registry.npmjs.org/tiktoken/-/tiktoken-1.0.22.tgz",
|
||||
"integrity": "sha512-PKvy1rVF1RibfF3JlXBSP0Jrcw2uq3yXdgcEXtKTYn3QJ/cBRBHDnrJ5jHky+MENZ6DIPwNUGWpkVx+7joCpNA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
@@ -17316,7 +17343,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.0-preview.1",
|
||||
"dependencies": {
|
||||
"@google/genai": "1.30.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
@@ -17328,7 +17355,6 @@
|
||||
"comment-json": "^4.2.5",
|
||||
"diff": "^7.0.0",
|
||||
"dotenv": "^17.1.0",
|
||||
"extract-zip": "^2.0.1",
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^10.5.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
@@ -17346,7 +17372,6 @@
|
||||
"string-width": "^7.1.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"strip-json-comments": "^3.1.1",
|
||||
"tar": "^7.5.2",
|
||||
"undici": "^6.22.0",
|
||||
"update-notifier": "^7.3.1",
|
||||
"wrap-ansi": "9.0.2",
|
||||
@@ -17370,7 +17395,6 @@
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/tar": "^6.1.13",
|
||||
"@types/yargs": "^17.0.32",
|
||||
"archiver": "^7.0.1",
|
||||
"ink-testing-library": "^4.0.0",
|
||||
@@ -17953,11 +17977,12 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@qwen-code/qwen-code-core",
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.0-preview.1",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.36.1",
|
||||
"@google/genai": "1.30.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/exporter-logs-otlp-grpc": "^0.203.0",
|
||||
@@ -17974,8 +17999,10 @@
|
||||
"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",
|
||||
"extract-zip": "^2.0.1",
|
||||
"fast-levenshtein": "^2.0.6",
|
||||
"fast-uri": "^3.0.6",
|
||||
"fdir": "^6.4.6",
|
||||
@@ -17992,10 +18019,11 @@
|
||||
"open": "^10.1.2",
|
||||
"openai": "5.11.0",
|
||||
"picomatch": "^4.0.1",
|
||||
"prompts": "^2.4.2",
|
||||
"shell-quote": "^1.8.3",
|
||||
"simple-git": "^3.28.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"tiktoken": "^1.0.21",
|
||||
"tar": "^7.5.2",
|
||||
"undici": "^6.22.0",
|
||||
"uuid": "^9.0.1",
|
||||
"ws": "^8.18.0"
|
||||
@@ -18007,6 +18035,8 @@
|
||||
"@types/fast-levenshtein": "^0.0.4",
|
||||
"@types/minimatch": "^5.1.2",
|
||||
"@types/picomatch": "^4.0.1",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@types/tar": "^6.1.13",
|
||||
"@types/ws": "^8.5.10",
|
||||
"msw": "^2.3.4",
|
||||
"typescript": "^5.3.3",
|
||||
@@ -18593,11 +18623,10 @@
|
||||
},
|
||||
"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",
|
||||
"tiktoken": "^1.0.21",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -21413,7 +21442,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@qwen-code/qwen-code-test-utils",
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.0-preview.1",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
@@ -21425,7 +21454,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "qwen-code-vscode-ide-companion",
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.0-preview.1",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.0-preview.1",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -13,7 +13,7 @@
|
||||
"url": "git+https://github.com/QwenLM/qwen-code.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.0"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.8.0-preview.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env node scripts/start.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.0-preview.1",
|
||||
"description": "Qwen Code",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -33,13 +33,13 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.0"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.8.0-preview.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "1.30.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@qwen-code/qwen-code-core": "file:../core",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"@qwen-code/qwen-code-core": "file:../core",
|
||||
"@types/update-notifier": "^6.0.8",
|
||||
"ansi-regex": "^6.2.2",
|
||||
"command-exists": "^1.2.9",
|
||||
@@ -63,9 +63,7 @@
|
||||
"string-width": "^7.1.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"strip-json-comments": "^3.1.1",
|
||||
"tar": "^7.5.2",
|
||||
"undici": "^6.22.0",
|
||||
"extract-zip": "^2.0.1",
|
||||
"update-notifier": "^7.3.1",
|
||||
"wrap-ansi": "9.0.2",
|
||||
"yargs": "^17.7.2",
|
||||
@@ -74,6 +72,7 @@
|
||||
"devDependencies": {
|
||||
"@babel/runtime": "^7.27.6",
|
||||
"@google/gemini-cli-test-utils": "file:../test-utils",
|
||||
"@qwen-code/qwen-code-test-utils": "file:../test-utils",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/archiver": "^6.0.3",
|
||||
"@types/command-exists": "^1.2.3",
|
||||
@@ -84,7 +83,6 @@
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/tar": "^6.1.13",
|
||||
"@types/yargs": "^17.0.32",
|
||||
"archiver": "^7.0.1",
|
||||
"ink-testing-library": "^4.0.0",
|
||||
@@ -92,8 +90,7 @@
|
||||
"pretty-format": "^30.0.2",
|
||||
"react-dom": "^19.1.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^3.1.1",
|
||||
"@qwen-code/qwen-code-test-utils": "file:../test-utils"
|
||||
"vitest": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { z } from 'zod';
|
||||
import * as schema from './schema.js';
|
||||
import { ACP_ERROR_CODES } from './errorCodes.js';
|
||||
export * from './schema.js';
|
||||
|
||||
import type { WritableStream, ReadableStream } from 'node:stream/web';
|
||||
@@ -70,6 +71,13 @@ export class AgentSideConnection implements Client {
|
||||
const validatedParams = schema.setModeRequestSchema.parse(params);
|
||||
return agent.setMode(validatedParams);
|
||||
}
|
||||
case schema.AGENT_METHODS.session_set_model: {
|
||||
if (!agent.setModel) {
|
||||
throw RequestError.methodNotFound();
|
||||
}
|
||||
const validatedParams = schema.setModelRequestSchema.parse(params);
|
||||
return agent.setModel(validatedParams);
|
||||
}
|
||||
default:
|
||||
throw RequestError.methodNotFound(method);
|
||||
}
|
||||
@@ -342,27 +350,51 @@ export class RequestError extends Error {
|
||||
}
|
||||
|
||||
static parseError(details?: string): RequestError {
|
||||
return new RequestError(-32700, 'Parse error', details);
|
||||
return new RequestError(
|
||||
ACP_ERROR_CODES.PARSE_ERROR,
|
||||
'Parse error',
|
||||
details,
|
||||
);
|
||||
}
|
||||
|
||||
static invalidRequest(details?: string): RequestError {
|
||||
return new RequestError(-32600, 'Invalid request', details);
|
||||
return new RequestError(
|
||||
ACP_ERROR_CODES.INVALID_REQUEST,
|
||||
'Invalid request',
|
||||
details,
|
||||
);
|
||||
}
|
||||
|
||||
static methodNotFound(details?: string): RequestError {
|
||||
return new RequestError(-32601, 'Method not found', details);
|
||||
return new RequestError(
|
||||
ACP_ERROR_CODES.METHOD_NOT_FOUND,
|
||||
'Method not found',
|
||||
details,
|
||||
);
|
||||
}
|
||||
|
||||
static invalidParams(details?: string): RequestError {
|
||||
return new RequestError(-32602, 'Invalid params', details);
|
||||
return new RequestError(
|
||||
ACP_ERROR_CODES.INVALID_PARAMS,
|
||||
'Invalid params',
|
||||
details,
|
||||
);
|
||||
}
|
||||
|
||||
static internalError(details?: string): RequestError {
|
||||
return new RequestError(-32603, 'Internal error', details);
|
||||
return new RequestError(
|
||||
ACP_ERROR_CODES.INTERNAL_ERROR,
|
||||
'Internal error',
|
||||
details,
|
||||
);
|
||||
}
|
||||
|
||||
static authRequired(details?: string): RequestError {
|
||||
return new RequestError(-32000, 'Authentication required', details);
|
||||
return new RequestError(
|
||||
ACP_ERROR_CODES.AUTH_REQUIRED,
|
||||
'Authentication required',
|
||||
details,
|
||||
);
|
||||
}
|
||||
|
||||
toResult<T>(): Result<T> {
|
||||
@@ -408,4 +440,5 @@ export interface Agent {
|
||||
prompt(params: schema.PromptRequest): Promise<schema.PromptResponse>;
|
||||
cancel(params: schema.CancelNotification): Promise<void>;
|
||||
setMode?(params: schema.SetModeRequest): Promise<schema.SetModeResponse>;
|
||||
setModel?(params: schema.SetModelRequest): Promise<schema.SetModelResponse>;
|
||||
}
|
||||
|
||||
@@ -27,10 +27,8 @@ import { Readable, Writable } from 'node:stream';
|
||||
import type { LoadedSettings } from '../config/settings.js';
|
||||
import { SettingScope } from '../config/settings.js';
|
||||
import { z } from 'zod';
|
||||
import { ExtensionStorage, type Extension } from '../config/extension.js';
|
||||
import type { CliArgs } from '../config/config.js';
|
||||
import { loadCliConfig } from '../config/config.js';
|
||||
import { ExtensionEnablementManager } from '../config/extensions/extensionEnablement.js';
|
||||
|
||||
// Import the modular Session class
|
||||
import { Session } from './session/Session.js';
|
||||
@@ -38,7 +36,6 @@ import { Session } from './session/Session.js';
|
||||
export async function runAcpAgent(
|
||||
config: Config,
|
||||
settings: LoadedSettings,
|
||||
extensions: Extension[],
|
||||
argv: CliArgs,
|
||||
) {
|
||||
const stdout = Writable.toWeb(process.stdout) as WritableStream;
|
||||
@@ -51,8 +48,7 @@ export async function runAcpAgent(
|
||||
console.debug = console.error;
|
||||
|
||||
new acp.AgentSideConnection(
|
||||
(client: acp.Client) =>
|
||||
new GeminiAgent(config, settings, extensions, argv, client),
|
||||
(client: acp.Client) => new GeminiAgent(config, settings, argv, client),
|
||||
stdout,
|
||||
stdin,
|
||||
);
|
||||
@@ -65,7 +61,6 @@ class GeminiAgent {
|
||||
constructor(
|
||||
private config: Config,
|
||||
private settings: LoadedSettings,
|
||||
private extensions: Extension[],
|
||||
private argv: CliArgs,
|
||||
private client: acp.Client,
|
||||
) {}
|
||||
@@ -165,30 +160,11 @@ class GeminiAgent {
|
||||
this.setupFileSystem(config);
|
||||
|
||||
const session = await this.createAndStoreSession(config);
|
||||
const configuredModel = (
|
||||
config.getModel() ||
|
||||
this.config.getModel() ||
|
||||
''
|
||||
).trim();
|
||||
const modelId = configuredModel || 'default';
|
||||
const modelName = configuredModel || modelId;
|
||||
const availableModels = this.buildAvailableModels(config);
|
||||
|
||||
return {
|
||||
sessionId: session.getId(),
|
||||
models: {
|
||||
currentModelId: modelId,
|
||||
availableModels: [
|
||||
{
|
||||
modelId,
|
||||
name: modelName,
|
||||
description: null,
|
||||
_meta: {
|
||||
contextLimit: tokenLimit(modelId),
|
||||
},
|
||||
},
|
||||
],
|
||||
_meta: null,
|
||||
},
|
||||
models: availableModels,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -215,16 +191,7 @@ class GeminiAgent {
|
||||
continue: false,
|
||||
};
|
||||
|
||||
const config = await loadCliConfig(
|
||||
settings,
|
||||
this.extensions,
|
||||
new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
this.argv.extensions,
|
||||
),
|
||||
argvForSession,
|
||||
cwd,
|
||||
);
|
||||
const config = await loadCliConfig(settings, argvForSession, cwd);
|
||||
|
||||
await config.initialize();
|
||||
return config;
|
||||
@@ -305,15 +272,29 @@ class GeminiAgent {
|
||||
async setMode(params: acp.SetModeRequest): Promise<acp.SetModeResponse> {
|
||||
const session = this.sessions.get(params.sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Session not found: ${params.sessionId}`);
|
||||
throw acp.RequestError.invalidParams(
|
||||
`Session not found for id: ${params.sessionId}`,
|
||||
);
|
||||
}
|
||||
return session.setMode(params);
|
||||
}
|
||||
|
||||
async setModel(params: acp.SetModelRequest): Promise<acp.SetModelResponse> {
|
||||
const session = this.sessions.get(params.sessionId);
|
||||
if (!session) {
|
||||
throw acp.RequestError.invalidParams(
|
||||
`Session not found for id: ${params.sessionId}`,
|
||||
);
|
||||
}
|
||||
return session.setModel(params);
|
||||
}
|
||||
|
||||
private async ensureAuthenticated(config: Config): Promise<void> {
|
||||
const selectedType = this.settings.merged.security?.auth?.selectedType;
|
||||
if (!selectedType) {
|
||||
throw acp.RequestError.authRequired('No Selected Type');
|
||||
throw acp.RequestError.authRequired(
|
||||
'Use Qwen Code CLI to authenticate first.',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -382,4 +363,43 @@ class GeminiAgent {
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
private buildAvailableModels(
|
||||
config: Config,
|
||||
): acp.NewSessionResponse['models'] {
|
||||
const currentModelId = (
|
||||
config.getModel() ||
|
||||
this.config.getModel() ||
|
||||
''
|
||||
).trim();
|
||||
const availableModels = config.getAvailableModels();
|
||||
|
||||
const mappedAvailableModels = availableModels.map((model) => ({
|
||||
modelId: model.id,
|
||||
name: model.label,
|
||||
description: model.description ?? null,
|
||||
_meta: {
|
||||
contextLimit: tokenLimit(model.id),
|
||||
},
|
||||
}));
|
||||
|
||||
if (
|
||||
currentModelId &&
|
||||
!mappedAvailableModels.some((model) => model.modelId === currentModelId)
|
||||
) {
|
||||
mappedAvailableModels.unshift({
|
||||
modelId: currentModelId,
|
||||
name: currentModelId,
|
||||
description: null,
|
||||
_meta: {
|
||||
contextLimit: tokenLimit(currentModelId),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
currentModelId,
|
||||
availableModels: mappedAvailableModels,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
25
packages/cli/src/acp-integration/errorCodes.ts
Normal file
25
packages/cli/src/acp-integration/errorCodes.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export const ACP_ERROR_CODES = {
|
||||
// Parse error: invalid JSON received by server.
|
||||
PARSE_ERROR: -32700,
|
||||
// Invalid request: JSON is not a valid Request object.
|
||||
INVALID_REQUEST: -32600,
|
||||
// Method not found: method does not exist or is unavailable.
|
||||
METHOD_NOT_FOUND: -32601,
|
||||
// Invalid params: invalid method parameter(s).
|
||||
INVALID_PARAMS: -32602,
|
||||
// Internal error: implementation-defined server error.
|
||||
INTERNAL_ERROR: -32603,
|
||||
// Authentication required: must authenticate before operation.
|
||||
AUTH_REQUIRED: -32000,
|
||||
// Resource not found: e.g. missing file.
|
||||
RESOURCE_NOT_FOUND: -32002,
|
||||
} as const;
|
||||
|
||||
export type AcpErrorCode =
|
||||
(typeof ACP_ERROR_CODES)[keyof typeof ACP_ERROR_CODES];
|
||||
@@ -15,6 +15,7 @@ export const AGENT_METHODS = {
|
||||
session_prompt: 'session/prompt',
|
||||
session_list: 'session/list',
|
||||
session_set_mode: 'session/set_mode',
|
||||
session_set_model: 'session/set_model',
|
||||
};
|
||||
|
||||
export const CLIENT_METHODS = {
|
||||
@@ -266,6 +267,18 @@ export const modelInfoSchema = z.object({
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const setModelRequestSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
modelId: z.string(),
|
||||
});
|
||||
|
||||
export const setModelResponseSchema = z.object({
|
||||
modelId: z.string(),
|
||||
});
|
||||
|
||||
export type SetModelRequest = z.infer<typeof setModelRequestSchema>;
|
||||
export type SetModelResponse = z.infer<typeof setModelResponseSchema>;
|
||||
|
||||
export const sessionModelStateSchema = z.object({
|
||||
_meta: acpMetaSchema,
|
||||
availableModels: z.array(modelInfoSchema),
|
||||
@@ -592,6 +605,7 @@ export const agentResponseSchema = z.union([
|
||||
promptResponseSchema,
|
||||
listSessionsResponseSchema,
|
||||
setModeResponseSchema,
|
||||
setModelResponseSchema,
|
||||
]);
|
||||
|
||||
export const requestPermissionRequestSchema = z.object({
|
||||
@@ -624,6 +638,7 @@ export const agentRequestSchema = z.union([
|
||||
promptRequestSchema,
|
||||
listSessionsRequestSchema,
|
||||
setModeRequestSchema,
|
||||
setModelRequestSchema,
|
||||
]);
|
||||
|
||||
export const agentNotificationSchema = sessionNotificationSchema;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { FileSystemService } from '@qwen-code/qwen-code-core';
|
||||
import { AcpFileSystemService } from './filesystem.js';
|
||||
import { ACP_ERROR_CODES } from '../errorCodes.js';
|
||||
|
||||
const createFallback = (): FileSystemService => ({
|
||||
readTextFile: vi.fn(),
|
||||
@@ -16,11 +17,13 @@ const createFallback = (): FileSystemService => ({
|
||||
|
||||
describe('AcpFileSystemService', () => {
|
||||
describe('readTextFile ENOENT handling', () => {
|
||||
it('parses path from ACP ENOENT message (quoted)', async () => {
|
||||
it('converts RESOURCE_NOT_FOUND error to ENOENT', async () => {
|
||||
const resourceNotFoundError = {
|
||||
code: ACP_ERROR_CODES.RESOURCE_NOT_FOUND,
|
||||
message: 'File not found',
|
||||
};
|
||||
const client = {
|
||||
readTextFile: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ content: 'ERROR: ENOENT: "/remote/file.txt"' }),
|
||||
readTextFile: vi.fn().mockRejectedValue(resourceNotFoundError),
|
||||
} as unknown as import('../acp.js').Client;
|
||||
|
||||
const svc = new AcpFileSystemService(
|
||||
@@ -30,15 +33,20 @@ describe('AcpFileSystemService', () => {
|
||||
createFallback(),
|
||||
);
|
||||
|
||||
await expect(svc.readTextFile('/local/file.txt')).rejects.toMatchObject({
|
||||
await expect(svc.readTextFile('/some/file.txt')).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
path: '/remote/file.txt',
|
||||
errno: -2,
|
||||
path: '/some/file.txt',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to requested path when none provided', async () => {
|
||||
it('re-throws other errors unchanged', async () => {
|
||||
const otherError = {
|
||||
code: ACP_ERROR_CODES.INTERNAL_ERROR,
|
||||
message: 'Internal error',
|
||||
};
|
||||
const client = {
|
||||
readTextFile: vi.fn().mockResolvedValue({ content: 'ERROR: ENOENT:' }),
|
||||
readTextFile: vi.fn().mockRejectedValue(otherError),
|
||||
} as unknown as import('../acp.js').Client;
|
||||
|
||||
const svc = new AcpFileSystemService(
|
||||
@@ -48,12 +56,34 @@ describe('AcpFileSystemService', () => {
|
||||
createFallback(),
|
||||
);
|
||||
|
||||
await expect(
|
||||
svc.readTextFile('/fallback/path.txt'),
|
||||
).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
path: '/fallback/path.txt',
|
||||
await expect(svc.readTextFile('/some/file.txt')).rejects.toMatchObject({
|
||||
code: ACP_ERROR_CODES.INTERNAL_ERROR,
|
||||
message: 'Internal error',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses fallback when readTextFile capability is disabled', async () => {
|
||||
const client = {
|
||||
readTextFile: vi.fn(),
|
||||
} as unknown as import('../acp.js').Client;
|
||||
|
||||
const fallback = createFallback();
|
||||
(fallback.readTextFile as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
'fallback content',
|
||||
);
|
||||
|
||||
const svc = new AcpFileSystemService(
|
||||
client,
|
||||
'session-3',
|
||||
{ readTextFile: false, writeTextFile: true },
|
||||
fallback,
|
||||
);
|
||||
|
||||
const result = await svc.readTextFile('/some/file.txt');
|
||||
|
||||
expect(result).toBe('fallback content');
|
||||
expect(fallback.readTextFile).toHaveBeenCalledWith('/some/file.txt');
|
||||
expect(client.readTextFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import type { FileSystemService } from '@qwen-code/qwen-code-core';
|
||||
import type * as acp from '../acp.js';
|
||||
import { ACP_ERROR_CODES } from '../errorCodes.js';
|
||||
|
||||
/**
|
||||
* ACP client-based implementation of FileSystemService
|
||||
@@ -23,25 +24,31 @@ export class AcpFileSystemService implements FileSystemService {
|
||||
return this.fallback.readTextFile(filePath);
|
||||
}
|
||||
|
||||
const response = await this.client.readTextFile({
|
||||
path: filePath,
|
||||
sessionId: this.sessionId,
|
||||
line: null,
|
||||
limit: null,
|
||||
});
|
||||
let response: { content: string };
|
||||
try {
|
||||
response = await this.client.readTextFile({
|
||||
path: filePath,
|
||||
sessionId: this.sessionId,
|
||||
line: null,
|
||||
limit: null,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorCode =
|
||||
typeof error === 'object' && error !== null && 'code' in error
|
||||
? (error as { code?: unknown }).code
|
||||
: undefined;
|
||||
|
||||
if (response.content.startsWith('ERROR: ENOENT:')) {
|
||||
// Treat ACP error strings as structured ENOENT errors without
|
||||
// assuming a specific platform format.
|
||||
const match = /^ERROR:\s*ENOENT:\s*(?<path>.*)$/i.exec(response.content);
|
||||
const err = new Error(response.content) as NodeJS.ErrnoException;
|
||||
err.code = 'ENOENT';
|
||||
err.errno = -2;
|
||||
const rawPath = match?.groups?.['path']?.trim();
|
||||
err['path'] = rawPath
|
||||
? rawPath.replace(/^['"]|['"]$/g, '') || filePath
|
||||
: filePath;
|
||||
throw err;
|
||||
if (errorCode === ACP_ERROR_CODES.RESOURCE_NOT_FOUND) {
|
||||
const err = new Error(
|
||||
`File not found: ${filePath}`,
|
||||
) as NodeJS.ErrnoException;
|
||||
err.code = 'ENOENT';
|
||||
err.errno = -2;
|
||||
err.path = filePath;
|
||||
throw err;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response.content;
|
||||
|
||||
174
packages/cli/src/acp-integration/session/Session.test.ts
Normal file
174
packages/cli/src/acp-integration/session/Session.test.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { Session } from './Session.js';
|
||||
import type { Config, GeminiChat } from '@qwen-code/qwen-code-core';
|
||||
import { ApprovalMode } from '@qwen-code/qwen-code-core';
|
||||
import type * as acp from '../acp.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import * as nonInteractiveCliCommands from '../../nonInteractiveCliCommands.js';
|
||||
|
||||
vi.mock('../../nonInteractiveCliCommands.js', () => ({
|
||||
getAvailableCommands: vi.fn(),
|
||||
handleSlashCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('Session', () => {
|
||||
let mockChat: GeminiChat;
|
||||
let mockConfig: Config;
|
||||
let mockClient: acp.Client;
|
||||
let mockSettings: LoadedSettings;
|
||||
let session: Session;
|
||||
let currentModel: string;
|
||||
let setModelSpy: ReturnType<typeof vi.fn>;
|
||||
let getAvailableCommandsSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
currentModel = 'qwen3-code-plus';
|
||||
setModelSpy = vi.fn().mockImplementation(async (modelId: string) => {
|
||||
currentModel = modelId;
|
||||
});
|
||||
|
||||
mockChat = {
|
||||
sendMessageStream: vi.fn(),
|
||||
addHistory: vi.fn(),
|
||||
} as unknown as GeminiChat;
|
||||
|
||||
mockConfig = {
|
||||
setApprovalMode: vi.fn(),
|
||||
setModel: setModelSpy,
|
||||
getModel: vi.fn().mockImplementation(() => currentModel),
|
||||
} as unknown as Config;
|
||||
|
||||
mockClient = {
|
||||
sessionUpdate: vi.fn().mockResolvedValue(undefined),
|
||||
requestPermission: vi.fn().mockResolvedValue({
|
||||
outcome: { outcome: 'selected', optionId: 'proceed_once' },
|
||||
}),
|
||||
sendCustomNotification: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as acp.Client;
|
||||
|
||||
mockSettings = {
|
||||
merged: {},
|
||||
} as LoadedSettings;
|
||||
|
||||
getAvailableCommandsSpy = vi.mocked(nonInteractiveCliCommands)
|
||||
.getAvailableCommands as unknown as ReturnType<typeof vi.fn>;
|
||||
getAvailableCommandsSpy.mockResolvedValue([]);
|
||||
|
||||
session = new Session(
|
||||
'test-session-id',
|
||||
mockChat,
|
||||
mockConfig,
|
||||
mockClient,
|
||||
mockSettings,
|
||||
);
|
||||
});
|
||||
|
||||
describe('setMode', () => {
|
||||
it.each([
|
||||
['plan', ApprovalMode.PLAN],
|
||||
['default', ApprovalMode.DEFAULT],
|
||||
['auto-edit', ApprovalMode.AUTO_EDIT],
|
||||
['yolo', ApprovalMode.YOLO],
|
||||
] as const)('maps %s mode', async (modeId, expected) => {
|
||||
const result = await session.setMode({
|
||||
sessionId: 'test-session-id',
|
||||
modeId,
|
||||
});
|
||||
|
||||
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(expected);
|
||||
expect(result).toEqual({ modeId });
|
||||
});
|
||||
});
|
||||
|
||||
describe('setModel', () => {
|
||||
it('sets model via config and returns current model', async () => {
|
||||
const result = await session.setModel({
|
||||
sessionId: 'test-session-id',
|
||||
modelId: ' qwen3-coder-plus ',
|
||||
});
|
||||
|
||||
expect(mockConfig.setModel).toHaveBeenCalledWith('qwen3-coder-plus', {
|
||||
reason: 'user_request_acp',
|
||||
context: 'session/set_model',
|
||||
});
|
||||
expect(mockConfig.getModel).toHaveBeenCalled();
|
||||
expect(result).toEqual({ modelId: 'qwen3-coder-plus' });
|
||||
});
|
||||
|
||||
it('rejects empty/whitespace model IDs', async () => {
|
||||
await expect(
|
||||
session.setModel({
|
||||
sessionId: 'test-session-id',
|
||||
modelId: ' ',
|
||||
}),
|
||||
).rejects.toThrow('Invalid params');
|
||||
|
||||
expect(mockConfig.setModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates errors from config.setModel', async () => {
|
||||
const configError = new Error('Invalid model');
|
||||
setModelSpy.mockRejectedValueOnce(configError);
|
||||
|
||||
await expect(
|
||||
session.setModel({
|
||||
sessionId: 'test-session-id',
|
||||
modelId: 'invalid-model',
|
||||
}),
|
||||
).rejects.toThrow('Invalid model');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendAvailableCommandsUpdate', () => {
|
||||
it('sends available_commands_update from getAvailableCommands()', async () => {
|
||||
getAvailableCommandsSpy.mockResolvedValueOnce([
|
||||
{
|
||||
name: 'init',
|
||||
description: 'Initialize project context',
|
||||
},
|
||||
]);
|
||||
|
||||
await session.sendAvailableCommandsUpdate();
|
||||
|
||||
expect(getAvailableCommandsSpy).toHaveBeenCalledWith(
|
||||
mockConfig,
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(mockClient.sessionUpdate).toHaveBeenCalledWith({
|
||||
sessionId: 'test-session-id',
|
||||
update: {
|
||||
sessionUpdate: 'available_commands_update',
|
||||
availableCommands: [
|
||||
{
|
||||
name: 'init',
|
||||
description: 'Initialize project context',
|
||||
input: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('swallows errors and does not throw', async () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
getAvailableCommandsSpy.mockRejectedValueOnce(
|
||||
new Error('Command discovery failed'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
session.sendAvailableCommandsUpdate(),
|
||||
).resolves.toBeUndefined();
|
||||
expect(mockClient.sessionUpdate).not.toHaveBeenCalled();
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,8 @@ import type {
|
||||
AvailableCommandsUpdate,
|
||||
SetModeRequest,
|
||||
SetModeResponse,
|
||||
SetModelRequest,
|
||||
SetModelResponse,
|
||||
ApprovalModeValue,
|
||||
CurrentModeUpdate,
|
||||
} from '../schema.js';
|
||||
@@ -348,6 +350,31 @@ export class Session implements SessionContext {
|
||||
return { modeId: params.modeId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the model for the current session.
|
||||
* Validates the model ID and switches the model via Config.
|
||||
*/
|
||||
async setModel(params: SetModelRequest): Promise<SetModelResponse> {
|
||||
const modelId = params.modelId.trim();
|
||||
|
||||
if (!modelId) {
|
||||
throw acp.RequestError.invalidParams('modelId cannot be empty');
|
||||
}
|
||||
|
||||
// Attempt to set the model using config
|
||||
await this.config.setModel(modelId, {
|
||||
reason: 'user_request_acp',
|
||||
context: 'session/set_model',
|
||||
});
|
||||
|
||||
// Get updated model info
|
||||
const currentModel = this.config.getModel();
|
||||
|
||||
return {
|
||||
modelId: currentModel,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a current_mode_update notification to the client.
|
||||
* Called after the agent switches modes (e.g., from exit_plan_mode tool).
|
||||
|
||||
106
packages/cli/src/commands/extensions.test.tsx
Normal file
106
packages/cli/src/commands/extensions.test.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { extensionsCommand } from './extensions.js';
|
||||
import { updateCommand } from './extensions/update.js';
|
||||
import { disableCommand } from './extensions/disable.js';
|
||||
import { enableCommand } from './extensions/enable.js';
|
||||
import { linkCommand } from './extensions/link.js';
|
||||
import { newCommand } from './extensions/new.js';
|
||||
import yargs from 'yargs';
|
||||
|
||||
describe('extensions command', () => {
|
||||
it('should have correct command name', () => {
|
||||
expect(extensionsCommand.command).toBe('extensions <command>');
|
||||
});
|
||||
|
||||
it('should have a description', () => {
|
||||
expect(extensionsCommand.describe).toBe('Manage Qwen Code extensions.');
|
||||
});
|
||||
|
||||
it('should require a subcommand', () => {
|
||||
const parser = yargs([])
|
||||
.command(extensionsCommand)
|
||||
.fail(false)
|
||||
.locale('en');
|
||||
|
||||
expect(() => parser.parse('extensions')).toThrow();
|
||||
});
|
||||
|
||||
it('should register install subcommand', () => {
|
||||
const parser = yargs([])
|
||||
.command(extensionsCommand)
|
||||
.fail(false)
|
||||
.locale('en');
|
||||
|
||||
// This should throw as 'install' requires a source argument
|
||||
expect(() => parser.parse('extensions install')).toThrow(
|
||||
'Not enough non-option arguments',
|
||||
);
|
||||
});
|
||||
|
||||
it('should register uninstall subcommand', () => {
|
||||
const parser = yargs([])
|
||||
.command(extensionsCommand)
|
||||
.fail(false)
|
||||
.locale('en');
|
||||
|
||||
expect(() => parser.parse('extensions uninstall')).toThrow(
|
||||
'Not enough non-option arguments',
|
||||
);
|
||||
});
|
||||
|
||||
it('should register list subcommand', () => {
|
||||
const parser = yargs([])
|
||||
.command(extensionsCommand)
|
||||
.fail(false)
|
||||
.locale('en');
|
||||
|
||||
// list doesn't require arguments, so it should not throw
|
||||
expect(() => parser.parse('extensions list')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should register update subcommand', () => {
|
||||
const parser = yargs([]).command(updateCommand).fail(false).locale('en');
|
||||
|
||||
expect(() => parser.parse('update')).toThrow(
|
||||
'Either an extension name or --all must be provided',
|
||||
);
|
||||
});
|
||||
|
||||
it('should register disable subcommand', () => {
|
||||
const parser = yargs([]).command(disableCommand).fail(false).locale('en');
|
||||
|
||||
expect(() => parser.parse('disable')).toThrow(
|
||||
'Not enough non-option arguments',
|
||||
);
|
||||
});
|
||||
|
||||
it('should register enable subcommand', () => {
|
||||
const parser = yargs([]).command(enableCommand).fail(false).locale('en');
|
||||
|
||||
expect(() => parser.parse('enable')).toThrow(
|
||||
'Not enough non-option arguments',
|
||||
);
|
||||
});
|
||||
|
||||
it('should register link subcommand', () => {
|
||||
const parser = yargs([]).command(linkCommand).fail(false).locale('en');
|
||||
|
||||
expect(() => parser.parse('link')).toThrow(
|
||||
'Not enough non-option arguments',
|
||||
);
|
||||
});
|
||||
|
||||
it('should register new subcommand', async () => {
|
||||
const parser = yargs([]).command(newCommand).fail(false).locale('en');
|
||||
|
||||
await expect(parser.parseAsync('new')).rejects.toThrow(
|
||||
'Not enough non-option arguments',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import { disableCommand } from './extensions/disable.js';
|
||||
import { enableCommand } from './extensions/enable.js';
|
||||
import { linkCommand } from './extensions/link.js';
|
||||
import { newCommand } from './extensions/new.js';
|
||||
import { settingsCommand } from './extensions/settings.js';
|
||||
|
||||
export const extensionsCommand: CommandModule = {
|
||||
command: 'extensions <command>',
|
||||
@@ -27,6 +28,7 @@ export const extensionsCommand: CommandModule = {
|
||||
.command(enableCommand)
|
||||
.command(linkCommand)
|
||||
.command(newCommand)
|
||||
.command(settingsCommand)
|
||||
.demandCommand(1, 'You need at least one command before continuing.')
|
||||
.version(false),
|
||||
handler: () => {
|
||||
|
||||
243
packages/cli/src/commands/extensions/consent.test.ts
Normal file
243
packages/cli/src/commands/extensions/consent.test.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { extensionConsentString, requestConsentOrFail } from './consent.js';
|
||||
import type { ExtensionConfig } from '@qwen-code/qwen-code-core';
|
||||
|
||||
vi.mock('../../i18n/index.js', () => ({
|
||||
t: vi.fn((str: string, params?: Record<string, string>) => {
|
||||
if (params) {
|
||||
return Object.entries(params).reduce(
|
||||
(acc, [key, value]) => acc.replace(`{{${key}}}`, value),
|
||||
str,
|
||||
);
|
||||
}
|
||||
return str;
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('extensionConsentString', () => {
|
||||
it('should include extension name', () => {
|
||||
const config: ExtensionConfig = {
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
};
|
||||
|
||||
const result = extensionConsentString(config);
|
||||
|
||||
expect(result).toContain('Installing extension "test-extension".');
|
||||
});
|
||||
|
||||
it('should include warning message', () => {
|
||||
const config: ExtensionConfig = {
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
};
|
||||
|
||||
const result = extensionConsentString(config);
|
||||
|
||||
expect(result).toContain('Extensions may introduce unexpected behavior');
|
||||
});
|
||||
|
||||
it('should include MCP servers when present', () => {
|
||||
const config: ExtensionConfig = {
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = extensionConsentString(config);
|
||||
|
||||
expect(result).toContain(
|
||||
'This extension will run the following MCP servers',
|
||||
);
|
||||
expect(result).toContain('test-server');
|
||||
expect(result).toContain('local');
|
||||
expect(result).toContain('node server.js');
|
||||
});
|
||||
|
||||
it('should include remote MCP servers', () => {
|
||||
const config: ExtensionConfig = {
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
mcpServers: {
|
||||
'remote-server': {
|
||||
httpUrl: 'https://example.com/mcp',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = extensionConsentString(config);
|
||||
|
||||
expect(result).toContain('remote');
|
||||
expect(result).toContain('https://example.com/mcp');
|
||||
});
|
||||
|
||||
it('should include commands when present', () => {
|
||||
const config: ExtensionConfig = {
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
};
|
||||
|
||||
const result = extensionConsentString(config, ['command1', 'command2']);
|
||||
|
||||
expect(result).toContain('This extension will add the following commands');
|
||||
expect(result).toContain('command1, command2');
|
||||
});
|
||||
|
||||
it('should include context file name when present (string)', () => {
|
||||
const config: ExtensionConfig = {
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
contextFileName: 'CUSTOM.md',
|
||||
};
|
||||
|
||||
const result = extensionConsentString(config);
|
||||
|
||||
expect(result).toContain('CUSTOM.md');
|
||||
});
|
||||
|
||||
it('should include context file name when present (array)', () => {
|
||||
const config: ExtensionConfig = {
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
contextFileName: ['FILE1.md', 'FILE2.md'],
|
||||
};
|
||||
|
||||
const result = extensionConsentString(config);
|
||||
|
||||
expect(result).toContain('FILE1.md, FILE2.md');
|
||||
});
|
||||
|
||||
it('should include skills when present', () => {
|
||||
const config: ExtensionConfig = {
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
};
|
||||
|
||||
const result = extensionConsentString(
|
||||
config,
|
||||
[],
|
||||
[
|
||||
{
|
||||
name: 'skill1',
|
||||
description: 'Skill 1 description',
|
||||
level: 'extension',
|
||||
filePath: '/test/skill1',
|
||||
body: 'skill body',
|
||||
},
|
||||
{
|
||||
name: 'skill2',
|
||||
description: 'Skill 2 description',
|
||||
level: 'extension',
|
||||
filePath: '/test/skill2',
|
||||
body: 'skill body',
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(result).toContain(
|
||||
'This extension will install the following skills',
|
||||
);
|
||||
expect(result).toContain('skill1');
|
||||
expect(result).toContain('Skill 1 description');
|
||||
});
|
||||
|
||||
it('should include subagents when present', () => {
|
||||
const config: ExtensionConfig = {
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
};
|
||||
|
||||
const result = extensionConsentString(
|
||||
config,
|
||||
[],
|
||||
[],
|
||||
[
|
||||
{
|
||||
name: 'agent1',
|
||||
description: 'Agent 1 description',
|
||||
systemPrompt: 'You are agent1',
|
||||
level: 'extension',
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(result).toContain(
|
||||
'This extension will install the following subagents',
|
||||
);
|
||||
expect(result).toContain('agent1');
|
||||
expect(result).toContain('Agent 1 description');
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestConsentOrFail', () => {
|
||||
let mockRequestConsent: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRequestConsent = vi.fn();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should do nothing when options is undefined', async () => {
|
||||
await requestConsentOrFail(mockRequestConsent, undefined);
|
||||
|
||||
expect(mockRequestConsent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should request consent for new extension', async () => {
|
||||
mockRequestConsent.mockResolvedValueOnce(true);
|
||||
|
||||
await requestConsentOrFail(mockRequestConsent, {
|
||||
extensionConfig: { name: 'test-extension', version: '1.0.0' },
|
||||
});
|
||||
|
||||
expect(mockRequestConsent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw error when user declines consent', async () => {
|
||||
mockRequestConsent.mockResolvedValueOnce(false);
|
||||
|
||||
await expect(
|
||||
requestConsentOrFail(mockRequestConsent, {
|
||||
extensionConfig: { name: 'test-extension', version: '1.0.0' },
|
||||
}),
|
||||
).rejects.toThrow('Installation cancelled for "test-extension".');
|
||||
});
|
||||
|
||||
it('should skip consent when consent string is unchanged', async () => {
|
||||
const extensionConfig: ExtensionConfig = {
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
};
|
||||
|
||||
await requestConsentOrFail(mockRequestConsent, {
|
||||
extensionConfig,
|
||||
previousExtensionConfig: extensionConfig,
|
||||
});
|
||||
|
||||
expect(mockRequestConsent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should request consent when commands change', async () => {
|
||||
mockRequestConsent.mockResolvedValueOnce(true);
|
||||
|
||||
await requestConsentOrFail(mockRequestConsent, {
|
||||
extensionConfig: { name: 'test-extension', version: '1.0.0' },
|
||||
commands: ['command1'],
|
||||
previousExtensionConfig: { name: 'test-extension', version: '1.0.0' },
|
||||
previousCommands: [],
|
||||
});
|
||||
|
||||
expect(mockRequestConsent).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
211
packages/cli/src/commands/extensions/consent.ts
Normal file
211
packages/cli/src/commands/extensions/consent.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import type {
|
||||
ExtensionConfig,
|
||||
ExtensionRequestOptions,
|
||||
SkillConfig,
|
||||
SubagentConfig,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { ConfirmationRequest } from '../../ui/types.js';
|
||||
import chalk from 'chalk';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
/**
|
||||
* Requests consent from the user to perform an action, by reading a Y/n
|
||||
* character from stdin.
|
||||
*
|
||||
* This should not be called from interactive mode as it will break the CLI.
|
||||
*
|
||||
* @param consentDescription The description of the thing they will be consenting to.
|
||||
* @returns boolean, whether they consented or not.
|
||||
*/
|
||||
export async function requestConsentNonInteractive(
|
||||
consentDescription: string,
|
||||
): Promise<boolean> {
|
||||
console.info(consentDescription);
|
||||
const result = await promptForConsentNonInteractive(
|
||||
t('Do you want to continue? [Y/n]: '),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests consent from the user to perform an action, in interactive mode.
|
||||
*
|
||||
* This should not be called from non-interactive mode as it will not work.
|
||||
*
|
||||
* @param consentDescription The description of the thing they will be consenting to.
|
||||
* @param addExtensionUpdateConfirmationRequest A function to actually add a prompt to the UI.
|
||||
* @returns boolean, whether they consented or not.
|
||||
*/
|
||||
export async function requestConsentInteractive(
|
||||
consentDescription: string,
|
||||
addExtensionUpdateConfirmationRequest: (value: ConfirmationRequest) => void,
|
||||
): Promise<boolean> {
|
||||
return promptForConsentInteractive(
|
||||
consentDescription + '\n\n' + t('Do you want to continue?'),
|
||||
addExtensionUpdateConfirmationRequest,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks users a prompt and awaits for a y/n response on stdin.
|
||||
*
|
||||
* This should not be called from interactive mode as it will break the CLI.
|
||||
*
|
||||
* @param prompt A yes/no prompt to ask the user
|
||||
* @returns Whether or not the user answers 'y' (yes). Defaults to 'yes' on enter.
|
||||
*/
|
||||
async function promptForConsentNonInteractive(
|
||||
prompt: string,
|
||||
): Promise<boolean> {
|
||||
const readline = await import('node:readline');
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
rl.question(prompt, (answer) => {
|
||||
rl.close();
|
||||
resolve(['y', ''].includes(answer.trim().toLowerCase()));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks users an interactive yes/no prompt.
|
||||
*
|
||||
* This should not be called from non-interactive mode as it will break the CLI.
|
||||
*
|
||||
* @param prompt A markdown prompt to ask the user
|
||||
* @param addExtensionUpdateConfirmationRequest Function to update the UI state with the confirmation request.
|
||||
* @returns Whether or not the user answers yes.
|
||||
*/
|
||||
async function promptForConsentInteractive(
|
||||
prompt: string,
|
||||
addExtensionUpdateConfirmationRequest: (value: ConfirmationRequest) => void,
|
||||
): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
addExtensionUpdateConfirmationRequest({
|
||||
prompt,
|
||||
onConfirm: (resolvedConfirmed) => {
|
||||
resolve(resolvedConfirmed);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a consent string for installing an extension based on it's
|
||||
* extensionConfig.
|
||||
*/
|
||||
export function extensionConsentString(
|
||||
extensionConfig: ExtensionConfig,
|
||||
commands: string[] = [],
|
||||
skills: SkillConfig[] = [],
|
||||
subagents: SubagentConfig[] = [],
|
||||
): string {
|
||||
const output: string[] = [];
|
||||
const mcpServerEntries = Object.entries(extensionConfig.mcpServers || {});
|
||||
output.push(
|
||||
t('Installing extension "{{name}}".', { name: extensionConfig.name }),
|
||||
);
|
||||
output.push(
|
||||
t(
|
||||
'**Extensions may introduce unexpected behavior. Ensure you have investigated the extension source and trust the author.**',
|
||||
),
|
||||
);
|
||||
|
||||
if (mcpServerEntries.length) {
|
||||
output.push(t('This extension will run the following MCP servers:'));
|
||||
for (const [key, mcpServer] of mcpServerEntries) {
|
||||
const isLocal = !!mcpServer.command;
|
||||
const source =
|
||||
mcpServer.httpUrl ??
|
||||
`${mcpServer.command || ''}${mcpServer.args ? ' ' + mcpServer.args.join(' ') : ''}`;
|
||||
output.push(
|
||||
` * ${key} (${isLocal ? t('local') : t('remote')}): ${source}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (commands && commands.length > 0) {
|
||||
output.push(
|
||||
t('This extension will add the following commands: {{commands}}.', {
|
||||
commands: commands.join(', '),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (extensionConfig.contextFileName) {
|
||||
const fileName = Array.isArray(extensionConfig.contextFileName)
|
||||
? extensionConfig.contextFileName.join(', ')
|
||||
: extensionConfig.contextFileName;
|
||||
output.push(
|
||||
t(
|
||||
'This extension will append info to your QWEN.md context using {{fileName}}',
|
||||
{ fileName },
|
||||
),
|
||||
);
|
||||
}
|
||||
if (skills.length > 0) {
|
||||
output.push(t('This extension will install the following skills:'));
|
||||
for (const skill of skills) {
|
||||
output.push(` * ${chalk.bold(skill.name)}: ${skill.description}`);
|
||||
}
|
||||
}
|
||||
if (subagents.length > 0) {
|
||||
output.push(t('This extension will install the following subagents:'));
|
||||
for (const subagent of subagents) {
|
||||
output.push(` * ${chalk.bold(subagent.name)}: ${subagent.description}`);
|
||||
}
|
||||
}
|
||||
return output.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests consent from the user to install an extension (extensionConfig), if
|
||||
* there is any difference between the consent string for `extensionConfig` and
|
||||
* `previousExtensionConfig`.
|
||||
*
|
||||
* Always requests consent if previousExtensionConfig is null.
|
||||
*
|
||||
* Throws if the user does not consent.
|
||||
*/
|
||||
export const requestConsentOrFail = async (
|
||||
requestConsent: (consent: string) => Promise<boolean>,
|
||||
options?: ExtensionRequestOptions,
|
||||
) => {
|
||||
if (!options) return;
|
||||
const {
|
||||
extensionConfig,
|
||||
commands = [],
|
||||
skills = [],
|
||||
subagents = [],
|
||||
previousExtensionConfig,
|
||||
previousCommands = [],
|
||||
previousSkills = [],
|
||||
previousSubagents = [],
|
||||
} = options;
|
||||
const extensionConsent = extensionConsentString(
|
||||
extensionConfig,
|
||||
commands,
|
||||
skills,
|
||||
subagents,
|
||||
);
|
||||
if (previousExtensionConfig) {
|
||||
const previousExtensionConsent = extensionConsentString(
|
||||
previousExtensionConfig,
|
||||
previousCommands,
|
||||
previousSkills,
|
||||
previousSubagents,
|
||||
);
|
||||
if (previousExtensionConsent === extensionConsent) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!(await requestConsent(extensionConsent))) {
|
||||
throw new Error(
|
||||
t('Installation cancelled for "{{name}}".', {
|
||||
name: extensionConfig.name,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
129
packages/cli/src/commands/extensions/disable.test.ts
Normal file
129
packages/cli/src/commands/extensions/disable.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
import { disableCommand, handleDisable } from './disable.js';
|
||||
import yargs from 'yargs';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
|
||||
const mockDisableExtension = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('./utils.js', () => ({
|
||||
getExtensionManager: vi.fn().mockResolvedValue({
|
||||
disableExtension: mockDisableExtension,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/errors.js', () => ({
|
||||
getErrorMessage: vi.fn((error: Error) => error.message),
|
||||
}));
|
||||
|
||||
describe('extensions disable command', () => {
|
||||
it('should fail if no name is provided', () => {
|
||||
const validationParser = yargs([])
|
||||
.command(disableCommand)
|
||||
.fail(false)
|
||||
.locale('en');
|
||||
expect(() => validationParser.parse('disable')).toThrow(
|
||||
'Not enough non-option arguments: got 0, need at least 1',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail if invalid scope is provided', () => {
|
||||
const validationParser = yargs([])
|
||||
.command(disableCommand)
|
||||
.fail(false)
|
||||
.locale('en');
|
||||
expect(() =>
|
||||
validationParser.parse('disable test-extension --scope=invalid'),
|
||||
).toThrow(/Invalid scope: invalid/);
|
||||
});
|
||||
|
||||
it('should accept valid scope values', () => {
|
||||
const parser = yargs([]).command(disableCommand).fail(false).locale('en');
|
||||
// Just check that the scope option is recognized, actual execution needs name first
|
||||
expect(() =>
|
||||
parser.parse('disable my-extension --scope=user'),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleDisable', () => {
|
||||
let consoleLogSpy: MockInstance;
|
||||
let consoleErrorSpy: MockInstance;
|
||||
let processExitSpy: MockInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
processExitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation(() => undefined as never);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should disable an extension with user scope', async () => {
|
||||
await handleDisable({
|
||||
name: 'test-extension',
|
||||
scope: 'user',
|
||||
});
|
||||
|
||||
expect(mockDisableExtension).toHaveBeenCalledWith(
|
||||
'test-extension',
|
||||
SettingScope.User,
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
'Extension "test-extension" successfully disabled for scope "user".',
|
||||
);
|
||||
});
|
||||
|
||||
it('should disable an extension with workspace scope', async () => {
|
||||
await handleDisable({
|
||||
name: 'test-extension',
|
||||
scope: 'workspace',
|
||||
});
|
||||
|
||||
expect(mockDisableExtension).toHaveBeenCalledWith(
|
||||
'test-extension',
|
||||
SettingScope.Workspace,
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
'Extension "test-extension" successfully disabled for scope "workspace".',
|
||||
);
|
||||
});
|
||||
|
||||
it('should default to user scope when no scope is provided', async () => {
|
||||
await handleDisable({
|
||||
name: 'test-extension',
|
||||
});
|
||||
|
||||
expect(mockDisableExtension).toHaveBeenCalledWith(
|
||||
'test-extension',
|
||||
SettingScope.User,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle errors and exit with code 1', async () => {
|
||||
mockDisableExtension.mockImplementationOnce(() => {
|
||||
throw new Error('Disable failed');
|
||||
});
|
||||
|
||||
await handleDisable({
|
||||
name: 'test-extension',
|
||||
scope: 'user',
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Disable failed');
|
||||
expect(processExitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
@@ -5,24 +5,29 @@
|
||||
*/
|
||||
|
||||
import { type CommandModule } from 'yargs';
|
||||
import { disableExtension } from '../../config/extension.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { getExtensionManager } from './utils.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
interface DisableArgs {
|
||||
name: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export function handleDisable(args: DisableArgs) {
|
||||
export async function handleDisable(args: DisableArgs) {
|
||||
const extensionManager = await getExtensionManager();
|
||||
try {
|
||||
if (args.scope?.toLowerCase() === 'workspace') {
|
||||
disableExtension(args.name, SettingScope.Workspace);
|
||||
extensionManager.disableExtension(args.name, SettingScope.Workspace);
|
||||
} else {
|
||||
disableExtension(args.name, SettingScope.User);
|
||||
extensionManager.disableExtension(args.name, SettingScope.User);
|
||||
}
|
||||
console.log(
|
||||
`Extension "${args.name}" successfully disabled for scope "${args.scope}".`,
|
||||
t('Extension "{{name}}" successfully disabled for scope "{{scope}}".', {
|
||||
name: args.name,
|
||||
scope: args.scope || SettingScope.User,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(getErrorMessage(error));
|
||||
@@ -32,15 +37,15 @@ export function handleDisable(args: DisableArgs) {
|
||||
|
||||
export const disableCommand: CommandModule = {
|
||||
command: 'disable [--scope] <name>',
|
||||
describe: 'Disables an extension.',
|
||||
describe: t('Disables an extension.'),
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional('name', {
|
||||
describe: 'The name of the extension to disable.',
|
||||
describe: t('The name of the extension to disable.'),
|
||||
type: 'string',
|
||||
})
|
||||
.option('scope', {
|
||||
describe: 'The scope to disable the extenison in.',
|
||||
describe: t('The scope to disable the extenison in.'),
|
||||
type: 'string',
|
||||
default: SettingScope.User,
|
||||
})
|
||||
@@ -52,17 +57,18 @@ export const disableCommand: CommandModule = {
|
||||
.includes((argv.scope as string).toLowerCase())
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid scope: ${argv.scope}. Please use one of ${Object.values(
|
||||
SettingScope,
|
||||
)
|
||||
.map((s) => s.toLowerCase())
|
||||
.join(', ')}.`,
|
||||
t('Invalid scope: {{scope}}. Please use one of {{scopes}}.', {
|
||||
scope: argv.scope as string,
|
||||
scopes: Object.values(SettingScope)
|
||||
.map((s) => s.toLowerCase())
|
||||
.join(', '),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
handler: (argv) => {
|
||||
handleDisable({
|
||||
handler: async (argv) => {
|
||||
await handleDisable({
|
||||
name: argv['name'] as string,
|
||||
scope: argv['scope'] as string,
|
||||
});
|
||||
|
||||
136
packages/cli/src/commands/extensions/enable.test.ts
Normal file
136
packages/cli/src/commands/extensions/enable.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
import { enableCommand, handleEnable } from './enable.js';
|
||||
import yargs from 'yargs';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
|
||||
const mockEnableExtension = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('./utils.js', () => ({
|
||||
getExtensionManager: vi.fn().mockResolvedValue({
|
||||
enableExtension: mockEnableExtension,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
||||
return {
|
||||
...actual,
|
||||
FatalConfigError: class FatalConfigError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'FatalConfigError';
|
||||
}
|
||||
},
|
||||
getErrorMessage: (error: Error) => error.message,
|
||||
};
|
||||
});
|
||||
|
||||
describe('extensions enable command', () => {
|
||||
it('should fail if no name is provided', () => {
|
||||
const validationParser = yargs([])
|
||||
.command(enableCommand)
|
||||
.fail(false)
|
||||
.locale('en');
|
||||
expect(() => validationParser.parse('enable')).toThrow(
|
||||
'Not enough non-option arguments: got 0, need at least 1',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail if invalid scope is provided', () => {
|
||||
const validationParser = yargs([])
|
||||
.command(enableCommand)
|
||||
.fail(false)
|
||||
.locale('en');
|
||||
expect(() =>
|
||||
validationParser.parse('enable test-extension --scope=invalid'),
|
||||
).toThrow(/Invalid scope: invalid/);
|
||||
});
|
||||
|
||||
it('should accept valid scope values', () => {
|
||||
const parser = yargs([]).command(enableCommand).fail(false).locale('en');
|
||||
// Just check that the scope option is recognized, actual execution needs name first
|
||||
expect(() =>
|
||||
parser.parse('enable my-extension --scope=user'),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleEnable', () => {
|
||||
let consoleLogSpy: MockInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should enable an extension with user scope', async () => {
|
||||
await handleEnable({
|
||||
name: 'test-extension',
|
||||
scope: 'user',
|
||||
});
|
||||
|
||||
expect(mockEnableExtension).toHaveBeenCalledWith(
|
||||
'test-extension',
|
||||
SettingScope.User,
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
'Extension "test-extension" successfully enabled for scope "user".',
|
||||
);
|
||||
});
|
||||
|
||||
it('should enable an extension with workspace scope', async () => {
|
||||
await handleEnable({
|
||||
name: 'test-extension',
|
||||
scope: 'workspace',
|
||||
});
|
||||
|
||||
expect(mockEnableExtension).toHaveBeenCalledWith(
|
||||
'test-extension',
|
||||
SettingScope.Workspace,
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
'Extension "test-extension" successfully enabled for scope "workspace".',
|
||||
);
|
||||
});
|
||||
|
||||
it('should default to user scope when no scope is provided', async () => {
|
||||
await handleEnable({
|
||||
name: 'test-extension',
|
||||
});
|
||||
|
||||
expect(mockEnableExtension).toHaveBeenCalledWith(
|
||||
'test-extension',
|
||||
SettingScope.User,
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
'Extension "test-extension" successfully enabled in all scopes.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw FatalConfigError when enable fails', async () => {
|
||||
mockEnableExtension.mockImplementationOnce(() => {
|
||||
throw new Error('Enable failed');
|
||||
});
|
||||
|
||||
await expect(
|
||||
handleEnable({
|
||||
name: 'test-extension',
|
||||
scope: 'user',
|
||||
}),
|
||||
).rejects.toThrow('Enable failed');
|
||||
});
|
||||
});
|
||||
@@ -6,28 +6,36 @@
|
||||
|
||||
import { type CommandModule } from 'yargs';
|
||||
import { FatalConfigError, getErrorMessage } from '@qwen-code/qwen-code-core';
|
||||
import { enableExtension } from '../../config/extension.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import { getExtensionManager } from './utils.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
interface EnableArgs {
|
||||
name: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export function handleEnable(args: EnableArgs) {
|
||||
export async function handleEnable(args: EnableArgs) {
|
||||
const extensionManager = await getExtensionManager();
|
||||
|
||||
try {
|
||||
if (args.scope?.toLowerCase() === 'workspace') {
|
||||
enableExtension(args.name, SettingScope.Workspace);
|
||||
extensionManager.enableExtension(args.name, SettingScope.Workspace);
|
||||
} else {
|
||||
enableExtension(args.name, SettingScope.User);
|
||||
extensionManager.enableExtension(args.name, SettingScope.User);
|
||||
}
|
||||
if (args.scope) {
|
||||
console.log(
|
||||
`Extension "${args.name}" successfully enabled for scope "${args.scope}".`,
|
||||
t('Extension "{{name}}" successfully enabled for scope "{{scope}}".', {
|
||||
name: args.name,
|
||||
scope: args.scope,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`Extension "${args.name}" successfully enabled in all scopes.`,
|
||||
t('Extension "{{name}}" successfully enabled in all scopes.', {
|
||||
name: args.name,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -37,16 +45,17 @@ export function handleEnable(args: EnableArgs) {
|
||||
|
||||
export const enableCommand: CommandModule = {
|
||||
command: 'enable [--scope] <name>',
|
||||
describe: 'Enables an extension.',
|
||||
describe: t('Enables an extension.'),
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional('name', {
|
||||
describe: 'The name of the extension to enable.',
|
||||
describe: t('The name of the extension to enable.'),
|
||||
type: 'string',
|
||||
})
|
||||
.option('scope', {
|
||||
describe:
|
||||
describe: t(
|
||||
'The scope to enable the extenison in. If not set, will be enabled in all scopes.',
|
||||
),
|
||||
type: 'string',
|
||||
})
|
||||
.check((argv) => {
|
||||
@@ -57,17 +66,18 @@ export const enableCommand: CommandModule = {
|
||||
.includes((argv.scope as string).toLowerCase())
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid scope: ${argv.scope}. Please use one of ${Object.values(
|
||||
SettingScope,
|
||||
)
|
||||
.map((s) => s.toLowerCase())
|
||||
.join(', ')}.`,
|
||||
t('Invalid scope: {{scope}}. Please use one of {{scopes}}.', {
|
||||
scope: argv.scope as string,
|
||||
scopes: Object.values(SettingScope)
|
||||
.map((s) => s.toLowerCase())
|
||||
.join(', '),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
handler: (argv) => {
|
||||
handleEnable({
|
||||
handler: async (argv) => {
|
||||
await handleEnable({
|
||||
name: argv['name'] as string,
|
||||
scope: argv['scope'] as string,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
name: diary-writer
|
||||
description: generate a diary for user
|
||||
color: yellow
|
||||
tools:
|
||||
- Glob
|
||||
- Grep
|
||||
- ListFiles
|
||||
- ReadFile
|
||||
- ReadManyFiles
|
||||
- NotebookRead
|
||||
- WebFetch
|
||||
- TodoWrite
|
||||
- WebSearch
|
||||
modelConfig:
|
||||
model: qwen3-coder-plus
|
||||
---
|
||||
|
||||
You are a personal diary writing assistant who helps users capture their daily experiences, thoughts, and reflections in meaningful journal entries.
|
||||
|
||||
## Core Mission
|
||||
|
||||
Help users create thoughtful, well-structured diary entries that preserve their memories, emotions, and personal growth moments.
|
||||
|
||||
## Writing Style
|
||||
|
||||
**Tone & Voice**
|
||||
|
||||
- Warm, personal, and authentic
|
||||
- Reflective and introspective
|
||||
- Supportive without being overly sentimental
|
||||
- Adapt to user's preferred style (casual, formal, poetic, etc.)
|
||||
|
||||
**Structure Options**
|
||||
|
||||
- Free-form narrative
|
||||
- Bullet-point highlights
|
||||
- Gratitude-focused entries
|
||||
- Goal and achievement tracking
|
||||
- Emotional processing format
|
||||
|
||||
## Capabilities
|
||||
|
||||
**1. Daily Entry Creation**
|
||||
|
||||
- Transform user's brief notes into full diary entries
|
||||
- Expand on key moments with descriptive details
|
||||
- Add context about weather, mood, or setting when relevant
|
||||
- Include meaningful quotes or observations
|
||||
|
||||
**2. Reflection Prompts**
|
||||
|
||||
- Ask thoughtful questions to deepen entries
|
||||
- Suggest areas worth exploring further
|
||||
- Help identify patterns in thoughts and behaviors
|
||||
- Encourage gratitude and positive reflection
|
||||
|
||||
**3. Memory Enhancement**
|
||||
|
||||
- Help recall specific details from the day
|
||||
- Connect current events to past experiences
|
||||
- Highlight personal growth and progress
|
||||
- Preserve important conversations or interactions
|
||||
|
||||
**4. Organization**
|
||||
|
||||
- Suggest tags or themes for entries
|
||||
- Create summaries for weekly/monthly reviews
|
||||
- Track recurring topics or goals
|
||||
- Maintain consistency in formatting
|
||||
|
||||
## Guidelines
|
||||
|
||||
- **Privacy First**: Treat all content as deeply personal and confidential
|
||||
- **User's Voice**: Write in a way that sounds like the user, not generic
|
||||
- **No Judgment**: Accept all emotions and experiences without criticism
|
||||
- **Encourage Honesty**: Create a safe space for authentic expression
|
||||
- **Balance**: Mix facts with feelings, events with reflections
|
||||
|
||||
## Output Format
|
||||
|
||||
When creating a diary entry, include:
|
||||
|
||||
1. **Date & Title** (optional creative title)
|
||||
2. **Main Content** - The narrative or bullet points
|
||||
3. **Reflection** - A brief closing thought or takeaway
|
||||
4. **Tags** (optional) - For organization and future reference
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "agent-example",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -1,6 +1,3 @@
|
||||
prompt = """
|
||||
Please summarize the findings for the pattern `{{args}}`.
|
||||
|
||||
Search Results:
|
||||
!{grep -r {{args}} .}
|
||||
"""
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "commands-example",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"name": "custom-commands",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"name": "excludeTools",
|
||||
"version": "1.0.0",
|
||||
"excludeTools": ["run_shell_command(rm -rf)"]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mcp-server-example",
|
||||
"version": "1.0.0",
|
||||
"description": "Example MCP Server for Gemini CLI Extension",
|
||||
"description": "Example MCP Server for Qwen Code Extension",
|
||||
"type": "module",
|
||||
"main": "example.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "skills-example",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
name: synonyms
|
||||
description: Generate synonyms for words or phrases. Use this skill when the user needs alternative words with similar meanings, wants to expand vocabulary, or seeks varied expressions for writing.
|
||||
license: Complete terms in LICENSE.txt
|
||||
---
|
||||
|
||||
This skill helps generate synonyms and alternative expressions for given words or phrases. It provides contextually appropriate alternatives to enhance vocabulary and improve writing variety.
|
||||
|
||||
The user provides a word, phrase, or sentence where they need synonym suggestions. They may specify the context, tone, or formality level desired.
|
||||
|
||||
## Synonym Generation Guidelines
|
||||
|
||||
When generating synonyms, consider:
|
||||
|
||||
- **Context**: The specific domain or situation where the word will be used
|
||||
- **Tone**: Formal, informal, neutral, academic, conversational, etc.
|
||||
- **Nuance**: Subtle differences in meaning between similar words
|
||||
- **Register**: Appropriate level of formality for the intended audience
|
||||
|
||||
## Output Format
|
||||
|
||||
For each input word or phrase, provide:
|
||||
|
||||
1. **Direct Synonyms**: Words with nearly identical meanings
|
||||
2. **Related Alternatives**: Words with similar but slightly different connotations
|
||||
3. **Context Examples**: Brief usage examples when helpful
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Prioritize commonly used synonyms over obscure alternatives
|
||||
- Note any subtle differences in meaning or usage
|
||||
- Consider regional variations when relevant
|
||||
- Indicate formality levels (formal/informal/neutral)
|
||||
- Provide multiple options to give users choices
|
||||
|
||||
## Example
|
||||
|
||||
**Input**: "happy"
|
||||
|
||||
**Synonyms**:
|
||||
|
||||
- **Direct**: joyful, cheerful, delighted, pleased, content
|
||||
- **Informal**: thrilled, stoked, over the moon
|
||||
- **Formal**: elated, gratified, blissful
|
||||
- **Subtle variations**:
|
||||
- _content_ - peaceful satisfaction
|
||||
- _ecstatic_ - intense, overwhelming happiness
|
||||
- _cheerful_ - outwardly expressing happiness
|
||||
@@ -4,30 +4,51 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, type MockInstance } from 'vitest';
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
import { handleInstall, installCommand } from './install.js';
|
||||
import yargs from 'yargs';
|
||||
|
||||
const mockInstallExtension = vi.hoisted(() => vi.fn());
|
||||
const mockRefreshCache = vi.hoisted(() => vi.fn());
|
||||
const mockParseInstallSource = vi.hoisted(() => vi.fn());
|
||||
const mockRequestConsentNonInteractive = vi.hoisted(() => vi.fn());
|
||||
const mockStat = vi.hoisted(() => vi.fn());
|
||||
const mockRequestConsentOrFail = vi.hoisted(() => vi.fn());
|
||||
const mockIsWorkspaceTrusted = vi.hoisted(() => vi.fn());
|
||||
const mockLoadSettings = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../config/extension.js', () => ({
|
||||
installExtension: mockInstallExtension,
|
||||
vi.mock('@qwen-code/qwen-code-core', () => ({
|
||||
ExtensionManager: vi.fn().mockImplementation(() => ({
|
||||
installExtension: mockInstallExtension,
|
||||
refreshCache: mockRefreshCache,
|
||||
})),
|
||||
parseInstallSource: mockParseInstallSource,
|
||||
}));
|
||||
|
||||
vi.mock('./consent.js', () => ({
|
||||
requestConsentNonInteractive: mockRequestConsentNonInteractive,
|
||||
requestConsentOrFail: mockRequestConsentOrFail,
|
||||
}));
|
||||
|
||||
vi.mock('../../config/trustedFolders.js', () => ({
|
||||
isWorkspaceTrusted: mockIsWorkspaceTrusted,
|
||||
}));
|
||||
|
||||
vi.mock('../../config/settings.js', () => ({
|
||||
loadSettings: mockLoadSettings,
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/errors.js', () => ({
|
||||
getErrorMessage: vi.fn((error: Error) => error.message),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
stat: mockStat,
|
||||
default: {
|
||||
stat: mockStat,
|
||||
},
|
||||
}));
|
||||
|
||||
describe('extensions install command', () => {
|
||||
it('should fail if no source is provided', () => {
|
||||
const validationParser = yargs([])
|
||||
@@ -51,17 +72,21 @@ describe('handleInstall', () => {
|
||||
processSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation(() => undefined as never);
|
||||
mockRefreshCache.mockResolvedValue(undefined);
|
||||
mockLoadSettings.mockReturnValue({ merged: {} });
|
||||
mockIsWorkspaceTrusted.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockInstallExtension.mockClear();
|
||||
mockRequestConsentNonInteractive.mockClear();
|
||||
mockStat.mockClear();
|
||||
vi.resetAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should install an extension from a http source', async () => {
|
||||
mockInstallExtension.mockResolvedValue('http-extension');
|
||||
mockParseInstallSource.mockResolvedValue({
|
||||
type: 'http',
|
||||
url: 'http://google.com',
|
||||
});
|
||||
mockInstallExtension.mockResolvedValue({ name: 'http-extension' });
|
||||
|
||||
await handleInstall({
|
||||
source: 'http://google.com',
|
||||
@@ -73,7 +98,11 @@ describe('handleInstall', () => {
|
||||
});
|
||||
|
||||
it('should install an extension from a https source', async () => {
|
||||
mockInstallExtension.mockResolvedValue('https-extension');
|
||||
mockParseInstallSource.mockResolvedValue({
|
||||
type: 'https',
|
||||
url: 'https://google.com',
|
||||
});
|
||||
mockInstallExtension.mockResolvedValue({ name: 'https-extension' });
|
||||
|
||||
await handleInstall({
|
||||
source: 'https://google.com',
|
||||
@@ -85,7 +114,11 @@ describe('handleInstall', () => {
|
||||
});
|
||||
|
||||
it('should install an extension from a git source', async () => {
|
||||
mockInstallExtension.mockResolvedValue('git-extension');
|
||||
mockParseInstallSource.mockResolvedValue({
|
||||
type: 'git',
|
||||
url: 'git@some-url',
|
||||
});
|
||||
mockInstallExtension.mockResolvedValue({ name: 'git-extension' });
|
||||
|
||||
await handleInstall({
|
||||
source: 'git@some-url',
|
||||
@@ -97,7 +130,9 @@ describe('handleInstall', () => {
|
||||
});
|
||||
|
||||
it('throws an error from an unknown source', async () => {
|
||||
mockStat.mockRejectedValue(new Error('ENOENT: no such file or directory'));
|
||||
mockParseInstallSource.mockRejectedValue(
|
||||
new Error('Install source not found.'),
|
||||
);
|
||||
await handleInstall({
|
||||
source: 'test://google.com',
|
||||
});
|
||||
@@ -107,7 +142,11 @@ describe('handleInstall', () => {
|
||||
});
|
||||
|
||||
it('should install an extension from a sso source', async () => {
|
||||
mockInstallExtension.mockResolvedValue('sso-extension');
|
||||
mockParseInstallSource.mockResolvedValue({
|
||||
type: 'sso',
|
||||
url: 'sso://google.com',
|
||||
});
|
||||
mockInstallExtension.mockResolvedValue({ name: 'sso-extension' });
|
||||
|
||||
await handleInstall({
|
||||
source: 'sso://google.com',
|
||||
@@ -119,8 +158,12 @@ describe('handleInstall', () => {
|
||||
});
|
||||
|
||||
it('should install an extension from a local path', async () => {
|
||||
mockInstallExtension.mockResolvedValue('local-extension');
|
||||
mockStat.mockResolvedValue({});
|
||||
mockParseInstallSource.mockResolvedValue({
|
||||
type: 'local',
|
||||
path: '/some/path',
|
||||
});
|
||||
mockInstallExtension.mockResolvedValue({ name: 'local-extension' });
|
||||
|
||||
await handleInstall({
|
||||
source: '/some/path',
|
||||
});
|
||||
@@ -131,6 +174,10 @@ describe('handleInstall', () => {
|
||||
});
|
||||
|
||||
it('should throw an error if install extension fails', async () => {
|
||||
mockParseInstallSource.mockResolvedValue({
|
||||
type: 'git',
|
||||
url: 'git@some-url',
|
||||
});
|
||||
mockInstallExtension.mockRejectedValue(
|
||||
new Error('Install extension failed'),
|
||||
);
|
||||
|
||||
@@ -5,58 +5,72 @@
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
|
||||
import {
|
||||
installExtension,
|
||||
requestConsentNonInteractive,
|
||||
} from '../../config/extension.js';
|
||||
import type { ExtensionInstallMetadata } from '@qwen-code/qwen-code-core';
|
||||
ExtensionManager,
|
||||
parseInstallSource,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { isWorkspaceTrusted } from '../../config/trustedFolders.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import {
|
||||
requestConsentOrFail,
|
||||
requestConsentNonInteractive,
|
||||
} from './consent.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
interface InstallArgs {
|
||||
source: string;
|
||||
ref?: string;
|
||||
autoUpdate?: boolean;
|
||||
allowPreRelease?: boolean;
|
||||
consent?: boolean;
|
||||
}
|
||||
|
||||
export async function handleInstall(args: InstallArgs) {
|
||||
try {
|
||||
let installMetadata: ExtensionInstallMetadata;
|
||||
const { source } = args;
|
||||
const installMetadata = await parseInstallSource(args.source);
|
||||
|
||||
if (
|
||||
source.startsWith('http://') ||
|
||||
source.startsWith('https://') ||
|
||||
source.startsWith('git@') ||
|
||||
source.startsWith('sso://')
|
||||
installMetadata.type !== 'git' &&
|
||||
installMetadata.type !== 'github-release'
|
||||
) {
|
||||
installMetadata = {
|
||||
source,
|
||||
type: 'git',
|
||||
ref: args.ref,
|
||||
autoUpdate: args.autoUpdate,
|
||||
};
|
||||
} else {
|
||||
if (args.ref || args.autoUpdate) {
|
||||
throw new Error(
|
||||
'--ref and --auto-update are not applicable for local extensions.',
|
||||
t(
|
||||
'--ref and --auto-update are not applicable for marketplace extensions.',
|
||||
),
|
||||
);
|
||||
}
|
||||
try {
|
||||
await stat(source);
|
||||
installMetadata = {
|
||||
source,
|
||||
type: 'local',
|
||||
};
|
||||
} catch {
|
||||
throw new Error('Install source not found.');
|
||||
}
|
||||
}
|
||||
|
||||
const name = await installExtension(
|
||||
installMetadata,
|
||||
requestConsentNonInteractive,
|
||||
const requestConsent = args.consent
|
||||
? () => Promise.resolve()
|
||||
: requestConsentOrFail.bind(null, requestConsentNonInteractive);
|
||||
const workspaceDir = process.cwd();
|
||||
const extensionManager = new ExtensionManager({
|
||||
workspaceDir,
|
||||
isWorkspaceTrusted: !!isWorkspaceTrusted(
|
||||
loadSettings(workspaceDir).merged,
|
||||
),
|
||||
requestConsent,
|
||||
});
|
||||
await extensionManager.refreshCache();
|
||||
|
||||
const extension = await extensionManager.installExtension(
|
||||
{
|
||||
...installMetadata,
|
||||
ref: args.ref,
|
||||
autoUpdate: args.autoUpdate,
|
||||
allowPreRelease: args.allowPreRelease,
|
||||
},
|
||||
requestConsent,
|
||||
);
|
||||
console.log(
|
||||
t('Extension "{{name}}" installed successfully and enabled.', {
|
||||
name: extension.name,
|
||||
}),
|
||||
);
|
||||
console.log(`Extension "${name}" installed successfully and enabled.`);
|
||||
} catch (error) {
|
||||
console.error(getErrorMessage(error));
|
||||
process.exit(1);
|
||||
@@ -65,25 +79,40 @@ export async function handleInstall(args: InstallArgs) {
|
||||
|
||||
export const installCommand: CommandModule = {
|
||||
command: 'install <source>',
|
||||
describe: 'Installs an extension from a git repository URL or a local path.',
|
||||
describe: t(
|
||||
'Installs an extension from a git repository URL, local path, or claude marketplace (marketplace-url:plugin-name).',
|
||||
),
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional('source', {
|
||||
describe: 'The github URL or local path of the extension to install.',
|
||||
describe: t(
|
||||
'The github URL, local path, or marketplace source (marketplace-url:plugin-name) of the extension to install.',
|
||||
),
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
})
|
||||
.option('ref', {
|
||||
describe: 'The git ref to install from.',
|
||||
describe: t('The git ref to install from.'),
|
||||
type: 'string',
|
||||
})
|
||||
.option('auto-update', {
|
||||
describe: 'Enable auto-update for this extension.',
|
||||
describe: t('Enable auto-update for this extension.'),
|
||||
type: 'boolean',
|
||||
})
|
||||
.option('pre-release', {
|
||||
describe: t('Enable pre-release versions for this extension.'),
|
||||
type: 'boolean',
|
||||
})
|
||||
.option('consent', {
|
||||
describe: t(
|
||||
'Acknowledge the security risks of installing an extension and skip the confirmation prompt.',
|
||||
),
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
})
|
||||
.check((argv) => {
|
||||
if (!argv.source) {
|
||||
throw new Error('The source argument must be provided.');
|
||||
throw new Error(t('The source argument must be provided.'));
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
@@ -92,6 +121,8 @@ export const installCommand: CommandModule = {
|
||||
source: argv['source'] as string,
|
||||
ref: argv['ref'] as string | undefined,
|
||||
autoUpdate: argv['auto-update'] as boolean | undefined,
|
||||
allowPreRelease: argv['pre-release'] as boolean | undefined,
|
||||
consent: argv['consent'] as boolean | undefined,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
95
packages/cli/src/commands/extensions/link.test.ts
Normal file
95
packages/cli/src/commands/extensions/link.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
import { linkCommand, handleLink } from './link.js';
|
||||
import yargs from 'yargs';
|
||||
|
||||
const mockInstallExtension = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('./utils.js', () => ({
|
||||
getExtensionManager: vi.fn().mockResolvedValue({
|
||||
installExtension: mockInstallExtension,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('./consent.js', () => ({
|
||||
requestConsentNonInteractive: vi.fn().mockResolvedValue(true),
|
||||
requestConsentOrFail: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/errors.js', () => ({
|
||||
getErrorMessage: vi.fn((error: Error) => error.message),
|
||||
}));
|
||||
|
||||
describe('extensions link command', () => {
|
||||
it('should fail if no path is provided', () => {
|
||||
const validationParser = yargs([])
|
||||
.command(linkCommand)
|
||||
.fail(false)
|
||||
.locale('en');
|
||||
expect(() => validationParser.parse('link')).toThrow(
|
||||
'Not enough non-option arguments: got 0, need at least 1',
|
||||
);
|
||||
});
|
||||
|
||||
it('should accept a path argument', () => {
|
||||
const parser = yargs([]).command(linkCommand).fail(false).locale('en');
|
||||
expect(() => parser.parse('link /some/path')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleLink', () => {
|
||||
let consoleLogSpy: MockInstance;
|
||||
let consoleErrorSpy: MockInstance;
|
||||
let processExitSpy: MockInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
processExitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation(() => undefined as never);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should link an extension from a local path', async () => {
|
||||
mockInstallExtension.mockResolvedValueOnce({ name: 'linked-extension' });
|
||||
|
||||
await handleLink({
|
||||
path: '/some/local/path',
|
||||
});
|
||||
|
||||
expect(mockInstallExtension).toHaveBeenCalledWith(
|
||||
{
|
||||
source: '/some/local/path',
|
||||
type: 'link',
|
||||
},
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
'Extension "linked-extension" linked successfully and enabled.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle errors and exit with code 1', async () => {
|
||||
mockInstallExtension.mockRejectedValueOnce(new Error('Link failed'));
|
||||
|
||||
await handleLink({
|
||||
path: '/some/local/path',
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Link failed');
|
||||
expect(processExitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
@@ -5,13 +5,14 @@
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import {
|
||||
installExtension,
|
||||
requestConsentNonInteractive,
|
||||
} from '../../config/extension.js';
|
||||
import type { ExtensionInstallMetadata } from '@qwen-code/qwen-code-core';
|
||||
|
||||
import { type ExtensionInstallMetadata } from '@qwen-code/qwen-code-core';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import {
|
||||
requestConsentNonInteractive,
|
||||
requestConsentOrFail,
|
||||
} from './consent.js';
|
||||
import { getExtensionManager } from './utils.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
interface InstallArgs {
|
||||
path: string;
|
||||
@@ -23,12 +24,20 @@ export async function handleLink(args: InstallArgs) {
|
||||
source: args.path,
|
||||
type: 'link',
|
||||
};
|
||||
const extensionName = await installExtension(
|
||||
const extensionManager = await getExtensionManager();
|
||||
|
||||
const extension = await extensionManager.installExtension(
|
||||
installMetadata,
|
||||
requestConsentNonInteractive,
|
||||
requestConsentOrFail.bind(null, requestConsentNonInteractive),
|
||||
);
|
||||
if (!extension) {
|
||||
console.log(t('Link extension failed to install.'));
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`Extension "${extensionName}" linked successfully and enabled.`,
|
||||
t('Extension "{{name}}" linked successfully and enabled.', {
|
||||
name: extension.name,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(getErrorMessage(error));
|
||||
@@ -38,12 +47,13 @@ export async function handleLink(args: InstallArgs) {
|
||||
|
||||
export const linkCommand: CommandModule = {
|
||||
command: 'link <path>',
|
||||
describe:
|
||||
describe: t(
|
||||
'Links an extension from a local path. Updates made to the local path will always be reflected.',
|
||||
),
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional('path', {
|
||||
describe: 'The name of the extension to link.',
|
||||
describe: t('The name of the extension to link.'),
|
||||
type: 'string',
|
||||
})
|
||||
.check((_) => true),
|
||||
|
||||
91
packages/cli/src/commands/extensions/list.test.ts
Normal file
91
packages/cli/src/commands/extensions/list.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
import { listCommand, handleList } from './list.js';
|
||||
import yargs from 'yargs';
|
||||
|
||||
const mockGetLoadedExtensions = vi.hoisted(() => vi.fn());
|
||||
const mockToOutputString = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('./utils.js', () => ({
|
||||
getExtensionManager: vi.fn().mockResolvedValue({
|
||||
getLoadedExtensions: mockGetLoadedExtensions,
|
||||
toOutputString: mockToOutputString,
|
||||
}),
|
||||
extensionToOutputString: mockToOutputString,
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/errors.js', () => ({
|
||||
getErrorMessage: vi.fn((error: Error) => error.message),
|
||||
}));
|
||||
|
||||
describe('extensions list command', () => {
|
||||
it('should parse the list command', () => {
|
||||
const parser = yargs([]).command(listCommand).fail(false).locale('en');
|
||||
expect(() => parser.parse('list')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleList', () => {
|
||||
let consoleLogSpy: MockInstance;
|
||||
let consoleErrorSpy: MockInstance;
|
||||
let processExitSpy: MockInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
processExitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation(() => undefined as never);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should display message when no extensions are installed', async () => {
|
||||
mockGetLoadedExtensions.mockReturnValueOnce([]);
|
||||
|
||||
await handleList();
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith('No extensions installed.');
|
||||
});
|
||||
|
||||
it('should list installed extensions', async () => {
|
||||
const mockExtensions = [
|
||||
{ name: 'extension-1', version: '1.0.0' },
|
||||
{ name: 'extension-2', version: '2.0.0' },
|
||||
];
|
||||
mockGetLoadedExtensions.mockReturnValueOnce(mockExtensions);
|
||||
mockToOutputString.mockImplementation(
|
||||
(ext) => `${ext.name} (${ext.version})`,
|
||||
);
|
||||
|
||||
await handleList();
|
||||
|
||||
expect(mockGetLoadedExtensions).toHaveBeenCalled();
|
||||
expect(mockToOutputString).toHaveBeenCalledTimes(2);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
'extension-1 (1.0.0)\n\nextension-2 (2.0.0)',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle errors and exit with code 1', async () => {
|
||||
mockGetLoadedExtensions.mockImplementationOnce(() => {
|
||||
throw new Error('List failed');
|
||||
});
|
||||
|
||||
await handleList();
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('List failed');
|
||||
expect(processExitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
@@ -5,19 +5,24 @@
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { loadUserExtensions, toOutputString } from '../../config/extension.js';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { extensionToOutputString, getExtensionManager } from './utils.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export async function handleList() {
|
||||
try {
|
||||
const extensions = loadUserExtensions();
|
||||
if (extensions.length === 0) {
|
||||
console.log('No extensions installed.');
|
||||
const extensionManager = await getExtensionManager();
|
||||
const extensions = extensionManager.getLoadedExtensions();
|
||||
|
||||
if (!extensions || extensions.length === 0) {
|
||||
console.log(t('No extensions installed.'));
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
extensions
|
||||
.map((extension, _): string => toOutputString(extension, process.cwd()))
|
||||
.map((extension, _): string =>
|
||||
extensionToOutputString(extension, extensionManager, process.cwd()),
|
||||
)
|
||||
.join('\n\n'),
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -28,7 +33,7 @@ export async function handleList() {
|
||||
|
||||
export const listCommand: CommandModule = {
|
||||
command: 'list',
|
||||
describe: 'Lists installed extensions.',
|
||||
describe: t('Lists installed extensions.'),
|
||||
builder: (yargs) => yargs,
|
||||
handler: async () => {
|
||||
await handleList();
|
||||
|
||||
345
packages/cli/src/commands/extensions/settings.test.ts
Normal file
345
packages/cli/src/commands/extensions/settings.test.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
import { settingsCommand } from './settings.js';
|
||||
import yargs from 'yargs';
|
||||
|
||||
const mockGetLoadedExtensions = vi.hoisted(() => vi.fn());
|
||||
const mockGetScopedEnvContents = vi.hoisted(() => vi.fn());
|
||||
const mockUpdateSetting = vi.hoisted(() => vi.fn());
|
||||
const mockPromptForSetting = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('./utils.js', () => ({
|
||||
getExtensionManager: vi.fn().mockResolvedValue({
|
||||
getLoadedExtensions: mockGetLoadedExtensions,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', () => ({
|
||||
ExtensionSettingScope: {
|
||||
USER: 'user',
|
||||
WORKSPACE: 'workspace',
|
||||
},
|
||||
getScopedEnvContents: mockGetScopedEnvContents,
|
||||
promptForSetting: mockPromptForSetting,
|
||||
updateSetting: mockUpdateSetting,
|
||||
}));
|
||||
|
||||
describe('extensions settings command', () => {
|
||||
it('should fail if no subcommand is provided', () => {
|
||||
const validationParser = yargs([])
|
||||
.command(settingsCommand)
|
||||
.fail(false)
|
||||
.locale('en');
|
||||
expect(() => validationParser.parse('settings')).toThrow(
|
||||
'Not enough non-option arguments: got 0, need at least 1',
|
||||
);
|
||||
});
|
||||
|
||||
it('should register set subcommand', () => {
|
||||
const parser = yargs([]).command(settingsCommand).fail(false).locale('en');
|
||||
expect(() => parser.parse('settings set')).toThrow(
|
||||
'Not enough non-option arguments',
|
||||
);
|
||||
});
|
||||
|
||||
it('should register list subcommand', () => {
|
||||
const parser = yargs([]).command(settingsCommand).fail(false).locale('en');
|
||||
expect(() => parser.parse('settings list')).toThrow(
|
||||
'Not enough non-option arguments',
|
||||
);
|
||||
});
|
||||
|
||||
it('should accept set command with name and setting', () => {
|
||||
const parser = yargs([]).command(settingsCommand).fail(false).locale('en');
|
||||
expect(() =>
|
||||
parser.parse('settings set my-extension API_KEY'),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept set command with scope option', () => {
|
||||
const parser = yargs([]).command(settingsCommand).fail(false).locale('en');
|
||||
expect(() =>
|
||||
parser.parse('settings set my-extension API_KEY --scope=workspace'),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('should fail set command with invalid scope', () => {
|
||||
const parser = yargs([]).command(settingsCommand).fail(false).locale('en');
|
||||
expect(() =>
|
||||
parser.parse('settings set my-extension API_KEY --scope=invalid'),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('should accept list command with name', () => {
|
||||
const parser = yargs([]).command(settingsCommand).fail(false).locale('en');
|
||||
expect(() => parser.parse('settings list my-extension')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('settings set handler', () => {
|
||||
let consoleLogSpy: MockInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return early if extension manager is not available', async () => {
|
||||
const { getExtensionManager } = await import('./utils.js');
|
||||
vi.mocked(getExtensionManager).mockResolvedValueOnce(null as never);
|
||||
|
||||
const parser = yargs([]).command(settingsCommand);
|
||||
await parser.parseAsync('settings set my-extension API_KEY');
|
||||
|
||||
expect(mockUpdateSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return early if no extensions are loaded', async () => {
|
||||
mockGetLoadedExtensions.mockReturnValueOnce([]);
|
||||
|
||||
const parser = yargs([]).command(settingsCommand);
|
||||
await parser.parseAsync('settings set my-extension API_KEY');
|
||||
|
||||
expect(mockUpdateSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should log error if extension is not found', async () => {
|
||||
mockGetLoadedExtensions.mockReturnValueOnce([
|
||||
{ name: 'other-extension', id: 'other-id', config: {} },
|
||||
]);
|
||||
|
||||
const parser = yargs([]).command(settingsCommand);
|
||||
await parser.parseAsync('settings set my-extension API_KEY');
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
'Extension "my-extension" not found.',
|
||||
);
|
||||
expect(mockUpdateSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call updateSetting with correct arguments for user scope', async () => {
|
||||
const mockExtension = {
|
||||
name: 'my-extension',
|
||||
id: 'ext-id-123',
|
||||
config: { name: 'my-extension', settings: [] },
|
||||
};
|
||||
mockGetLoadedExtensions.mockReturnValueOnce([mockExtension]);
|
||||
|
||||
const parser = yargs([]).command(settingsCommand);
|
||||
await parser.parseAsync('settings set my-extension API_KEY');
|
||||
|
||||
expect(mockUpdateSetting).toHaveBeenCalledWith(
|
||||
mockExtension.config,
|
||||
mockExtension.id,
|
||||
'API_KEY',
|
||||
mockPromptForSetting,
|
||||
'user',
|
||||
);
|
||||
});
|
||||
|
||||
it('should call updateSetting with workspace scope when specified', async () => {
|
||||
const mockExtension = {
|
||||
name: 'my-extension',
|
||||
id: 'ext-id-123',
|
||||
config: { name: 'my-extension', settings: [] },
|
||||
};
|
||||
mockGetLoadedExtensions.mockReturnValueOnce([mockExtension]);
|
||||
|
||||
const parser = yargs([]).command(settingsCommand);
|
||||
await parser.parseAsync(
|
||||
'settings set my-extension API_KEY --scope=workspace',
|
||||
);
|
||||
|
||||
expect(mockUpdateSetting).toHaveBeenCalledWith(
|
||||
mockExtension.config,
|
||||
mockExtension.id,
|
||||
'API_KEY',
|
||||
mockPromptForSetting,
|
||||
'workspace',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('settings list handler', () => {
|
||||
let consoleLogSpy: MockInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return early if extension manager is not available', async () => {
|
||||
const { getExtensionManager } = await import('./utils.js');
|
||||
vi.mocked(getExtensionManager).mockResolvedValueOnce(null as never);
|
||||
|
||||
const parser = yargs([]).command(settingsCommand);
|
||||
await parser.parseAsync('settings list my-extension');
|
||||
|
||||
expect(mockGetScopedEnvContents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return early if no extensions are loaded', async () => {
|
||||
mockGetLoadedExtensions.mockReturnValueOnce([]);
|
||||
|
||||
const parser = yargs([]).command(settingsCommand);
|
||||
await parser.parseAsync('settings list my-extension');
|
||||
|
||||
expect(mockGetScopedEnvContents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should log error if extension is not found', async () => {
|
||||
mockGetLoadedExtensions.mockReturnValueOnce([
|
||||
{ name: 'other-extension', id: 'other-id', config: {} },
|
||||
]);
|
||||
|
||||
const parser = yargs([]).command(settingsCommand);
|
||||
await parser.parseAsync('settings list my-extension');
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
'Extension "my-extension" not found.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should log message if extension has no settings', async () => {
|
||||
const mockExtension = {
|
||||
name: 'my-extension',
|
||||
id: 'ext-id-123',
|
||||
config: { name: 'my-extension' },
|
||||
settings: [],
|
||||
};
|
||||
mockGetLoadedExtensions.mockReturnValueOnce([mockExtension]);
|
||||
|
||||
const parser = yargs([]).command(settingsCommand);
|
||||
await parser.parseAsync('settings list my-extension');
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
'Extension "my-extension" has no settings to configure.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should list settings with their values', async () => {
|
||||
const mockExtension = {
|
||||
name: 'my-extension',
|
||||
id: 'ext-id-123',
|
||||
config: { name: 'my-extension' },
|
||||
settings: [
|
||||
{
|
||||
name: 'API Key',
|
||||
envVar: 'API_KEY',
|
||||
description: 'Your API key',
|
||||
sensitive: false,
|
||||
},
|
||||
{
|
||||
name: 'Secret Token',
|
||||
envVar: 'SECRET_TOKEN',
|
||||
description: 'A secret token',
|
||||
sensitive: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
mockGetLoadedExtensions.mockReturnValueOnce([mockExtension]);
|
||||
mockGetScopedEnvContents
|
||||
.mockResolvedValueOnce({ API_KEY: 'my-api-key' }) // user scope
|
||||
.mockResolvedValueOnce({}); // workspace scope
|
||||
|
||||
const parser = yargs([]).command(settingsCommand);
|
||||
await parser.parseAsync('settings list my-extension');
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith('Settings for "my-extension":');
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith('\n- API Key (API_KEY)');
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(' Description: Your API key');
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(' Value: my-api-key (user)');
|
||||
});
|
||||
|
||||
it('should show workspace scope for workspace-scoped settings', async () => {
|
||||
const mockExtension = {
|
||||
name: 'my-extension',
|
||||
id: 'ext-id-123',
|
||||
config: { name: 'my-extension' },
|
||||
settings: [
|
||||
{
|
||||
name: 'API Key',
|
||||
envVar: 'API_KEY',
|
||||
description: 'Your API key',
|
||||
sensitive: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
mockGetLoadedExtensions.mockReturnValueOnce([mockExtension]);
|
||||
mockGetScopedEnvContents
|
||||
.mockResolvedValueOnce({ API_KEY: 'user-value' }) // user scope
|
||||
.mockResolvedValueOnce({ API_KEY: 'workspace-value' }); // workspace scope
|
||||
|
||||
const parser = yargs([]).command(settingsCommand);
|
||||
await parser.parseAsync('settings list my-extension');
|
||||
|
||||
// Workspace should override user, and show (workspace) scope
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
' Value: workspace-value (workspace)',
|
||||
);
|
||||
});
|
||||
|
||||
it('should show [not set] for undefined settings', async () => {
|
||||
const mockExtension = {
|
||||
name: 'my-extension',
|
||||
id: 'ext-id-123',
|
||||
config: { name: 'my-extension' },
|
||||
settings: [
|
||||
{
|
||||
name: 'API Key',
|
||||
envVar: 'API_KEY',
|
||||
description: 'Your API key',
|
||||
sensitive: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
mockGetLoadedExtensions.mockReturnValueOnce([mockExtension]);
|
||||
mockGetScopedEnvContents
|
||||
.mockResolvedValueOnce({}) // user scope
|
||||
.mockResolvedValueOnce({}); // workspace scope
|
||||
|
||||
const parser = yargs([]).command(settingsCommand);
|
||||
await parser.parseAsync('settings list my-extension');
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(' Value: [not set]');
|
||||
});
|
||||
|
||||
it('should show [value stored in keychain] for sensitive settings', async () => {
|
||||
const mockExtension = {
|
||||
name: 'my-extension',
|
||||
id: 'ext-id-123',
|
||||
config: { name: 'my-extension' },
|
||||
settings: [
|
||||
{
|
||||
name: 'Secret Token',
|
||||
envVar: 'SECRET_TOKEN',
|
||||
description: 'A secret token',
|
||||
sensitive: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
mockGetLoadedExtensions.mockReturnValueOnce([mockExtension]);
|
||||
mockGetScopedEnvContents
|
||||
.mockResolvedValueOnce({ SECRET_TOKEN: 'secret-value' }) // user scope
|
||||
.mockResolvedValueOnce({}); // workspace scope
|
||||
|
||||
const parser = yargs([]).command(settingsCommand);
|
||||
await parser.parseAsync('settings list my-extension');
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
' Value: [value stored in keychain] (user)',
|
||||
);
|
||||
});
|
||||
});
|
||||
151
packages/cli/src/commands/extensions/settings.ts
Normal file
151
packages/cli/src/commands/extensions/settings.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { getExtensionManager } from './utils.js';
|
||||
import {
|
||||
ExtensionSettingScope,
|
||||
getScopedEnvContents,
|
||||
promptForSetting,
|
||||
updateSetting,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
// --- SET COMMAND ---
|
||||
interface SetArgs {
|
||||
name: string;
|
||||
setting: string;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
const setCommand: CommandModule<object, SetArgs> = {
|
||||
command: 'set [--scope] <name> <setting>',
|
||||
describe: t('Set a specific setting for an extension.'),
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional('name', {
|
||||
describe: t('Name of the extension to configure.'),
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
})
|
||||
.positional('setting', {
|
||||
describe: t('The setting to configure (name or env var).'),
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
})
|
||||
.option('scope', {
|
||||
describe: t('The scope to set the setting in.'),
|
||||
type: 'string',
|
||||
choices: ['user', 'workspace'],
|
||||
default: 'user',
|
||||
}),
|
||||
handler: async (args) => {
|
||||
const { name, setting, scope } = args;
|
||||
const extensionManager = await getExtensionManager();
|
||||
if (!extensionManager) return;
|
||||
const extensions = extensionManager.getLoadedExtensions();
|
||||
if (!extensions || extensions.length === 0) return;
|
||||
const extension = extensions.find((e) => e.name === name);
|
||||
if (!extension) {
|
||||
console.log(t('Extension "{{name}}" not found.', { name }));
|
||||
return;
|
||||
}
|
||||
await updateSetting(
|
||||
extension.config,
|
||||
extension.id,
|
||||
setting,
|
||||
promptForSetting,
|
||||
scope as ExtensionSettingScope,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// --- LIST COMMAND ---
|
||||
interface ListArgs {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const listCommand: CommandModule<object, ListArgs> = {
|
||||
command: 'list <name>',
|
||||
describe: t('List all settings for an extension.'),
|
||||
builder: (yargs) =>
|
||||
yargs.positional('name', {
|
||||
describe: t('Name of the extension.'),
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
}),
|
||||
handler: async (args) => {
|
||||
const { name } = args;
|
||||
const extensionManager = await getExtensionManager();
|
||||
if (!extensionManager) return;
|
||||
const extensions = extensionManager.getLoadedExtensions();
|
||||
if (!extensions || extensions.length === 0) return;
|
||||
const extension = extensions.find((e) => e.name === name);
|
||||
if (!extension) {
|
||||
console.log(t('Extension "{{name}}" not found.', { name }));
|
||||
return;
|
||||
}
|
||||
if (!extension || !extension.settings || extension.settings.length === 0) {
|
||||
console.log(
|
||||
t('Extension "{{name}}" has no settings to configure.', { name }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const userSettings = await getScopedEnvContents(
|
||||
extension.config,
|
||||
extension.id,
|
||||
ExtensionSettingScope.USER,
|
||||
);
|
||||
const workspaceSettings = await getScopedEnvContents(
|
||||
extension.config,
|
||||
extension.id,
|
||||
ExtensionSettingScope.WORKSPACE,
|
||||
);
|
||||
const mergedSettings = { ...userSettings, ...workspaceSettings };
|
||||
|
||||
console.log(t('Settings for "{{name}}":', { name }));
|
||||
for (const setting of extension.settings) {
|
||||
const value = mergedSettings[setting.envVar];
|
||||
let displayValue: string;
|
||||
let scopeInfo = '';
|
||||
|
||||
if (workspaceSettings[setting.envVar] !== undefined) {
|
||||
scopeInfo = ' ' + t('(workspace)');
|
||||
} else if (userSettings[setting.envVar] !== undefined) {
|
||||
scopeInfo = ' ' + t('(user)');
|
||||
}
|
||||
|
||||
if (value === undefined) {
|
||||
displayValue = t('[not set]');
|
||||
} else if (setting.sensitive) {
|
||||
displayValue = t('[value stored in keychain]');
|
||||
} else {
|
||||
displayValue = value;
|
||||
}
|
||||
console.log(`
|
||||
- ${setting.name} (${setting.envVar})`);
|
||||
console.log(` ${t('Description:')} ${setting.description}`);
|
||||
console.log(` ${t('Value:')} ${displayValue}${scopeInfo}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// --- SETTINGS COMMAND ---
|
||||
export const settingsCommand: CommandModule = {
|
||||
command: 'settings <command>',
|
||||
describe: t('Manage extension settings.'),
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.command(setCommand)
|
||||
.command(listCommand)
|
||||
.demandCommand(1, t('You need to specify a command (set or list).'))
|
||||
.version(false),
|
||||
handler: () => {
|
||||
// This handler is not called when a subcommand is provided.
|
||||
// Yargs will show the help menu.
|
||||
},
|
||||
};
|
||||
@@ -5,8 +5,15 @@
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { uninstallExtension } from '../../config/extension.js';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { ExtensionManager } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
requestConsentNonInteractive,
|
||||
requestConsentOrFail,
|
||||
} from './consent.js';
|
||||
import { isWorkspaceTrusted } from '../../config/trustedFolders.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
interface UninstallArgs {
|
||||
name: string; // can be extension name or source URL.
|
||||
@@ -14,8 +21,22 @@ interface UninstallArgs {
|
||||
|
||||
export async function handleUninstall(args: UninstallArgs) {
|
||||
try {
|
||||
await uninstallExtension(args.name);
|
||||
console.log(`Extension "${args.name}" successfully uninstalled.`);
|
||||
const workspaceDir = process.cwd();
|
||||
const extensionManager = new ExtensionManager({
|
||||
workspaceDir,
|
||||
requestConsent: requestConsentOrFail.bind(
|
||||
null,
|
||||
requestConsentNonInteractive,
|
||||
),
|
||||
isWorkspaceTrusted: !!isWorkspaceTrusted(
|
||||
loadSettings(workspaceDir).merged,
|
||||
),
|
||||
});
|
||||
await extensionManager.refreshCache();
|
||||
await extensionManager.uninstallExtension(args.name, false);
|
||||
console.log(
|
||||
t('Extension "{{name}}" successfully uninstalled.', { name: args.name }),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(getErrorMessage(error));
|
||||
process.exit(1);
|
||||
@@ -24,17 +45,19 @@ export async function handleUninstall(args: UninstallArgs) {
|
||||
|
||||
export const uninstallCommand: CommandModule = {
|
||||
command: 'uninstall <name>',
|
||||
describe: 'Uninstalls an extension.',
|
||||
describe: t('Uninstalls an extension.'),
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional('name', {
|
||||
describe: 'The name or source path of the extension to uninstall.',
|
||||
describe: t('The name or source path of the extension to uninstall.'),
|
||||
type: 'string',
|
||||
})
|
||||
.check((argv) => {
|
||||
if (!argv.name) {
|
||||
throw new Error(
|
||||
'Please include the name of the extension to uninstall as a positional argument.',
|
||||
t(
|
||||
'Please include the name of the extension to uninstall as a positional argument.',
|
||||
),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
|
||||
262
packages/cli/src/commands/extensions/update.test.ts
Normal file
262
packages/cli/src/commands/extensions/update.test.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
import { updateCommand, handleUpdate } from './update.js';
|
||||
import yargs from 'yargs';
|
||||
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
|
||||
|
||||
const mockGetLoadedExtensions = vi.hoisted(() => vi.fn());
|
||||
const mockUpdateExtension = vi.hoisted(() => vi.fn());
|
||||
const mockCheckForAllExtensionUpdates = vi.hoisted(() => vi.fn());
|
||||
const mockUpdateAllUpdatableExtensions = vi.hoisted(() => vi.fn());
|
||||
const mockCheckForExtensionUpdate = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('./utils.js', () => ({
|
||||
getExtensionManager: vi.fn().mockResolvedValue({
|
||||
getLoadedExtensions: mockGetLoadedExtensions,
|
||||
updateExtension: mockUpdateExtension,
|
||||
checkForAllExtensionUpdates: mockCheckForAllExtensionUpdates,
|
||||
updateAllUpdatableExtensions: mockUpdateAllUpdatableExtensions,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', () => ({
|
||||
checkForExtensionUpdate: mockCheckForExtensionUpdate,
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/errors.js', () => ({
|
||||
getErrorMessage: vi.fn((error: Error) => error.message),
|
||||
}));
|
||||
|
||||
vi.mock('../../ui/state/extensions.js', () => ({
|
||||
ExtensionUpdateState: {
|
||||
UPDATE_AVAILABLE: 'update available',
|
||||
UP_TO_DATE: 'up to date',
|
||||
ERROR: 'error',
|
||||
},
|
||||
}));
|
||||
|
||||
describe('extensions update command', () => {
|
||||
it('should fail if neither name nor --all is provided', () => {
|
||||
const validationParser = yargs([])
|
||||
.command(updateCommand)
|
||||
.fail(false)
|
||||
.locale('en');
|
||||
expect(() => validationParser.parse('update')).toThrow(
|
||||
'Either an extension name or --all must be provided',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail if both name and --all are provided', () => {
|
||||
const validationParser = yargs([])
|
||||
.command(updateCommand)
|
||||
.fail(false)
|
||||
.locale('en');
|
||||
expect(() => validationParser.parse('update test-extension --all')).toThrow(
|
||||
/Arguments .* are mutually exclusive/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should accept --all flag', () => {
|
||||
const parser = yargs([]).command(updateCommand).fail(false).locale('en');
|
||||
expect(() => parser.parse('update --all')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept an extension name', () => {
|
||||
const parser = yargs([]).command(updateCommand).fail(false).locale('en');
|
||||
expect(() => parser.parse('update test-extension')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleUpdate', () => {
|
||||
let consoleLogSpy: MockInstance;
|
||||
let consoleErrorSpy: MockInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('update by name', () => {
|
||||
it('should show message when extension is not found', async () => {
|
||||
mockGetLoadedExtensions.mockReturnValueOnce([]);
|
||||
|
||||
await handleUpdate({ name: 'non-existent-extension' });
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
'Extension "non-existent-extension" not found.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should show message when extension has no install metadata', async () => {
|
||||
mockGetLoadedExtensions.mockReturnValueOnce([
|
||||
{ name: 'test-extension', installMetadata: undefined },
|
||||
]);
|
||||
|
||||
await handleUpdate({ name: 'test-extension' });
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
'Unable to install extension "test-extension" due to missing install metadata',
|
||||
);
|
||||
});
|
||||
|
||||
it('should show message when extension is already up to date', async () => {
|
||||
const mockExtension = {
|
||||
name: 'test-extension',
|
||||
installMetadata: { source: 'test' },
|
||||
};
|
||||
mockGetLoadedExtensions.mockReturnValueOnce([mockExtension]);
|
||||
mockCheckForExtensionUpdate.mockResolvedValueOnce(
|
||||
ExtensionUpdateState.UP_TO_DATE,
|
||||
);
|
||||
|
||||
await handleUpdate({ name: 'test-extension' });
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
'Extension "test-extension" is already up to date.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should update extension when update is available', async () => {
|
||||
const mockExtension = {
|
||||
name: 'test-extension',
|
||||
installMetadata: { source: 'test' },
|
||||
};
|
||||
mockGetLoadedExtensions.mockReturnValueOnce([mockExtension]);
|
||||
mockCheckForExtensionUpdate.mockResolvedValueOnce(
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
);
|
||||
mockUpdateExtension.mockResolvedValueOnce({
|
||||
name: 'test-extension',
|
||||
originalVersion: '1.0.0',
|
||||
updatedVersion: '2.0.0',
|
||||
});
|
||||
|
||||
await handleUpdate({ name: 'test-extension' });
|
||||
|
||||
expect(mockUpdateExtension).toHaveBeenCalledWith(
|
||||
mockExtension,
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
'Extension "test-extension" successfully updated: 1.0.0 → 2.0.0.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should show up to date message when versions are the same after update', async () => {
|
||||
const mockExtension = {
|
||||
name: 'test-extension',
|
||||
installMetadata: { source: 'test' },
|
||||
};
|
||||
mockGetLoadedExtensions.mockReturnValueOnce([mockExtension]);
|
||||
mockCheckForExtensionUpdate.mockResolvedValueOnce(
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
);
|
||||
mockUpdateExtension.mockResolvedValueOnce({
|
||||
name: 'test-extension',
|
||||
originalVersion: '1.0.0',
|
||||
updatedVersion: '1.0.0',
|
||||
});
|
||||
|
||||
await handleUpdate({ name: 'test-extension' });
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
'Extension "test-extension" is already up to date.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle errors during update', async () => {
|
||||
const mockExtension = {
|
||||
name: 'test-extension',
|
||||
installMetadata: { source: 'test' },
|
||||
};
|
||||
mockGetLoadedExtensions.mockReturnValueOnce([mockExtension]);
|
||||
mockCheckForExtensionUpdate.mockRejectedValueOnce(
|
||||
new Error('Update check failed'),
|
||||
);
|
||||
|
||||
await handleUpdate({ name: 'test-extension' });
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Update check failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update all', () => {
|
||||
it('should show message when no extensions to update', async () => {
|
||||
mockCheckForAllExtensionUpdates.mockResolvedValueOnce(undefined);
|
||||
mockUpdateAllUpdatableExtensions.mockResolvedValueOnce([]);
|
||||
|
||||
await handleUpdate({ all: true });
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith('No extensions to update.');
|
||||
});
|
||||
|
||||
it('should update all extensions with updates available', async () => {
|
||||
mockCheckForAllExtensionUpdates.mockResolvedValueOnce(undefined);
|
||||
mockUpdateAllUpdatableExtensions.mockResolvedValueOnce([
|
||||
{
|
||||
name: 'extension-1',
|
||||
originalVersion: '1.0.0',
|
||||
updatedVersion: '2.0.0',
|
||||
},
|
||||
{
|
||||
name: 'extension-2',
|
||||
originalVersion: '1.0.0',
|
||||
updatedVersion: '1.5.0',
|
||||
},
|
||||
]);
|
||||
|
||||
await handleUpdate({ all: true });
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
'Extension "extension-1" successfully updated: 1.0.0 → 2.0.0.\n' +
|
||||
'Extension "extension-2" successfully updated: 1.0.0 → 1.5.0.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter out extensions with same version after update', async () => {
|
||||
mockCheckForAllExtensionUpdates.mockResolvedValueOnce(undefined);
|
||||
mockUpdateAllUpdatableExtensions.mockResolvedValueOnce([
|
||||
{
|
||||
name: 'extension-1',
|
||||
originalVersion: '1.0.0',
|
||||
updatedVersion: '2.0.0',
|
||||
},
|
||||
{
|
||||
name: 'extension-2',
|
||||
originalVersion: '1.0.0',
|
||||
updatedVersion: '1.0.0',
|
||||
},
|
||||
]);
|
||||
|
||||
await handleUpdate({ all: true });
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
'Extension "extension-1" successfully updated: 1.0.0 → 2.0.0.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle errors during update all', async () => {
|
||||
mockCheckForAllExtensionUpdates.mockRejectedValueOnce(
|
||||
new Error('Update all failed'),
|
||||
);
|
||||
|
||||
await handleUpdate({ all: true });
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Update all failed');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,22 +5,14 @@
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import {
|
||||
loadExtensions,
|
||||
annotateActiveExtensions,
|
||||
ExtensionStorage,
|
||||
requestConsentNonInteractive,
|
||||
} from '../../config/extension.js';
|
||||
import {
|
||||
updateAllUpdatableExtensions,
|
||||
type ExtensionUpdateInfo,
|
||||
checkForAllExtensionUpdates,
|
||||
updateExtension,
|
||||
} from '../../config/extensions/update.js';
|
||||
import { checkForExtensionUpdate } from '../../config/extensions/github.js';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
|
||||
import { ExtensionEnablementManager } from '../../config/extensions/extensionEnablement.js';
|
||||
import {
|
||||
checkForExtensionUpdate,
|
||||
type ExtensionUpdateInfo,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { getExtensionManager } from './utils.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
interface UpdateArgs {
|
||||
name?: string;
|
||||
@@ -28,50 +20,50 @@ interface UpdateArgs {
|
||||
}
|
||||
|
||||
const updateOutput = (info: ExtensionUpdateInfo) =>
|
||||
`Extension "${info.name}" successfully updated: ${info.originalVersion} → ${info.updatedVersion}.`;
|
||||
t(
|
||||
'Extension "{{name}}" successfully updated: {{oldVersion}} → {{newVersion}}.',
|
||||
{
|
||||
name: info.name,
|
||||
oldVersion: info.originalVersion,
|
||||
newVersion: info.updatedVersion,
|
||||
},
|
||||
);
|
||||
|
||||
export async function handleUpdate(args: UpdateArgs) {
|
||||
const workingDir = process.cwd();
|
||||
const extensionEnablementManager = new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
// Force enable named extensions, otherwise we will only update the enabled
|
||||
// ones.
|
||||
args.name ? [args.name] : [],
|
||||
);
|
||||
const allExtensions = loadExtensions(extensionEnablementManager);
|
||||
const extensions = annotateActiveExtensions(
|
||||
allExtensions,
|
||||
workingDir,
|
||||
extensionEnablementManager,
|
||||
);
|
||||
const extensionManager = await getExtensionManager();
|
||||
const extensions = extensionManager.getLoadedExtensions();
|
||||
|
||||
if (args.name) {
|
||||
try {
|
||||
const extension = extensions.find(
|
||||
(extension) => extension.name === args.name,
|
||||
);
|
||||
if (!extension) {
|
||||
console.log(`Extension "${args.name}" not found.`);
|
||||
console.log(t('Extension "{{name}}" not found.', { name: args.name }));
|
||||
return;
|
||||
}
|
||||
let updateState: ExtensionUpdateState | undefined;
|
||||
if (!extension.installMetadata) {
|
||||
console.log(
|
||||
`Unable to install extension "${args.name}" due to missing install metadata`,
|
||||
t(
|
||||
'Unable to install extension "{{name}}" due to missing install metadata',
|
||||
{ name: args.name },
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await checkForExtensionUpdate(extension, (newState) => {
|
||||
updateState = newState;
|
||||
});
|
||||
const updateState = await checkForExtensionUpdate(
|
||||
extension,
|
||||
extensionManager,
|
||||
);
|
||||
if (updateState !== ExtensionUpdateState.UPDATE_AVAILABLE) {
|
||||
console.log(`Extension "${args.name}" is already up to date.`);
|
||||
console.log(
|
||||
t('Extension "{{name}}" is already up to date.', { name: args.name }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// TODO(chrstnb): we should list extensions if the requested extension is not installed.
|
||||
const updatedExtensionInfo = (await updateExtension(
|
||||
const updatedExtensionInfo = (await extensionManager.updateExtension(
|
||||
extension,
|
||||
workingDir,
|
||||
requestConsentNonInteractive,
|
||||
updateState,
|
||||
() => {},
|
||||
))!;
|
||||
@@ -80,10 +72,19 @@ export async function handleUpdate(args: UpdateArgs) {
|
||||
updatedExtensionInfo.updatedVersion
|
||||
) {
|
||||
console.log(
|
||||
`Extension "${args.name}" successfully updated: ${updatedExtensionInfo.originalVersion} → ${updatedExtensionInfo.updatedVersion}.`,
|
||||
t(
|
||||
'Extension "{{name}}" successfully updated: {{oldVersion}} → {{newVersion}}.',
|
||||
{
|
||||
name: args.name,
|
||||
oldVersion: updatedExtensionInfo.originalVersion,
|
||||
newVersion: updatedExtensionInfo.updatedVersion,
|
||||
},
|
||||
),
|
||||
);
|
||||
} else {
|
||||
console.log(`Extension "${args.name}" is already up to date.`);
|
||||
console.log(
|
||||
t('Extension "{{name}}" is already up to date.', { name: args.name }),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(getErrorMessage(error));
|
||||
@@ -92,18 +93,15 @@ export async function handleUpdate(args: UpdateArgs) {
|
||||
if (args.all) {
|
||||
try {
|
||||
const extensionState = new Map();
|
||||
await checkForAllExtensionUpdates(extensions, (action) => {
|
||||
if (action.type === 'SET_STATE') {
|
||||
extensionState.set(action.payload.name, {
|
||||
status: action.payload.state,
|
||||
await extensionManager.checkForAllExtensionUpdates(
|
||||
(extensionName, state) => {
|
||||
extensionState.set(extensionName, {
|
||||
status: state,
|
||||
processed: true, // No need to process as we will force the update.
|
||||
});
|
||||
}
|
||||
});
|
||||
let updateInfos = await updateAllUpdatableExtensions(
|
||||
workingDir,
|
||||
requestConsentNonInteractive,
|
||||
extensions,
|
||||
},
|
||||
);
|
||||
let updateInfos = await extensionManager.updateAllUpdatableExtensions(
|
||||
extensionState,
|
||||
() => {},
|
||||
);
|
||||
@@ -111,7 +109,7 @@ export async function handleUpdate(args: UpdateArgs) {
|
||||
(info) => info.originalVersion !== info.updatedVersion,
|
||||
);
|
||||
if (updateInfos.length === 0) {
|
||||
console.log('No extensions to update.');
|
||||
console.log(t('No extensions to update.'));
|
||||
return;
|
||||
}
|
||||
console.log(updateInfos.map((info) => updateOutput(info)).join('\n'));
|
||||
@@ -123,22 +121,25 @@ export async function handleUpdate(args: UpdateArgs) {
|
||||
|
||||
export const updateCommand: CommandModule = {
|
||||
command: 'update [<name>] [--all]',
|
||||
describe:
|
||||
describe: t(
|
||||
'Updates all extensions or a named extension to the latest version.',
|
||||
),
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional('name', {
|
||||
describe: 'The name of the extension to update.',
|
||||
describe: t('The name of the extension to update.'),
|
||||
type: 'string',
|
||||
})
|
||||
.option('all', {
|
||||
describe: 'Update all extensions.',
|
||||
describe: t('Update all extensions.'),
|
||||
type: 'boolean',
|
||||
})
|
||||
.conflicts('name', 'all')
|
||||
.check((argv) => {
|
||||
if (!argv.all && !argv.name) {
|
||||
throw new Error('Either an extension name or --all must be provided');
|
||||
throw new Error(
|
||||
t('Either an extension name or --all must be provided'),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
|
||||
66
packages/cli/src/commands/extensions/utils.test.ts
Normal file
66
packages/cli/src/commands/extensions/utils.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { getExtensionManager } from './utils.js';
|
||||
|
||||
const mockRefreshCache = vi.fn();
|
||||
const mockExtensionManagerInstance = {
|
||||
refreshCache: mockRefreshCache,
|
||||
};
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', () => ({
|
||||
ExtensionManager: vi
|
||||
.fn()
|
||||
.mockImplementation(() => mockExtensionManagerInstance),
|
||||
}));
|
||||
|
||||
vi.mock('../../config/settings.js', () => ({
|
||||
loadSettings: vi.fn().mockReturnValue({
|
||||
merged: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../config/trustedFolders.js', () => ({
|
||||
isWorkspaceTrusted: vi.fn().mockReturnValue({ isTrusted: true }),
|
||||
}));
|
||||
|
||||
vi.mock('./consent.js', () => ({
|
||||
requestConsentOrFail: vi.fn(),
|
||||
requestConsentNonInteractive: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('getExtensionManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockRefreshCache.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('should return an ExtensionManager instance', async () => {
|
||||
const manager = await getExtensionManager();
|
||||
|
||||
expect(manager).toBeDefined();
|
||||
expect(manager).toBe(mockExtensionManagerInstance);
|
||||
});
|
||||
|
||||
it('should call refreshCache on the ExtensionManager', async () => {
|
||||
await getExtensionManager();
|
||||
|
||||
expect(mockRefreshCache).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use current working directory as workspace', async () => {
|
||||
const { ExtensionManager } = await import('@qwen-code/qwen-code-core');
|
||||
|
||||
await getExtensionManager();
|
||||
|
||||
expect(ExtensionManager).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspaceDir: process.cwd(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
79
packages/cli/src/commands/extensions/utils.ts
Normal file
79
packages/cli/src/commands/extensions/utils.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { ExtensionManager, type Extension } from '@qwen-code/qwen-code-core';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import {
|
||||
requestConsentOrFail,
|
||||
requestConsentNonInteractive,
|
||||
} from './consent.js';
|
||||
import { isWorkspaceTrusted } from '../../config/trustedFolders.js';
|
||||
import * as os from 'node:os';
|
||||
import chalk from 'chalk';
|
||||
|
||||
export async function getExtensionManager(): Promise<ExtensionManager> {
|
||||
const workspaceDir = process.cwd();
|
||||
const extensionManager = new ExtensionManager({
|
||||
workspaceDir,
|
||||
requestConsent: requestConsentOrFail.bind(
|
||||
null,
|
||||
requestConsentNonInteractive,
|
||||
),
|
||||
isWorkspaceTrusted: !!isWorkspaceTrusted(loadSettings(workspaceDir).merged),
|
||||
});
|
||||
await extensionManager.refreshCache();
|
||||
return extensionManager;
|
||||
}
|
||||
|
||||
export function extensionToOutputString(
|
||||
extension: Extension,
|
||||
extensionManager: ExtensionManager,
|
||||
workspaceDir: string,
|
||||
): string {
|
||||
const cwd = workspaceDir;
|
||||
const userEnabled = extensionManager.isEnabled(
|
||||
extension.config.name,
|
||||
os.homedir(),
|
||||
);
|
||||
const workspaceEnabled = extensionManager.isEnabled(
|
||||
extension.config.name,
|
||||
cwd,
|
||||
);
|
||||
|
||||
const status = workspaceEnabled ? chalk.green('✓') : chalk.red('✗');
|
||||
let output = `${status} ${extension.config.name} (${extension.config.version})`;
|
||||
output += `\n Path: ${extension.path}`;
|
||||
if (extension.installMetadata) {
|
||||
output += `\n Source: ${extension.installMetadata.source} (Type: ${extension.installMetadata.type})`;
|
||||
if (extension.installMetadata.ref) {
|
||||
output += `\n Ref: ${extension.installMetadata.ref}`;
|
||||
}
|
||||
if (extension.installMetadata.releaseTag) {
|
||||
output += `\n Release tag: ${extension.installMetadata.releaseTag}`;
|
||||
}
|
||||
}
|
||||
output += `\n Enabled (User): ${userEnabled}`;
|
||||
output += `\n Enabled (Workspace): ${workspaceEnabled}`;
|
||||
if (extension.contextFiles.length > 0) {
|
||||
output += `\n Context files:`;
|
||||
extension.contextFiles.forEach((contextFile) => {
|
||||
output += `\n ${contextFile}`;
|
||||
});
|
||||
}
|
||||
if (extension.commands && extension.commands.length > 0) {
|
||||
output += `\n Commands:`;
|
||||
extension.commands.forEach((command) => {
|
||||
output += `\n /${command}`;
|
||||
});
|
||||
}
|
||||
if (extension.config.mcpServers) {
|
||||
output += `\n MCP servers:`;
|
||||
Object.keys(extension.config.mcpServers).forEach((key) => {
|
||||
output += `\n ${key}`;
|
||||
});
|
||||
}
|
||||
return output;
|
||||
}
|
||||
@@ -7,11 +7,16 @@
|
||||
import yargs from 'yargs';
|
||||
import { addCommand } from './add.js';
|
||||
import { loadSettings, SettingScope } from '../../config/settings.js';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
}));
|
||||
vi.mock('fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('fs/promises')>();
|
||||
return {
|
||||
...actual,
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('os', () => {
|
||||
const homedir = vi.fn(() => '/home/user');
|
||||
|
||||
@@ -7,18 +7,15 @@
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { listMcpServers } from './list.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import { ExtensionStorage, loadExtensions } from '../../config/extension.js';
|
||||
import { createTransport } from '@qwen-code/qwen-code-core';
|
||||
import { isWorkspaceTrusted } from '../../config/trustedFolders.js';
|
||||
import { createTransport, ExtensionManager } from '@qwen-code/qwen-code-core';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
|
||||
vi.mock('../../config/settings.js', () => ({
|
||||
loadSettings: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../config/extension.js', () => ({
|
||||
loadExtensions: vi.fn(),
|
||||
ExtensionStorage: {
|
||||
getUserExtensionsDir: vi.fn(),
|
||||
},
|
||||
vi.mock('../../config/trustedFolders.js', () => ({
|
||||
isWorkspaceTrusted: vi.fn(),
|
||||
}));
|
||||
vi.mock('@qwen-code/qwen-code-core', () => ({
|
||||
createTransport: vi.fn(),
|
||||
@@ -27,20 +24,15 @@ vi.mock('@qwen-code/qwen-code-core', () => ({
|
||||
CONNECTING: 'CONNECTING',
|
||||
DISCONNECTED: 'DISCONNECTED',
|
||||
},
|
||||
Storage: vi.fn().mockImplementation((_cwd: string) => ({
|
||||
getGlobalSettingsPath: () => '/tmp/qwen/settings.json',
|
||||
getWorkspaceSettingsPath: () => '/tmp/qwen/workspace-settings.json',
|
||||
getProjectTempDir: () => '/test/home/.qwen/tmp/mocked_hash',
|
||||
})),
|
||||
QWEN_CONFIG_DIR: '.qwen',
|
||||
ExtensionManager: vi.fn(),
|
||||
getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)),
|
||||
}));
|
||||
vi.mock('@modelcontextprotocol/sdk/client/index.js');
|
||||
|
||||
const mockedExtensionStorage = ExtensionStorage as vi.Mock;
|
||||
const mockedLoadSettings = loadSettings as vi.Mock;
|
||||
const mockedLoadExtensions = loadExtensions as vi.Mock;
|
||||
const mockedIsWorkspaceTrusted = isWorkspaceTrusted as vi.Mock;
|
||||
const mockedCreateTransport = createTransport as vi.Mock;
|
||||
const MockedExtensionManager = ExtensionManager as vi.Mock;
|
||||
const MockedClient = Client as vi.Mock;
|
||||
|
||||
interface MockClient {
|
||||
@@ -57,6 +49,10 @@ describe('mcp list command', () => {
|
||||
let consoleSpy: vi.SpyInstance;
|
||||
let mockClient: MockClient;
|
||||
let mockTransport: MockTransport;
|
||||
let mockExtensionManager: {
|
||||
refreshCache: vi.Mock;
|
||||
getLoadedExtensions: vi.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
@@ -70,12 +66,15 @@ describe('mcp list command', () => {
|
||||
close: vi.fn(),
|
||||
};
|
||||
|
||||
mockExtensionManager = {
|
||||
refreshCache: vi.fn().mockResolvedValue(undefined),
|
||||
getLoadedExtensions: vi.fn().mockReturnValue([]),
|
||||
};
|
||||
|
||||
MockedClient.mockImplementation(() => mockClient);
|
||||
mockedCreateTransport.mockResolvedValue(mockTransport);
|
||||
mockedLoadExtensions.mockReturnValue([]);
|
||||
mockedExtensionStorage.getUserExtensionsDir.mockReturnValue(
|
||||
'/mocked/extensions/dir',
|
||||
);
|
||||
MockedExtensionManager.mockImplementation(() => mockExtensionManager);
|
||||
mockedIsWorkspaceTrusted.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -151,8 +150,9 @@ describe('mcp list command', () => {
|
||||
},
|
||||
});
|
||||
|
||||
mockedLoadExtensions.mockReturnValue([
|
||||
mockExtensionManager.getLoadedExtensions.mockReturnValue([
|
||||
{
|
||||
isActive: true,
|
||||
config: {
|
||||
name: 'test-extension',
|
||||
mcpServers: { 'extension-server': { command: '/ext/server' } },
|
||||
|
||||
@@ -8,10 +8,13 @@
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import type { MCPServerConfig } from '@qwen-code/qwen-code-core';
|
||||
import { MCPServerStatus, createTransport } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
MCPServerStatus,
|
||||
createTransport,
|
||||
ExtensionManager,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { ExtensionStorage, loadExtensions } from '../../config/extension.js';
|
||||
import { ExtensionEnablementManager } from '../../config/extensions/extensionEnablement.js';
|
||||
import { isWorkspaceTrusted } from '../../config/trustedFolders.js';
|
||||
|
||||
const COLOR_GREEN = '\u001b[32m';
|
||||
const COLOR_YELLOW = '\u001b[33m';
|
||||
@@ -22,22 +25,27 @@ async function getMcpServersFromConfig(): Promise<
|
||||
Record<string, MCPServerConfig>
|
||||
> {
|
||||
const settings = loadSettings();
|
||||
const extensions = loadExtensions(
|
||||
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
|
||||
);
|
||||
const extensionManager = new ExtensionManager({
|
||||
isWorkspaceTrusted: !!isWorkspaceTrusted(settings.merged),
|
||||
telemetrySettings: settings.merged.telemetry,
|
||||
});
|
||||
await extensionManager.refreshCache();
|
||||
const extensions = extensionManager.getLoadedExtensions();
|
||||
const mcpServers = { ...(settings.merged.mcpServers || {}) };
|
||||
for (const extension of extensions) {
|
||||
Object.entries(extension.config.mcpServers || {}).forEach(
|
||||
([key, server]) => {
|
||||
if (mcpServers[key]) {
|
||||
return;
|
||||
}
|
||||
mcpServers[key] = {
|
||||
...server,
|
||||
extensionName: extension.config.name,
|
||||
};
|
||||
},
|
||||
);
|
||||
if (extension.isActive) {
|
||||
Object.entries(extension.config.mcpServers || {}).forEach(
|
||||
([key, server]) => {
|
||||
if (mcpServers[key]) {
|
||||
return;
|
||||
}
|
||||
mcpServers[key] = {
|
||||
...server,
|
||||
extensionName: extension.config.name,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
return mcpServers;
|
||||
}
|
||||
|
||||
@@ -9,10 +9,14 @@ import yargs from 'yargs';
|
||||
import { loadSettings, SettingScope } from '../../config/settings.js';
|
||||
import { removeCommand } from './remove.js';
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
}));
|
||||
vi.mock('fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('fs/promises')>();
|
||||
return {
|
||||
...actual,
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../config/settings.js', async () => {
|
||||
const actual = await vi.importActual('../../config/settings.js');
|
||||
|
||||
@@ -1,41 +1,112 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||
import { vi } from 'vitest';
|
||||
import { validateAuthMethod } from './auth.js';
|
||||
import * as settings from './settings.js';
|
||||
|
||||
vi.mock('./settings.js', () => ({
|
||||
loadEnvironment: vi.fn(),
|
||||
loadSettings: vi.fn().mockReturnValue({
|
||||
merged: vi.fn().mockReturnValue({}),
|
||||
merged: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('validateAuthMethod', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
// Reset mock to default
|
||||
vi.mocked(settings.loadSettings).mockReturnValue({
|
||||
merged: {},
|
||||
} as ReturnType<typeof settings.loadSettings>);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
delete process.env['OPENAI_API_KEY'];
|
||||
delete process.env['CUSTOM_API_KEY'];
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
delete process.env['GEMINI_API_KEY_ALTERED'];
|
||||
delete process.env['ANTHROPIC_API_KEY'];
|
||||
delete process.env['ANTHROPIC_BASE_URL'];
|
||||
delete process.env['GOOGLE_API_KEY'];
|
||||
});
|
||||
|
||||
it('should return null for USE_OPENAI', () => {
|
||||
it('should return null for USE_OPENAI with default env key', () => {
|
||||
process.env['OPENAI_API_KEY'] = 'fake-key';
|
||||
expect(validateAuthMethod(AuthType.USE_OPENAI)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return an error message for USE_OPENAI if OPENAI_API_KEY is not set', () => {
|
||||
delete process.env['OPENAI_API_KEY'];
|
||||
it('should return an error message for USE_OPENAI if no API key is available', () => {
|
||||
expect(validateAuthMethod(AuthType.USE_OPENAI)).toBe(
|
||||
'OPENAI_API_KEY environment variable not found. You can enter it interactively or add it to your .env file.',
|
||||
"Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the 'OPENAI_API_KEY' environment variable.",
|
||||
);
|
||||
});
|
||||
|
||||
it('should return null for USE_OPENAI with custom envKey from modelProviders', () => {
|
||||
vi.mocked(settings.loadSettings).mockReturnValue({
|
||||
merged: {
|
||||
model: { name: 'custom-model' },
|
||||
modelProviders: {
|
||||
openai: [{ id: 'custom-model', envKey: 'CUSTOM_API_KEY' }],
|
||||
},
|
||||
},
|
||||
} as unknown as ReturnType<typeof settings.loadSettings>);
|
||||
process.env['CUSTOM_API_KEY'] = 'custom-key';
|
||||
|
||||
expect(validateAuthMethod(AuthType.USE_OPENAI)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return error with custom envKey hint when modelProviders envKey is set but env var is missing', () => {
|
||||
vi.mocked(settings.loadSettings).mockReturnValue({
|
||||
merged: {
|
||||
model: { name: 'custom-model' },
|
||||
modelProviders: {
|
||||
openai: [{ id: 'custom-model', envKey: 'CUSTOM_API_KEY' }],
|
||||
},
|
||||
},
|
||||
} as unknown as ReturnType<typeof settings.loadSettings>);
|
||||
|
||||
const result = validateAuthMethod(AuthType.USE_OPENAI);
|
||||
expect(result).toContain('CUSTOM_API_KEY');
|
||||
});
|
||||
|
||||
it('should return null for USE_GEMINI with custom envKey', () => {
|
||||
vi.mocked(settings.loadSettings).mockReturnValue({
|
||||
merged: {
|
||||
model: { name: 'gemini-1.5-flash' },
|
||||
modelProviders: {
|
||||
gemini: [
|
||||
{ id: 'gemini-1.5-flash', envKey: 'GEMINI_API_KEY_ALTERED' },
|
||||
],
|
||||
},
|
||||
},
|
||||
} as unknown as ReturnType<typeof settings.loadSettings>);
|
||||
process.env['GEMINI_API_KEY_ALTERED'] = 'altered-key';
|
||||
|
||||
expect(validateAuthMethod(AuthType.USE_GEMINI)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return error with custom envKey for USE_GEMINI when env var is missing', () => {
|
||||
vi.mocked(settings.loadSettings).mockReturnValue({
|
||||
merged: {
|
||||
model: { name: 'gemini-1.5-flash' },
|
||||
modelProviders: {
|
||||
gemini: [
|
||||
{ id: 'gemini-1.5-flash', envKey: 'GEMINI_API_KEY_ALTERED' },
|
||||
],
|
||||
},
|
||||
},
|
||||
} as unknown as ReturnType<typeof settings.loadSettings>);
|
||||
|
||||
const result = validateAuthMethod(AuthType.USE_GEMINI);
|
||||
expect(result).toContain('GEMINI_API_KEY_ALTERED');
|
||||
});
|
||||
|
||||
it('should return null for QWEN_OAUTH', () => {
|
||||
expect(validateAuthMethod(AuthType.QWEN_OAUTH)).toBeNull();
|
||||
});
|
||||
@@ -45,4 +116,115 @@ describe('validateAuthMethod', () => {
|
||||
'Invalid auth method selected.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return null for USE_ANTHROPIC with custom envKey and baseUrl', () => {
|
||||
vi.mocked(settings.loadSettings).mockReturnValue({
|
||||
merged: {
|
||||
model: { name: 'claude-3' },
|
||||
modelProviders: {
|
||||
anthropic: [
|
||||
{
|
||||
id: 'claude-3',
|
||||
envKey: 'CUSTOM_ANTHROPIC_KEY',
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as unknown as ReturnType<typeof settings.loadSettings>);
|
||||
process.env['CUSTOM_ANTHROPIC_KEY'] = 'custom-anthropic-key';
|
||||
|
||||
expect(validateAuthMethod(AuthType.USE_ANTHROPIC)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return error for USE_ANTHROPIC when baseUrl is missing', () => {
|
||||
vi.mocked(settings.loadSettings).mockReturnValue({
|
||||
merged: {
|
||||
model: { name: 'claude-3' },
|
||||
modelProviders: {
|
||||
anthropic: [{ id: 'claude-3', envKey: 'CUSTOM_ANTHROPIC_KEY' }],
|
||||
},
|
||||
},
|
||||
} as unknown as ReturnType<typeof settings.loadSettings>);
|
||||
process.env['CUSTOM_ANTHROPIC_KEY'] = 'custom-key';
|
||||
|
||||
const result = validateAuthMethod(AuthType.USE_ANTHROPIC);
|
||||
expect(result).toContain('modelProviders[].baseUrl');
|
||||
});
|
||||
|
||||
it('should return null for USE_VERTEX_AI with custom envKey', () => {
|
||||
vi.mocked(settings.loadSettings).mockReturnValue({
|
||||
merged: {
|
||||
model: { name: 'vertex-model' },
|
||||
modelProviders: {
|
||||
'vertex-ai': [
|
||||
{ id: 'vertex-model', envKey: 'GOOGLE_API_KEY_VERTEX' },
|
||||
],
|
||||
},
|
||||
},
|
||||
} as unknown as ReturnType<typeof settings.loadSettings>);
|
||||
process.env['GOOGLE_API_KEY_VERTEX'] = 'vertex-key';
|
||||
|
||||
expect(validateAuthMethod(AuthType.USE_VERTEX_AI)).toBeNull();
|
||||
});
|
||||
|
||||
it('should use config.modelsConfig.getModel() when Config is provided', () => {
|
||||
// Settings has a different model
|
||||
vi.mocked(settings.loadSettings).mockReturnValue({
|
||||
merged: {
|
||||
model: { name: 'settings-model' },
|
||||
modelProviders: {
|
||||
openai: [
|
||||
{ id: 'settings-model', envKey: 'SETTINGS_API_KEY' },
|
||||
{ id: 'cli-model', envKey: 'CLI_API_KEY' },
|
||||
],
|
||||
},
|
||||
},
|
||||
} as unknown as ReturnType<typeof settings.loadSettings>);
|
||||
|
||||
// Mock Config object that returns a different model (e.g., from CLI args)
|
||||
const mockConfig = {
|
||||
modelsConfig: {
|
||||
getModel: vi.fn().mockReturnValue('cli-model'),
|
||||
},
|
||||
} as unknown as import('@qwen-code/qwen-code-core').Config;
|
||||
|
||||
// Set the env key for the CLI model, not the settings model
|
||||
process.env['CLI_API_KEY'] = 'cli-key';
|
||||
|
||||
// Should use 'cli-model' from config.modelsConfig.getModel(), not 'settings-model'
|
||||
const result = validateAuthMethod(AuthType.USE_OPENAI, mockConfig);
|
||||
expect(result).toBeNull();
|
||||
expect(mockConfig.modelsConfig.getModel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fail validation when Config provides different model without matching env key', () => {
|
||||
// Clean up any existing env keys first
|
||||
delete process.env['CLI_API_KEY'];
|
||||
delete process.env['SETTINGS_API_KEY'];
|
||||
delete process.env['OPENAI_API_KEY'];
|
||||
|
||||
vi.mocked(settings.loadSettings).mockReturnValue({
|
||||
merged: {
|
||||
model: { name: 'settings-model' },
|
||||
modelProviders: {
|
||||
openai: [
|
||||
{ id: 'settings-model', envKey: 'SETTINGS_API_KEY' },
|
||||
{ id: 'cli-model', envKey: 'CLI_API_KEY' },
|
||||
],
|
||||
},
|
||||
},
|
||||
} as unknown as ReturnType<typeof settings.loadSettings>);
|
||||
|
||||
const mockConfig = {
|
||||
modelsConfig: {
|
||||
getModel: vi.fn().mockReturnValue('cli-model'),
|
||||
},
|
||||
} as unknown as import('@qwen-code/qwen-code-core').Config;
|
||||
|
||||
// Don't set CLI_API_KEY - validation should fail
|
||||
const result = validateAuthMethod(AuthType.USE_OPENAI, mockConfig);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toContain('CLI_API_KEY');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,21 +1,169 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||
import { loadEnvironment, loadSettings } from './settings.js';
|
||||
import {
|
||||
AuthType,
|
||||
type Config,
|
||||
type ModelProvidersConfig,
|
||||
type ProviderModelConfig,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { loadEnvironment, loadSettings, type Settings } from './settings.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
export function validateAuthMethod(authMethod: string): string | null {
|
||||
/**
|
||||
* Default environment variable names for each auth type
|
||||
*/
|
||||
const DEFAULT_ENV_KEYS: Record<string, string> = {
|
||||
[AuthType.USE_OPENAI]: 'OPENAI_API_KEY',
|
||||
[AuthType.USE_ANTHROPIC]: 'ANTHROPIC_API_KEY',
|
||||
[AuthType.USE_GEMINI]: 'GEMINI_API_KEY',
|
||||
[AuthType.USE_VERTEX_AI]: 'GOOGLE_API_KEY',
|
||||
};
|
||||
|
||||
/**
|
||||
* Find model configuration from modelProviders by authType and modelId
|
||||
*/
|
||||
function findModelConfig(
|
||||
modelProviders: ModelProvidersConfig | undefined,
|
||||
authType: string,
|
||||
modelId: string | undefined,
|
||||
): ProviderModelConfig | undefined {
|
||||
if (!modelProviders || !modelId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const models = modelProviders[authType];
|
||||
if (!Array.isArray(models)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return models.find((m) => m.id === modelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if API key is available for the given auth type and model configuration.
|
||||
* Prioritizes custom envKey from modelProviders over default environment variables.
|
||||
*/
|
||||
function hasApiKeyForAuth(
|
||||
authType: string,
|
||||
settings: Settings,
|
||||
config?: Config,
|
||||
): {
|
||||
hasKey: boolean;
|
||||
checkedEnvKey: string | undefined;
|
||||
isExplicitEnvKey: boolean;
|
||||
} {
|
||||
const modelProviders = settings.modelProviders as
|
||||
| ModelProvidersConfig
|
||||
| undefined;
|
||||
|
||||
// Use config.modelsConfig.getModel() if available for accurate model ID resolution
|
||||
// that accounts for CLI args, env vars, and settings. Fall back to settings.model.name.
|
||||
const modelId = config?.modelsConfig.getModel() ?? settings.model?.name;
|
||||
|
||||
// Try to find model-specific envKey from modelProviders
|
||||
const modelConfig = findModelConfig(modelProviders, authType, modelId);
|
||||
if (modelConfig?.envKey) {
|
||||
// Explicit envKey configured - only check this env var, no apiKey fallback
|
||||
const hasKey = !!process.env[modelConfig.envKey];
|
||||
return {
|
||||
hasKey,
|
||||
checkedEnvKey: modelConfig.envKey,
|
||||
isExplicitEnvKey: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Using default environment variable - apiKey fallback is allowed
|
||||
const defaultEnvKey = DEFAULT_ENV_KEYS[authType];
|
||||
if (defaultEnvKey) {
|
||||
const hasKey = !!process.env[defaultEnvKey];
|
||||
if (hasKey) {
|
||||
return { hasKey, checkedEnvKey: defaultEnvKey, isExplicitEnvKey: false };
|
||||
}
|
||||
}
|
||||
|
||||
// Also check settings.security.auth.apiKey as fallback (only for default env key)
|
||||
if (settings.security?.auth?.apiKey) {
|
||||
return {
|
||||
hasKey: true,
|
||||
checkedEnvKey: defaultEnvKey || undefined,
|
||||
isExplicitEnvKey: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hasKey: false,
|
||||
checkedEnvKey: defaultEnvKey,
|
||||
isExplicitEnvKey: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate API key error message based on auth check result.
|
||||
* Returns null if API key is present, otherwise returns the appropriate error message.
|
||||
*/
|
||||
function getApiKeyError(
|
||||
authMethod: string,
|
||||
settings: Settings,
|
||||
config?: Config,
|
||||
): string | null {
|
||||
const { hasKey, checkedEnvKey, isExplicitEnvKey } = hasApiKeyForAuth(
|
||||
authMethod,
|
||||
settings,
|
||||
config,
|
||||
);
|
||||
if (hasKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const envKeyHint = checkedEnvKey || DEFAULT_ENV_KEYS[authMethod];
|
||||
if (isExplicitEnvKey) {
|
||||
return t(
|
||||
'{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.',
|
||||
{ envKeyHint },
|
||||
);
|
||||
}
|
||||
return t(
|
||||
'{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.',
|
||||
{ envKeyHint },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the required credentials and configuration exist for the given auth method.
|
||||
*/
|
||||
export function validateAuthMethod(
|
||||
authMethod: string,
|
||||
config?: Config,
|
||||
): string | null {
|
||||
const settings = loadSettings();
|
||||
loadEnvironment(settings.merged);
|
||||
|
||||
if (authMethod === AuthType.USE_OPENAI) {
|
||||
const hasApiKey =
|
||||
process.env['OPENAI_API_KEY'] || settings.merged.security?.auth?.apiKey;
|
||||
if (!hasApiKey) {
|
||||
return 'OPENAI_API_KEY environment variable not found. You can enter it interactively or add it to your .env file.';
|
||||
const { hasKey, checkedEnvKey, isExplicitEnvKey } = hasApiKeyForAuth(
|
||||
authMethod,
|
||||
settings.merged,
|
||||
config,
|
||||
);
|
||||
if (!hasKey) {
|
||||
const envKeyHint = checkedEnvKey
|
||||
? `'${checkedEnvKey}'`
|
||||
: "'OPENAI_API_KEY'";
|
||||
if (isExplicitEnvKey) {
|
||||
// Explicit envKey configured - only suggest setting the env var
|
||||
return t(
|
||||
'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.',
|
||||
{ envKeyHint },
|
||||
);
|
||||
}
|
||||
// Default env key - can use either apiKey or env var
|
||||
return t(
|
||||
'Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the {{envKeyHint}} environment variable.',
|
||||
{ envKeyHint },
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -27,36 +175,49 @@ export function validateAuthMethod(authMethod: string): string | null {
|
||||
}
|
||||
|
||||
if (authMethod === AuthType.USE_ANTHROPIC) {
|
||||
const hasApiKey = process.env['ANTHROPIC_API_KEY'];
|
||||
if (!hasApiKey) {
|
||||
return 'ANTHROPIC_API_KEY environment variable not found.';
|
||||
const apiKeyError = getApiKeyError(authMethod, settings.merged, config);
|
||||
if (apiKeyError) {
|
||||
return apiKeyError;
|
||||
}
|
||||
|
||||
const hasBaseUrl = process.env['ANTHROPIC_BASE_URL'];
|
||||
if (!hasBaseUrl) {
|
||||
return 'ANTHROPIC_BASE_URL environment variable not found.';
|
||||
// Check baseUrl - can come from modelProviders or environment
|
||||
const modelProviders = settings.merged.modelProviders as
|
||||
| ModelProvidersConfig
|
||||
| undefined;
|
||||
// Use config.modelsConfig.getModel() if available for accurate model ID
|
||||
const modelId =
|
||||
config?.modelsConfig.getModel() ?? settings.merged.model?.name;
|
||||
const modelConfig = findModelConfig(modelProviders, authMethod, modelId);
|
||||
|
||||
if (modelConfig && !modelConfig.baseUrl) {
|
||||
return t(
|
||||
'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.',
|
||||
);
|
||||
}
|
||||
if (!modelConfig && !process.env['ANTHROPIC_BASE_URL']) {
|
||||
return t('ANTHROPIC_BASE_URL environment variable not found.');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (authMethod === AuthType.USE_GEMINI) {
|
||||
const hasApiKey = process.env['GEMINI_API_KEY'];
|
||||
if (!hasApiKey) {
|
||||
return 'GEMINI_API_KEY environment variable not found. Please set it in your .env file or environment variables.';
|
||||
const apiKeyError = getApiKeyError(authMethod, settings.merged, config);
|
||||
if (apiKeyError) {
|
||||
return apiKeyError;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (authMethod === AuthType.USE_VERTEX_AI) {
|
||||
const hasApiKey = process.env['GOOGLE_API_KEY'];
|
||||
if (!hasApiKey) {
|
||||
return 'GOOGLE_API_KEY environment variable not found. Please set it in your .env file or environment variables.';
|
||||
const apiKeyError = getApiKeyError(authMethod, settings.merged, config);
|
||||
if (apiKeyError) {
|
||||
return apiKeyError;
|
||||
}
|
||||
|
||||
process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true';
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'Invalid auth method selected.';
|
||||
return t('Invalid auth method selected.');
|
||||
}
|
||||
|
||||
@@ -231,18 +231,21 @@ describe('Configuration Integration Tests', () => {
|
||||
expect(config.getExtensionContextFilePaths()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should correctly store and return extension context file paths', () => {
|
||||
const contextFiles = ['/path/to/file1.txt', '/path/to/file2.js'];
|
||||
it('should correctly store and return extension context file paths with outputLanguageFilePath', () => {
|
||||
const outputLanguageFilePath = '/path/to/language.txt';
|
||||
const configParams: ConfigParameters = {
|
||||
cwd: '/tmp',
|
||||
generationConfig: TEST_CONTENT_GENERATOR_CONFIG,
|
||||
embeddingModel: 'test-embedding-model',
|
||||
targetDir: tempDir,
|
||||
debugMode: false,
|
||||
extensionContextFilePaths: contextFiles,
|
||||
outputLanguageFilePath,
|
||||
};
|
||||
const config = new Config(configParams);
|
||||
expect(config.getExtensionContextFilePaths()).toEqual(contextFiles);
|
||||
// outputLanguageFilePath should be included in extension context file paths
|
||||
expect(config.getExtensionContextFilePaths()).toContain(
|
||||
outputLanguageFilePath,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,26 +9,29 @@ import {
|
||||
AuthType,
|
||||
Config,
|
||||
DEFAULT_QWEN_EMBEDDING_MODEL,
|
||||
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
|
||||
EditTool,
|
||||
FileDiscoveryService,
|
||||
getCurrentGeminiMdFilename,
|
||||
loadServerHierarchicalMemory,
|
||||
setGeminiMdFilename as setServerGeminiMdFilename,
|
||||
ShellTool,
|
||||
WriteFileTool,
|
||||
resolveTelemetrySettings,
|
||||
FatalConfigError,
|
||||
Storage,
|
||||
InputFormat,
|
||||
OutputFormat,
|
||||
isToolEnabled,
|
||||
SessionService,
|
||||
type ResumedSessionData,
|
||||
type FileFilteringOptions,
|
||||
type MCPServerConfig,
|
||||
type ToolName,
|
||||
EditTool,
|
||||
ShellTool,
|
||||
WriteFileTool,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { extensionsCommand } from '../commands/extensions.js';
|
||||
import type { Settings } from './settings.js';
|
||||
import {
|
||||
resolveCliGenerationConfig,
|
||||
getAuthTypeFromEnv,
|
||||
} from '../utils/modelConfigUtils.js';
|
||||
import yargs, { type Argv } from 'yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
import * as fs from 'node:fs';
|
||||
@@ -37,14 +40,11 @@ import { homedir } from 'node:os';
|
||||
|
||||
import { resolvePath } from '../utils/resolvePath.js';
|
||||
import { getCliVersion } from '../utils/version.js';
|
||||
import type { Extension } from './extension.js';
|
||||
import { annotateActiveExtensions } from './extension.js';
|
||||
import { loadSandboxConfig } from './sandboxConfig.js';
|
||||
import { appEvents } from '../utils/events.js';
|
||||
import { mcpCommand } from '../commands/mcp.js';
|
||||
|
||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||
import type { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
|
||||
import { buildWebSearchConfig } from './webSearch.js';
|
||||
|
||||
// Simple console logger for now - replace with actual logger if available
|
||||
@@ -99,7 +99,6 @@ export interface CliArgs {
|
||||
prompt: string | undefined;
|
||||
promptInteractive: string | undefined;
|
||||
allFiles: boolean | undefined;
|
||||
showMemoryUsage: boolean | undefined;
|
||||
yolo: boolean | undefined;
|
||||
approvalMode: string | undefined;
|
||||
telemetry: boolean | undefined;
|
||||
@@ -111,6 +110,7 @@ export interface CliArgs {
|
||||
telemetryOutfile: string | undefined;
|
||||
allowedMcpServerNames: string[] | undefined;
|
||||
allowedTools: string[] | undefined;
|
||||
acp: boolean | undefined;
|
||||
experimentalAcp: boolean | undefined;
|
||||
experimentalSkills: boolean | undefined;
|
||||
extensions: string[] | undefined;
|
||||
@@ -163,7 +163,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')
|
||||
@@ -281,11 +291,6 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
description: 'Include ALL files in context?',
|
||||
default: false,
|
||||
})
|
||||
.option('show-memory-usage', {
|
||||
type: 'boolean',
|
||||
description: 'Show memory usage in status bar',
|
||||
default: false,
|
||||
})
|
||||
.option('yolo', {
|
||||
alias: 'y',
|
||||
type: 'boolean',
|
||||
@@ -304,14 +309,27 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
description: 'Enables checkpointing of file edits',
|
||||
default: false,
|
||||
})
|
||||
.option('experimental-acp', {
|
||||
.option('acp', {
|
||||
type: 'boolean',
|
||||
description: 'Starts the agent in ACP mode',
|
||||
})
|
||||
.option('experimental-acp', {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Starts the agent in ACP mode (deprecated, use --acp instead)',
|
||||
hidden: true,
|
||||
})
|
||||
.option('experimental-skills', {
|
||||
type: 'boolean',
|
||||
description: 'Enable experimental Skills feature',
|
||||
default: false,
|
||||
default: (() => {
|
||||
const legacySkills = (
|
||||
settings as Settings & {
|
||||
tools?: { experimental?: { skills?: boolean } };
|
||||
}
|
||||
).tools?.experimental?.skills;
|
||||
return settings.experimental?.skills ?? legacySkills ?? false;
|
||||
})(),
|
||||
})
|
||||
.option('channel', {
|
||||
type: 'string',
|
||||
@@ -475,10 +493,6 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
],
|
||||
description: 'Authentication type',
|
||||
})
|
||||
.deprecateOption(
|
||||
'show-memory-usage',
|
||||
'Use the "ui.showMemoryUsage" setting in settings.json instead. This flag will be removed in a future version.',
|
||||
)
|
||||
.deprecateOption(
|
||||
'sandbox-image',
|
||||
'Use the "tools.sandbox" setting in settings.json instead. This flag will be removed in a future version.',
|
||||
@@ -537,11 +551,9 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
}),
|
||||
)
|
||||
// Register MCP subcommands
|
||||
.command(mcpCommand);
|
||||
|
||||
if (settings?.experimental?.extensionManagement ?? true) {
|
||||
yargsInstance.command(extensionsCommand);
|
||||
}
|
||||
.command(mcpCommand)
|
||||
// Register Extension subcommands
|
||||
.command(extensionsCommand);
|
||||
|
||||
yargsInstance
|
||||
.version(await getCliVersion()) // This will enable the --version flag based on package.json
|
||||
@@ -589,8 +601,19 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
// The import format is now only controlled by settings.memoryImportFormat
|
||||
// We no longer accept it as a CLI argument
|
||||
|
||||
// Apply ACP fallback: if experimental-acp is present but no explicit --channel, treat as ACP
|
||||
if (result['experimentalAcp'] && !result['channel']) {
|
||||
// Handle deprecated --experimental-acp flag
|
||||
if (result['experimentalAcp']) {
|
||||
console.warn(
|
||||
'\x1b[33m⚠ Warning: --experimental-acp is deprecated and will be removed in a future release. Please use --acp instead.\x1b[0m',
|
||||
);
|
||||
// Map experimental-acp to acp if acp is not explicitly set
|
||||
if (!result['acp']) {
|
||||
(result as Record<string, unknown>)['acp'] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply ACP fallback: if acp or experimental-acp is present but no explicit --channel, treat as ACP
|
||||
if ((result['acp'] || result['experimentalAcp']) && !result['channel']) {
|
||||
(result as Record<string, unknown>)['channel'] = 'ACP';
|
||||
}
|
||||
|
||||
@@ -605,11 +628,9 @@ export async function loadHierarchicalGeminiMemory(
|
||||
includeDirectoriesToReadGemini: readonly string[] = [],
|
||||
debugMode: boolean,
|
||||
fileService: FileDiscoveryService,
|
||||
settings: Settings,
|
||||
extensionContextFilePaths: string[] = [],
|
||||
folderTrust: boolean,
|
||||
memoryImportFormat: 'flat' | 'tree' = 'tree',
|
||||
fileFilteringOptions?: FileFilteringOptions,
|
||||
): Promise<{ memoryContent: string; fileCount: number }> {
|
||||
// FIX: Use real, canonical paths for a reliable comparison to handle symlinks.
|
||||
const realCwd = fs.realpathSync(path.resolve(currentWorkingDirectory));
|
||||
@@ -635,8 +656,6 @@ export async function loadHierarchicalGeminiMemory(
|
||||
extensionContextFilePaths,
|
||||
folderTrust,
|
||||
memoryImportFormat,
|
||||
fileFilteringOptions,
|
||||
settings.context?.discoveryMaxDirs,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -651,30 +670,17 @@ export function isDebugMode(argv: CliArgs): boolean {
|
||||
|
||||
export async function loadCliConfig(
|
||||
settings: Settings,
|
||||
extensions: Extension[],
|
||||
extensionEnablementManager: ExtensionEnablementManager,
|
||||
argv: CliArgs,
|
||||
cwd: string = process.cwd(),
|
||||
overrideExtensions?: string[],
|
||||
): Promise<Config> {
|
||||
const debugMode = isDebugMode(argv);
|
||||
|
||||
const memoryImportFormat = settings.context?.importFormat || 'tree';
|
||||
|
||||
const ideMode = settings.ide?.enabled ?? false;
|
||||
|
||||
const folderTrust = settings.security?.folderTrust?.enabled ?? false;
|
||||
const trustedFolder = isWorkspaceTrusted(settings)?.isTrusted ?? true;
|
||||
|
||||
const allExtensions = annotateActiveExtensions(
|
||||
extensions,
|
||||
cwd,
|
||||
extensionEnablementManager,
|
||||
);
|
||||
|
||||
const activeExtensions = extensions.filter(
|
||||
(_, i) => allExtensions[i].isActive,
|
||||
);
|
||||
|
||||
// Set the context filename in the server's memoryTool module BEFORE loading memory
|
||||
// TODO(b/343434939): This is a bit of a hack. The contextFileName should ideally be passed
|
||||
// directly to the Config constructor in core, and have core handle setGeminiMdFilename.
|
||||
@@ -686,51 +692,27 @@ export async function loadCliConfig(
|
||||
setServerGeminiMdFilename(getCurrentGeminiMdFilename());
|
||||
}
|
||||
|
||||
const extensionContextFilePaths = activeExtensions.flatMap(
|
||||
(e) => e.contextFiles,
|
||||
);
|
||||
|
||||
// Automatically load output-language.md if it exists
|
||||
const outputLanguageFilePath = path.join(
|
||||
let outputLanguageFilePath: string | undefined = path.join(
|
||||
Storage.getGlobalQwenDir(),
|
||||
'output-language.md',
|
||||
);
|
||||
if (fs.existsSync(outputLanguageFilePath)) {
|
||||
extensionContextFilePaths.push(outputLanguageFilePath);
|
||||
if (debugMode) {
|
||||
logger.debug(
|
||||
`Found output-language.md, adding to context files: ${outputLanguageFilePath}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
outputLanguageFilePath = undefined;
|
||||
}
|
||||
|
||||
const fileService = new FileDiscoveryService(cwd);
|
||||
|
||||
const fileFiltering = {
|
||||
...DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
|
||||
...settings.context?.fileFiltering,
|
||||
};
|
||||
|
||||
const includeDirectories = (settings.context?.includeDirectories || [])
|
||||
.map(resolvePath)
|
||||
.concat((argv.includeDirectories || []).map(resolvePath));
|
||||
|
||||
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
|
||||
const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory(
|
||||
cwd,
|
||||
settings.context?.loadMemoryFromIncludeDirectories
|
||||
? includeDirectories
|
||||
: [],
|
||||
debugMode,
|
||||
fileService,
|
||||
settings,
|
||||
extensionContextFilePaths,
|
||||
trustedFolder,
|
||||
memoryImportFormat,
|
||||
fileFiltering,
|
||||
);
|
||||
|
||||
let mcpServers = mergeMcpServers(settings, activeExtensions);
|
||||
const question = argv.promptInteractive || argv.prompt || '';
|
||||
const inputFormat: InputFormat =
|
||||
(argv.inputFormat as InputFormat | undefined) ?? InputFormat.TEXT;
|
||||
@@ -818,20 +800,44 @@ export async function loadCliConfig(
|
||||
// However, if stream-json input is used, control can be requested via JSON messages,
|
||||
// so tools should not be excluded in that case.
|
||||
const extraExcludes: string[] = [];
|
||||
if (
|
||||
!interactive &&
|
||||
!argv.experimentalAcp &&
|
||||
inputFormat !== InputFormat.STREAM_JSON
|
||||
) {
|
||||
const resolvedCoreTools = argv.coreTools || settings.tools?.core || [];
|
||||
const resolvedAllowedTools =
|
||||
argv.allowedTools || settings.tools?.allowed || [];
|
||||
const isExplicitlyEnabled = (toolName: ToolName): boolean => {
|
||||
if (resolvedCoreTools.length > 0) {
|
||||
if (isToolEnabled(toolName, resolvedCoreTools, [])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (resolvedAllowedTools.length > 0) {
|
||||
if (isToolEnabled(toolName, resolvedAllowedTools, [])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const excludeUnlessExplicit = (toolName: ToolName): void => {
|
||||
if (!isExplicitlyEnabled(toolName)) {
|
||||
extraExcludes.push(toolName);
|
||||
}
|
||||
};
|
||||
|
||||
// ACP mode check: must include both --acp (current) and --experimental-acp (deprecated).
|
||||
// Without this check, edit, write_file, run_shell_command would be excluded in ACP mode.
|
||||
const isAcpMode = argv.acp || argv.experimentalAcp;
|
||||
if (!interactive && !isAcpMode && inputFormat !== InputFormat.STREAM_JSON) {
|
||||
switch (approvalMode) {
|
||||
case ApprovalMode.PLAN:
|
||||
case ApprovalMode.DEFAULT:
|
||||
// In default non-interactive mode, all tools that require approval are excluded.
|
||||
extraExcludes.push(ShellTool.Name, EditTool.Name, WriteFileTool.Name);
|
||||
// In default non-interactive mode, all tools that require approval are excluded,
|
||||
// unless explicitly enabled via coreTools/allowedTools.
|
||||
excludeUnlessExplicit(ShellTool.Name as ToolName);
|
||||
excludeUnlessExplicit(EditTool.Name as ToolName);
|
||||
excludeUnlessExplicit(WriteFileTool.Name as ToolName);
|
||||
break;
|
||||
case ApprovalMode.AUTO_EDIT:
|
||||
// In auto-edit non-interactive mode, only tools that still require a prompt are excluded.
|
||||
extraExcludes.push(ShellTool.Name);
|
||||
excludeUnlessExplicit(ShellTool.Name as ToolName);
|
||||
break;
|
||||
case ApprovalMode.YOLO:
|
||||
// No extra excludes for YOLO mode.
|
||||
@@ -844,63 +850,45 @@ export async function loadCliConfig(
|
||||
|
||||
const excludeTools = mergeExcludeTools(
|
||||
settings,
|
||||
activeExtensions,
|
||||
extraExcludes.length > 0 ? extraExcludes : undefined,
|
||||
argv.excludeTools,
|
||||
);
|
||||
const blockedMcpServers: Array<{ name: string; extensionName: string }> = [];
|
||||
|
||||
if (!argv.allowedMcpServerNames) {
|
||||
if (settings.mcp?.allowed) {
|
||||
mcpServers = allowedMcpServers(
|
||||
mcpServers,
|
||||
settings.mcp.allowed,
|
||||
blockedMcpServers,
|
||||
);
|
||||
}
|
||||
|
||||
if (settings.mcp?.excluded) {
|
||||
const excludedNames = new Set(settings.mcp.excluded.filter(Boolean));
|
||||
if (excludedNames.size > 0) {
|
||||
mcpServers = Object.fromEntries(
|
||||
Object.entries(mcpServers).filter(([key]) => !excludedNames.has(key)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let allowedMcpServers: Set<string> | undefined;
|
||||
let excludedMcpServers: Set<string> | undefined;
|
||||
if (argv.allowedMcpServerNames) {
|
||||
mcpServers = allowedMcpServers(
|
||||
mcpServers,
|
||||
argv.allowedMcpServerNames,
|
||||
blockedMcpServers,
|
||||
);
|
||||
allowedMcpServers = new Set(argv.allowedMcpServerNames.filter(Boolean));
|
||||
excludedMcpServers = undefined;
|
||||
} else {
|
||||
allowedMcpServers = settings.mcp?.allowed
|
||||
? new Set(settings.mcp.allowed.filter(Boolean))
|
||||
: undefined;
|
||||
excludedMcpServers = settings.mcp?.excluded
|
||||
? new Set(settings.mcp.excluded.filter(Boolean))
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const selectedAuthType =
|
||||
(argv.authType as AuthType | undefined) ||
|
||||
settings.security?.auth?.selectedType;
|
||||
settings.security?.auth?.selectedType ||
|
||||
/* getAuthTypeFromEnv means no authType was explicitly provided, we infer the authType from env vars */
|
||||
getAuthTypeFromEnv();
|
||||
|
||||
const apiKey =
|
||||
(selectedAuthType === AuthType.USE_OPENAI
|
||||
? argv.openaiApiKey ||
|
||||
process.env['OPENAI_API_KEY'] ||
|
||||
settings.security?.auth?.apiKey
|
||||
: '') || '';
|
||||
const baseUrl =
|
||||
(selectedAuthType === AuthType.USE_OPENAI
|
||||
? argv.openaiBaseUrl ||
|
||||
process.env['OPENAI_BASE_URL'] ||
|
||||
settings.security?.auth?.baseUrl
|
||||
: '') || '';
|
||||
const resolvedModel =
|
||||
argv.model ||
|
||||
(selectedAuthType === AuthType.USE_OPENAI
|
||||
? process.env['OPENAI_MODEL'] ||
|
||||
process.env['QWEN_MODEL'] ||
|
||||
settings.model?.name
|
||||
: '') ||
|
||||
'';
|
||||
// Unified resolution of generation config with source attribution
|
||||
const resolvedCliConfig = resolveCliGenerationConfig({
|
||||
argv: {
|
||||
model: argv.model,
|
||||
openaiApiKey: argv.openaiApiKey,
|
||||
openaiBaseUrl: argv.openaiBaseUrl,
|
||||
openaiLogging: argv.openaiLogging,
|
||||
openaiLoggingDir: argv.openaiLoggingDir,
|
||||
},
|
||||
settings,
|
||||
selectedAuthType,
|
||||
env: process.env as Record<string, string | undefined>,
|
||||
});
|
||||
|
||||
const { model: resolvedModel } = resolvedCliConfig;
|
||||
|
||||
const sandboxConfig = await loadSandboxConfig(settings, argv);
|
||||
const screenReader =
|
||||
@@ -934,6 +922,8 @@ export async function loadCliConfig(
|
||||
}
|
||||
}
|
||||
|
||||
const modelProvidersConfig = settings.modelProviders;
|
||||
|
||||
return new Config({
|
||||
sessionId,
|
||||
sessionData,
|
||||
@@ -943,6 +933,7 @@ export async function loadCliConfig(
|
||||
includeDirectories,
|
||||
loadMemoryFromIncludeDirectories:
|
||||
settings.context?.loadMemoryFromIncludeDirectories || false,
|
||||
importFormat: settings.context?.importFormat || 'tree',
|
||||
debugMode,
|
||||
question,
|
||||
fullContext: argv.allFiles || false,
|
||||
@@ -952,12 +943,14 @@ export async function loadCliConfig(
|
||||
toolDiscoveryCommand: settings.tools?.discoveryCommand,
|
||||
toolCallCommand: settings.tools?.callCommand,
|
||||
mcpServerCommand: settings.mcp?.serverCommand,
|
||||
mcpServers,
|
||||
userMemory: memoryContent,
|
||||
geminiMdFileCount: fileCount,
|
||||
mcpServers: settings.mcpServers || {},
|
||||
allowedMcpServers: allowedMcpServers
|
||||
? Array.from(allowedMcpServers)
|
||||
: undefined,
|
||||
excludedMcpServers: excludedMcpServers
|
||||
? Array.from(excludedMcpServers)
|
||||
: undefined,
|
||||
approvalMode,
|
||||
showMemoryUsage:
|
||||
argv.showMemoryUsage || settings.ui?.showMemoryUsage || false,
|
||||
accessibility: {
|
||||
...settings.ui?.accessibility,
|
||||
screenReader,
|
||||
@@ -977,38 +970,24 @@ export async function loadCliConfig(
|
||||
fileDiscoveryService: fileService,
|
||||
bugCommand: settings.advanced?.bugCommand,
|
||||
model: resolvedModel,
|
||||
extensionContextFilePaths,
|
||||
outputLanguageFilePath,
|
||||
sessionTokenLimit: settings.model?.sessionTokenLimit ?? -1,
|
||||
maxSessionTurns:
|
||||
argv.maxSessionTurns ?? settings.model?.maxSessionTurns ?? -1,
|
||||
experimentalZedIntegration: argv.experimentalAcp || false,
|
||||
experimentalZedIntegration: argv.acp || argv.experimentalAcp || false,
|
||||
experimentalSkills: argv.experimentalSkills || false,
|
||||
listExtensions: argv.listExtensions || false,
|
||||
extensions: allExtensions,
|
||||
blockedMcpServers,
|
||||
overrideExtensions: overrideExtensions || argv.extensions,
|
||||
noBrowser: !!process.env['NO_BROWSER'],
|
||||
authType: selectedAuthType,
|
||||
inputFormat,
|
||||
outputFormat,
|
||||
includePartialMessages,
|
||||
generationConfig: {
|
||||
...(settings.model?.generationConfig || {}),
|
||||
model: resolvedModel,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
enableOpenAILogging:
|
||||
(typeof argv.openaiLogging === 'undefined'
|
||||
? settings.model?.enableOpenAILogging
|
||||
: argv.openaiLogging) ?? false,
|
||||
openAILoggingDir:
|
||||
argv.openaiLoggingDir || settings.model?.openAILoggingDir,
|
||||
},
|
||||
modelProvidersConfig,
|
||||
generationConfigSources: resolvedCliConfig.sources,
|
||||
generationConfig: resolvedCliConfig.generationConfig,
|
||||
cliVersion: await getCliVersion(),
|
||||
webSearch: buildWebSearchConfig(
|
||||
argv,
|
||||
settings,
|
||||
settings.security?.auth?.selectedType,
|
||||
),
|
||||
webSearch: buildWebSearchConfig(argv, settings, selectedAuthType),
|
||||
summarizeToolOutput: settings.model?.summarizeToolOutput,
|
||||
ideMode,
|
||||
chatCompression: settings.model?.chatCompression,
|
||||
@@ -1040,61 +1019,8 @@ export async function loadCliConfig(
|
||||
});
|
||||
}
|
||||
|
||||
function allowedMcpServers(
|
||||
mcpServers: { [x: string]: MCPServerConfig },
|
||||
allowMCPServers: string[],
|
||||
blockedMcpServers: Array<{ name: string; extensionName: string }>,
|
||||
) {
|
||||
const allowedNames = new Set(allowMCPServers.filter(Boolean));
|
||||
if (allowedNames.size > 0) {
|
||||
mcpServers = Object.fromEntries(
|
||||
Object.entries(mcpServers).filter(([key, server]) => {
|
||||
const isAllowed = allowedNames.has(key);
|
||||
if (!isAllowed) {
|
||||
blockedMcpServers.push({
|
||||
name: key,
|
||||
extensionName: server.extensionName || '',
|
||||
});
|
||||
}
|
||||
return isAllowed;
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
blockedMcpServers.push(
|
||||
...Object.entries(mcpServers).map(([key, server]) => ({
|
||||
name: key,
|
||||
extensionName: server.extensionName || '',
|
||||
})),
|
||||
);
|
||||
mcpServers = {};
|
||||
}
|
||||
return mcpServers;
|
||||
}
|
||||
|
||||
function mergeMcpServers(settings: Settings, extensions: Extension[]) {
|
||||
const mcpServers = { ...(settings.mcpServers || {}) };
|
||||
for (const extension of extensions) {
|
||||
Object.entries(extension.config.mcpServers || {}).forEach(
|
||||
([key, server]) => {
|
||||
if (mcpServers[key]) {
|
||||
logger.warn(
|
||||
`Skipping extension MCP config for server with key "${key}" as it already exists.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
mcpServers[key] = {
|
||||
...server,
|
||||
extensionName: extension.config.name,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
return mcpServers;
|
||||
}
|
||||
|
||||
function mergeExcludeTools(
|
||||
settings: Settings,
|
||||
extensions: Extension[],
|
||||
extraExcludes?: string[] | undefined,
|
||||
cliExcludeTools?: string[] | undefined,
|
||||
): string[] {
|
||||
@@ -1103,10 +1029,5 @@ function mergeExcludeTools(
|
||||
...(settings.tools?.exclude || []),
|
||||
...(extraExcludes || []),
|
||||
]);
|
||||
for (const extension of extensions) {
|
||||
for (const tool of extension.config.excludeTools || []) {
|
||||
allExcludeTools.add(tool);
|
||||
}
|
||||
}
|
||||
return [...allExcludeTools];
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,786 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
MCPServerConfig,
|
||||
GeminiCLIExtension,
|
||||
ExtensionInstallMetadata,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
QWEN_DIR,
|
||||
Storage,
|
||||
Config,
|
||||
ExtensionInstallEvent,
|
||||
ExtensionUninstallEvent,
|
||||
ExtensionDisableEvent,
|
||||
ExtensionEnableEvent,
|
||||
logExtensionEnable,
|
||||
logExtensionInstallEvent,
|
||||
logExtensionUninstall,
|
||||
logExtensionDisable,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { SettingScope, loadSettings } from '../config/settings.js';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
import { recursivelyHydrateStrings } from './extensions/variables.js';
|
||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
|
||||
import {
|
||||
cloneFromGit,
|
||||
downloadFromGitHubRelease,
|
||||
} from './extensions/github.js';
|
||||
import type { LoadExtensionContext } from './extensions/variableSchema.js';
|
||||
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
|
||||
import chalk from 'chalk';
|
||||
import type { ConfirmationRequest } from '../ui/types.js';
|
||||
|
||||
export const EXTENSIONS_DIRECTORY_NAME = path.join(QWEN_DIR, 'extensions');
|
||||
|
||||
export const EXTENSIONS_CONFIG_FILENAME = 'qwen-extension.json';
|
||||
export const INSTALL_METADATA_FILENAME = '.qwen-extension-install.json';
|
||||
|
||||
export interface Extension {
|
||||
path: string;
|
||||
config: ExtensionConfig;
|
||||
contextFiles: string[];
|
||||
installMetadata?: ExtensionInstallMetadata | undefined;
|
||||
}
|
||||
|
||||
export interface ExtensionConfig {
|
||||
name: string;
|
||||
version: string;
|
||||
mcpServers?: Record<string, MCPServerConfig>;
|
||||
contextFileName?: string | string[];
|
||||
excludeTools?: string[];
|
||||
}
|
||||
|
||||
export interface ExtensionUpdateInfo {
|
||||
name: string;
|
||||
originalVersion: string;
|
||||
updatedVersion: string;
|
||||
}
|
||||
|
||||
export class ExtensionStorage {
|
||||
private readonly extensionName: string;
|
||||
|
||||
constructor(extensionName: string) {
|
||||
this.extensionName = extensionName;
|
||||
}
|
||||
|
||||
getExtensionDir(): string {
|
||||
return path.join(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
this.extensionName,
|
||||
);
|
||||
}
|
||||
|
||||
getConfigPath(): string {
|
||||
return path.join(this.getExtensionDir(), EXTENSIONS_CONFIG_FILENAME);
|
||||
}
|
||||
|
||||
static getUserExtensionsDir(): string {
|
||||
const storage = new Storage(os.homedir());
|
||||
return storage.getExtensionsDir();
|
||||
}
|
||||
|
||||
static async createTmpDir(): Promise<string> {
|
||||
return await fs.promises.mkdtemp(path.join(os.tmpdir(), 'qwen-extension'));
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkspaceExtensions(workspaceDir: string): Extension[] {
|
||||
// If the workspace dir is the user extensions dir, there are no workspace extensions.
|
||||
if (path.resolve(workspaceDir) === path.resolve(os.homedir())) {
|
||||
return [];
|
||||
}
|
||||
return loadExtensionsFromDir(workspaceDir);
|
||||
}
|
||||
|
||||
export async function copyExtension(
|
||||
source: string,
|
||||
destination: string,
|
||||
): Promise<void> {
|
||||
await fs.promises.cp(source, destination, { recursive: true });
|
||||
}
|
||||
|
||||
export async function performWorkspaceExtensionMigration(
|
||||
extensions: Extension[],
|
||||
requestConsent: (consent: string) => Promise<boolean>,
|
||||
): Promise<string[]> {
|
||||
const failedInstallNames: string[] = [];
|
||||
|
||||
for (const extension of extensions) {
|
||||
try {
|
||||
const installMetadata: ExtensionInstallMetadata = {
|
||||
source: extension.path,
|
||||
type: 'local',
|
||||
};
|
||||
await installExtension(installMetadata, requestConsent);
|
||||
} catch (_) {
|
||||
failedInstallNames.push(extension.config.name);
|
||||
}
|
||||
}
|
||||
return failedInstallNames;
|
||||
}
|
||||
|
||||
function getTelemetryConfig(cwd: string) {
|
||||
const settings = loadSettings(cwd);
|
||||
const config = new Config({
|
||||
telemetry: settings.merged.telemetry,
|
||||
interactive: false,
|
||||
targetDir: cwd,
|
||||
cwd,
|
||||
model: '',
|
||||
debugMode: false,
|
||||
});
|
||||
return config;
|
||||
}
|
||||
|
||||
export function loadExtensions(
|
||||
extensionEnablementManager: ExtensionEnablementManager,
|
||||
workspaceDir: string = process.cwd(),
|
||||
): Extension[] {
|
||||
const settings = loadSettings(workspaceDir).merged;
|
||||
const allExtensions = [...loadUserExtensions()];
|
||||
|
||||
if (
|
||||
(isWorkspaceTrusted(settings) ?? true) &&
|
||||
// Default management setting to true
|
||||
!(settings.experimental?.extensionManagement ?? true)
|
||||
) {
|
||||
allExtensions.push(...getWorkspaceExtensions(workspaceDir));
|
||||
}
|
||||
|
||||
const uniqueExtensions = new Map<string, Extension>();
|
||||
|
||||
for (const extension of allExtensions) {
|
||||
if (
|
||||
!uniqueExtensions.has(extension.config.name) &&
|
||||
extensionEnablementManager.isEnabled(extension.config.name, workspaceDir)
|
||||
) {
|
||||
uniqueExtensions.set(extension.config.name, extension);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(uniqueExtensions.values());
|
||||
}
|
||||
|
||||
export function loadUserExtensions(): Extension[] {
|
||||
const userExtensions = loadExtensionsFromDir(os.homedir());
|
||||
|
||||
const uniqueExtensions = new Map<string, Extension>();
|
||||
for (const extension of userExtensions) {
|
||||
if (!uniqueExtensions.has(extension.config.name)) {
|
||||
uniqueExtensions.set(extension.config.name, extension);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(uniqueExtensions.values());
|
||||
}
|
||||
|
||||
export function loadExtensionsFromDir(dir: string): Extension[] {
|
||||
const storage = new Storage(dir);
|
||||
const extensionsDir = storage.getExtensionsDir();
|
||||
if (!fs.existsSync(extensionsDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const extensions: Extension[] = [];
|
||||
for (const subdir of fs.readdirSync(extensionsDir)) {
|
||||
const extensionDir = path.join(extensionsDir, subdir);
|
||||
|
||||
const extension = loadExtension({ extensionDir, workspaceDir: dir });
|
||||
if (extension != null) {
|
||||
extensions.push(extension);
|
||||
}
|
||||
}
|
||||
return extensions;
|
||||
}
|
||||
|
||||
export function loadExtension(context: LoadExtensionContext): Extension | null {
|
||||
const { extensionDir, workspaceDir } = context;
|
||||
if (!fs.statSync(extensionDir).isDirectory()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const installMetadata = loadInstallMetadata(extensionDir);
|
||||
let effectiveExtensionPath = extensionDir;
|
||||
|
||||
if (installMetadata?.type === 'link') {
|
||||
effectiveExtensionPath = installMetadata.source;
|
||||
}
|
||||
|
||||
try {
|
||||
let config = loadExtensionConfig({
|
||||
extensionDir: effectiveExtensionPath,
|
||||
workspaceDir,
|
||||
});
|
||||
|
||||
config = resolveEnvVarsInObject(config);
|
||||
|
||||
if (config.mcpServers) {
|
||||
config.mcpServers = Object.fromEntries(
|
||||
Object.entries(config.mcpServers).map(([key, value]) => [
|
||||
key,
|
||||
filterMcpConfig(value),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
const contextFiles = getContextFileNames(config)
|
||||
.map((contextFileName) =>
|
||||
path.join(effectiveExtensionPath, contextFileName),
|
||||
)
|
||||
.filter((contextFilePath) => fs.existsSync(contextFilePath));
|
||||
|
||||
return {
|
||||
path: effectiveExtensionPath,
|
||||
config,
|
||||
contextFiles,
|
||||
installMetadata,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Warning: Skipping extension in ${effectiveExtensionPath}: ${getErrorMessage(
|
||||
e,
|
||||
)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadExtensionByName(
|
||||
name: string,
|
||||
workspaceDir: string = process.cwd(),
|
||||
): Extension | null {
|
||||
const userExtensionsDir = ExtensionStorage.getUserExtensionsDir();
|
||||
if (!fs.existsSync(userExtensionsDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const subdir of fs.readdirSync(userExtensionsDir)) {
|
||||
const extensionDir = path.join(userExtensionsDir, subdir);
|
||||
if (!fs.statSync(extensionDir).isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const extension = loadExtension({ extensionDir, workspaceDir });
|
||||
if (
|
||||
extension &&
|
||||
extension.config.name.toLowerCase() === name.toLowerCase()
|
||||
) {
|
||||
return extension;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function filterMcpConfig(original: MCPServerConfig): MCPServerConfig {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { trust, ...rest } = original;
|
||||
return Object.freeze(rest);
|
||||
}
|
||||
|
||||
export function loadInstallMetadata(
|
||||
extensionDir: string,
|
||||
): ExtensionInstallMetadata | undefined {
|
||||
const metadataFilePath = path.join(extensionDir, INSTALL_METADATA_FILENAME);
|
||||
try {
|
||||
const configContent = fs.readFileSync(metadataFilePath, 'utf-8');
|
||||
const metadata = JSON.parse(configContent) as ExtensionInstallMetadata;
|
||||
return metadata;
|
||||
} catch (_e) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function getContextFileNames(config: ExtensionConfig): string[] {
|
||||
if (!config.contextFileName) {
|
||||
return ['QWEN.md'];
|
||||
} else if (!Array.isArray(config.contextFileName)) {
|
||||
return [config.contextFileName];
|
||||
}
|
||||
return config.contextFileName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an annotated list of extensions. If an extension is listed in enabledExtensionNames, it will be active.
|
||||
* If enabledExtensionNames is empty, an extension is active unless it is disabled.
|
||||
* @param extensions The base list of extensions.
|
||||
* @param enabledExtensionNames The names of explicitly enabled extensions.
|
||||
* @param workspaceDir The current workspace directory.
|
||||
*/
|
||||
export function annotateActiveExtensions(
|
||||
extensions: Extension[],
|
||||
workspaceDir: string,
|
||||
manager: ExtensionEnablementManager,
|
||||
): GeminiCLIExtension[] {
|
||||
manager.validateExtensionOverrides(extensions);
|
||||
return extensions.map((extension) => ({
|
||||
name: extension.config.name,
|
||||
version: extension.config.version,
|
||||
isActive: manager.isEnabled(extension.config.name, workspaceDir),
|
||||
path: extension.path,
|
||||
installMetadata: extension.installMetadata,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests consent from the user to perform an action, by reading a Y/n
|
||||
* character from stdin.
|
||||
*
|
||||
* This should not be called from interactive mode as it will break the CLI.
|
||||
*
|
||||
* @param consentDescription The description of the thing they will be consenting to.
|
||||
* @returns boolean, whether they consented or not.
|
||||
*/
|
||||
export async function requestConsentNonInteractive(
|
||||
consentDescription: string,
|
||||
): Promise<boolean> {
|
||||
console.info(consentDescription);
|
||||
const result = await promptForConsentNonInteractive(
|
||||
'Do you want to continue? [Y/n]: ',
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests consent from the user to perform an action, in interactive mode.
|
||||
*
|
||||
* This should not be called from non-interactive mode as it will not work.
|
||||
*
|
||||
* @param consentDescription The description of the thing they will be consenting to.
|
||||
* @param setExtensionUpdateConfirmationRequest A function to actually add a prompt to the UI.
|
||||
* @returns boolean, whether they consented or not.
|
||||
*/
|
||||
export async function requestConsentInteractive(
|
||||
consentDescription: string,
|
||||
addExtensionUpdateConfirmationRequest: (value: ConfirmationRequest) => void,
|
||||
): Promise<boolean> {
|
||||
return await promptForConsentInteractive(
|
||||
consentDescription + '\n\nDo you want to continue?',
|
||||
addExtensionUpdateConfirmationRequest,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks users a prompt and awaits for a y/n response on stdin.
|
||||
*
|
||||
* This should not be called from interactive mode as it will break the CLI.
|
||||
*
|
||||
* @param prompt A yes/no prompt to ask the user
|
||||
* @returns Whether or not the user answers 'y' (yes). Defaults to 'yes' on enter.
|
||||
*/
|
||||
async function promptForConsentNonInteractive(
|
||||
prompt: string,
|
||||
): Promise<boolean> {
|
||||
const readline = await import('node:readline');
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
rl.question(prompt, (answer) => {
|
||||
rl.close();
|
||||
resolve(['y', ''].includes(answer.trim().toLowerCase()));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks users an interactive yes/no prompt.
|
||||
*
|
||||
* This should not be called from non-interactive mode as it will break the CLI.
|
||||
*
|
||||
* @param prompt A markdown prompt to ask the user
|
||||
* @param setExtensionUpdateConfirmationRequest Function to update the UI state with the confirmation request.
|
||||
* @returns Whether or not the user answers yes.
|
||||
*/
|
||||
async function promptForConsentInteractive(
|
||||
prompt: string,
|
||||
addExtensionUpdateConfirmationRequest: (value: ConfirmationRequest) => void,
|
||||
): Promise<boolean> {
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
addExtensionUpdateConfirmationRequest({
|
||||
prompt,
|
||||
onConfirm: (resolvedConfirmed) => {
|
||||
resolve(resolvedConfirmed);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function installExtension(
|
||||
installMetadata: ExtensionInstallMetadata,
|
||||
requestConsent: (consent: string) => Promise<boolean>,
|
||||
cwd: string = process.cwd(),
|
||||
previousExtensionConfig?: ExtensionConfig,
|
||||
): Promise<string> {
|
||||
const telemetryConfig = getTelemetryConfig(cwd);
|
||||
let newExtensionConfig: ExtensionConfig | null = null;
|
||||
let localSourcePath: string | undefined;
|
||||
|
||||
try {
|
||||
const settings = loadSettings(cwd).merged;
|
||||
if (!isWorkspaceTrusted(settings)) {
|
||||
throw new Error(
|
||||
`Could not install extension from untrusted folder at ${installMetadata.source}`,
|
||||
);
|
||||
}
|
||||
|
||||
const extensionsDir = ExtensionStorage.getUserExtensionsDir();
|
||||
await fs.promises.mkdir(extensionsDir, { recursive: true });
|
||||
|
||||
if (
|
||||
!path.isAbsolute(installMetadata.source) &&
|
||||
(installMetadata.type === 'local' || installMetadata.type === 'link')
|
||||
) {
|
||||
installMetadata.source = path.resolve(cwd, installMetadata.source);
|
||||
}
|
||||
|
||||
let tempDir: string | undefined;
|
||||
|
||||
if (
|
||||
installMetadata.type === 'git' ||
|
||||
installMetadata.type === 'github-release'
|
||||
) {
|
||||
tempDir = await ExtensionStorage.createTmpDir();
|
||||
try {
|
||||
const result = await downloadFromGitHubRelease(
|
||||
installMetadata,
|
||||
tempDir,
|
||||
);
|
||||
installMetadata.type = result.type;
|
||||
installMetadata.releaseTag = result.tagName;
|
||||
} catch (_error) {
|
||||
await cloneFromGit(installMetadata, tempDir);
|
||||
installMetadata.type = 'git';
|
||||
}
|
||||
localSourcePath = tempDir;
|
||||
} else if (
|
||||
installMetadata.type === 'local' ||
|
||||
installMetadata.type === 'link'
|
||||
) {
|
||||
localSourcePath = installMetadata.source;
|
||||
} else {
|
||||
throw new Error(`Unsupported install type: ${installMetadata.type}`);
|
||||
}
|
||||
|
||||
try {
|
||||
newExtensionConfig = loadExtensionConfig({
|
||||
extensionDir: localSourcePath,
|
||||
workspaceDir: cwd,
|
||||
});
|
||||
|
||||
const newExtensionName = newExtensionConfig.name;
|
||||
const extensionStorage = new ExtensionStorage(newExtensionName);
|
||||
const destinationPath = extensionStorage.getExtensionDir();
|
||||
|
||||
const installedExtensions = loadUserExtensions();
|
||||
if (
|
||||
installedExtensions.some(
|
||||
(installed) => installed.config.name === newExtensionName,
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`Extension "${newExtensionName}" is already installed. Please uninstall it first.`,
|
||||
);
|
||||
}
|
||||
await maybeRequestConsentOrFail(
|
||||
newExtensionConfig,
|
||||
requestConsent,
|
||||
previousExtensionConfig,
|
||||
);
|
||||
await fs.promises.mkdir(destinationPath, { recursive: true });
|
||||
|
||||
if (
|
||||
installMetadata.type === 'local' ||
|
||||
installMetadata.type === 'git' ||
|
||||
installMetadata.type === 'github-release'
|
||||
) {
|
||||
await copyExtension(localSourcePath, destinationPath);
|
||||
}
|
||||
|
||||
const metadataString = JSON.stringify(installMetadata, null, 2);
|
||||
const metadataPath = path.join(
|
||||
destinationPath,
|
||||
INSTALL_METADATA_FILENAME,
|
||||
);
|
||||
await fs.promises.writeFile(metadataPath, metadataString);
|
||||
} finally {
|
||||
if (tempDir) {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
logExtensionInstallEvent(
|
||||
telemetryConfig,
|
||||
new ExtensionInstallEvent(
|
||||
newExtensionConfig!.name,
|
||||
newExtensionConfig!.version,
|
||||
installMetadata.source,
|
||||
'success',
|
||||
),
|
||||
);
|
||||
|
||||
enableExtension(newExtensionConfig!.name, SettingScope.User);
|
||||
return newExtensionConfig!.name;
|
||||
} catch (error) {
|
||||
// Attempt to load config from the source path even if installation fails
|
||||
// to get the name and version for logging.
|
||||
if (!newExtensionConfig && localSourcePath) {
|
||||
try {
|
||||
newExtensionConfig = loadExtensionConfig({
|
||||
extensionDir: localSourcePath,
|
||||
workspaceDir: cwd,
|
||||
});
|
||||
} catch {
|
||||
// Ignore error, this is just for logging.
|
||||
}
|
||||
}
|
||||
logExtensionInstallEvent(
|
||||
telemetryConfig,
|
||||
new ExtensionInstallEvent(
|
||||
newExtensionConfig?.name ?? '',
|
||||
newExtensionConfig?.version ?? '',
|
||||
installMetadata.source,
|
||||
'error',
|
||||
),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a consent string for installing an extension based on it's
|
||||
* extensionConfig.
|
||||
*/
|
||||
function extensionConsentString(extensionConfig: ExtensionConfig): string {
|
||||
const output: string[] = [];
|
||||
const mcpServerEntries = Object.entries(extensionConfig.mcpServers || {});
|
||||
output.push(`Installing extension "${extensionConfig.name}".`);
|
||||
output.push(
|
||||
'**Extensions may introduce unexpected behavior. Ensure you have investigated the extension source and trust the author.**',
|
||||
);
|
||||
|
||||
if (mcpServerEntries.length) {
|
||||
output.push('This extension will run the following MCP servers:');
|
||||
for (const [key, mcpServer] of mcpServerEntries) {
|
||||
const isLocal = !!mcpServer.command;
|
||||
const source =
|
||||
mcpServer.httpUrl ??
|
||||
`${mcpServer.command || ''}${mcpServer.args ? ' ' + mcpServer.args.join(' ') : ''}`;
|
||||
output.push(` * ${key} (${isLocal ? 'local' : 'remote'}): ${source}`);
|
||||
}
|
||||
}
|
||||
if (extensionConfig.contextFileName) {
|
||||
output.push(
|
||||
`This extension will append info to your QWEN.md context using ${extensionConfig.contextFileName}`,
|
||||
);
|
||||
}
|
||||
if (extensionConfig.excludeTools) {
|
||||
output.push(
|
||||
`This extension will exclude the following core tools: ${extensionConfig.excludeTools}`,
|
||||
);
|
||||
}
|
||||
return output.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests consent from the user to install an extension (extensionConfig), if
|
||||
* there is any difference between the consent string for `extensionConfig` and
|
||||
* `previousExtensionConfig`.
|
||||
*
|
||||
* Always requests consent if previousExtensionConfig is null.
|
||||
*
|
||||
* Throws if the user does not consent.
|
||||
*/
|
||||
async function maybeRequestConsentOrFail(
|
||||
extensionConfig: ExtensionConfig,
|
||||
requestConsent: (consent: string) => Promise<boolean>,
|
||||
previousExtensionConfig?: ExtensionConfig,
|
||||
) {
|
||||
const extensionConsent = extensionConsentString(extensionConfig);
|
||||
if (previousExtensionConfig) {
|
||||
const previousExtensionConsent = extensionConsentString(
|
||||
previousExtensionConfig,
|
||||
);
|
||||
if (previousExtensionConsent === extensionConsent) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!(await requestConsent(extensionConsent))) {
|
||||
throw new Error(`Installation cancelled for "${extensionConfig.name}".`);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateName(name: string) {
|
||||
if (!/^[a-zA-Z0-9-]+$/.test(name)) {
|
||||
throw new Error(
|
||||
`Invalid extension name: "${name}". Only letters (a-z, A-Z), numbers (0-9), and dashes (-) are allowed.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function loadExtensionConfig(
|
||||
context: LoadExtensionContext,
|
||||
): ExtensionConfig {
|
||||
const { extensionDir, workspaceDir } = context;
|
||||
const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
|
||||
if (!fs.existsSync(configFilePath)) {
|
||||
throw new Error(`Configuration file not found at ${configFilePath}`);
|
||||
}
|
||||
try {
|
||||
const configContent = fs.readFileSync(configFilePath, 'utf-8');
|
||||
const config = recursivelyHydrateStrings(JSON.parse(configContent), {
|
||||
extensionPath: extensionDir,
|
||||
workspacePath: workspaceDir,
|
||||
'/': path.sep,
|
||||
pathSeparator: path.sep,
|
||||
}) as unknown as ExtensionConfig;
|
||||
if (!config.name || !config.version) {
|
||||
throw new Error(
|
||||
`Invalid configuration in ${configFilePath}: missing ${!config.name ? '"name"' : '"version"'}`,
|
||||
);
|
||||
}
|
||||
validateName(config.name);
|
||||
return config;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Failed to load extension config from ${configFilePath}: ${getErrorMessage(
|
||||
e,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function uninstallExtension(
|
||||
extensionIdentifier: string,
|
||||
cwd: string = process.cwd(),
|
||||
): Promise<void> {
|
||||
const telemetryConfig = getTelemetryConfig(cwd);
|
||||
const installedExtensions = loadUserExtensions();
|
||||
const extensionName = installedExtensions.find(
|
||||
(installed) =>
|
||||
installed.config.name.toLowerCase() ===
|
||||
extensionIdentifier.toLowerCase() ||
|
||||
installed.installMetadata?.source.toLowerCase() ===
|
||||
extensionIdentifier.toLowerCase(),
|
||||
)?.config.name;
|
||||
if (!extensionName) {
|
||||
throw new Error(`Extension not found.`);
|
||||
}
|
||||
const manager = new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
[extensionName],
|
||||
);
|
||||
manager.remove(extensionName);
|
||||
const storage = new ExtensionStorage(extensionName);
|
||||
|
||||
await fs.promises.rm(storage.getExtensionDir(), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
logExtensionUninstall(
|
||||
telemetryConfig,
|
||||
new ExtensionUninstallEvent(extensionName, 'success'),
|
||||
);
|
||||
}
|
||||
|
||||
export function toOutputString(
|
||||
extension: Extension,
|
||||
workspaceDir: string,
|
||||
): string {
|
||||
const manager = new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
);
|
||||
const userEnabled = manager.isEnabled(extension.config.name, os.homedir());
|
||||
const workspaceEnabled = manager.isEnabled(
|
||||
extension.config.name,
|
||||
workspaceDir,
|
||||
);
|
||||
|
||||
const status = workspaceEnabled ? chalk.green('✓') : chalk.red('✗');
|
||||
let output = `${status} ${extension.config.name} (${extension.config.version})`;
|
||||
output += `\n Path: ${extension.path}`;
|
||||
if (extension.installMetadata) {
|
||||
output += `\n Source: ${extension.installMetadata.source} (Type: ${extension.installMetadata.type})`;
|
||||
if (extension.installMetadata.ref) {
|
||||
output += `\n Ref: ${extension.installMetadata.ref}`;
|
||||
}
|
||||
if (extension.installMetadata.releaseTag) {
|
||||
output += `\n Release tag: ${extension.installMetadata.releaseTag}`;
|
||||
}
|
||||
}
|
||||
output += `\n Enabled (User): ${userEnabled}`;
|
||||
output += `\n Enabled (Workspace): ${workspaceEnabled}`;
|
||||
if (extension.contextFiles.length > 0) {
|
||||
output += `\n Context files:`;
|
||||
extension.contextFiles.forEach((contextFile) => {
|
||||
output += `\n ${contextFile}`;
|
||||
});
|
||||
}
|
||||
if (extension.config.mcpServers) {
|
||||
output += `\n MCP servers:`;
|
||||
Object.keys(extension.config.mcpServers).forEach((key) => {
|
||||
output += `\n ${key}`;
|
||||
});
|
||||
}
|
||||
if (extension.config.excludeTools) {
|
||||
output += `\n Excluded tools:`;
|
||||
extension.config.excludeTools.forEach((tool) => {
|
||||
output += `\n ${tool}`;
|
||||
});
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export function disableExtension(
|
||||
name: string,
|
||||
scope: SettingScope,
|
||||
cwd: string = process.cwd(),
|
||||
) {
|
||||
const config = getTelemetryConfig(cwd);
|
||||
if (scope === SettingScope.System || scope === SettingScope.SystemDefaults) {
|
||||
throw new Error('System and SystemDefaults scopes are not supported.');
|
||||
}
|
||||
const extension = loadExtensionByName(name, cwd);
|
||||
if (!extension) {
|
||||
throw new Error(`Extension with name ${name} does not exist.`);
|
||||
}
|
||||
|
||||
const manager = new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
[name],
|
||||
);
|
||||
const scopePath = scope === SettingScope.Workspace ? cwd : os.homedir();
|
||||
manager.disable(name, true, scopePath);
|
||||
logExtensionDisable(config, new ExtensionDisableEvent(name, scope));
|
||||
}
|
||||
|
||||
export function enableExtension(
|
||||
name: string,
|
||||
scope: SettingScope,
|
||||
cwd: string = process.cwd(),
|
||||
) {
|
||||
if (scope === SettingScope.System || scope === SettingScope.SystemDefaults) {
|
||||
throw new Error('System and SystemDefaults scopes are not supported.');
|
||||
}
|
||||
const extension = loadExtensionByName(name, cwd);
|
||||
if (!extension) {
|
||||
throw new Error(`Extension with name ${name} does not exist.`);
|
||||
}
|
||||
const manager = new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
);
|
||||
const scopePath = scope === SettingScope.Workspace ? cwd : os.homedir();
|
||||
manager.enable(name, true, scopePath);
|
||||
const config = getTelemetryConfig(cwd);
|
||||
logExtensionEnable(config, new ExtensionEnableEvent(name, scope));
|
||||
}
|
||||
@@ -1,424 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { ExtensionEnablementManager, Override } from './extensionEnablement.js';
|
||||
import type { Extension } from '../extension.js';
|
||||
|
||||
// Helper to create a temporary directory for testing
|
||||
function createTestDir() {
|
||||
const dirPath = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-test-'));
|
||||
return {
|
||||
path: dirPath,
|
||||
cleanup: () => fs.rmSync(dirPath, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
let testDir: { path: string; cleanup: () => void };
|
||||
let configDir: string;
|
||||
let manager: ExtensionEnablementManager;
|
||||
|
||||
describe('ExtensionEnablementManager', () => {
|
||||
beforeEach(() => {
|
||||
testDir = createTestDir();
|
||||
configDir = path.join(testDir.path, '.gemini');
|
||||
manager = new ExtensionEnablementManager(configDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
testDir.cleanup();
|
||||
// Reset the singleton instance for test isolation
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(ExtensionEnablementManager as any).instance = undefined;
|
||||
});
|
||||
|
||||
describe('isEnabled', () => {
|
||||
it('should return true if extension is not configured', () => {
|
||||
expect(manager.isEnabled('ext-test', '/any/path')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if no overrides match', () => {
|
||||
manager.disable('ext-test', false, '/another/path');
|
||||
expect(manager.isEnabled('ext-test', '/any/path')).toBe(true);
|
||||
});
|
||||
|
||||
it('should enable a path based on an override rule', () => {
|
||||
manager.disable('ext-test', true, '/');
|
||||
manager.enable('ext-test', true, '/home/user/projects/');
|
||||
expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should disable a path based on a disable override rule', () => {
|
||||
manager.enable('ext-test', true, '/');
|
||||
manager.disable('ext-test', true, '/home/user/projects/');
|
||||
expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should respect the last matching rule (enable wins)', () => {
|
||||
manager.disable('ext-test', true, '/home/user/projects/');
|
||||
manager.enable('ext-test', false, '/home/user/projects/my-app');
|
||||
expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should respect the last matching rule (disable wins)', () => {
|
||||
manager.enable('ext-test', true, '/home/user/projects/');
|
||||
manager.disable('ext-test', false, '/home/user/projects/my-app');
|
||||
expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle', () => {
|
||||
manager.enable('ext-test', true, '/home/user/projects');
|
||||
manager.disable('ext-test', false, '/home/user/projects/my-app');
|
||||
expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
manager.isEnabled('ext-test', '/home/user/projects/something-else'),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('includeSubdirs', () => {
|
||||
it('should add a glob when enabling with includeSubdirs', () => {
|
||||
manager.enable('ext-test', true, '/path/to/dir');
|
||||
const config = manager.readConfig();
|
||||
expect(config['ext-test'].overrides).toContain('/path/to/dir/*');
|
||||
});
|
||||
|
||||
it('should not add a glob when enabling without includeSubdirs', () => {
|
||||
manager.enable('ext-test', false, '/path/to/dir');
|
||||
const config = manager.readConfig();
|
||||
expect(config['ext-test'].overrides).toContain('/path/to/dir/');
|
||||
expect(config['ext-test'].overrides).not.toContain('/path/to/dir/*');
|
||||
});
|
||||
|
||||
it('should add a glob when disabling with includeSubdirs', () => {
|
||||
manager.disable('ext-test', true, '/path/to/dir');
|
||||
const config = manager.readConfig();
|
||||
expect(config['ext-test'].overrides).toContain('!/path/to/dir/*');
|
||||
});
|
||||
|
||||
it('should remove conflicting glob rule when enabling without subdirs', () => {
|
||||
manager.enable('ext-test', true, '/path/to/dir'); // Adds /path/to/dir*
|
||||
manager.enable('ext-test', false, '/path/to/dir'); // Should remove the glob
|
||||
const config = manager.readConfig();
|
||||
expect(config['ext-test'].overrides).toContain('/path/to/dir/');
|
||||
expect(config['ext-test'].overrides).not.toContain('/path/to/dir/*');
|
||||
});
|
||||
|
||||
it('should remove conflicting non-glob rule when enabling with subdirs', () => {
|
||||
manager.enable('ext-test', false, '/path/to/dir'); // Adds /path/to/dir
|
||||
manager.enable('ext-test', true, '/path/to/dir'); // Should remove the non-glob
|
||||
const config = manager.readConfig();
|
||||
expect(config['ext-test'].overrides).toContain('/path/to/dir/*');
|
||||
expect(config['ext-test'].overrides).not.toContain('/path/to/dir/');
|
||||
});
|
||||
|
||||
it('should remove conflicting rules when disabling', () => {
|
||||
manager.enable('ext-test', true, '/path/to/dir'); // enabled with glob
|
||||
manager.disable('ext-test', false, '/path/to/dir'); // disabled without
|
||||
const config = manager.readConfig();
|
||||
expect(config['ext-test'].overrides).toContain('!/path/to/dir/');
|
||||
expect(config['ext-test'].overrides).not.toContain('/path/to/dir/*');
|
||||
});
|
||||
|
||||
it('should correctly evaluate isEnabled with subdirs', () => {
|
||||
manager.disable('ext-test', true, '/');
|
||||
manager.enable('ext-test', true, '/path/to/dir');
|
||||
expect(manager.isEnabled('ext-test', '/path/to/dir/')).toBe(true);
|
||||
expect(manager.isEnabled('ext-test', '/path/to/dir/sub/')).toBe(true);
|
||||
expect(manager.isEnabled('ext-test', '/path/to/another/')).toBe(false);
|
||||
});
|
||||
|
||||
it('should correctly evaluate isEnabled without subdirs', () => {
|
||||
manager.disable('ext-test', true, '/*');
|
||||
manager.enable('ext-test', false, '/path/to/dir');
|
||||
expect(manager.isEnabled('ext-test', '/path/to/dir')).toBe(true);
|
||||
expect(manager.isEnabled('ext-test', '/path/to/dir/sub')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pruning child rules', () => {
|
||||
it('should remove child rules when enabling a parent with subdirs', () => {
|
||||
// Pre-existing rules for children
|
||||
manager.enable('ext-test', false, '/path/to/dir/subdir1');
|
||||
manager.disable('ext-test', true, '/path/to/dir/subdir2');
|
||||
manager.enable('ext-test', false, '/path/to/another/dir');
|
||||
|
||||
// Enable the parent directory
|
||||
manager.enable('ext-test', true, '/path/to/dir');
|
||||
|
||||
const config = manager.readConfig();
|
||||
const overrides = config['ext-test'].overrides;
|
||||
|
||||
// The new parent rule should be present
|
||||
expect(overrides).toContain(`/path/to/dir/*`);
|
||||
|
||||
// Child rules should be removed
|
||||
expect(overrides).not.toContain('/path/to/dir/subdir1/');
|
||||
expect(overrides).not.toContain(`!/path/to/dir/subdir2/*`);
|
||||
|
||||
// Unrelated rules should remain
|
||||
expect(overrides).toContain('/path/to/another/dir/');
|
||||
});
|
||||
|
||||
it('should remove child rules when disabling a parent with subdirs', () => {
|
||||
// Pre-existing rules for children
|
||||
manager.enable('ext-test', false, '/path/to/dir/subdir1');
|
||||
manager.disable('ext-test', true, '/path/to/dir/subdir2');
|
||||
manager.enable('ext-test', false, '/path/to/another/dir');
|
||||
|
||||
// Disable the parent directory
|
||||
manager.disable('ext-test', true, '/path/to/dir');
|
||||
|
||||
const config = manager.readConfig();
|
||||
const overrides = config['ext-test'].overrides;
|
||||
|
||||
// The new parent rule should be present
|
||||
expect(overrides).toContain(`!/path/to/dir/*`);
|
||||
|
||||
// Child rules should be removed
|
||||
expect(overrides).not.toContain('/path/to/dir/subdir1/');
|
||||
expect(overrides).not.toContain(`!/path/to/dir/subdir2/*`);
|
||||
|
||||
// Unrelated rules should remain
|
||||
expect(overrides).toContain('/path/to/another/dir/');
|
||||
});
|
||||
|
||||
it('should not remove child rules if includeSubdirs is false', () => {
|
||||
manager.enable('ext-test', false, '/path/to/dir/subdir1');
|
||||
manager.enable('ext-test', false, '/path/to/dir'); // Not including subdirs
|
||||
|
||||
const config = manager.readConfig();
|
||||
const overrides = config['ext-test'].overrides;
|
||||
|
||||
expect(overrides).toContain('/path/to/dir/subdir1/');
|
||||
expect(overrides).toContain('/path/to/dir/');
|
||||
});
|
||||
});
|
||||
|
||||
it('should enable a path based on an enable override', () => {
|
||||
manager.disable('ext-test', true, '/Users/chrstn');
|
||||
manager.enable('ext-test', true, '/Users/chrstn/gemini-cli');
|
||||
|
||||
expect(manager.isEnabled('ext-test', '/Users/chrstn/gemini-cli')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should ignore subdirs', () => {
|
||||
manager.disable('ext-test', false, '/Users/chrstn');
|
||||
expect(manager.isEnabled('ext-test', '/Users/chrstn/gemini-cli')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
describe('extension overrides (-e <name>)', () => {
|
||||
beforeEach(() => {
|
||||
manager = new ExtensionEnablementManager(configDir, ['ext-test']);
|
||||
});
|
||||
|
||||
it('can enable extensions, case-insensitive', () => {
|
||||
manager.disable('ext-test', true, '/');
|
||||
expect(manager.isEnabled('ext-test', '/')).toBe(true);
|
||||
expect(manager.isEnabled('Ext-Test', '/')).toBe(true);
|
||||
// Double check that it would have been disabled otherwise
|
||||
expect(
|
||||
new ExtensionEnablementManager(configDir).isEnabled('ext-test', '/'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('disable all other extensions', () => {
|
||||
manager = new ExtensionEnablementManager(configDir, ['ext-test']);
|
||||
manager.enable('ext-test-2', true, '/');
|
||||
expect(manager.isEnabled('ext-test-2', '/')).toBe(false);
|
||||
// Double check that it would have been enabled otherwise
|
||||
expect(
|
||||
new ExtensionEnablementManager(configDir).isEnabled('ext-test-2', '/'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('none disables all extensions', () => {
|
||||
manager = new ExtensionEnablementManager(configDir, ['none']);
|
||||
manager.enable('ext-test', true, '/');
|
||||
expect(manager.isEnabled('ext-test', '/path/to/dir')).toBe(false);
|
||||
// Double check that it would have been enabled otherwise
|
||||
expect(
|
||||
new ExtensionEnablementManager(configDir).isEnabled('ext-test', '/'),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateExtensionOverrides', () => {
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not log an error if enabledExtensionNamesOverride is empty', () => {
|
||||
const manager = new ExtensionEnablementManager(configDir, []);
|
||||
manager.validateExtensionOverrides([]);
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not log an error if all enabledExtensionNamesOverride are valid', () => {
|
||||
const manager = new ExtensionEnablementManager(configDir, [
|
||||
'ext-one',
|
||||
'ext-two',
|
||||
]);
|
||||
const extensions = [
|
||||
{ config: { name: 'ext-one' } },
|
||||
{ config: { name: 'ext-two' } },
|
||||
] as Extension[];
|
||||
manager.validateExtensionOverrides(extensions);
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should log an error for each invalid extension name in enabledExtensionNamesOverride', () => {
|
||||
const manager = new ExtensionEnablementManager(configDir, [
|
||||
'ext-one',
|
||||
'ext-invalid',
|
||||
'ext-another-invalid',
|
||||
]);
|
||||
const extensions = [
|
||||
{ config: { name: 'ext-one' } },
|
||||
{ config: { name: 'ext-two' } },
|
||||
] as Extension[];
|
||||
manager.validateExtensionOverrides(extensions);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledTimes(2);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Extension not found: ext-invalid',
|
||||
);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Extension not found: ext-another-invalid',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not log an error if "none" is in enabledExtensionNamesOverride', () => {
|
||||
const manager = new ExtensionEnablementManager(configDir, ['none']);
|
||||
manager.validateExtensionOverrides([]);
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Override', () => {
|
||||
it('should create an override from input', () => {
|
||||
const override = Override.fromInput('/path/to/dir', true);
|
||||
expect(override.baseRule).toBe(`/path/to/dir/`);
|
||||
expect(override.isDisable).toBe(false);
|
||||
expect(override.includeSubdirs).toBe(true);
|
||||
});
|
||||
|
||||
it('should create a disable override from input', () => {
|
||||
const override = Override.fromInput('!/path/to/dir', false);
|
||||
expect(override.baseRule).toBe(`/path/to/dir/`);
|
||||
expect(override.isDisable).toBe(true);
|
||||
expect(override.includeSubdirs).toBe(false);
|
||||
});
|
||||
|
||||
it('should create an override from a file rule', () => {
|
||||
const override = Override.fromFileRule('/path/to/dir');
|
||||
expect(override.baseRule).toBe('/path/to/dir');
|
||||
expect(override.isDisable).toBe(false);
|
||||
expect(override.includeSubdirs).toBe(false);
|
||||
});
|
||||
|
||||
it('should create a disable override from a file rule', () => {
|
||||
const override = Override.fromFileRule('!/path/to/dir/');
|
||||
expect(override.isDisable).toBe(true);
|
||||
expect(override.baseRule).toBe('/path/to/dir/');
|
||||
expect(override.includeSubdirs).toBe(false);
|
||||
});
|
||||
|
||||
it('should create an override with subdirs from a file rule', () => {
|
||||
const override = Override.fromFileRule('/path/to/dir/*');
|
||||
expect(override.baseRule).toBe('/path/to/dir/');
|
||||
expect(override.isDisable).toBe(false);
|
||||
expect(override.includeSubdirs).toBe(true);
|
||||
});
|
||||
|
||||
it('should correctly identify conflicting overrides', () => {
|
||||
const override1 = Override.fromInput('/path/to/dir', true);
|
||||
const override2 = Override.fromInput('/path/to/dir', false);
|
||||
expect(override1.conflictsWith(override2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should correctly identify non-conflicting overrides', () => {
|
||||
const override1 = Override.fromInput('/path/to/dir', true);
|
||||
const override2 = Override.fromInput('/path/to/another/dir', true);
|
||||
expect(override1.conflictsWith(override2)).toBe(false);
|
||||
});
|
||||
|
||||
it('should correctly identify equal overrides', () => {
|
||||
const override1 = Override.fromInput('/path/to/dir', true);
|
||||
const override2 = Override.fromInput('/path/to/dir', true);
|
||||
expect(override1.isEqualTo(override2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should correctly identify unequal overrides', () => {
|
||||
const override1 = Override.fromInput('/path/to/dir', true);
|
||||
const override2 = Override.fromInput('!/path/to/dir', true);
|
||||
expect(override1.isEqualTo(override2)).toBe(false);
|
||||
});
|
||||
|
||||
it('should generate the correct regex', () => {
|
||||
const override = Override.fromInput('/path/to/dir', true);
|
||||
const regex = override.asRegex();
|
||||
expect(regex.test('/path/to/dir/')).toBe(true);
|
||||
expect(regex.test('/path/to/dir/subdir')).toBe(true);
|
||||
expect(regex.test('/path/to/another/dir')).toBe(false);
|
||||
});
|
||||
|
||||
it('should correctly identify child overrides', () => {
|
||||
const parent = Override.fromInput('/path/to/dir', true);
|
||||
const child = Override.fromInput('/path/to/dir/subdir', false);
|
||||
expect(child.isChildOf(parent)).toBe(true);
|
||||
});
|
||||
|
||||
it('should correctly identify child overrides with glob', () => {
|
||||
const parent = Override.fromInput('/path/to/dir/*', true);
|
||||
const child = Override.fromInput('/path/to/dir/subdir', false);
|
||||
expect(child.isChildOf(parent)).toBe(true);
|
||||
});
|
||||
|
||||
it('should correctly identify non-child overrides', () => {
|
||||
const parent = Override.fromInput('/path/to/dir', true);
|
||||
const other = Override.fromInput('/path/to/another/dir', false);
|
||||
expect(other.isChildOf(parent)).toBe(false);
|
||||
});
|
||||
|
||||
it('should generate the correct output string', () => {
|
||||
const override = Override.fromInput('/path/to/dir', true);
|
||||
expect(override.output()).toBe(`/path/to/dir/*`);
|
||||
});
|
||||
|
||||
it('should generate the correct output string for a disable override', () => {
|
||||
const override = Override.fromInput('!/path/to/dir', false);
|
||||
expect(override.output()).toBe(`!/path/to/dir/`);
|
||||
});
|
||||
|
||||
it('should disable a path based on a disable override rule', () => {
|
||||
const override = Override.fromInput('!/path/to/dir', false);
|
||||
expect(override.output()).toBe(`!/path/to/dir/`);
|
||||
});
|
||||
});
|
||||
@@ -1,239 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { type Extension } from '../extension.js';
|
||||
|
||||
export interface ExtensionEnablementConfig {
|
||||
overrides: string[];
|
||||
}
|
||||
|
||||
export interface AllExtensionsEnablementConfig {
|
||||
[extensionName: string]: ExtensionEnablementConfig;
|
||||
}
|
||||
|
||||
export class Override {
|
||||
constructor(
|
||||
public baseRule: string,
|
||||
public isDisable: boolean,
|
||||
public includeSubdirs: boolean,
|
||||
) {}
|
||||
|
||||
static fromInput(inputRule: string, includeSubdirs: boolean): Override {
|
||||
const isDisable = inputRule.startsWith('!');
|
||||
let baseRule = isDisable ? inputRule.substring(1) : inputRule;
|
||||
baseRule = ensureLeadingAndTrailingSlash(baseRule);
|
||||
return new Override(baseRule, isDisable, includeSubdirs);
|
||||
}
|
||||
|
||||
static fromFileRule(fileRule: string): Override {
|
||||
const isDisable = fileRule.startsWith('!');
|
||||
let baseRule = isDisable ? fileRule.substring(1) : fileRule;
|
||||
const includeSubdirs = baseRule.endsWith('*');
|
||||
baseRule = includeSubdirs
|
||||
? baseRule.substring(0, baseRule.length - 1)
|
||||
: baseRule;
|
||||
return new Override(baseRule, isDisable, includeSubdirs);
|
||||
}
|
||||
|
||||
conflictsWith(other: Override): boolean {
|
||||
if (this.baseRule === other.baseRule) {
|
||||
return (
|
||||
this.includeSubdirs !== other.includeSubdirs ||
|
||||
this.isDisable !== other.isDisable
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
isEqualTo(other: Override): boolean {
|
||||
return (
|
||||
this.baseRule === other.baseRule &&
|
||||
this.includeSubdirs === other.includeSubdirs &&
|
||||
this.isDisable === other.isDisable
|
||||
);
|
||||
}
|
||||
|
||||
asRegex(): RegExp {
|
||||
return globToRegex(`${this.baseRule}${this.includeSubdirs ? '*' : ''}`);
|
||||
}
|
||||
|
||||
isChildOf(parent: Override) {
|
||||
if (!parent.includeSubdirs) {
|
||||
return false;
|
||||
}
|
||||
return parent.asRegex().test(this.baseRule);
|
||||
}
|
||||
|
||||
output(): string {
|
||||
return `${this.isDisable ? '!' : ''}${this.baseRule}${this.includeSubdirs ? '*' : ''}`;
|
||||
}
|
||||
|
||||
matchesPath(path: string) {
|
||||
return this.asRegex().test(path);
|
||||
}
|
||||
}
|
||||
|
||||
const ensureLeadingAndTrailingSlash = function (dirPath: string): string {
|
||||
// Normalize separators to forward slashes for consistent matching across platforms.
|
||||
let result = dirPath.replace(/\\/g, '/');
|
||||
if (result.charAt(0) !== '/') {
|
||||
result = '/' + result;
|
||||
}
|
||||
if (result.charAt(result.length - 1) !== '/') {
|
||||
result = result + '/';
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a glob pattern to a RegExp object.
|
||||
* This is a simplified implementation that supports `*`.
|
||||
*
|
||||
* @param glob The glob pattern to convert.
|
||||
* @returns A RegExp object.
|
||||
*/
|
||||
function globToRegex(glob: string): RegExp {
|
||||
const regexString = glob
|
||||
.replace(/[.+?^${}()|[\]\\]/g, '\\$&') // Escape special regex characters
|
||||
.replace(/(\/?)\*/g, '($1.*)?'); // Convert * to optional group
|
||||
|
||||
return new RegExp(`^${regexString}$`);
|
||||
}
|
||||
|
||||
export class ExtensionEnablementManager {
|
||||
private configFilePath: string;
|
||||
private configDir: string;
|
||||
// If non-empty, this overrides all other extension configuration and enables
|
||||
// only the ones in this list.
|
||||
private enabledExtensionNamesOverride: string[];
|
||||
|
||||
constructor(configDir: string, enabledExtensionNames?: string[]) {
|
||||
this.configDir = configDir;
|
||||
this.configFilePath = path.join(configDir, 'extension-enablement.json');
|
||||
this.enabledExtensionNamesOverride =
|
||||
enabledExtensionNames?.map((name) => name.toLowerCase()) ?? [];
|
||||
}
|
||||
|
||||
validateExtensionOverrides(extensions: Extension[]) {
|
||||
for (const name of this.enabledExtensionNamesOverride) {
|
||||
if (name === 'none') continue;
|
||||
if (
|
||||
!extensions.some(
|
||||
(ext) => ext.config.name.toLowerCase() === name.toLowerCase(),
|
||||
)
|
||||
) {
|
||||
console.error(`Extension not found: ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if an extension is enabled based on its name and the current
|
||||
* path. The last matching rule in the overrides list wins.
|
||||
*
|
||||
* @param extensionName The name of the extension.
|
||||
* @param currentPath The absolute path of the current working directory.
|
||||
* @returns True if the extension is enabled, false otherwise.
|
||||
*/
|
||||
isEnabled(extensionName: string, currentPath: string): boolean {
|
||||
// If we have a single override called 'none', this disables all extensions.
|
||||
// Typically, this comes from the user passing `-e none`.
|
||||
if (
|
||||
this.enabledExtensionNamesOverride.length === 1 &&
|
||||
this.enabledExtensionNamesOverride[0] === 'none'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If we have explicit overrides, only enable those extensions.
|
||||
if (this.enabledExtensionNamesOverride.length > 0) {
|
||||
// When checking against overrides ONLY, we use a case insensitive match.
|
||||
// The override names are already lowercased in the constructor.
|
||||
return this.enabledExtensionNamesOverride.includes(
|
||||
extensionName.toLocaleLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise, we use the configuration settings
|
||||
const config = this.readConfig();
|
||||
const extensionConfig = config[extensionName];
|
||||
// Extensions are enabled by default.
|
||||
let enabled = true;
|
||||
const allOverrides = extensionConfig?.overrides ?? [];
|
||||
for (const rule of allOverrides) {
|
||||
const override = Override.fromFileRule(rule);
|
||||
if (override.matchesPath(ensureLeadingAndTrailingSlash(currentPath))) {
|
||||
enabled = !override.isDisable;
|
||||
}
|
||||
}
|
||||
return enabled;
|
||||
}
|
||||
|
||||
readConfig(): AllExtensionsEnablementConfig {
|
||||
try {
|
||||
const content = fs.readFileSync(this.configFilePath, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT'
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
console.error('Error reading extension enablement config:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
writeConfig(config: AllExtensionsEnablementConfig): void {
|
||||
fs.mkdirSync(this.configDir, { recursive: true });
|
||||
fs.writeFileSync(this.configFilePath, JSON.stringify(config, null, 2));
|
||||
}
|
||||
|
||||
enable(
|
||||
extensionName: string,
|
||||
includeSubdirs: boolean,
|
||||
scopePath: string,
|
||||
): void {
|
||||
const config = this.readConfig();
|
||||
if (!config[extensionName]) {
|
||||
config[extensionName] = { overrides: [] };
|
||||
}
|
||||
const override = Override.fromInput(scopePath, includeSubdirs);
|
||||
const overrides = config[extensionName].overrides.filter((rule) => {
|
||||
const fileOverride = Override.fromFileRule(rule);
|
||||
if (
|
||||
fileOverride.conflictsWith(override) ||
|
||||
fileOverride.isEqualTo(override)
|
||||
) {
|
||||
return false; // Remove conflicts and equivalent values.
|
||||
}
|
||||
return !fileOverride.isChildOf(override);
|
||||
});
|
||||
overrides.push(override.output());
|
||||
config[extensionName].overrides = overrides;
|
||||
this.writeConfig(config);
|
||||
}
|
||||
|
||||
disable(
|
||||
extensionName: string,
|
||||
includeSubdirs: boolean,
|
||||
scopePath: string,
|
||||
): void {
|
||||
this.enable(extensionName, includeSubdirs, `!${scopePath}`);
|
||||
}
|
||||
|
||||
remove(extensionName: string): void {
|
||||
const config = this.readConfig();
|
||||
if (config[extensionName]) {
|
||||
delete config[extensionName];
|
||||
this.writeConfig(config);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,468 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
EXTENSIONS_CONFIG_FILENAME,
|
||||
ExtensionStorage,
|
||||
INSTALL_METADATA_FILENAME,
|
||||
annotateActiveExtensions,
|
||||
loadExtension,
|
||||
} from '../extension.js';
|
||||
import { checkForAllExtensionUpdates, updateExtension } from './update.js';
|
||||
import { QWEN_DIR } from '@qwen-code/qwen-code-core';
|
||||
import { isWorkspaceTrusted } from '../trustedFolders.js';
|
||||
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
|
||||
import { createExtension } from '../../test-utils/createExtension.js';
|
||||
import { ExtensionEnablementManager } from './extensionEnablement.js';
|
||||
|
||||
const mockGit = {
|
||||
clone: vi.fn(),
|
||||
getRemotes: vi.fn(),
|
||||
fetch: vi.fn(),
|
||||
checkout: vi.fn(),
|
||||
listRemote: vi.fn(),
|
||||
revparse: vi.fn(),
|
||||
// Not a part of the actual API, but we need to use this to do the correct
|
||||
// file system interactions.
|
||||
path: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('simple-git', () => ({
|
||||
simpleGit: vi.fn((path: string) => {
|
||||
mockGit.path.mockReturnValue(path);
|
||||
return mockGit;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../extensions/github.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../extensions/github.js')>();
|
||||
return {
|
||||
...actual,
|
||||
downloadFromGitHubRelease: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Mocked GitHub release download failure')),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
const mockedOs = await importOriginal<typeof os>();
|
||||
return {
|
||||
...mockedOs,
|
||||
homedir: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../trustedFolders.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../trustedFolders.js')>();
|
||||
return {
|
||||
...actual,
|
||||
isWorkspaceTrusted: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockLogExtensionInstallEvent = vi.hoisted(() => vi.fn());
|
||||
const mockLogExtensionUninstall = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
||||
return {
|
||||
...actual,
|
||||
logExtensionInstallEvent: mockLogExtensionInstallEvent,
|
||||
logExtensionUninstall: mockLogExtensionUninstall,
|
||||
ExtensionInstallEvent: vi.fn(),
|
||||
ExtensionUninstallEvent: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('update tests', () => {
|
||||
let tempHomeDir: string;
|
||||
let tempWorkspaceDir: string;
|
||||
let userExtensionsDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempHomeDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'qwen-code-test-home-'),
|
||||
);
|
||||
tempWorkspaceDir = fs.mkdtempSync(
|
||||
path.join(tempHomeDir, 'qwen-code-test-workspace-'),
|
||||
);
|
||||
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
|
||||
userExtensionsDir = path.join(tempHomeDir, QWEN_DIR, 'extensions');
|
||||
// Clean up before each test
|
||||
fs.rmSync(userExtensionsDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(userExtensionsDir, { recursive: true });
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
});
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
|
||||
Object.values(mockGit).forEach((fn) => fn.mockReset());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempHomeDir, { recursive: true, force: true });
|
||||
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('updateExtension', () => {
|
||||
it('should update a git-installed extension', async () => {
|
||||
const gitUrl = 'https://github.com/google/gemini-extensions.git';
|
||||
const extensionName = 'qwen-extensions';
|
||||
const targetExtDir = path.join(userExtensionsDir, extensionName);
|
||||
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
|
||||
|
||||
fs.mkdirSync(targetExtDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME),
|
||||
JSON.stringify({ name: extensionName, version: '1.0.0' }),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
metadataPath,
|
||||
JSON.stringify({ source: gitUrl, type: 'git' }),
|
||||
);
|
||||
|
||||
mockGit.clone.mockImplementation(async (_, destination) => {
|
||||
fs.mkdirSync(path.join(mockGit.path(), destination), {
|
||||
recursive: true,
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME),
|
||||
JSON.stringify({ name: extensionName, version: '1.1.0' }),
|
||||
);
|
||||
});
|
||||
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
|
||||
const extension = annotateActiveExtensions(
|
||||
[
|
||||
loadExtension({
|
||||
extensionDir: targetExtDir,
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
})!,
|
||||
],
|
||||
process.cwd(),
|
||||
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
|
||||
)[0];
|
||||
const updateInfo = await updateExtension(
|
||||
extension,
|
||||
tempHomeDir,
|
||||
async (_) => true,
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
() => {},
|
||||
);
|
||||
|
||||
expect(updateInfo).toEqual({
|
||||
name: 'qwen-extensions',
|
||||
originalVersion: '1.0.0',
|
||||
updatedVersion: '1.1.0',
|
||||
});
|
||||
|
||||
const updatedConfig = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME),
|
||||
'utf-8',
|
||||
),
|
||||
);
|
||||
expect(updatedConfig.version).toBe('1.1.0');
|
||||
});
|
||||
|
||||
it('should call setExtensionUpdateState with UPDATING and then UPDATED_NEEDS_RESTART on success', async () => {
|
||||
const extensionName = 'test-extension';
|
||||
const extensionDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: extensionName,
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
source: 'https://some.git/repo',
|
||||
type: 'git',
|
||||
},
|
||||
});
|
||||
|
||||
mockGit.clone.mockImplementation(async (_, destination) => {
|
||||
fs.mkdirSync(path.join(mockGit.path(), destination), {
|
||||
recursive: true,
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME),
|
||||
JSON.stringify({ name: extensionName, version: '1.1.0' }),
|
||||
);
|
||||
});
|
||||
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
|
||||
|
||||
const dispatch = vi.fn();
|
||||
const extension = annotateActiveExtensions(
|
||||
[
|
||||
loadExtension({
|
||||
extensionDir,
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
})!,
|
||||
],
|
||||
process.cwd(),
|
||||
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
|
||||
)[0];
|
||||
await updateExtension(
|
||||
extension,
|
||||
tempHomeDir,
|
||||
async (_) => true,
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
dispatch,
|
||||
);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
name: extensionName,
|
||||
state: ExtensionUpdateState.UPDATING,
|
||||
},
|
||||
});
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
name: extensionName,
|
||||
state: ExtensionUpdateState.UPDATED_NEEDS_RESTART,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should call setExtensionUpdateState with ERROR on failure', async () => {
|
||||
const extensionName = 'test-extension';
|
||||
const extensionDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: extensionName,
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
source: 'https://some.git/repo',
|
||||
type: 'git',
|
||||
},
|
||||
});
|
||||
|
||||
mockGit.clone.mockRejectedValue(new Error('Git clone failed'));
|
||||
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
|
||||
|
||||
const dispatch = vi.fn();
|
||||
const extension = annotateActiveExtensions(
|
||||
[
|
||||
loadExtension({
|
||||
extensionDir,
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
})!,
|
||||
],
|
||||
process.cwd(),
|
||||
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
|
||||
)[0];
|
||||
await expect(
|
||||
updateExtension(
|
||||
extension,
|
||||
tempHomeDir,
|
||||
async (_) => true,
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
dispatch,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
name: extensionName,
|
||||
state: ExtensionUpdateState.UPDATING,
|
||||
},
|
||||
});
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
name: extensionName,
|
||||
state: ExtensionUpdateState.ERROR,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkForAllExtensionUpdates', () => {
|
||||
it('should return UpdateAvailable for a git extension with updates', async () => {
|
||||
const extensionDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
source: 'https://some.git/repo',
|
||||
type: 'git',
|
||||
},
|
||||
});
|
||||
const extension = annotateActiveExtensions(
|
||||
[
|
||||
loadExtension({
|
||||
extensionDir,
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
})!,
|
||||
],
|
||||
process.cwd(),
|
||||
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
|
||||
)[0];
|
||||
|
||||
mockGit.getRemotes.mockResolvedValue([
|
||||
{ name: 'origin', refs: { fetch: 'https://some.git/repo' } },
|
||||
]);
|
||||
mockGit.listRemote.mockResolvedValue('remoteHash HEAD');
|
||||
mockGit.revparse.mockResolvedValue('localHash');
|
||||
|
||||
const dispatch = vi.fn();
|
||||
await checkForAllExtensionUpdates([extension], dispatch);
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
name: 'test-extension',
|
||||
state: ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return UpToDate for a git extension with no updates', async () => {
|
||||
const extensionDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
source: 'https://some.git/repo',
|
||||
type: 'git',
|
||||
},
|
||||
});
|
||||
const extension = annotateActiveExtensions(
|
||||
[
|
||||
loadExtension({
|
||||
extensionDir,
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
})!,
|
||||
],
|
||||
process.cwd(),
|
||||
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
|
||||
)[0];
|
||||
|
||||
mockGit.getRemotes.mockResolvedValue([
|
||||
{ name: 'origin', refs: { fetch: 'https://some.git/repo' } },
|
||||
]);
|
||||
mockGit.listRemote.mockResolvedValue('sameHash HEAD');
|
||||
mockGit.revparse.mockResolvedValue('sameHash');
|
||||
|
||||
const dispatch = vi.fn();
|
||||
await checkForAllExtensionUpdates([extension], dispatch);
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
name: 'test-extension',
|
||||
state: ExtensionUpdateState.UP_TO_DATE,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return UpToDate for a local extension with no updates', async () => {
|
||||
const localExtensionSourcePath = path.join(tempHomeDir, 'local-source');
|
||||
const sourceExtensionDir = createExtension({
|
||||
extensionsDir: localExtensionSourcePath,
|
||||
name: 'my-local-ext',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
const installedExtensionDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'local-extension',
|
||||
version: '1.0.0',
|
||||
installMetadata: { source: sourceExtensionDir, type: 'local' },
|
||||
});
|
||||
const extension = annotateActiveExtensions(
|
||||
[
|
||||
loadExtension({
|
||||
extensionDir: installedExtensionDir,
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
})!,
|
||||
],
|
||||
process.cwd(),
|
||||
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
|
||||
)[0];
|
||||
const dispatch = vi.fn();
|
||||
await checkForAllExtensionUpdates([extension], dispatch);
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
name: 'local-extension',
|
||||
state: ExtensionUpdateState.UP_TO_DATE,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return UpdateAvailable for a local extension with updates', async () => {
|
||||
const localExtensionSourcePath = path.join(tempHomeDir, 'local-source');
|
||||
const sourceExtensionDir = createExtension({
|
||||
extensionsDir: localExtensionSourcePath,
|
||||
name: 'my-local-ext',
|
||||
version: '1.1.0',
|
||||
});
|
||||
|
||||
const installedExtensionDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'local-extension',
|
||||
version: '1.0.0',
|
||||
installMetadata: { source: sourceExtensionDir, type: 'local' },
|
||||
});
|
||||
const extension = annotateActiveExtensions(
|
||||
[
|
||||
loadExtension({
|
||||
extensionDir: installedExtensionDir,
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
})!,
|
||||
],
|
||||
process.cwd(),
|
||||
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
|
||||
)[0];
|
||||
const dispatch = vi.fn();
|
||||
await checkForAllExtensionUpdates([extension], dispatch);
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
name: 'local-extension',
|
||||
state: ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return Error when git check fails', async () => {
|
||||
const extensionDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'error-extension',
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
source: 'https://some.git/repo',
|
||||
type: 'git',
|
||||
},
|
||||
});
|
||||
const extension = annotateActiveExtensions(
|
||||
[
|
||||
loadExtension({
|
||||
extensionDir,
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
})!,
|
||||
],
|
||||
process.cwd(),
|
||||
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
|
||||
)[0];
|
||||
|
||||
mockGit.getRemotes.mockRejectedValue(new Error('Git error'));
|
||||
|
||||
const dispatch = vi.fn();
|
||||
await checkForAllExtensionUpdates([extension], dispatch);
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
name: 'error-extension',
|
||||
state: ExtensionUpdateState.ERROR,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,182 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
type ExtensionUpdateAction,
|
||||
ExtensionUpdateState,
|
||||
type ExtensionUpdateStatus,
|
||||
} from '../../ui/state/extensions.js';
|
||||
import {
|
||||
copyExtension,
|
||||
installExtension,
|
||||
uninstallExtension,
|
||||
loadExtension,
|
||||
loadInstallMetadata,
|
||||
ExtensionStorage,
|
||||
loadExtensionConfig,
|
||||
} from '../extension.js';
|
||||
import { checkForExtensionUpdate } from './github.js';
|
||||
import type { GeminiCLIExtension } from '@qwen-code/qwen-code-core';
|
||||
import * as fs from 'node:fs';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
|
||||
export interface ExtensionUpdateInfo {
|
||||
name: string;
|
||||
originalVersion: string;
|
||||
updatedVersion: string;
|
||||
}
|
||||
|
||||
export async function updateExtension(
|
||||
extension: GeminiCLIExtension,
|
||||
cwd: string = process.cwd(),
|
||||
requestConsent: (consent: string) => Promise<boolean>,
|
||||
currentState: ExtensionUpdateState,
|
||||
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void,
|
||||
): Promise<ExtensionUpdateInfo | undefined> {
|
||||
if (currentState === ExtensionUpdateState.UPDATING) {
|
||||
return undefined;
|
||||
}
|
||||
dispatchExtensionStateUpdate({
|
||||
type: 'SET_STATE',
|
||||
payload: { name: extension.name, state: ExtensionUpdateState.UPDATING },
|
||||
});
|
||||
const installMetadata = loadInstallMetadata(extension.path);
|
||||
|
||||
if (!installMetadata?.type) {
|
||||
dispatchExtensionStateUpdate({
|
||||
type: 'SET_STATE',
|
||||
payload: { name: extension.name, state: ExtensionUpdateState.ERROR },
|
||||
});
|
||||
throw new Error(
|
||||
`Extension ${extension.name} cannot be updated, type is unknown.`,
|
||||
);
|
||||
}
|
||||
if (installMetadata?.type === 'link') {
|
||||
dispatchExtensionStateUpdate({
|
||||
type: 'SET_STATE',
|
||||
payload: { name: extension.name, state: ExtensionUpdateState.UP_TO_DATE },
|
||||
});
|
||||
throw new Error(`Extension is linked so does not need to be updated`);
|
||||
}
|
||||
const originalVersion = extension.version;
|
||||
|
||||
const tempDir = await ExtensionStorage.createTmpDir();
|
||||
try {
|
||||
await copyExtension(extension.path, tempDir);
|
||||
const previousExtensionConfig = await loadExtensionConfig({
|
||||
extensionDir: extension.path,
|
||||
workspaceDir: cwd,
|
||||
});
|
||||
await uninstallExtension(extension.name, cwd);
|
||||
await installExtension(
|
||||
installMetadata,
|
||||
requestConsent,
|
||||
cwd,
|
||||
previousExtensionConfig,
|
||||
);
|
||||
|
||||
const updatedExtensionStorage = new ExtensionStorage(extension.name);
|
||||
const updatedExtension = loadExtension({
|
||||
extensionDir: updatedExtensionStorage.getExtensionDir(),
|
||||
workspaceDir: cwd,
|
||||
});
|
||||
if (!updatedExtension) {
|
||||
dispatchExtensionStateUpdate({
|
||||
type: 'SET_STATE',
|
||||
payload: { name: extension.name, state: ExtensionUpdateState.ERROR },
|
||||
});
|
||||
throw new Error('Updated extension not found after installation.');
|
||||
}
|
||||
const updatedVersion = updatedExtension.config.version;
|
||||
dispatchExtensionStateUpdate({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
name: extension.name,
|
||||
state: ExtensionUpdateState.UPDATED_NEEDS_RESTART,
|
||||
},
|
||||
});
|
||||
return {
|
||||
name: extension.name,
|
||||
originalVersion,
|
||||
updatedVersion,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Error updating extension, rolling back. ${getErrorMessage(e)}`,
|
||||
);
|
||||
dispatchExtensionStateUpdate({
|
||||
type: 'SET_STATE',
|
||||
payload: { name: extension.name, state: ExtensionUpdateState.ERROR },
|
||||
});
|
||||
await copyExtension(tempDir, extension.path);
|
||||
throw e;
|
||||
} finally {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAllUpdatableExtensions(
|
||||
cwd: string = process.cwd(),
|
||||
requestConsent: (consent: string) => Promise<boolean>,
|
||||
extensions: GeminiCLIExtension[],
|
||||
extensionsState: Map<string, ExtensionUpdateStatus>,
|
||||
dispatch: (action: ExtensionUpdateAction) => void,
|
||||
): Promise<ExtensionUpdateInfo[]> {
|
||||
return (
|
||||
await Promise.all(
|
||||
extensions
|
||||
.filter(
|
||||
(extension) =>
|
||||
extensionsState.get(extension.name)?.status ===
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
)
|
||||
.map((extension) =>
|
||||
updateExtension(
|
||||
extension,
|
||||
cwd,
|
||||
requestConsent,
|
||||
extensionsState.get(extension.name)!.status,
|
||||
dispatch,
|
||||
),
|
||||
),
|
||||
)
|
||||
).filter((updateInfo) => !!updateInfo);
|
||||
}
|
||||
|
||||
export interface ExtensionUpdateCheckResult {
|
||||
state: ExtensionUpdateState;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function checkForAllExtensionUpdates(
|
||||
extensions: GeminiCLIExtension[],
|
||||
dispatch: (action: ExtensionUpdateAction) => void,
|
||||
): Promise<void> {
|
||||
dispatch({ type: 'BATCH_CHECK_START' });
|
||||
const promises: Array<Promise<void>> = [];
|
||||
for (const extension of extensions) {
|
||||
if (!extension.installMetadata) {
|
||||
dispatch({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
name: extension.name,
|
||||
state: ExtensionUpdateState.NOT_UPDATABLE,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
promises.push(
|
||||
checkForExtensionUpdate(extension, (updatedState) => {
|
||||
dispatch({
|
||||
type: 'SET_STATE',
|
||||
payload: { name: extension.name, state: updatedState },
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
await Promise.all(promises);
|
||||
dispatch({ type: 'BATCH_CHECK_END' });
|
||||
}
|
||||
@@ -122,9 +122,10 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
||||
|
||||
// Auto-completion
|
||||
[Command.ACCEPT_SUGGESTION]: [{ key: 'tab' }, { key: 'return', ctrl: false }],
|
||||
// Completion navigation (arrow or Ctrl+P/N)
|
||||
[Command.COMPLETION_UP]: [{ key: 'up' }, { key: 'p', ctrl: true }],
|
||||
[Command.COMPLETION_DOWN]: [{ key: 'down' }, { key: 'n', ctrl: true }],
|
||||
// Completion navigation uses only arrow keys
|
||||
// Ctrl+P/N are reserved for history navigation (HISTORY_UP/DOWN)
|
||||
[Command.COMPLETION_UP]: [{ key: 'up' }],
|
||||
[Command.COMPLETION_DOWN]: [{ key: 'down' }],
|
||||
|
||||
// Text input
|
||||
// Must also exclude shift to allow shift+enter for newline
|
||||
|
||||
87
packages/cli/src/config/modelProvidersScope.test.ts
Normal file
87
packages/cli/src/config/modelProvidersScope.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SettingScope } from './settings.js';
|
||||
import { getPersistScopeForModelSelection } from './modelProvidersScope.js';
|
||||
|
||||
function makeSettings({
|
||||
isTrusted,
|
||||
userModelProviders,
|
||||
workspaceModelProviders,
|
||||
}: {
|
||||
isTrusted: boolean;
|
||||
userModelProviders?: unknown;
|
||||
workspaceModelProviders?: unknown;
|
||||
}) {
|
||||
const userSettings: Record<string, unknown> = {};
|
||||
const workspaceSettings: Record<string, unknown> = {};
|
||||
|
||||
// When undefined, treat as "not present in this scope" (the key is omitted),
|
||||
// matching how LoadedSettings is shaped when a settings file doesn't define it.
|
||||
if (userModelProviders !== undefined) {
|
||||
userSettings['modelProviders'] = userModelProviders;
|
||||
}
|
||||
if (workspaceModelProviders !== undefined) {
|
||||
workspaceSettings['modelProviders'] = workspaceModelProviders;
|
||||
}
|
||||
|
||||
return {
|
||||
isTrusted,
|
||||
user: { settings: userSettings },
|
||||
workspace: { settings: workspaceSettings },
|
||||
} as unknown as import('./settings.js').LoadedSettings;
|
||||
}
|
||||
|
||||
describe('getPersistScopeForModelSelection', () => {
|
||||
it('prefers workspace when trusted and workspace defines modelProviders', () => {
|
||||
const settings = makeSettings({
|
||||
isTrusted: true,
|
||||
workspaceModelProviders: {},
|
||||
userModelProviders: { anything: true },
|
||||
});
|
||||
|
||||
expect(getPersistScopeForModelSelection(settings)).toBe(
|
||||
SettingScope.Workspace,
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to user when workspace does not define modelProviders', () => {
|
||||
const settings = makeSettings({
|
||||
isTrusted: true,
|
||||
workspaceModelProviders: undefined,
|
||||
userModelProviders: {},
|
||||
});
|
||||
|
||||
expect(getPersistScopeForModelSelection(settings)).toBe(SettingScope.User);
|
||||
});
|
||||
|
||||
it('ignores workspace modelProviders when workspace is untrusted', () => {
|
||||
const settings = makeSettings({
|
||||
isTrusted: false,
|
||||
workspaceModelProviders: {},
|
||||
userModelProviders: undefined,
|
||||
});
|
||||
|
||||
expect(getPersistScopeForModelSelection(settings)).toBe(SettingScope.User);
|
||||
});
|
||||
|
||||
it('falls back to legacy trust heuristic when neither scope defines modelProviders', () => {
|
||||
const trusted = makeSettings({
|
||||
isTrusted: true,
|
||||
userModelProviders: undefined,
|
||||
workspaceModelProviders: undefined,
|
||||
});
|
||||
expect(getPersistScopeForModelSelection(trusted)).toBe(SettingScope.User);
|
||||
|
||||
const untrusted = makeSettings({
|
||||
isTrusted: false,
|
||||
userModelProviders: undefined,
|
||||
workspaceModelProviders: undefined,
|
||||
});
|
||||
expect(getPersistScopeForModelSelection(untrusted)).toBe(SettingScope.User);
|
||||
});
|
||||
});
|
||||
48
packages/cli/src/config/modelProvidersScope.ts
Normal file
48
packages/cli/src/config/modelProvidersScope.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { SettingScope, type LoadedSettings } from './settings.js';
|
||||
|
||||
function hasOwnModelProviders(settingsObj: unknown): boolean {
|
||||
if (!settingsObj || typeof settingsObj !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const obj = settingsObj as Record<string, unknown>;
|
||||
// Treat an explicitly configured empty object (modelProviders: {}) as "owned"
|
||||
// by this scope, which is important when mergeStrategy is REPLACE.
|
||||
return Object.prototype.hasOwnProperty.call(obj, 'modelProviders');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns which writable scope (Workspace/User) owns the effective modelProviders
|
||||
* configuration.
|
||||
*
|
||||
* Note: Workspace scope is only considered when the workspace is trusted.
|
||||
*/
|
||||
export function getModelProvidersOwnerScope(
|
||||
settings: LoadedSettings,
|
||||
): SettingScope | undefined {
|
||||
if (settings.isTrusted && hasOwnModelProviders(settings.workspace.settings)) {
|
||||
return SettingScope.Workspace;
|
||||
}
|
||||
|
||||
if (hasOwnModelProviders(settings.user.settings)) {
|
||||
return SettingScope.User;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the settings scope to persist a model selection.
|
||||
* Prefer persisting back to the scope that contains the effective modelProviders
|
||||
* config, otherwise fall back to the legacy trust-based heuristic.
|
||||
*/
|
||||
export function getPersistScopeForModelSelection(
|
||||
settings: LoadedSettings,
|
||||
): SettingScope {
|
||||
return getModelProvidersOwnerScope(settings) ?? SettingScope.User;
|
||||
}
|
||||
@@ -51,10 +51,10 @@ import {
|
||||
import * as fs from 'node:fs'; // fs will be mocked separately
|
||||
import stripJsonComments from 'strip-json-comments'; // Will be mocked separately
|
||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||
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,
|
||||
@@ -64,8 +64,6 @@ import {
|
||||
needsMigration,
|
||||
type Settings,
|
||||
loadEnvironment,
|
||||
migrateDeprecatedSettings,
|
||||
SettingScope,
|
||||
SETTINGS_VERSION,
|
||||
SETTINGS_VERSION_KEY,
|
||||
} from './settings.js';
|
||||
@@ -418,6 +416,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,
|
||||
@@ -2179,7 +2257,7 @@ describe('Settings Loading and Merging', () => {
|
||||
disableAutoUpdate: true,
|
||||
},
|
||||
ui: {
|
||||
hideBanner: true,
|
||||
hideTips: true,
|
||||
customThemes: {
|
||||
myTheme: {},
|
||||
},
|
||||
@@ -2202,7 +2280,7 @@ describe('Settings Loading and Merging', () => {
|
||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
||||
expect(v1Settings).toEqual({
|
||||
disableAutoUpdate: true,
|
||||
hideBanner: true,
|
||||
hideTips: true,
|
||||
customThemes: {
|
||||
myTheme: {},
|
||||
},
|
||||
@@ -2649,122 +2727,4 @@ describe('Settings Loading and Merging', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('migrateDeprecatedSettings', () => {
|
||||
let mockFsExistsSync: Mocked<typeof fs.existsSync>;
|
||||
let mockFsReadFileSync: Mocked<typeof fs.readFileSync>;
|
||||
let mockDisableExtension: Mocked<typeof disableExtension>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
mockFsExistsSync = vi.mocked(fs.existsSync);
|
||||
mockFsReadFileSync = vi.mocked(fs.readFileSync);
|
||||
mockDisableExtension = vi.mocked(disableExtension);
|
||||
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should migrate disabled extensions from user and workspace settings', () => {
|
||||
const userSettingsContent = {
|
||||
extensions: {
|
||||
disabled: ['user-ext-1', 'shared-ext'],
|
||||
},
|
||||
};
|
||||
const workspaceSettingsContent = {
|
||||
extensions: {
|
||||
disabled: ['workspace-ext-1', 'shared-ext'],
|
||||
},
|
||||
};
|
||||
|
||||
(mockFsReadFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
const setValueSpy = vi.spyOn(loadedSettings, 'setValue');
|
||||
|
||||
migrateDeprecatedSettings(loadedSettings, MOCK_WORKSPACE_DIR);
|
||||
|
||||
// Check user settings migration
|
||||
expect(mockDisableExtension).toHaveBeenCalledWith(
|
||||
'user-ext-1',
|
||||
SettingScope.User,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
expect(mockDisableExtension).toHaveBeenCalledWith(
|
||||
'shared-ext',
|
||||
SettingScope.User,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
|
||||
// Check workspace settings migration
|
||||
expect(mockDisableExtension).toHaveBeenCalledWith(
|
||||
'workspace-ext-1',
|
||||
SettingScope.Workspace,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
expect(mockDisableExtension).toHaveBeenCalledWith(
|
||||
'shared-ext',
|
||||
SettingScope.Workspace,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
|
||||
// Check that setValue was called to remove the deprecated setting
|
||||
expect(setValueSpy).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'extensions',
|
||||
{
|
||||
disabled: undefined,
|
||||
},
|
||||
);
|
||||
expect(setValueSpy).toHaveBeenCalledWith(
|
||||
SettingScope.Workspace,
|
||||
'extensions',
|
||||
{
|
||||
disabled: undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should not do anything if there are no deprecated settings', () => {
|
||||
const userSettingsContent = {
|
||||
extensions: {
|
||||
enabled: ['user-ext-1'],
|
||||
},
|
||||
};
|
||||
const workspaceSettingsContent = {
|
||||
someOtherSetting: 'value',
|
||||
};
|
||||
|
||||
(mockFsReadFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
const setValueSpy = vi.spyOn(loadedSettings, 'setValue');
|
||||
|
||||
migrateDeprecatedSettings(loadedSettings, MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(mockDisableExtension).not.toHaveBeenCalled();
|
||||
expect(setValueSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
|
||||
import { customDeepMerge, type MergeableObject } from '../utils/deepMerge.js';
|
||||
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
|
||||
import { disableExtension } from './extension.js';
|
||||
|
||||
function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined {
|
||||
let current: SettingDefinition | undefined = undefined;
|
||||
@@ -81,7 +80,6 @@ const MIGRATION_MAP: Record<string, string> = {
|
||||
excludeTools: 'tools.exclude',
|
||||
excludeMCPServers: 'mcp.excluded',
|
||||
excludedProjectEnvVars: 'advanced.excludedEnvVars',
|
||||
extensionManagement: 'experimental.extensionManagement',
|
||||
extensions: 'extensions',
|
||||
fileFiltering: 'context.fileFiltering',
|
||||
folderTrustFeature: 'security.folderTrust.featureEnabled',
|
||||
@@ -90,13 +88,6 @@ const MIGRATION_MAP: Record<string, string> = {
|
||||
hideWindowTitle: 'ui.hideWindowTitle',
|
||||
showStatusInTitle: 'ui.showStatusInTitle',
|
||||
hideTips: 'ui.hideTips',
|
||||
hideBanner: 'ui.hideBanner',
|
||||
hideFooter: 'ui.hideFooter',
|
||||
hideCWD: 'ui.footer.hideCWD',
|
||||
hideSandboxStatus: 'ui.footer.hideSandboxStatus',
|
||||
hideModelInfo: 'ui.footer.hideModelInfo',
|
||||
hideContextSummary: 'ui.hideContextSummary',
|
||||
showMemoryUsage: 'ui.showMemoryUsage',
|
||||
showLineNumbers: 'ui.showLineNumbers',
|
||||
showCitations: 'ui.showCitations',
|
||||
ideMode: 'ide.enabled',
|
||||
@@ -106,7 +97,6 @@ const MIGRATION_MAP: Record<string, string> = {
|
||||
mcpServers: 'mcpServers',
|
||||
mcpServerCommand: 'mcp.serverCommand',
|
||||
memoryImportFormat: 'context.importFormat',
|
||||
memoryDiscoveryMaxDirs: 'context.discoveryMaxDirs',
|
||||
model: 'model.name',
|
||||
preferredEditor: 'general.preferredEditor',
|
||||
sandbox: 'tools.sandbox',
|
||||
@@ -344,6 +334,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> {
|
||||
@@ -814,22 +895,22 @@ export function loadSettings(
|
||||
|
||||
export function migrateDeprecatedSettings(
|
||||
loadedSettings: LoadedSettings,
|
||||
workspaceDir: string = process.cwd(),
|
||||
): void {
|
||||
const processScope = (scope: SettingScope) => {
|
||||
const settings = loadedSettings.forScope(scope).settings;
|
||||
if (settings.extensions?.disabled) {
|
||||
console.log(
|
||||
`Migrating deprecated extensions.disabled settings from ${scope} settings...`,
|
||||
);
|
||||
for (const extension of settings.extensions.disabled ?? []) {
|
||||
disableExtension(extension, scope, workspaceDir);
|
||||
const legacySkills = (
|
||||
settings as Settings & {
|
||||
tools?: { experimental?: { skills?: boolean } };
|
||||
}
|
||||
|
||||
const newExtensionsValue = { ...settings.extensions };
|
||||
newExtensionsValue.disabled = undefined;
|
||||
|
||||
loadedSettings.setValue(scope, 'extensions', newExtensionsValue);
|
||||
).tools?.experimental?.skills;
|
||||
if (
|
||||
legacySkills !== undefined &&
|
||||
settings.experimental?.skills === undefined
|
||||
) {
|
||||
console.log(
|
||||
`Migrating deprecated tools.experimental.skills setting from ${scope} settings...`,
|
||||
);
|
||||
loadedSettings.setValue(scope, 'experimental.skills', legacySkills);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -157,9 +157,6 @@ describe('SettingsSchema', () => {
|
||||
|
||||
it('should have showInDialog property configured', () => {
|
||||
// Check that user-facing settings are marked for dialog display
|
||||
expect(
|
||||
getSettingsSchema().ui.properties.showMemoryUsage.showInDialog,
|
||||
).toBe(true);
|
||||
expect(getSettingsSchema().general.properties.vimMode.showInDialog).toBe(
|
||||
true,
|
||||
);
|
||||
@@ -171,17 +168,14 @@ describe('SettingsSchema', () => {
|
||||
).toBe(true);
|
||||
expect(
|
||||
getSettingsSchema().ui.properties.hideWindowTitle.showInDialog,
|
||||
).toBe(true);
|
||||
).toBe(false);
|
||||
expect(getSettingsSchema().ui.properties.hideTips.showInDialog).toBe(
|
||||
true,
|
||||
);
|
||||
expect(getSettingsSchema().ui.properties.hideBanner.showInDialog).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
getSettingsSchema().privacy.properties.usageStatisticsEnabled
|
||||
.showInDialog,
|
||||
).toBe(false);
|
||||
).toBe(true);
|
||||
|
||||
// Check that advanced settings are hidden from dialog
|
||||
expect(getSettingsSchema().security.properties.auth.showInDialog).toBe(
|
||||
@@ -194,7 +188,7 @@ describe('SettingsSchema', () => {
|
||||
expect(getSettingsSchema().telemetry.showInDialog).toBe(false);
|
||||
|
||||
// Check that some settings are appropriately hidden
|
||||
expect(getSettingsSchema().ui.properties.theme.showInDialog).toBe(false); // Changed to false
|
||||
expect(getSettingsSchema().ui.properties.theme.showInDialog).toBe(true);
|
||||
expect(getSettingsSchema().ui.properties.customThemes.showInDialog).toBe(
|
||||
false,
|
||||
); // Managed via theme editor
|
||||
@@ -203,13 +197,13 @@ describe('SettingsSchema', () => {
|
||||
).toBe(false); // Experimental feature
|
||||
expect(getSettingsSchema().ui.properties.accessibility.showInDialog).toBe(
|
||||
false,
|
||||
); // Changed to false
|
||||
);
|
||||
expect(
|
||||
getSettingsSchema().context.properties.fileFiltering.showInDialog,
|
||||
).toBe(false); // Changed to false
|
||||
).toBe(false);
|
||||
expect(
|
||||
getSettingsSchema().general.properties.preferredEditor.showInDialog,
|
||||
).toBe(false); // Changed to false
|
||||
).toBe(true);
|
||||
expect(
|
||||
getSettingsSchema().advanced.properties.autoConfigureMemory
|
||||
.showInDialog,
|
||||
@@ -287,7 +281,7 @@ describe('SettingsSchema', () => {
|
||||
expect(
|
||||
getSettingsSchema().security.properties.folderTrust.properties.enabled
|
||||
.showInDialog,
|
||||
).toBe(true);
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should have debugKeystrokeLogging setting in schema', () => {
|
||||
@@ -310,7 +304,7 @@ describe('SettingsSchema', () => {
|
||||
expect(
|
||||
getSettingsSchema().general.properties.debugKeystrokeLogging
|
||||
.showInDialog,
|
||||
).toBe(true);
|
||||
).toBe(false);
|
||||
expect(
|
||||
getSettingsSchema().general.properties.debugKeystrokeLogging
|
||||
.description,
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
TelemetrySettings,
|
||||
AuthType,
|
||||
ChatCompressionSettings,
|
||||
ModelProvidersConfig,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
ApprovalMode,
|
||||
@@ -102,6 +103,19 @@ const SETTINGS_SCHEMA = {
|
||||
mergeStrategy: MergeStrategy.SHALLOW_MERGE,
|
||||
},
|
||||
|
||||
// Model providers configuration grouped by authType
|
||||
modelProviders: {
|
||||
type: 'object',
|
||||
label: 'Model Providers',
|
||||
category: 'Model',
|
||||
requiresRestart: false,
|
||||
default: {} as ModelProvidersConfig,
|
||||
description:
|
||||
'Model providers configuration grouped by authType. Each authType contains an array of model configurations.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.REPLACE,
|
||||
},
|
||||
|
||||
general: {
|
||||
type: 'object',
|
||||
label: 'General',
|
||||
@@ -118,7 +132,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: false,
|
||||
default: undefined as string | undefined,
|
||||
description: 'The preferred editor to open files in.',
|
||||
showInDialog: false,
|
||||
showInDialog: true,
|
||||
},
|
||||
vimMode: {
|
||||
type: 'boolean',
|
||||
@@ -149,13 +163,13 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
gitCoAuthor: {
|
||||
type: 'boolean',
|
||||
label: 'Git Co-Author',
|
||||
label: 'Attribution: commit',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description:
|
||||
'Automatically add a Co-authored-by trailer to git commit messages when commits are made through Qwen Code.',
|
||||
showInDialog: false,
|
||||
showInDialog: true,
|
||||
},
|
||||
checkpointing: {
|
||||
type: 'object',
|
||||
@@ -184,13 +198,13 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Enable debug logging of keystrokes to the console.',
|
||||
showInDialog: true,
|
||||
showInDialog: false,
|
||||
},
|
||||
language: {
|
||||
type: 'enum',
|
||||
label: 'Language',
|
||||
label: 'Language: UI',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
requiresRestart: true,
|
||||
default: 'auto',
|
||||
description:
|
||||
'The language for the user interface. Use "auto" to detect from system settings. ' +
|
||||
@@ -202,11 +216,23 @@ const SETTINGS_SCHEMA = {
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'zh', label: '中文 (Chinese)' },
|
||||
{ value: 'ru', label: 'Русский (Russian)' },
|
||||
{ value: 'de', label: 'Deutsch (German)' },
|
||||
],
|
||||
},
|
||||
outputLanguage: {
|
||||
type: 'string',
|
||||
label: 'Language: Model',
|
||||
category: 'General',
|
||||
requiresRestart: true,
|
||||
default: 'auto',
|
||||
description:
|
||||
'The language for LLM output. Use "auto" to detect from system settings, ' +
|
||||
'or set a specific language (e.g., "English", "中文", "日本語").',
|
||||
showInDialog: true,
|
||||
},
|
||||
terminalBell: {
|
||||
type: 'boolean',
|
||||
label: 'Terminal Bell',
|
||||
label: 'Terminal Bell Notification',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
@@ -242,7 +268,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: false,
|
||||
default: 'text',
|
||||
description: 'The format of the CLI output.',
|
||||
showInDialog: true,
|
||||
showInDialog: false,
|
||||
options: [
|
||||
{ value: 'text', label: 'Text' },
|
||||
{ value: 'json', label: 'JSON' },
|
||||
@@ -265,9 +291,9 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Theme',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: undefined as string | undefined,
|
||||
default: 'Qwen Dark' as string,
|
||||
description: 'The color theme for the UI.',
|
||||
showInDialog: false,
|
||||
showInDialog: true,
|
||||
},
|
||||
customThemes: {
|
||||
type: 'object',
|
||||
@@ -285,7 +311,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Hide the window title bar',
|
||||
showInDialog: true,
|
||||
showInDialog: false,
|
||||
},
|
||||
showStatusInTitle: {
|
||||
type: 'boolean',
|
||||
@@ -295,7 +321,7 @@ const SETTINGS_SCHEMA = {
|
||||
default: false,
|
||||
description:
|
||||
'Show Qwen Code status and thoughts in the terminal window title',
|
||||
showInDialog: true,
|
||||
showInDialog: false,
|
||||
},
|
||||
hideTips: {
|
||||
type: 'boolean',
|
||||
@@ -306,89 +332,13 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Hide helpful tips in the UI',
|
||||
showInDialog: true,
|
||||
},
|
||||
hideBanner: {
|
||||
type: 'boolean',
|
||||
label: 'Hide Banner',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Hide the application banner',
|
||||
showInDialog: true,
|
||||
},
|
||||
hideContextSummary: {
|
||||
type: 'boolean',
|
||||
label: 'Hide Context Summary',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Hide the context summary (QWEN.md, MCP servers) above the input.',
|
||||
showInDialog: true,
|
||||
},
|
||||
footer: {
|
||||
type: 'object',
|
||||
label: 'Footer',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: {},
|
||||
description: 'Settings for the footer.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
hideCWD: {
|
||||
type: 'boolean',
|
||||
label: 'Hide CWD',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Hide the current working directory path in the footer.',
|
||||
showInDialog: true,
|
||||
},
|
||||
hideSandboxStatus: {
|
||||
type: 'boolean',
|
||||
label: 'Hide Sandbox Status',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Hide the sandbox status indicator in the footer.',
|
||||
showInDialog: true,
|
||||
},
|
||||
hideModelInfo: {
|
||||
type: 'boolean',
|
||||
label: 'Hide Model Info',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Hide the model name and context usage in the footer.',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
hideFooter: {
|
||||
type: 'boolean',
|
||||
label: 'Hide Footer',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Hide the footer from the UI',
|
||||
showInDialog: true,
|
||||
},
|
||||
showMemoryUsage: {
|
||||
type: 'boolean',
|
||||
label: 'Show Memory Usage',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Display memory usage information in the UI',
|
||||
showInDialog: true,
|
||||
},
|
||||
showLineNumbers: {
|
||||
type: 'boolean',
|
||||
label: 'Show Line Numbers',
|
||||
label: 'Show Line Numbers in Code',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Show line numbers in the chat.',
|
||||
description: 'Show line numbers in the code output.',
|
||||
showInDialog: true,
|
||||
},
|
||||
showCitations: {
|
||||
@@ -398,7 +348,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Show citations for generated text in the chat.',
|
||||
showInDialog: true,
|
||||
showInDialog: false,
|
||||
},
|
||||
customWittyPhrases: {
|
||||
type: 'array',
|
||||
@@ -411,7 +361,7 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
enableWelcomeBack: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Welcome Back',
|
||||
label: 'Show Welcome Back Dialog',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
@@ -419,6 +369,16 @@ const SETTINGS_SCHEMA = {
|
||||
'Show welcome back dialog when returning to a project with conversation history.',
|
||||
showInDialog: true,
|
||||
},
|
||||
enableUserFeedback: {
|
||||
type: 'boolean',
|
||||
label: 'Enable User Feedback',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description:
|
||||
'Show optional feedback dialog after conversations to help improve Qwen performance.',
|
||||
showInDialog: true,
|
||||
},
|
||||
accessibility: {
|
||||
type: 'object',
|
||||
label: 'Accessibility',
|
||||
@@ -435,7 +395,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Disable loading phrases for accessibility',
|
||||
showInDialog: true,
|
||||
showInDialog: false,
|
||||
},
|
||||
screenReader: {
|
||||
type: 'boolean',
|
||||
@@ -445,10 +405,19 @@ const SETTINGS_SCHEMA = {
|
||||
default: undefined as boolean | undefined,
|
||||
description:
|
||||
'Render output in plain-text to be more screen reader accessible',
|
||||
showInDialog: true,
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
feedbackLastShownTimestamp: {
|
||||
type: 'number',
|
||||
label: 'Feedback Last Shown Timestamp',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: 0,
|
||||
description: 'The last time the feedback dialog was shown.',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -463,7 +432,7 @@ const SETTINGS_SCHEMA = {
|
||||
properties: {
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
label: 'IDE Mode',
|
||||
label: 'Auto-connect to IDE',
|
||||
category: 'IDE',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
@@ -498,7 +467,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description: 'Enable collection of usage statistics',
|
||||
showInDialog: false,
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -539,7 +508,7 @@ const SETTINGS_SCHEMA = {
|
||||
default: -1,
|
||||
description:
|
||||
'Maximum number of user/model/tool turns to keep in a session. -1 means unlimited.',
|
||||
showInDialog: true,
|
||||
showInDialog: false,
|
||||
},
|
||||
summarizeToolOutput: {
|
||||
type: 'object',
|
||||
@@ -577,7 +546,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description: 'Skip the next speaker check.',
|
||||
showInDialog: true,
|
||||
showInDialog: false,
|
||||
},
|
||||
skipLoopDetection: {
|
||||
type: 'boolean',
|
||||
@@ -586,7 +555,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Disable all loop detection checks (streaming and LLM).',
|
||||
showInDialog: true,
|
||||
showInDialog: false,
|
||||
},
|
||||
skipStartupContext: {
|
||||
type: 'boolean',
|
||||
@@ -596,7 +565,7 @@ const SETTINGS_SCHEMA = {
|
||||
default: false,
|
||||
description:
|
||||
'Avoid sending the workspace startup context at the beginning of each session.',
|
||||
showInDialog: true,
|
||||
showInDialog: false,
|
||||
},
|
||||
enableOpenAILogging: {
|
||||
type: 'boolean',
|
||||
@@ -605,7 +574,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Enable OpenAI logging.',
|
||||
showInDialog: true,
|
||||
showInDialog: false,
|
||||
},
|
||||
openAILoggingDir: {
|
||||
type: 'string',
|
||||
@@ -615,7 +584,7 @@ const SETTINGS_SCHEMA = {
|
||||
default: undefined as string | undefined,
|
||||
description:
|
||||
'Custom directory path for OpenAI API logs. If not specified, defaults to logs/openai in the current working directory.',
|
||||
showInDialog: true,
|
||||
showInDialog: false,
|
||||
},
|
||||
generationConfig: {
|
||||
type: 'object',
|
||||
@@ -635,7 +604,7 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Request timeout in milliseconds.',
|
||||
parentKey: 'generationConfig',
|
||||
childKey: 'timeout',
|
||||
showInDialog: true,
|
||||
showInDialog: false,
|
||||
},
|
||||
maxRetries: {
|
||||
type: 'number',
|
||||
@@ -646,7 +615,7 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Maximum number of retries for failed requests.',
|
||||
parentKey: 'generationConfig',
|
||||
childKey: 'maxRetries',
|
||||
showInDialog: true,
|
||||
showInDialog: false,
|
||||
},
|
||||
disableCacheControl: {
|
||||
type: 'boolean',
|
||||
@@ -657,7 +626,7 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Disable cache control for DashScope providers.',
|
||||
parentKey: 'generationConfig',
|
||||
childKey: 'disableCacheControl',
|
||||
showInDialog: true,
|
||||
showInDialog: false,
|
||||
},
|
||||
schemaCompliance: {
|
||||
type: 'enum',
|
||||
@@ -669,7 +638,7 @@ const SETTINGS_SCHEMA = {
|
||||
'The compliance mode for tool schemas sent to the model. Use "openapi_30" for strict OpenAPI 3.0 compatibility (e.g., for Gemini).',
|
||||
parentKey: 'generationConfig',
|
||||
childKey: 'schemaCompliance',
|
||||
showInDialog: true,
|
||||
showInDialog: false,
|
||||
options: [
|
||||
{ value: 'auto', label: 'Auto (Default)' },
|
||||
{ value: 'openapi_30', label: 'OpenAPI 3.0 Strict' },
|
||||
@@ -707,15 +676,6 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'The format to use when importing memory.',
|
||||
showInDialog: false,
|
||||
},
|
||||
discoveryMaxDirs: {
|
||||
type: 'number',
|
||||
label: 'Memory Discovery Max Dirs',
|
||||
category: 'Context',
|
||||
requiresRestart: false,
|
||||
default: 200,
|
||||
description: 'Maximum number of directories to search for memory.',
|
||||
showInDialog: true,
|
||||
},
|
||||
includeDirectories: {
|
||||
type: 'array',
|
||||
label: 'Include Directories',
|
||||
@@ -734,7 +694,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Whether to load memory files from include directories.',
|
||||
showInDialog: true,
|
||||
showInDialog: false,
|
||||
},
|
||||
fileFiltering: {
|
||||
type: 'object',
|
||||
@@ -770,7 +730,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description: 'Enable recursive file search functionality',
|
||||
showInDialog: true,
|
||||
showInDialog: false,
|
||||
},
|
||||
disableFuzzySearch: {
|
||||
type: 'boolean',
|
||||
@@ -779,7 +739,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Disable fuzzy search when searching for files.',
|
||||
showInDialog: true,
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -816,7 +776,7 @@ const SETTINGS_SCHEMA = {
|
||||
properties: {
|
||||
enableInteractiveShell: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Interactive Shell',
|
||||
label: 'Interactive Shell (PTY)',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
@@ -841,20 +801,10 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Show color in shell output.',
|
||||
showInDialog: true,
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
autoAccept: {
|
||||
type: 'boolean',
|
||||
label: 'Auto Accept',
|
||||
category: 'Tools',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Automatically accept and execute tool calls that are considered safe (e.g., read-only operations).',
|
||||
showInDialog: true,
|
||||
},
|
||||
core: {
|
||||
type: 'array',
|
||||
label: 'Core Tools',
|
||||
@@ -886,7 +836,7 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
approvalMode: {
|
||||
type: 'enum',
|
||||
label: 'Approval Mode',
|
||||
label: 'Tool Approval Mode',
|
||||
category: 'Tools',
|
||||
requiresRestart: false,
|
||||
default: ApprovalMode.DEFAULT,
|
||||
@@ -900,6 +850,16 @@ const SETTINGS_SCHEMA = {
|
||||
{ value: ApprovalMode.YOLO, label: 'YOLO' },
|
||||
],
|
||||
},
|
||||
autoAccept: {
|
||||
type: 'boolean',
|
||||
label: 'Auto Accept',
|
||||
category: 'Tools',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Automatically accept and execute tool calls that are considered safe (e.g., read-only operations) without explicit user confirmation.',
|
||||
showInDialog: false,
|
||||
},
|
||||
discoveryCommand: {
|
||||
type: 'string',
|
||||
label: 'Tool Discovery Command',
|
||||
@@ -926,7 +886,7 @@ const SETTINGS_SCHEMA = {
|
||||
default: true,
|
||||
description:
|
||||
'Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance.',
|
||||
showInDialog: true,
|
||||
showInDialog: false,
|
||||
},
|
||||
useBuiltinRipgrep: {
|
||||
type: 'boolean',
|
||||
@@ -936,7 +896,7 @@ const SETTINGS_SCHEMA = {
|
||||
default: true,
|
||||
description:
|
||||
'Use the bundled ripgrep binary. When set to false, the system-level "rg" command will be used instead. This setting is only effective when useRipgrep is true.',
|
||||
showInDialog: true,
|
||||
showInDialog: false,
|
||||
},
|
||||
enableToolOutputTruncation: {
|
||||
type: 'boolean',
|
||||
@@ -945,7 +905,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description: 'Enable truncation of large tool outputs.',
|
||||
showInDialog: true,
|
||||
showInDialog: false,
|
||||
},
|
||||
truncateToolOutputThreshold: {
|
||||
type: 'number',
|
||||
@@ -955,7 +915,7 @@ const SETTINGS_SCHEMA = {
|
||||
default: DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||
description:
|
||||
'Truncate tool output if it is larger than this many characters. Set to -1 to disable.',
|
||||
showInDialog: true,
|
||||
showInDialog: false,
|
||||
},
|
||||
truncateToolOutputLines: {
|
||||
type: 'number',
|
||||
@@ -964,7 +924,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: true,
|
||||
default: DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
|
||||
description: 'The number of lines to keep when truncating tool output.',
|
||||
showInDialog: true,
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1041,7 +1001,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Setting to track whether Folder trust is enabled.',
|
||||
showInDialog: true,
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1192,14 +1152,15 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Setting to enable experimental features',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
extensionManagement: {
|
||||
skills: {
|
||||
type: 'boolean',
|
||||
label: 'Extension Management',
|
||||
label: 'Experimental: Skills',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description: 'Enable extension management features.',
|
||||
showInDialog: false,
|
||||
default: false,
|
||||
description:
|
||||
'Enable experimental Agent Skills feature. When enabled, Qwen Code can use Skills from .qwen/skills/ and ~/.qwen/skills/.',
|
||||
showInDialog: true,
|
||||
},
|
||||
visionModelPreview: {
|
||||
type: 'boolean',
|
||||
@@ -1209,7 +1170,7 @@ const SETTINGS_SCHEMA = {
|
||||
default: true,
|
||||
description:
|
||||
'Enable vision model support and auto-switching functionality. When disabled, vision models like qwen-vl-max-latest will be hidden and auto-switching will not occur.',
|
||||
showInDialog: true,
|
||||
showInDialog: false,
|
||||
},
|
||||
vlmSwitchMode: {
|
||||
type: 'string',
|
||||
@@ -1223,39 +1184,6 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
extensions: {
|
||||
type: 'object',
|
||||
label: 'Extensions',
|
||||
category: 'Extensions',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description: 'Settings for extensions.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
disabled: {
|
||||
type: 'array',
|
||||
label: 'Disabled Extensions',
|
||||
category: 'Extensions',
|
||||
requiresRestart: true,
|
||||
default: [] as string[],
|
||||
description: 'List of disabled extensions.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.UNION,
|
||||
},
|
||||
workspacesWithMigrationNudge: {
|
||||
type: 'array',
|
||||
label: 'Workspaces with Migration Nudge',
|
||||
category: 'Extensions',
|
||||
requiresRestart: false,
|
||||
default: [] as string[],
|
||||
description:
|
||||
'List of workspaces for which the migration nudge has been shown.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.UNION,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const satisfies SettingsSchema;
|
||||
|
||||
export type SettingsSchemaType = typeof SETTINGS_SCHEMA;
|
||||
@@ -1277,9 +1205,3 @@ type InferSettings<T extends SettingsSchema> = {
|
||||
};
|
||||
|
||||
export type Settings = InferSettings<SettingsSchemaType>;
|
||||
|
||||
export interface FooterSettings {
|
||||
hideCWD?: boolean;
|
||||
hideSandboxStatus?: boolean;
|
||||
hideModelInfo?: boolean;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { type LoadedSettings, SettingScope } from '../config/settings.js';
|
||||
import { performInitialAuth } from './auth.js';
|
||||
import { validateTheme } from './theme.js';
|
||||
import { initializeI18n } from '../i18n/index.js';
|
||||
import { initializeLlmOutputLanguage } from '../ui/commands/languageCommand.js';
|
||||
import { initializeLlmOutputLanguage } from '../utils/languageUtils.js';
|
||||
|
||||
export interface InitializationResult {
|
||||
authError: string | null;
|
||||
@@ -43,9 +43,11 @@ export async function initializeApp(
|
||||
await initializeI18n(languageSetting);
|
||||
|
||||
// Auto-detect and set LLM output language on first use
|
||||
initializeLlmOutputLanguage();
|
||||
initializeLlmOutputLanguage(settings.merged.general?.outputLanguage);
|
||||
|
||||
const authType = settings.merged.security?.auth?.selectedType;
|
||||
// Use authType from modelsConfig which respects CLI --auth-type argument
|
||||
// over settings.security.auth.selectedType
|
||||
const authType = config.modelsConfig.getCurrentAuthType();
|
||||
const authError = await performInitialAuth(config, authType);
|
||||
|
||||
// Fallback to user select when initial authentication fails
|
||||
@@ -59,7 +61,7 @@ export async function initializeApp(
|
||||
const themeError = validateTheme(settings);
|
||||
|
||||
const shouldOpenAuthDialog =
|
||||
settings.merged.security?.auth?.selectedType === undefined || !!authError;
|
||||
!config.modelsConfig.wasAuthTypeExplicitlyProvided() || !!authError;
|
||||
|
||||
if (config.getIdeMode()) {
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
|
||||
@@ -87,6 +87,15 @@ vi.mock('./config/sandboxConfig.js', () => ({
|
||||
loadSandboxConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./core/initializer.js', () => ({
|
||||
initializeApp: vi.fn().mockResolvedValue({
|
||||
authError: null,
|
||||
themeError: null,
|
||||
shouldOpenAuthDialog: false,
|
||||
geminiMdFileCount: 0,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('gemini.tsx main function', () => {
|
||||
let originalEnvGeminiSandbox: string | undefined;
|
||||
let originalEnvSandbox: string | undefined;
|
||||
@@ -262,7 +271,6 @@ describe('gemini.tsx main function', () => {
|
||||
);
|
||||
const { loadSettings } = await import('./config/settings.js');
|
||||
const cleanupModule = await import('./utils/cleanup.js');
|
||||
const extensionModule = await import('./config/extension.js');
|
||||
const validatorModule = await import('./validateNonInterActiveAuth.js');
|
||||
const streamJsonModule = await import('./nonInteractive/session.js');
|
||||
const initializerModule = await import('./core/initializer.js');
|
||||
@@ -275,11 +283,6 @@ describe('gemini.tsx main function', () => {
|
||||
vi.mocked(cleanupModule.registerCleanup).mockImplementation(() => {});
|
||||
const runExitCleanupMock = vi.mocked(cleanupModule.runExitCleanup);
|
||||
runExitCleanupMock.mockResolvedValue(undefined);
|
||||
vi.spyOn(extensionModule, 'loadExtensions').mockReturnValue([]);
|
||||
vi.spyOn(
|
||||
extensionModule.ExtensionStorage,
|
||||
'getUserExtensionsDir',
|
||||
).mockReturnValue('/tmp/extensions');
|
||||
vi.spyOn(initializerModule, 'initializeApp').mockResolvedValue({
|
||||
authError: null,
|
||||
themeError: null,
|
||||
@@ -362,7 +365,6 @@ describe('gemini.tsx main function', () => {
|
||||
expect(inputArg).toBe('hello stream');
|
||||
|
||||
expect(validateAuthSpy).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
undefined,
|
||||
configStub,
|
||||
expect.any(Object),
|
||||
@@ -448,7 +450,6 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
promptInteractive: undefined,
|
||||
query: undefined,
|
||||
allFiles: undefined,
|
||||
showMemoryUsage: undefined,
|
||||
yolo: undefined,
|
||||
approvalMode: undefined,
|
||||
telemetry: undefined,
|
||||
@@ -460,6 +461,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
telemetryOutfile: undefined,
|
||||
allowedMcpServerNames: undefined,
|
||||
allowedTools: undefined,
|
||||
acp: undefined,
|
||||
experimentalAcp: undefined,
|
||||
experimentalSkills: undefined,
|
||||
extensions: undefined,
|
||||
@@ -639,4 +641,37 @@ describe('startInteractiveUI', () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(checkForUpdates).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not check for updates when update nag is disabled', async () => {
|
||||
const { checkForUpdates } = await import('./ui/utils/updateCheck.js');
|
||||
|
||||
const mockInitializationResult = {
|
||||
authError: null,
|
||||
themeError: null,
|
||||
shouldOpenAuthDialog: false,
|
||||
geminiMdFileCount: 0,
|
||||
};
|
||||
|
||||
const settingsWithUpdateNagDisabled = {
|
||||
merged: {
|
||||
general: {
|
||||
disableUpdateNag: true,
|
||||
},
|
||||
ui: {
|
||||
hideWindowTitle: false,
|
||||
},
|
||||
},
|
||||
} as LoadedSettings;
|
||||
|
||||
await startInteractiveUI(
|
||||
mockConfig,
|
||||
settingsWithUpdateNagDisabled,
|
||||
mockStartupWarnings,
|
||||
mockWorkspaceRoot,
|
||||
mockInitializationResult,
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(checkForUpdates).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Config, AuthType } from '@qwen-code/qwen-code-core';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import { InputFormat, logUserPrompt } from '@qwen-code/qwen-code-core';
|
||||
import { render } from 'ink';
|
||||
import dns from 'node:dns';
|
||||
@@ -15,9 +15,8 @@ import React from 'react';
|
||||
import { validateAuthMethod } from './config/auth.js';
|
||||
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 } from './config/settings.js';
|
||||
import {
|
||||
initializeApp,
|
||||
type InitializationResult,
|
||||
@@ -103,7 +102,6 @@ function getNodeMemoryArgs(isDebugMode: boolean): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
import { ExtensionEnablementManager } from './config/extensions/extensionEnablement.js';
|
||||
import { loadSandboxConfig } from './config/sandboxConfig.js';
|
||||
import { runAcpAgent } from './acp-integration/acpAgent.js';
|
||||
|
||||
@@ -183,16 +181,18 @@ export async function startInteractiveUI(
|
||||
},
|
||||
);
|
||||
|
||||
checkForUpdates()
|
||||
.then((info) => {
|
||||
handleAutoUpdate(info, settings, config.getProjectRoot());
|
||||
})
|
||||
.catch((err) => {
|
||||
// Silently ignore update check errors.
|
||||
if (config.getDebugMode()) {
|
||||
console.error('Update check failed:', err);
|
||||
}
|
||||
});
|
||||
if (!settings.merged.general?.disableUpdateNag) {
|
||||
checkForUpdates()
|
||||
.then((info) => {
|
||||
handleAutoUpdate(info, settings, config.getProjectRoot());
|
||||
})
|
||||
.catch((err) => {
|
||||
// Silently ignore update check errors.
|
||||
if (config.getDebugMode()) {
|
||||
console.error('Update check failed:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
registerCleanup(() => instance.unmount());
|
||||
}
|
||||
@@ -200,7 +200,6 @@ export async function startInteractiveUI(
|
||||
export async function main() {
|
||||
setupUnhandledRejectionHandler();
|
||||
const settings = loadSettings();
|
||||
migrateDeprecatedSettings(settings);
|
||||
await cleanupCheckpoints();
|
||||
|
||||
let argv = await parseArguments(settings.merged);
|
||||
@@ -245,27 +244,25 @@ export async function main() {
|
||||
if (sandboxConfig) {
|
||||
const partialConfig = await loadCliConfig(
|
||||
settings.merged,
|
||||
[],
|
||||
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
|
||||
argv,
|
||||
undefined,
|
||||
[],
|
||||
);
|
||||
|
||||
if (
|
||||
settings.merged.security?.auth?.selectedType &&
|
||||
!settings.merged.security?.auth?.useExternal
|
||||
) {
|
||||
if (!settings.merged.security?.auth?.useExternal) {
|
||||
// Validate authentication here because the sandbox will interfere with the Oauth2 web redirect.
|
||||
try {
|
||||
const err = validateAuthMethod(
|
||||
settings.merged.security.auth.selectedType,
|
||||
);
|
||||
if (err) {
|
||||
throw new Error(err);
|
||||
}
|
||||
const authType = partialConfig.modelsConfig.getCurrentAuthType();
|
||||
// Fresh users may not have selected/persisted an authType yet.
|
||||
// In that case, defer auth prompting/selection to the main interactive flow.
|
||||
if (authType) {
|
||||
const err = validateAuthMethod(authType, partialConfig);
|
||||
if (err) {
|
||||
throw new Error(err);
|
||||
}
|
||||
|
||||
await partialConfig.refreshAuth(
|
||||
settings.merged.security.auth.selectedType,
|
||||
);
|
||||
await partialConfig.refreshAuth(authType);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error authenticating:', err);
|
||||
process.exit(1);
|
||||
@@ -331,25 +328,22 @@ export async function main() {
|
||||
// to run Gemini CLI. It is now safe to perform expensive initialization that
|
||||
// may have side effects.
|
||||
{
|
||||
const extensionEnablementManager = new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
argv.extensions,
|
||||
);
|
||||
const extensions = loadExtensions(extensionEnablementManager);
|
||||
const config = await loadCliConfig(
|
||||
settings.merged,
|
||||
extensions,
|
||||
extensionEnablementManager,
|
||||
argv,
|
||||
process.cwd(),
|
||||
argv.extensions,
|
||||
);
|
||||
registerCleanup(() => config.shutdown());
|
||||
|
||||
if (config.getListExtensions()) {
|
||||
console.log('Installed extensions:');
|
||||
for (const extension of extensions) {
|
||||
console.log(`- ${extension.config.name}`);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
// FIXME: list extensions after the config initialize
|
||||
// if (config.getListExtensions()) {
|
||||
// console.log('Installed extensions:');
|
||||
// for (const extension of extensions) {
|
||||
// console.log(`- ${extension.config.name}`);
|
||||
// }
|
||||
// process.exit(0);
|
||||
// }
|
||||
|
||||
// Setup unified ConsolePatcher based on interactive mode
|
||||
const isInteractive = config.isInteractive();
|
||||
@@ -395,17 +389,20 @@ export async function main() {
|
||||
}
|
||||
|
||||
if (config.getExperimentalZedIntegration()) {
|
||||
return runAcpAgent(config, settings, extensions, argv);
|
||||
return runAcpAgent(config, settings, argv);
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -438,8 +435,6 @@ export async function main() {
|
||||
}
|
||||
|
||||
const nonInteractiveConfig = await validateNonInteractiveAuth(
|
||||
(argv.authType as AuthType) ||
|
||||
settings.merged.security?.auth?.selectedType,
|
||||
settings.merged.security?.auth?.useExternal,
|
||||
config,
|
||||
settings,
|
||||
|
||||
1348
packages/cli/src/i18n/locales/de.js
Normal file
1348
packages/cli/src/i18n/locales/de.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,25 @@ export default {
|
||||
'Model Context Protocol command (from external servers)':
|
||||
'Model Context Protocol command (from external servers)',
|
||||
'Keyboard Shortcuts:': 'Keyboard Shortcuts:',
|
||||
'Toggle this help display': 'Toggle this help display',
|
||||
'Toggle shell mode': 'Toggle shell mode',
|
||||
'Open command menu': 'Open command menu',
|
||||
'Add file context': 'Add file context',
|
||||
'Accept suggestion / Autocomplete': 'Accept suggestion / Autocomplete',
|
||||
'Reverse search history': 'Reverse search history',
|
||||
'Press ? again to close': 'Press ? again to close',
|
||||
// Keyboard shortcuts panel descriptions
|
||||
'for shell mode': 'for shell mode',
|
||||
'for commands': 'for commands',
|
||||
'for file paths': 'for file paths',
|
||||
'to clear input': 'to clear input',
|
||||
'to cycle approvals': 'to cycle approvals',
|
||||
'to quit': 'to quit',
|
||||
'for newline': 'for newline',
|
||||
'to clear screen': 'to clear screen',
|
||||
'to search history': 'to search history',
|
||||
'to paste images': 'to paste images',
|
||||
'for external editor': 'for external editor',
|
||||
'Jump through words in the input': 'Jump through words in the input',
|
||||
'Close dialogs, cancel requests, or quit application':
|
||||
'Close dialogs, cancel requests, or quit application',
|
||||
@@ -46,6 +65,7 @@ export default {
|
||||
'Connecting to MCP servers... ({{connected}}/{{total}})':
|
||||
'Connecting to MCP servers... ({{connected}}/{{total}})',
|
||||
'Type your message or @path/to/file': 'Type your message or @path/to/file',
|
||||
'? for shortcuts': '? for shortcuts',
|
||||
"Press 'i' for INSERT mode and 'Esc' for NORMAL mode.":
|
||||
"Press 'i' for INSERT mode and 'Esc' for NORMAL mode.",
|
||||
'Cancel operation / Clear input (double press)':
|
||||
@@ -59,10 +79,15 @@ export default {
|
||||
'show version info': 'show version info',
|
||||
'submit a bug report': 'submit a bug report',
|
||||
'About Qwen Code': 'About Qwen Code',
|
||||
Status: 'Status',
|
||||
|
||||
// ============================================================================
|
||||
// System Information Fields
|
||||
// ============================================================================
|
||||
'Qwen Code': 'Qwen Code',
|
||||
Runtime: 'Runtime',
|
||||
OS: 'OS',
|
||||
Auth: 'Auth',
|
||||
'CLI Version': 'CLI Version',
|
||||
'Git Commit': 'Git Commit',
|
||||
Model: 'Model',
|
||||
@@ -75,6 +100,7 @@ export default {
|
||||
'Session ID': 'Session ID',
|
||||
'Auth Method': 'Auth Method',
|
||||
'Base URL': 'Base URL',
|
||||
Proxy: 'Proxy',
|
||||
'Memory Usage': 'Memory Usage',
|
||||
'IDE Client': 'IDE Client',
|
||||
|
||||
@@ -89,14 +115,17 @@ export default {
|
||||
'No tools available': 'No tools available',
|
||||
'View or change the approval mode for tool usage':
|
||||
'View or change the approval mode for tool usage',
|
||||
'Invalid approval mode "{{arg}}". Valid modes: {{modes}}':
|
||||
'Invalid approval mode "{{arg}}". Valid modes: {{modes}}',
|
||||
'Approval mode set to "{{mode}}"': 'Approval mode set to "{{mode}}"',
|
||||
'View or change the language setting': 'View or change the language setting',
|
||||
'change the theme': 'change the theme',
|
||||
'Select Theme': 'Select Theme',
|
||||
Preview: 'Preview',
|
||||
'(Use Enter to select, Tab to configure scope)':
|
||||
'(Use Enter to select, Tab to configure scope)',
|
||||
'(Use Enter to apply scope, Tab to select theme)':
|
||||
'(Use Enter to apply scope, Tab to select theme)',
|
||||
'(Use Enter to apply scope, Tab to go back)':
|
||||
'(Use Enter to apply scope, Tab to go back)',
|
||||
'Theme configuration unavailable due to NO_COLOR env variable.':
|
||||
'Theme configuration unavailable due to NO_COLOR env variable.',
|
||||
'Theme "{{themeName}}" not found.': 'Theme "{{themeName}}" not found.',
|
||||
@@ -149,6 +178,7 @@ export default {
|
||||
'Project Level ({{path}})': 'Project Level ({{path}})',
|
||||
'User Level ({{path}})': 'User Level ({{path}})',
|
||||
'Built-in Agents': 'Built-in Agents',
|
||||
'Extension Agents': 'Extension Agents',
|
||||
'Using: {{count}} agents': 'Using: {{count}} agents',
|
||||
'View Agent': 'View Agent',
|
||||
'Edit Agent': 'Edit Agent',
|
||||
@@ -254,8 +284,6 @@ export default {
|
||||
// ============================================================================
|
||||
'View and edit Qwen Code settings': 'View and edit Qwen Code settings',
|
||||
Settings: 'Settings',
|
||||
'(Use Enter to select{{tabText}})': '(Use Enter to select{{tabText}})',
|
||||
', Tab to change focus': ', Tab to change focus',
|
||||
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.':
|
||||
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.',
|
||||
'The command "/{{command}}" is not supported in non-interactive mode.':
|
||||
@@ -265,24 +293,31 @@ export default {
|
||||
// ============================================================================
|
||||
'Vim Mode': 'Vim Mode',
|
||||
'Disable Auto Update': 'Disable Auto Update',
|
||||
'Attribution: commit': 'Attribution: commit',
|
||||
'Terminal Bell Notification': 'Terminal Bell Notification',
|
||||
'Enable Usage Statistics': 'Enable Usage Statistics',
|
||||
Theme: 'Theme',
|
||||
'Preferred Editor': 'Preferred Editor',
|
||||
'Auto-connect to IDE': 'Auto-connect to IDE',
|
||||
'Enable Prompt Completion': 'Enable Prompt Completion',
|
||||
'Debug Keystroke Logging': 'Debug Keystroke Logging',
|
||||
Language: 'Language',
|
||||
'Language: UI': 'Language: UI',
|
||||
'Language: Model': 'Language: Model',
|
||||
'Output Format': 'Output Format',
|
||||
'Hide Window Title': 'Hide Window Title',
|
||||
'Show Status in Title': 'Show Status in Title',
|
||||
'Hide Tips': 'Hide Tips',
|
||||
'Hide Banner': 'Hide Banner',
|
||||
'Hide Context Summary': 'Hide Context Summary',
|
||||
'Hide CWD': 'Hide CWD',
|
||||
'Hide Sandbox Status': 'Hide Sandbox Status',
|
||||
'Hide Model Info': 'Hide Model Info',
|
||||
'Hide Footer': 'Hide Footer',
|
||||
'Show Memory Usage': 'Show Memory Usage',
|
||||
'Show Line Numbers': 'Show Line Numbers',
|
||||
'Show Line Numbers in Code': 'Show Line Numbers in Code',
|
||||
'Show Citations': 'Show Citations',
|
||||
'Custom Witty Phrases': 'Custom Witty Phrases',
|
||||
'Enable Welcome Back': 'Enable Welcome Back',
|
||||
'Show Welcome Back Dialog': 'Show Welcome Back Dialog',
|
||||
'Enable User Feedback': 'Enable User Feedback',
|
||||
'How is Qwen doing this session? (optional)':
|
||||
'How is Qwen doing this session? (optional)',
|
||||
Bad: 'Bad',
|
||||
Good: 'Good',
|
||||
'Not Sure Yet': 'Not Sure Yet',
|
||||
'Any other key': 'Any other key',
|
||||
'Disable Loading Phrases': 'Disable Loading Phrases',
|
||||
'Screen Reader Mode': 'Screen Reader Mode',
|
||||
'IDE Mode': 'IDE Mode',
|
||||
@@ -302,7 +337,7 @@ export default {
|
||||
'Respect .qwenignore': 'Respect .qwenignore',
|
||||
'Enable Recursive File Search': 'Enable Recursive File Search',
|
||||
'Disable Fuzzy Search': 'Disable Fuzzy Search',
|
||||
'Enable Interactive Shell': 'Enable Interactive Shell',
|
||||
'Interactive Shell (PTY)': 'Interactive Shell (PTY)',
|
||||
'Show Color': 'Show Color',
|
||||
'Auto Accept': 'Auto Accept',
|
||||
'Use Ripgrep': 'Use Ripgrep',
|
||||
@@ -313,6 +348,7 @@ export default {
|
||||
'Folder Trust': 'Folder Trust',
|
||||
'Vision Model Preview': 'Vision Model Preview',
|
||||
'Tool Schema Compliance': 'Tool Schema Compliance',
|
||||
'Experimental: Skills': 'Experimental: Skills',
|
||||
// Settings enum options
|
||||
'Auto (detect from system)': 'Auto (detect from system)',
|
||||
Text: 'Text',
|
||||
@@ -337,10 +373,148 @@ export default {
|
||||
'Show all directories in the workspace':
|
||||
'Show all directories in the workspace',
|
||||
'set external editor preference': 'set external editor preference',
|
||||
'Select Editor': 'Select Editor',
|
||||
'Editor Preference': 'Editor Preference',
|
||||
'These editors are currently supported. Please note that some editors cannot be used in sandbox mode.':
|
||||
'These editors are currently supported. Please note that some editors cannot be used in sandbox mode.',
|
||||
'Your preferred editor is:': 'Your preferred editor is:',
|
||||
'Manage extensions': 'Manage extensions',
|
||||
'List active extensions': 'List active extensions',
|
||||
'Update extensions. Usage: update <extension-names>|--all':
|
||||
'Update extensions. Usage: update <extension-names>|--all',
|
||||
'Disable an extension': 'Disable an extension',
|
||||
'Enable an extension': 'Enable an extension',
|
||||
'Install an extension from a git repo or local path':
|
||||
'Install an extension from a git repo or local path',
|
||||
'Uninstall an extension': 'Uninstall an extension',
|
||||
'No extensions installed.': 'No extensions installed.',
|
||||
'Usage: /extensions update <extension-names>|--all':
|
||||
'Usage: /extensions update <extension-names>|--all',
|
||||
'Extension "{{name}}" not found.': 'Extension "{{name}}" not found.',
|
||||
'No extensions to update.': 'No extensions to update.',
|
||||
'Usage: /extensions install <source>': 'Usage: /extensions install <source>',
|
||||
'Installing extension from "{{source}}"...':
|
||||
'Installing extension from "{{source}}"...',
|
||||
'Extension "{{name}}" installed successfully.':
|
||||
'Extension "{{name}}" installed successfully.',
|
||||
'Failed to install extension from "{{source}}": {{error}}':
|
||||
'Failed to install extension from "{{source}}": {{error}}',
|
||||
'Usage: /extensions uninstall <extension-name>':
|
||||
'Usage: /extensions uninstall <extension-name>',
|
||||
'Uninstalling extension "{{name}}"...':
|
||||
'Uninstalling extension "{{name}}"...',
|
||||
'Extension "{{name}}" uninstalled successfully.':
|
||||
'Extension "{{name}}" uninstalled successfully.',
|
||||
'Failed to uninstall extension "{{name}}": {{error}}':
|
||||
'Failed to uninstall extension "{{name}}": {{error}}',
|
||||
'Usage: /extensions {{command}} <extension> [--scope=<user|workspace>]':
|
||||
'Usage: /extensions {{command}} <extension> [--scope=<user|workspace>]',
|
||||
'Unsupported scope "{{scope}}", should be one of "user" or "workspace"':
|
||||
'Unsupported scope "{{scope}}", should be one of "user" or "workspace"',
|
||||
'Extension "{{name}}" disabled for scope "{{scope}}"':
|
||||
'Extension "{{name}}" disabled for scope "{{scope}}"',
|
||||
'Extension "{{name}}" enabled for scope "{{scope}}"':
|
||||
'Extension "{{name}}" enabled for scope "{{scope}}"',
|
||||
'Do you want to continue? [Y/n]: ': 'Do you want to continue? [Y/n]: ',
|
||||
'Do you want to continue?': 'Do you want to continue?',
|
||||
'Installing extension "{{name}}".': 'Installing extension "{{name}}".',
|
||||
'**Extensions may introduce unexpected behavior. Ensure you have investigated the extension source and trust the author.**':
|
||||
'**Extensions may introduce unexpected behavior. Ensure you have investigated the extension source and trust the author.**',
|
||||
'This extension will run the following MCP servers:':
|
||||
'This extension will run the following MCP servers:',
|
||||
local: 'local',
|
||||
remote: 'remote',
|
||||
'This extension will add the following commands: {{commands}}.':
|
||||
'This extension will add the following commands: {{commands}}.',
|
||||
'This extension will append info to your QWEN.md context using {{fileName}}':
|
||||
'This extension will append info to your QWEN.md context using {{fileName}}',
|
||||
'This extension will exclude the following core tools: {{tools}}':
|
||||
'This extension will exclude the following core tools: {{tools}}',
|
||||
'This extension will install the following skills:':
|
||||
'This extension will install the following skills:',
|
||||
'This extension will install the following subagents:':
|
||||
'This extension will install the following subagents:',
|
||||
'Installation cancelled for "{{name}}".':
|
||||
'Installation cancelled for "{{name}}".',
|
||||
'--ref and --auto-update are not applicable for marketplace extensions.':
|
||||
'--ref and --auto-update are not applicable for marketplace extensions.',
|
||||
'Extension "{{name}}" installed successfully and enabled.':
|
||||
'Extension "{{name}}" installed successfully and enabled.',
|
||||
'Installs an extension from a git repository URL, local path, or claude marketplace (marketplace-url:plugin-name).':
|
||||
'Installs an extension from a git repository URL, local path, or claude marketplace (marketplace-url:plugin-name).',
|
||||
'The github URL, local path, or marketplace source (marketplace-url:plugin-name) of the extension to install.':
|
||||
'The github URL, local path, or marketplace source (marketplace-url:plugin-name) of the extension to install.',
|
||||
'The git ref to install from.': 'The git ref to install from.',
|
||||
'Enable auto-update for this extension.':
|
||||
'Enable auto-update for this extension.',
|
||||
'Enable pre-release versions for this extension.':
|
||||
'Enable pre-release versions for this extension.',
|
||||
'Acknowledge the security risks of installing an extension and skip the confirmation prompt.':
|
||||
'Acknowledge the security risks of installing an extension and skip the confirmation prompt.',
|
||||
'The source argument must be provided.':
|
||||
'The source argument must be provided.',
|
||||
'Extension "{{name}}" successfully uninstalled.':
|
||||
'Extension "{{name}}" successfully uninstalled.',
|
||||
'Uninstalls an extension.': 'Uninstalls an extension.',
|
||||
'The name or source path of the extension to uninstall.':
|
||||
'The name or source path of the extension to uninstall.',
|
||||
'Please include the name of the extension to uninstall as a positional argument.':
|
||||
'Please include the name of the extension to uninstall as a positional argument.',
|
||||
'Enables an extension.': 'Enables an extension.',
|
||||
'The name of the extension to enable.':
|
||||
'The name of the extension to enable.',
|
||||
'The scope to enable the extenison in. If not set, will be enabled in all scopes.':
|
||||
'The scope to enable the extenison in. If not set, will be enabled in all scopes.',
|
||||
'Extension "{{name}}" successfully enabled for scope "{{scope}}".':
|
||||
'Extension "{{name}}" successfully enabled for scope "{{scope}}".',
|
||||
'Extension "{{name}}" successfully enabled in all scopes.':
|
||||
'Extension "{{name}}" successfully enabled in all scopes.',
|
||||
'Invalid scope: {{scope}}. Please use one of {{scopes}}.':
|
||||
'Invalid scope: {{scope}}. Please use one of {{scopes}}.',
|
||||
'Disables an extension.': 'Disables an extension.',
|
||||
'The name of the extension to disable.':
|
||||
'The name of the extension to disable.',
|
||||
'The scope to disable the extenison in.':
|
||||
'The scope to disable the extenison in.',
|
||||
'Extension "{{name}}" successfully disabled for scope "{{scope}}".':
|
||||
'Extension "{{name}}" successfully disabled for scope "{{scope}}".',
|
||||
'Extension "{{name}}" successfully updated: {{oldVersion}} → {{newVersion}}.':
|
||||
'Extension "{{name}}" successfully updated: {{oldVersion}} → {{newVersion}}.',
|
||||
'Unable to install extension "{{name}}" due to missing install metadata':
|
||||
'Unable to install extension "{{name}}" due to missing install metadata',
|
||||
'Extension "{{name}}" is already up to date.':
|
||||
'Extension "{{name}}" is already up to date.',
|
||||
'Updates all extensions or a named extension to the latest version.':
|
||||
'Updates all extensions or a named extension to the latest version.',
|
||||
'Update all extensions.': 'Update all extensions.',
|
||||
'Either an extension name or --all must be provided':
|
||||
'Either an extension name or --all must be provided',
|
||||
'Lists installed extensions.': 'Lists installed extensions.',
|
||||
'Link extension failed to install.': 'Link extension failed to install.',
|
||||
'Extension "{{name}}" linked successfully and enabled.':
|
||||
'Extension "{{name}}" linked successfully and enabled.',
|
||||
'Links an extension from a local path. Updates made to the local path will always be reflected.':
|
||||
'Links an extension from a local path. Updates made to the local path will always be reflected.',
|
||||
'The name of the extension to link.': 'The name of the extension to link.',
|
||||
'Set a specific setting for an extension.':
|
||||
'Set a specific setting for an extension.',
|
||||
'Name of the extension to configure.': 'Name of the extension to configure.',
|
||||
'The setting to configure (name or env var).':
|
||||
'The setting to configure (name or env var).',
|
||||
'The scope to set the setting in.': 'The scope to set the setting in.',
|
||||
'List all settings for an extension.': 'List all settings for an extension.',
|
||||
'Name of the extension.': 'Name of the extension.',
|
||||
'Extension "{{name}}" has no settings to configure.':
|
||||
'Extension "{{name}}" has no settings to configure.',
|
||||
'Settings for "{{name}}":': 'Settings for "{{name}}":',
|
||||
'(workspace)': '(workspace)',
|
||||
'(user)': '(user)',
|
||||
'[not set]': '[not set]',
|
||||
'[value stored in keychain]': '[value stored in keychain]',
|
||||
'Value:': 'Value:',
|
||||
'Manage extension settings.': 'Manage extension settings.',
|
||||
'You need to specify a command (set or list).':
|
||||
'You need to specify a command (set or list).',
|
||||
'manage IDE integration': 'manage IDE integration',
|
||||
'check status of IDE integration': 'check status of IDE integration',
|
||||
'install required IDE companion for {{ideName}}':
|
||||
@@ -403,6 +577,7 @@ export default {
|
||||
'Example: /language output English': 'Example: /language output English',
|
||||
'Example: /language output 日本語': 'Example: /language output 日本語',
|
||||
'UI language changed to {{lang}}': 'UI language changed to {{lang}}',
|
||||
'LLM output language set to {{lang}}': 'LLM output language set to {{lang}}',
|
||||
'LLM output language rule file generated at {{path}}':
|
||||
'LLM output language rule file generated at {{path}}',
|
||||
'Please restart the application for the changes to take effect.':
|
||||
@@ -424,7 +599,7 @@ export default {
|
||||
// ============================================================================
|
||||
// Commands - Approval Mode
|
||||
// ============================================================================
|
||||
'Approval Mode': 'Approval Mode',
|
||||
'Tool Approval Mode': 'Tool Approval Mode',
|
||||
'Current approval mode: {{mode}}': 'Current approval mode: {{mode}}',
|
||||
'Available approval modes:': 'Available approval modes:',
|
||||
'Approval mode changed to: {{mode}}': 'Approval mode changed to: {{mode}}',
|
||||
@@ -463,8 +638,6 @@ export default {
|
||||
'Automatically approve all tools': 'Automatically approve all tools',
|
||||
'Workspace approval mode exists and takes priority. User-level change will have no effect.':
|
||||
'Workspace approval mode exists and takes priority. User-level change will have no effect.',
|
||||
'(Use Enter to select, Tab to change focus)':
|
||||
'(Use Enter to select, Tab to change focus)',
|
||||
'Apply To': 'Apply To',
|
||||
'User Settings': 'User Settings',
|
||||
'Workspace Settings': 'Workspace Settings',
|
||||
@@ -767,6 +940,21 @@ export default {
|
||||
'Authentication timed out. Please try again.',
|
||||
'Waiting for auth... (Press ESC or CTRL+C to cancel)':
|
||||
'Waiting for auth... (Press ESC or CTRL+C to cancel)',
|
||||
'Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the {{envKeyHint}} environment variable.':
|
||||
'Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the {{envKeyHint}} environment variable.',
|
||||
'{{envKeyHint}} environment variable not found.':
|
||||
'{{envKeyHint}} environment variable not found.',
|
||||
'{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.':
|
||||
'{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.',
|
||||
'{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.':
|
||||
'{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.',
|
||||
'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.':
|
||||
'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.',
|
||||
'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.':
|
||||
'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.',
|
||||
'ANTHROPIC_BASE_URL environment variable not found.':
|
||||
'ANTHROPIC_BASE_URL environment variable not found.',
|
||||
'Invalid auth method selected.': 'Invalid auth method selected.',
|
||||
'Failed to authenticate. Message: {{message}}':
|
||||
'Failed to authenticate. Message: {{message}}',
|
||||
'Authenticated successfully with {{authType}} credentials.':
|
||||
@@ -788,6 +976,15 @@ export default {
|
||||
// ============================================================================
|
||||
'Select Model': 'Select Model',
|
||||
'(Press Esc to close)': '(Press Esc to close)',
|
||||
'Current (effective) configuration': 'Current (effective) configuration',
|
||||
AuthType: 'AuthType',
|
||||
'API Key': 'API Key',
|
||||
unset: 'unset',
|
||||
'(default)': '(default)',
|
||||
'(set)': '(set)',
|
||||
'(not set)': '(not set)',
|
||||
"Failed to switch model to '{{modelId}}'.\n\n{{error}}":
|
||||
"Failed to switch model to '{{modelId}}'.\n\n{{error}}",
|
||||
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)':
|
||||
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)',
|
||||
'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)':
|
||||
@@ -864,14 +1061,23 @@ export default {
|
||||
// ============================================================================
|
||||
// Startup Tips
|
||||
// ============================================================================
|
||||
'Tips for getting started:': 'Tips for getting started:',
|
||||
'1. Ask questions, edit files, or run commands.':
|
||||
'1. Ask questions, edit files, or run commands.',
|
||||
'2. Be specific for the best results.':
|
||||
'2. Be specific for the best results.',
|
||||
'files to customize your interactions with Qwen Code.':
|
||||
'files to customize your interactions with Qwen Code.',
|
||||
'for more information.': 'for more information.',
|
||||
'Tips:': 'Tips:',
|
||||
'Use /compress when the conversation gets long to summarize history and free up context.':
|
||||
'Use /compress when the conversation gets long to summarize history and free up context.',
|
||||
'Start a fresh idea with /clear or /new; the previous session stays available in history.':
|
||||
'Start a fresh idea with /clear or /new; the previous session stays available in history.',
|
||||
'Use /bug to submit issues to the maintainers when something goes off.':
|
||||
'Use /bug to submit issues to the maintainers when something goes off.',
|
||||
'Switch auth type quickly with /auth.':
|
||||
'Switch auth type quickly with /auth.',
|
||||
'You can run any shell commands from Qwen Code using ! (e.g. !ls).':
|
||||
'You can run any shell commands from Qwen Code using ! (e.g. !ls).',
|
||||
'Type / to open the command popup; Tab autocompletes slash commands and saved prompts.':
|
||||
'Type / to open the command popup; Tab autocompletes slash commands and saved prompts.',
|
||||
'You can resume a previous conversation by running qwen --continue or qwen --resume.':
|
||||
'You can resume a previous conversation by running qwen --continue or qwen --resume.',
|
||||
'You can switch permission mode quickly with Shift+Tab or /approval-mode.':
|
||||
'You can switch permission mode quickly with Shift+Tab or /approval-mode.',
|
||||
|
||||
// ============================================================================
|
||||
// Exit Screen / Stats
|
||||
@@ -931,6 +1137,18 @@ export default {
|
||||
'Session start time is unavailable, cannot calculate stats.':
|
||||
'Session start time is unavailable, cannot calculate stats.',
|
||||
|
||||
// ============================================================================
|
||||
// Command Format Migration
|
||||
// ============================================================================
|
||||
'Command Format Migration': 'Command Format Migration',
|
||||
'Found {{count}} TOML command file:': 'Found {{count}} TOML command file:',
|
||||
'Found {{count}} TOML command files:': 'Found {{count}} TOML command files:',
|
||||
'... and {{count}} more': '... and {{count}} more',
|
||||
'The TOML format is deprecated. Would you like to migrate them to Markdown format?':
|
||||
'The TOML format is deprecated. Would you like to migrate them to Markdown format?',
|
||||
'(Backups will be created and original files will be preserved)':
|
||||
'(Backups will be created and original files will be preserved)',
|
||||
|
||||
// ============================================================================
|
||||
// Loading Phrases
|
||||
// ============================================================================
|
||||
@@ -1037,7 +1255,6 @@ export default {
|
||||
'Applying percussive maintenance...',
|
||||
'Searching for the correct USB orientation...',
|
||||
'Ensuring the magic smoke stays inside the wires...',
|
||||
'Rewriting in Rust for no particular reason...',
|
||||
'Trying to exit Vim...',
|
||||
'Spinning up the hamster wheel...',
|
||||
"That's not a bug, it's an undocumented feature...",
|
||||
@@ -1070,4 +1287,55 @@ export default {
|
||||
'Have you tried turning it off and on again? (The loading screen, not me.)',
|
||||
'Constructing additional pylons...',
|
||||
],
|
||||
|
||||
// ============================================================================
|
||||
// Extension Settings Input
|
||||
// ============================================================================
|
||||
'Enter value...': 'Enter value...',
|
||||
'Enter sensitive value...': 'Enter sensitive value...',
|
||||
'Press Enter to submit, Escape to cancel':
|
||||
'Press Enter to submit, Escape to cancel',
|
||||
|
||||
// ============================================================================
|
||||
// Command Migration Tool
|
||||
// ============================================================================
|
||||
'Markdown file already exists: {{filename}}':
|
||||
'Markdown file already exists: {{filename}}',
|
||||
'TOML Command Format Deprecation Notice':
|
||||
'TOML Command Format Deprecation Notice',
|
||||
'Found {{count}} command file(s) in TOML format:':
|
||||
'Found {{count}} command file(s) in TOML format:',
|
||||
'The TOML format for commands is being deprecated in favor of Markdown format.':
|
||||
'The TOML format for commands is being deprecated in favor of Markdown format.',
|
||||
'Markdown format is more readable and easier to edit.':
|
||||
'Markdown format is more readable and easier to edit.',
|
||||
'You can migrate these files automatically using:':
|
||||
'You can migrate these files automatically using:',
|
||||
'Or manually convert each file:': 'Or manually convert each file:',
|
||||
'TOML: prompt = "..." / description = "..."':
|
||||
'TOML: prompt = "..." / description = "..."',
|
||||
'Markdown: YAML frontmatter + content':
|
||||
'Markdown: YAML frontmatter + content',
|
||||
'The migration tool will:': 'The migration tool will:',
|
||||
'Convert TOML files to Markdown': 'Convert TOML files to Markdown',
|
||||
'Create backups of original files': 'Create backups of original files',
|
||||
'Preserve all command functionality': 'Preserve all command functionality',
|
||||
'TOML format will continue to work for now, but migration is recommended.':
|
||||
'TOML format will continue to work for now, but migration is recommended.',
|
||||
|
||||
// ============================================================================
|
||||
// Extensions - Explore Command
|
||||
// ============================================================================
|
||||
'Open extensions page in your browser':
|
||||
'Open extensions page in your browser',
|
||||
'Unknown extensions source: {{source}}.':
|
||||
'Unknown extensions source: {{source}}.',
|
||||
'Would open extensions page in your browser: {{url}} (skipped in test environment)':
|
||||
'Would open extensions page in your browser: {{url}} (skipped in test environment)',
|
||||
'View available extensions at {{url}}':
|
||||
'View available extensions at {{url}}',
|
||||
'Opening extensions page in your browser: {{url}}':
|
||||
'Opening extensions page in your browser: {{url}}',
|
||||
'Failed to open browser. Check out the extensions gallery at {{url}}':
|
||||
'Failed to open browser. Check out the extensions gallery at {{url}}',
|
||||
};
|
||||
|
||||
@@ -33,6 +33,13 @@ export default {
|
||||
'Model Context Protocol command (from external servers)':
|
||||
'Команда Model Context Protocol (из внешних серверов)',
|
||||
'Keyboard Shortcuts:': 'Горячие клавиши:',
|
||||
'Toggle this help display': 'Показать/скрыть эту справку',
|
||||
'Toggle shell mode': 'Переключить режим оболочки',
|
||||
'Open command menu': 'Открыть меню команд',
|
||||
'Add file context': 'Добавить файл в контекст',
|
||||
'Accept suggestion / Autocomplete': 'Принять подсказку / Автодополнение',
|
||||
'Reverse search history': 'Обратный поиск по истории',
|
||||
'Press ? again to close': 'Нажмите ? ещё раз, чтобы закрыть',
|
||||
'Jump through words in the input': 'Переход по словам во вводе',
|
||||
'Close dialogs, cancel requests, or quit application':
|
||||
'Закрыть диалоги, отменить запросы или выйти из приложения',
|
||||
@@ -46,6 +53,7 @@ export default {
|
||||
'Connecting to MCP servers... ({{connected}}/{{total}})':
|
||||
'Подключение к MCP-серверам... ({{connected}}/{{total}})',
|
||||
'Type your message or @path/to/file': 'Введите сообщение или @путь/к/файлу',
|
||||
'? for shortcuts': '? — горячие клавиши',
|
||||
"Press 'i' for INSERT mode and 'Esc' for NORMAL mode.":
|
||||
"Нажмите 'i' для режима ВСТАВКА и 'Esc' для ОБЫЧНОГО режима.",
|
||||
'Cancel operation / Clear input (double press)':
|
||||
@@ -59,10 +67,28 @@ export default {
|
||||
'show version info': 'Просмотр информации о версии',
|
||||
'submit a bug report': 'Отправка отчёта об ошибке',
|
||||
'About Qwen Code': 'Об Qwen Code',
|
||||
Status: 'Статус',
|
||||
|
||||
// Keyboard shortcuts panel descriptions
|
||||
'for shell mode': 'режим оболочки',
|
||||
'for commands': 'меню команд',
|
||||
'for file paths': 'пути к файлам',
|
||||
'to clear input': 'очистить ввод',
|
||||
'to cycle approvals': 'переключить режим',
|
||||
'to quit': 'выход',
|
||||
'for newline': 'новая строка',
|
||||
'to clear screen': 'очистить экран',
|
||||
'to search history': 'поиск в истории',
|
||||
'to paste images': 'вставить изображения',
|
||||
'for external editor': 'внешний редактор',
|
||||
|
||||
// ============================================================================
|
||||
// Поля системной информации
|
||||
// ============================================================================
|
||||
'Qwen Code': 'Qwen Code',
|
||||
Runtime: 'Среда выполнения',
|
||||
OS: 'ОС',
|
||||
Auth: 'Аутентификация',
|
||||
'CLI Version': 'Версия CLI',
|
||||
'Git Commit': 'Git-коммит',
|
||||
Model: 'Модель',
|
||||
@@ -75,6 +101,7 @@ export default {
|
||||
'Session ID': 'ID сессии',
|
||||
'Auth Method': 'Метод авторизации',
|
||||
'Base URL': 'Базовый URL',
|
||||
Proxy: 'Прокси',
|
||||
'Memory Usage': 'Использование памяти',
|
||||
'IDE Client': 'Клиент IDE',
|
||||
|
||||
@@ -89,6 +116,10 @@ export default {
|
||||
'No tools available': 'Нет доступных инструментов',
|
||||
'View or change the approval mode for tool usage':
|
||||
'Просмотр или изменение режима подтверждения для использования инструментов',
|
||||
'Invalid approval mode "{{arg}}". Valid modes: {{modes}}':
|
||||
'Недопустимый режим подтверждения "{{arg}}". Допустимые режимы: {{modes}}',
|
||||
'Approval mode set to "{{mode}}"':
|
||||
'Режим подтверждения установлен на "{{mode}}"',
|
||||
'View or change the language setting':
|
||||
'Просмотр или изменение настроек языка',
|
||||
'change the theme': 'Изменение темы',
|
||||
@@ -96,8 +127,8 @@ export default {
|
||||
Preview: 'Предпросмотр',
|
||||
'(Use Enter to select, Tab to configure scope)':
|
||||
'(Enter для выбора, Tab для настройки области)',
|
||||
'(Use Enter to apply scope, Tab to select theme)':
|
||||
'(Enter для применения области, Tab для выбора темы)',
|
||||
'(Use Enter to apply scope, Tab to go back)':
|
||||
'(Enter для применения области, Tab для возврата)',
|
||||
'Theme configuration unavailable due to NO_COLOR env variable.':
|
||||
'Настройка темы недоступна из-за переменной окружения NO_COLOR.',
|
||||
'Theme "{{themeName}}" not found.': 'Тема "{{themeName}}" не найдена.',
|
||||
@@ -151,6 +182,7 @@ export default {
|
||||
'Project Level ({{path}})': 'Уровень проекта ({{path}})',
|
||||
'User Level ({{path}})': 'Уровень пользователя ({{path}})',
|
||||
'Built-in Agents': 'Встроенные агенты',
|
||||
'Extension Agents': 'Агенты расширений',
|
||||
'Using: {{count}} agents': 'Используется: {{count}} агент(ов)',
|
||||
'View Agent': 'Просмотреть агента',
|
||||
'Edit Agent': 'Редактировать агента',
|
||||
@@ -256,8 +288,6 @@ export default {
|
||||
// ============================================================================
|
||||
'View and edit Qwen Code settings': 'Просмотр и изменение настроек Qwen Code',
|
||||
Settings: 'Настройки',
|
||||
'(Use Enter to select{{tabText}})': '(Enter для выбора{{tabText}})',
|
||||
', Tab to change focus': ', Tab для смены фокуса',
|
||||
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.':
|
||||
'Для применения изменений необходимо перезапустить Qwen Code. Нажмите r для выхода и применения изменений.',
|
||||
'The command "/{{command}}" is not supported in non-interactive mode.':
|
||||
@@ -267,24 +297,31 @@ export default {
|
||||
// ============================================================================
|
||||
'Vim Mode': 'Режим Vim',
|
||||
'Disable Auto Update': 'Отключить автообновление',
|
||||
'Attribution: commit': 'Атрибуция: коммит',
|
||||
'Terminal Bell Notification': 'Звуковое уведомление терминала',
|
||||
'Enable Usage Statistics': 'Включить сбор статистики использования',
|
||||
Theme: 'Тема',
|
||||
'Preferred Editor': 'Предпочтительный редактор',
|
||||
'Auto-connect to IDE': 'Автоподключение к IDE',
|
||||
'Enable Prompt Completion': 'Включить автодополнение промптов',
|
||||
'Debug Keystroke Logging': 'Логирование нажатий клавиш для отладки',
|
||||
Language: 'Язык',
|
||||
'Language: UI': 'Язык: интерфейс',
|
||||
'Language: Model': 'Язык: модель',
|
||||
'Output Format': 'Формат вывода',
|
||||
'Hide Window Title': 'Скрыть заголовок окна',
|
||||
'Show Status in Title': 'Показывать статус в заголовке',
|
||||
'Hide Tips': 'Скрыть подсказки',
|
||||
'Hide Banner': 'Скрыть баннер',
|
||||
'Hide Context Summary': 'Скрыть сводку контекста',
|
||||
'Hide CWD': 'Скрыть текущую директорию',
|
||||
'Hide Sandbox Status': 'Скрыть статус песочницы',
|
||||
'Hide Model Info': 'Скрыть информацию о модели',
|
||||
'Hide Footer': 'Скрыть нижний колонтитул',
|
||||
'Show Memory Usage': 'Показывать использование памяти',
|
||||
'Show Line Numbers': 'Показывать номера строк',
|
||||
'Show Line Numbers in Code': 'Показывать номера строк в коде',
|
||||
'Show Citations': 'Показывать цитаты',
|
||||
'Custom Witty Phrases': 'Пользовательские остроумные фразы',
|
||||
'Enable Welcome Back': 'Включить приветствие при возврате',
|
||||
'Show Welcome Back Dialog': 'Показывать диалог приветствия',
|
||||
'Enable User Feedback': 'Включить отзывы пользователей',
|
||||
'How is Qwen doing this session? (optional)':
|
||||
'Как дела у Qwen в этой сессии? (необязательно)',
|
||||
Bad: 'Плохо',
|
||||
Good: 'Хорошо',
|
||||
'Not Sure Yet': 'Пока не уверен',
|
||||
'Any other key': 'Любая другая клавиша',
|
||||
'Disable Loading Phrases': 'Отключить фразы при загрузке',
|
||||
'Screen Reader Mode': 'Режим программы чтения с экрана',
|
||||
'IDE Mode': 'Режим IDE',
|
||||
@@ -304,7 +341,7 @@ export default {
|
||||
'Respect .qwenignore': 'Учитывать .qwenignore',
|
||||
'Enable Recursive File Search': 'Включить рекурсивный поиск файлов',
|
||||
'Disable Fuzzy Search': 'Отключить нечеткий поиск',
|
||||
'Enable Interactive Shell': 'Включить интерактивный терминал',
|
||||
'Interactive Shell (PTY)': 'Интерактивный терминал (PTY)',
|
||||
'Show Color': 'Показывать цвета',
|
||||
'Auto Accept': 'Автоподтверждение',
|
||||
'Use Ripgrep': 'Использовать Ripgrep',
|
||||
@@ -315,6 +352,7 @@ export default {
|
||||
'Folder Trust': 'Доверие к папке',
|
||||
'Vision Model Preview': 'Визуальная модель (предпросмотр)',
|
||||
'Tool Schema Compliance': 'Соответствие схеме инструмента',
|
||||
'Experimental: Skills': 'Экспериментальное: Навыки',
|
||||
// Варианты перечислений настроек
|
||||
'Auto (detect from system)': 'Авто (определить из системы)',
|
||||
Text: 'Текст',
|
||||
@@ -341,10 +379,146 @@ export default {
|
||||
'Показать все директории в рабочем пространстве',
|
||||
'set external editor preference':
|
||||
'Установка предпочитаемого внешнего редактора',
|
||||
'Select Editor': 'Выбрать редактор',
|
||||
'Editor Preference': 'Настройка редактора',
|
||||
'These editors are currently supported. Please note that some editors cannot be used in sandbox mode.':
|
||||
'В настоящее время поддерживаются следующие редакторы. Обратите внимание, что некоторые редакторы нельзя использовать в режиме песочницы.',
|
||||
'Your preferred editor is:': 'Ваш предпочитаемый редактор:',
|
||||
'Manage extensions': 'Управление расширениями',
|
||||
'List active extensions': 'Показать активные расширения',
|
||||
'Update extensions. Usage: update <extension-names>|--all':
|
||||
'Обновить расширения. Использование: update <extension-names>|--all',
|
||||
'Disable an extension': 'Отключить расширение',
|
||||
'Enable an extension': 'Включить расширение',
|
||||
'Install an extension from a git repo or local path':
|
||||
'Установить расширение из Git-репозитория или локального пути',
|
||||
'Uninstall an extension': 'Удалить расширение',
|
||||
'No extensions installed.': 'Расширения не установлены.',
|
||||
'Usage: /extensions update <extension-names>|--all':
|
||||
'Использование: /extensions update <имена-расширений>|--all',
|
||||
'Extension "{{name}}" not found.': 'Расширение "{{name}}" не найдено.',
|
||||
'No extensions to update.': 'Нет расширений для обновления.',
|
||||
'Usage: /extensions install <source>':
|
||||
'Использование: /extensions install <источник>',
|
||||
'Installing extension from "{{source}}"...':
|
||||
'Установка расширения из "{{source}}"...',
|
||||
'Extension "{{name}}" installed successfully.':
|
||||
'Расширение "{{name}}" успешно установлено.',
|
||||
'Failed to install extension from "{{source}}": {{error}}':
|
||||
'Не удалось установить расширение из "{{source}}": {{error}}',
|
||||
'Usage: /extensions uninstall <extension-name>':
|
||||
'Использование: /extensions uninstall <имя-расширения>',
|
||||
'Uninstalling extension "{{name}}"...': 'Удаление расширения "{{name}}"...',
|
||||
'Extension "{{name}}" uninstalled successfully.':
|
||||
'Расширение "{{name}}" успешно удалено.',
|
||||
'Failed to uninstall extension "{{name}}": {{error}}':
|
||||
'Не удалось удалить расширение "{{name}}": {{error}}',
|
||||
'Usage: /extensions {{command}} <extension> [--scope=<user|workspace>]':
|
||||
'Использование: /extensions {{command}} <расширение> [--scope=<user|workspace>]',
|
||||
'Unsupported scope "{{scope}}", should be one of "user" or "workspace"':
|
||||
'Неподдерживаемая область "{{scope}}", должна быть "user" или "workspace"',
|
||||
'Extension "{{name}}" disabled for scope "{{scope}}"':
|
||||
'Расширение "{{name}}" отключено для области "{{scope}}"',
|
||||
'Extension "{{name}}" enabled for scope "{{scope}}"':
|
||||
'Расширение "{{name}}" включено для области "{{scope}}"',
|
||||
'Do you want to continue? [Y/n]: ': 'Хотите продолжить? [Y/n]: ',
|
||||
'Do you want to continue?': 'Хотите продолжить?',
|
||||
'Installing extension "{{name}}".': 'Установка расширения "{{name}}".',
|
||||
'**Extensions may introduce unexpected behavior. Ensure you have investigated the extension source and trust the author.**':
|
||||
'**Расширения могут вызывать неожиданное поведение. Убедитесь, что вы изучили источник расширения и доверяете автору.**',
|
||||
'This extension will run the following MCP servers:':
|
||||
'Это расширение запустит следующие MCP-серверы:',
|
||||
local: 'локальный',
|
||||
remote: 'удалённый',
|
||||
'This extension will add the following commands: {{commands}}.':
|
||||
'Это расширение добавит следующие команды: {{commands}}.',
|
||||
'This extension will append info to your QWEN.md context using {{fileName}}':
|
||||
'Это расширение добавит информацию в ваш контекст QWEN.md с помощью {{fileName}}',
|
||||
'This extension will exclude the following core tools: {{tools}}':
|
||||
'Это расширение исключит следующие основные инструменты: {{tools}}',
|
||||
'This extension will install the following skills:':
|
||||
'Это расширение установит следующие навыки:',
|
||||
'This extension will install the following subagents:':
|
||||
'Это расширение установит следующие подагенты:',
|
||||
'Installation cancelled for "{{name}}".': 'Установка "{{name}}" отменена.',
|
||||
'--ref and --auto-update are not applicable for marketplace extensions.':
|
||||
'--ref и --auto-update неприменимы для расширений из маркетплейса.',
|
||||
'Extension "{{name}}" installed successfully and enabled.':
|
||||
'Расширение "{{name}}" успешно установлено и включено.',
|
||||
'Installs an extension from a git repository URL, local path, or claude marketplace (marketplace-url:plugin-name).':
|
||||
'Устанавливает расширение из URL Git-репозитория, локального пути или маркетплейса Claude (marketplace-url:plugin-name).',
|
||||
'The github URL, local path, or marketplace source (marketplace-url:plugin-name) of the extension to install.':
|
||||
'URL GitHub, локальный путь или источник в маркетплейсе (marketplace-url:plugin-name) устанавливаемого расширения.',
|
||||
'The git ref to install from.': 'Git-ссылка для установки.',
|
||||
'Enable auto-update for this extension.':
|
||||
'Включить автообновление для этого расширения.',
|
||||
'Enable pre-release versions for this extension.':
|
||||
'Включить пре-релизные версии для этого расширения.',
|
||||
'Acknowledge the security risks of installing an extension and skip the confirmation prompt.':
|
||||
'Подтвердить риски безопасности установки расширения и пропустить запрос подтверждения.',
|
||||
'The source argument must be provided.':
|
||||
'Необходимо указать аргумент источника.',
|
||||
'Extension "{{name}}" successfully uninstalled.':
|
||||
'Расширение "{{name}}" успешно удалено.',
|
||||
'Uninstalls an extension.': 'Удаляет расширение.',
|
||||
'The name or source path of the extension to uninstall.':
|
||||
'Имя или путь к источнику удаляемого расширения.',
|
||||
'Please include the name of the extension to uninstall as a positional argument.':
|
||||
'Пожалуйста, укажите имя удаляемого расширения как позиционный аргумент.',
|
||||
'Enables an extension.': 'Включает расширение.',
|
||||
'The name of the extension to enable.': 'Имя включаемого расширения.',
|
||||
'The scope to enable the extenison in. If not set, will be enabled in all scopes.':
|
||||
'Область для включения расширения. Если не задана, будет включено во всех областях.',
|
||||
'Extension "{{name}}" successfully enabled for scope "{{scope}}".':
|
||||
'Расширение "{{name}}" успешно включено для области "{{scope}}".',
|
||||
'Extension "{{name}}" successfully enabled in all scopes.':
|
||||
'Расширение "{{name}}" успешно включено во всех областях.',
|
||||
'Invalid scope: {{scope}}. Please use one of {{scopes}}.':
|
||||
'Недопустимая область: {{scope}}. Пожалуйста, используйте одну из {{scopes}}.',
|
||||
'Disables an extension.': 'Отключает расширение.',
|
||||
'The name of the extension to disable.': 'Имя отключаемого расширения.',
|
||||
'The scope to disable the extenison in.':
|
||||
'Область для отключения расширения.',
|
||||
'Extension "{{name}}" successfully disabled for scope "{{scope}}".':
|
||||
'Расширение "{{name}}" успешно отключено для области "{{scope}}".',
|
||||
'Extension "{{name}}" successfully updated: {{oldVersion}} → {{newVersion}}.':
|
||||
'Расширение "{{name}}" успешно обновлено: {{oldVersion}} → {{newVersion}}.',
|
||||
'Unable to install extension "{{name}}" due to missing install metadata':
|
||||
'Невозможно установить расширение "{{name}}" из-за отсутствия метаданных установки',
|
||||
'Extension "{{name}}" is already up to date.':
|
||||
'Расширение "{{name}}" уже актуально.',
|
||||
'Updates all extensions or a named extension to the latest version.':
|
||||
'Обновляет все расширения или указанное расширение до последней версии.',
|
||||
'The name of the extension to update.': 'Имя обновляемого расширения.',
|
||||
'Update all extensions.': 'Обновить все расширения.',
|
||||
'Either an extension name or --all must be provided':
|
||||
'Необходимо указать имя расширения или --all',
|
||||
'Lists installed extensions.': 'Показывает установленные расширения.',
|
||||
'Link extension failed to install.':
|
||||
'Не удалось установить связанное расширение.',
|
||||
'Extension "{{name}}" linked successfully and enabled.':
|
||||
'Расширение "{{name}}" успешно связано и включено.',
|
||||
'Links an extension from a local path. Updates made to the local path will always be reflected.':
|
||||
'Связывает расширение из локального пути. Изменения в локальном пути будут всегда отражаться.',
|
||||
'The name of the extension to link.': 'Имя связываемого расширения.',
|
||||
'Set a specific setting for an extension.':
|
||||
'Установить конкретную настройку для расширения.',
|
||||
'Name of the extension to configure.': 'Имя настраиваемого расширения.',
|
||||
'The setting to configure (name or env var).':
|
||||
'Настройка для конфигурирования (имя или переменная окружения).',
|
||||
'The scope to set the setting in.': 'Область для установки настройки.',
|
||||
'List all settings for an extension.': 'Показать все настройки расширения.',
|
||||
'Name of the extension.': 'Имя расширения.',
|
||||
'Extension "{{name}}" has no settings to configure.':
|
||||
'Расширение "{{name}}" не имеет настроек для конфигурирования.',
|
||||
'Settings for "{{name}}":': 'Настройки для "{{name}}":',
|
||||
'(workspace)': '(рабочее пространство)',
|
||||
'(user)': '(пользователь)',
|
||||
'[not set]': '[не задано]',
|
||||
'[value stored in keychain]': '[значение хранится в связке ключей]',
|
||||
'Manage extension settings.': 'Управление настройками расширений.',
|
||||
'You need to specify a command (set or list).':
|
||||
'Необходимо указать команду (set или list).',
|
||||
'manage IDE integration': 'Управление интеграцией с IDE',
|
||||
'check status of IDE integration': 'Проверить статус интеграции с IDE',
|
||||
'install required IDE companion for {{ideName}}':
|
||||
@@ -408,6 +582,8 @@ export default {
|
||||
'Example: /language output English': 'Пример: /language output English',
|
||||
'Example: /language output 日本語': 'Пример: /language output 日本語',
|
||||
'UI language changed to {{lang}}': 'Язык интерфейса изменен на {{lang}}',
|
||||
'LLM output language set to {{lang}}':
|
||||
'Язык вывода LLM установлен на {{lang}}',
|
||||
'LLM output language rule file generated at {{path}}':
|
||||
'Файл правил языка вывода LLM создан в {{path}}',
|
||||
'Please restart the application for the changes to take effect.':
|
||||
@@ -430,7 +606,7 @@ export default {
|
||||
// ============================================================================
|
||||
// Команды - Режим подтверждения
|
||||
// ============================================================================
|
||||
'Approval Mode': 'Режим подтверждения',
|
||||
'Tool Approval Mode': 'Режим подтверждения инструментов',
|
||||
'Current approval mode: {{mode}}': 'Текущий режим подтверждения: {{mode}}',
|
||||
'Available approval modes:': 'Доступные режимы подтверждения:',
|
||||
'Approval mode changed to: {{mode}}':
|
||||
@@ -472,8 +648,6 @@ export default {
|
||||
'Автоматически подтверждать все инструменты',
|
||||
'Workspace approval mode exists and takes priority. User-level change will have no effect.':
|
||||
'Режим подтверждения рабочего пространства существует и имеет приоритет. Изменение на уровне пользователя не будет иметь эффекта.',
|
||||
'(Use Enter to select, Tab to change focus)':
|
||||
'(Enter для выбора, Tab для смены фокуса)',
|
||||
'Apply To': 'Применить к',
|
||||
'User Settings': 'Настройки пользователя',
|
||||
'Workspace Settings': 'Настройки рабочего пространства',
|
||||
@@ -782,6 +956,21 @@ export default {
|
||||
'Время ожидания авторизации истекло. Пожалуйста, попробуйте снова.',
|
||||
'Waiting for auth... (Press ESC or CTRL+C to cancel)':
|
||||
'Ожидание авторизации... (Нажмите ESC или CTRL+C для отмены)',
|
||||
'Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the {{envKeyHint}} environment variable.':
|
||||
'Отсутствует API-ключ для аутентификации, совместимой с OpenAI. Укажите settings.security.auth.apiKey или переменную окружения {{envKeyHint}}.',
|
||||
'{{envKeyHint}} environment variable not found.':
|
||||
'Переменная окружения {{envKeyHint}} не найдена.',
|
||||
'{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.':
|
||||
'Переменная окружения {{envKeyHint}} не найдена. Укажите её в файле .env или среди системных переменных.',
|
||||
'{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.':
|
||||
'Переменная окружения {{envKeyHint}} не найдена (или установите settings.security.auth.apiKey). Укажите её в файле .env или среди системных переменных.',
|
||||
'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.':
|
||||
'Отсутствует API-ключ для аутентификации, совместимой с OpenAI. Установите переменную окружения {{envKeyHint}}.',
|
||||
'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.':
|
||||
'У провайдера Anthropic отсутствует обязательный baseUrl в modelProviders[].baseUrl.',
|
||||
'ANTHROPIC_BASE_URL environment variable not found.':
|
||||
'Переменная окружения ANTHROPIC_BASE_URL не найдена.',
|
||||
'Invalid auth method selected.': 'Выбран недопустимый метод авторизации.',
|
||||
'Failed to authenticate. Message: {{message}}':
|
||||
'Не удалось авторизоваться. Сообщение: {{message}}',
|
||||
'Authenticated successfully with {{authType}} credentials.':
|
||||
@@ -803,6 +992,15 @@ export default {
|
||||
// ============================================================================
|
||||
'Select Model': 'Выбрать модель',
|
||||
'(Press Esc to close)': '(Нажмите Esc для закрытия)',
|
||||
'Current (effective) configuration': 'Текущая (фактическая) конфигурация',
|
||||
AuthType: 'Тип авторизации',
|
||||
'API Key': 'API-ключ',
|
||||
unset: 'не задано',
|
||||
'(default)': '(по умолчанию)',
|
||||
'(set)': '(установлено)',
|
||||
'(not set)': '(не задано)',
|
||||
"Failed to switch model to '{{modelId}}'.\n\n{{error}}":
|
||||
"Не удалось переключиться на модель '{{modelId}}'.\n\n{{error}}",
|
||||
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)':
|
||||
'Последняя модель Qwen Coder от Alibaba Cloud ModelStudio (версия: qwen3-coder-plus-2025-09-23)',
|
||||
'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)':
|
||||
@@ -947,6 +1145,19 @@ export default {
|
||||
'Session start time is unavailable, cannot calculate stats.':
|
||||
'Время начала сессии недоступно, невозможно рассчитать статистику.',
|
||||
|
||||
// ============================================================================
|
||||
// Command Format Migration
|
||||
// ============================================================================
|
||||
'Command Format Migration': 'Миграция формата команд',
|
||||
'Found {{count}} TOML command file:': 'Найден {{count}} файл команд TOML:',
|
||||
'Found {{count}} TOML command files:':
|
||||
'Найдено {{count}} файлов команд TOML:',
|
||||
'... and {{count}} more': '... и ещё {{count}}',
|
||||
'The TOML format is deprecated. Would you like to migrate them to Markdown format?':
|
||||
'Формат TOML устарел. Хотите перенести их в формат Markdown?',
|
||||
'(Backups will be created and original files will be preserved)':
|
||||
'(Будут созданы резервные копии, исходные файлы будут сохранены)',
|
||||
|
||||
// ============================================================================
|
||||
// Loading Phrases
|
||||
// ============================================================================
|
||||
@@ -1056,7 +1267,6 @@ export default {
|
||||
'Провожу настройку методом тыка...',
|
||||
'Ищем, какой стороной вставлять флешку...',
|
||||
'Следим, чтобы волшебный дым не вышел из проводов...',
|
||||
'Переписываем всё на Rust без особой причины...',
|
||||
'Пытаемся выйти из Vim...',
|
||||
'Раскручиваем колесо для хомяка...',
|
||||
'Это не баг, а фича...',
|
||||
@@ -1089,4 +1299,55 @@ export default {
|
||||
'Пробовали выключить и включить снова? (Экран загрузки, не меня!)',
|
||||
'Нужно построить больше пилонов...',
|
||||
],
|
||||
|
||||
// ============================================================================
|
||||
// Extension Settings Input
|
||||
// ============================================================================
|
||||
'Enter value...': 'Введите значение...',
|
||||
'Enter sensitive value...': 'Введите секретное значение...',
|
||||
'Press Enter to submit, Escape to cancel':
|
||||
'Нажмите Enter для отправки, Escape для отмены',
|
||||
|
||||
// ============================================================================
|
||||
// Command Migration Tool
|
||||
// ============================================================================
|
||||
'Markdown file already exists: {{filename}}':
|
||||
'Markdown-файл уже существует: {{filename}}',
|
||||
'TOML Command Format Deprecation Notice':
|
||||
'Уведомление об устаревании формата TOML',
|
||||
'Found {{count}} command file(s) in TOML format:':
|
||||
'Найдено {{count}} файл(ов) команд в формате TOML:',
|
||||
'The TOML format for commands is being deprecated in favor of Markdown format.':
|
||||
'Формат TOML для команд устаревает в пользу формата Markdown.',
|
||||
'Markdown format is more readable and easier to edit.':
|
||||
'Формат Markdown более читаемый и простой для редактирования.',
|
||||
'You can migrate these files automatically using:':
|
||||
'Вы можете автоматически мигрировать эти файлы с помощью:',
|
||||
'Or manually convert each file:': 'Или вручную конвертировать каждый файл:',
|
||||
'TOML: prompt = "..." / description = "..."':
|
||||
'TOML: prompt = "..." / description = "..."',
|
||||
'Markdown: YAML frontmatter + content':
|
||||
'Markdown: YAML frontmatter + содержимое',
|
||||
'The migration tool will:': 'Инструмент миграции:',
|
||||
'Convert TOML files to Markdown': 'Конвертирует TOML-файлы в Markdown',
|
||||
'Create backups of original files': 'Создаёт резервные копии исходных файлов',
|
||||
'Preserve all command functionality': 'Сохраняет всю функциональность команд',
|
||||
'TOML format will continue to work for now, but migration is recommended.':
|
||||
'Формат TOML пока продолжит работать, но миграция рекомендуется.',
|
||||
|
||||
// ============================================================================
|
||||
// Extensions - Explore Command
|
||||
// ============================================================================
|
||||
'Open extensions page in your browser':
|
||||
'Открыть страницу расширений в браузере',
|
||||
'Unknown extensions source: {{source}}.':
|
||||
'Неизвестный источник расширений: {{source}}.',
|
||||
'Would open extensions page in your browser: {{url}} (skipped in test environment)':
|
||||
'Страница расширений была бы открыта в браузере: {{url}} (пропущено в тестовой среде)',
|
||||
'View available extensions at {{url}}':
|
||||
'Посмотреть доступные расширения на {{url}}',
|
||||
'Opening extensions page in your browser: {{url}}':
|
||||
'Открываем страницу расширений в браузере: {{url}}',
|
||||
'Failed to open browser. Check out the extensions gallery at {{url}}':
|
||||
'Не удалось открыть браузер. Посетите галерею расширений по адресу {{url}}',
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user