diff --git a/.github/workflows/release-vscode-companion.yml b/.github/workflows/release-vscode-companion.yml
new file mode 100644
index 000000000..97d37f403
--- /dev/null
+++ b/.github/workflows/release-vscode-companion.yml
@@ -0,0 +1,207 @@
+name: 'Release VSCode IDE Companion'
+
+on:
+ workflow_dispatch:
+ inputs:
+ version:
+ description: 'The version to release (e.g., v0.1.11). Required for manual patch releases.'
+ required: false
+ type: 'string'
+ ref:
+ description: 'The branch or ref (full git sha) to release from.'
+ required: true
+ type: 'string'
+ default: 'main'
+ dry_run:
+ description: 'Run a dry-run of the release process; no branches, vsix packages or GitHub releases will be created.'
+ required: true
+ type: 'boolean'
+ default: true
+ create_preview_release:
+ description: 'Auto apply the preview release tag, input version is ignored.'
+ required: false
+ type: 'boolean'
+ default: false
+ force_skip_tests:
+ description: 'Select to skip the "Run Tests" step in testing. Prod releases should run tests'
+ required: false
+ type: 'boolean'
+ default: false
+
+concurrency:
+ group: '${{ github.workflow }}'
+ cancel-in-progress: false
+
+jobs:
+ release-vscode-companion:
+ runs-on: 'ubuntu-latest'
+ environment:
+ name: 'production-release'
+ url: '${{ github.server_url }}/${{ github.repository }}/releases/tag/vscode-companion-${{ steps.version.outputs.RELEASE_TAG }}'
+ if: |-
+ ${{ github.repository == 'QwenLM/qwen-code' }}
+ permissions:
+ contents: 'read'
+ issues: 'write'
+
+ steps:
+ - name: 'Checkout'
+ uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
+ with:
+ ref: '${{ github.event.inputs.ref || github.sha }}'
+ fetch-depth: 0
+
+ - name: 'Set booleans for simplified logic'
+ env:
+ CREATE_PREVIEW_RELEASE: '${{ github.event.inputs.create_preview_release }}'
+ DRY_RUN_INPUT: '${{ github.event.inputs.dry_run }}'
+ id: 'vars'
+ run: |-
+ is_preview="false"
+ if [[ "${CREATE_PREVIEW_RELEASE}" == "true" ]]; then
+ is_preview="true"
+ fi
+ echo "is_preview=${is_preview}" >> "${GITHUB_OUTPUT}"
+
+ is_dry_run="false"
+ if [[ "${DRY_RUN_INPUT}" == "true" ]]; then
+ is_dry_run="true"
+ fi
+ echo "is_dry_run=${is_dry_run}" >> "${GITHUB_OUTPUT}"
+
+ - name: 'Setup Node.js'
+ uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
+ with:
+ node-version-file: '.nvmrc'
+ cache: 'npm'
+ cache-dependency-path: 'package-lock.json'
+
+ - name: 'Install Dependencies'
+ env:
+ NPM_CONFIG_PREFER_OFFLINE: 'true'
+ run: |-
+ npm ci
+
+ - name: 'Install VSCE and OVSX'
+ run: |-
+ npm install -g @vscode/vsce
+ npm install -g ovsx
+
+ - name: 'Get the version'
+ id: 'version'
+ working-directory: 'packages/vscode-ide-companion'
+ run: |
+ # Get the base version from package.json regardless of scenario
+ BASE_VERSION=$(node -p "require('./package.json').version")
+
+ if [[ "${IS_PREVIEW}" == "true" ]]; then
+ # Generate preview version with timestamp based on actual package version
+ TIMESTAMP=$(date +%Y%m%d%H%M%S)
+ PREVIEW_VERSION="${BASE_VERSION}-preview.${TIMESTAMP}"
+ RELEASE_TAG="preview.${TIMESTAMP}"
+
+ echo "RELEASE_TAG=${RELEASE_TAG}" >> "$GITHUB_OUTPUT"
+ echo "RELEASE_VERSION=${PREVIEW_VERSION}" >> "$GITHUB_OUTPUT"
+ echo "VSCODE_TAG=preview" >> "$GITHUB_OUTPUT"
+ else
+ # Use specified version or get from package.json
+ if [[ -n "${MANUAL_VERSION}" ]]; then
+ RELEASE_VERSION="${MANUAL_VERSION#v}" # Remove 'v' prefix if present
+ RELEASE_TAG="${MANUAL_VERSION#v}" # Remove 'v' prefix if present
+ else
+ RELEASE_VERSION="${BASE_VERSION}"
+ RELEASE_TAG="${BASE_VERSION}"
+ fi
+
+ echo "RELEASE_TAG=${RELEASE_TAG}" >> "$GITHUB_OUTPUT"
+ echo "RELEASE_VERSION=${RELEASE_VERSION}" >> "$GITHUB_OUTPUT"
+ echo "VSCODE_TAG=latest" >> "$GITHUB_OUTPUT"
+ fi
+ env:
+ IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}'
+ MANUAL_VERSION: '${{ inputs.version }}'
+
+ - name: 'Update package version (for preview releases)'
+ if: '${{ steps.vars.outputs.is_preview == ''true'' }}'
+ working-directory: 'packages/vscode-ide-companion'
+ env:
+ RELEASE_VERSION: '${{ steps.version.outputs.RELEASE_VERSION }}'
+ run: |-
+ # Update package.json with preview version
+ npm version "${RELEASE_VERSION}" --no-git-tag-version --allow-same-version
+
+ - name: 'Run Tests'
+ if: |-
+ ${{ github.event.inputs.force_skip_tests != 'true' }}
+ working-directory: 'packages/vscode-ide-companion'
+ run: |
+ npm run test:ci
+ env:
+ OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
+ OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
+ OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
+
+ - name: 'Prepare VSCode Extension'
+ run: |
+ # Build and stage the extension + bundled CLI once.
+ npm --workspace=qwen-code-vscode-ide-companion run prepackage
+
+ - name: 'Package VSIX (dry run)'
+ if: '${{ steps.vars.outputs.is_dry_run == ''true'' }}'
+ working-directory: 'packages/vscode-ide-companion'
+ run: |-
+ if [[ "${{ steps.vars.outputs.is_preview }}" == "true" ]]; then
+ vsce package --pre-release --out ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix
+ else
+ vsce package --out ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix
+ fi
+
+ - name: 'Upload VSIX Artifact (dry run)'
+ if: '${{ steps.vars.outputs.is_dry_run == ''true'' }}'
+ uses: 'actions/upload-artifact@v4'
+ with:
+ name: 'qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix'
+ path: 'packages/qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix'
+ if-no-files-found: 'error'
+
+ - name: 'Publish to Microsoft Marketplace'
+ if: '${{ steps.vars.outputs.is_dry_run == ''false'' }}'
+ working-directory: 'packages/vscode-ide-companion'
+ env:
+ VSCE_PAT: '${{ secrets.VSCE_PAT }}'
+ VSCODE_TAG: '${{ steps.version.outputs.VSCODE_TAG }}'
+ run: |-
+ if [[ "${{ steps.vars.outputs.is_preview }}" == "true" ]]; then
+ echo "Skipping Microsoft Marketplace for preview release"
+ else
+ vsce publish --pat "${VSCE_PAT}" --tag "${VSCODE_TAG}"
+ fi
+
+ - name: 'Publish to OpenVSX'
+ if: '${{ steps.vars.outputs.is_dry_run == ''false'' }}'
+ working-directory: 'packages/vscode-ide-companion'
+ env:
+ OVSX_TOKEN: '${{ secrets.OVSX_TOKEN }}'
+ VSCODE_TAG: '${{ steps.version.outputs.VSCODE_TAG }}'
+ run: |-
+ if [[ "${{ steps.vars.outputs.is_preview }}" == "true" ]]; then
+ # For preview releases, publish with preview tag
+ # First package the extension for preview
+ vsce package --pre-release --out ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix
+ ovsx publish ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix --pat "${OVSX_TOKEN}" --pre-release
+ else
+ # Package and publish normally
+ vsce package --out ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix
+ ovsx publish ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix --pat "${OVSX_TOKEN}" --tag "${VSCODE_TAG}"
+ fi
+
+ - name: 'Create Issue on Failure'
+ if: |-
+ ${{ failure() }}
+ env:
+ GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
+ DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
+ run: |-
+ gh issue create \
+ --title "VSCode IDE Companion Release Failed for ${{ steps.version.outputs.RELEASE_VERSION }} on $(date +'%Y-%m-%d')" \
+ --body "The VSCode IDE Companion release workflow failed. See the full run for details: ${DETAILS_URL}"
diff --git a/README.md b/README.md
index 75997c715..43398fac5 100644
--- a/README.md
+++ b/README.md
@@ -5,6 +5,8 @@
[](https://nodejs.org/)
[](https://www.npmjs.com/package/@qwen-code/qwen-code)
+
+
**An open-source AI agent that lives in your terminal.**
中文 |
diff --git a/docs/developers/_meta.ts b/docs/developers/_meta.ts
index 154ce1848..a7a316f77 100644
--- a/docs/developers/_meta.ts
+++ b/docs/developers/_meta.ts
@@ -19,9 +19,6 @@ export default {
tools: 'Tools',
- extensions: {
- display: 'hidden',
- },
examples: {
display: 'hidden',
},
diff --git a/docs/developers/development/npm.md b/docs/developers/development/npm.md
index 76dfb72d4..e0c4068b8 100644
--- a/docs/developers/development/npm.md
+++ b/docs/developers/development/npm.md
@@ -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:
diff --git a/docs/developers/extensions/extension.md b/docs/developers/extensions/extension.md
deleted file mode 100644
index 0d2e93eb0..000000000
--- a/docs/developers/extensions/extension.md
+++ /dev/null
@@ -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 `/.qwen/extensions`
-
-Extensions exist as a directory that contains a `qwen-extension.json` file. For example:
-
-`/.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). |
diff --git a/docs/users/_meta.ts b/docs/users/_meta.ts
index 2ec43d773..a822e8201 100644
--- a/docs/users/_meta.ts
+++ b/docs/users/_meta.ts
@@ -20,6 +20,7 @@ export default {
},
features: 'Features',
configuration: 'Configuration',
+ extension: 'Extension',
reference: 'Reference',
support: 'Support',
// need refine
diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md
index 3ce527bdc..877deeb95 100644
--- a/docs/users/configuration/settings.md
+++ b/docs/users/configuration/settings.md
@@ -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` |
@@ -273,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
@@ -294,14 +290,14 @@ If you are experiencing performance issues with file searching (e.g., with `@` c
Language Server Protocol (LSP) settings for code intelligence features like go-to-definition, find references, and diagnostics. See the [LSP documentation](../features/lsp) for more details.
-| Setting | Type | Description | Default |
-| ------------------ | ---------------- | ---------------------------------------------------------------------------------------------------- | ----------- |
-| `lsp.enabled` | boolean | Enable/disable LSP support. Has no effect unless `--experimental-lsp` is provided. | `false` |
-| `lsp.autoDetect` | boolean | Automatically detect and start language servers based on project files. | `true` |
-| `lsp.serverTimeout`| number | LSP server startup timeout in milliseconds. | `10000` |
-| `lsp.allowed` | array of strings | An allowlist of LSP servers to allow. Empty means allow all detected servers. | `[]` |
-| `lsp.excluded` | array of strings | A denylist of LSP servers to exclude. A server listed in both is excluded. | `[]` |
-| `lsp.languageServers` | object | Custom language server configurations. See the [LSP documentation](../features/lsp#custom-language-servers) for configuration format. | `{}` |
+| Setting | Type | Description | Default |
+| --------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ------- |
+| `lsp.enabled` | boolean | Enable/disable LSP support. Has no effect unless `--experimental-lsp` is provided. | `false` |
+| `lsp.autoDetect` | boolean | Automatically detect and start language servers based on project files. | `true` |
+| `lsp.serverTimeout` | number | LSP server startup timeout in milliseconds. | `10000` |
+| `lsp.allowed` | array of strings | An allowlist of LSP servers to allow. Empty means allow all detected servers. | `[]` |
+| `lsp.excluded` | array of strings | A denylist of LSP servers to exclude. A server listed in both is excluded. | `[]` |
+| `lsp.languageServers` | object | Custom language server configurations. See the [LSP documentation](../features/lsp#custom-language-servers) for configuration format. | `{}` |
> [!note]
>
@@ -381,7 +377,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",
diff --git a/docs/users/extension/_meta.ts b/docs/users/extension/_meta.ts
new file mode 100644
index 000000000..386bb68fc
--- /dev/null
+++ b/docs/users/extension/_meta.ts
@@ -0,0 +1,9 @@
+export default {
+ introduction: 'Introduction',
+ 'getting-start-extensions': {
+ display: 'hidden',
+ },
+ 'extension-releasing': {
+ display: 'hidden',
+ },
+};
diff --git a/docs/developers/extensions/extension-releasing.md b/docs/users/extension/extension-releasing.md
similarity index 100%
rename from docs/developers/extensions/extension-releasing.md
rename to docs/users/extension/extension-releasing.md
diff --git a/docs/developers/extensions/getting-started-extensions.md b/docs/users/extension/getting-started-extensions.md
similarity index 73%
rename from docs/developers/extensions/getting-started-extensions.md
rename to docs/users/extension/getting-started-extensions.md
index db4ed35fb..a09f74c8e 100644
--- a/docs/developers/extensions/getting-started-extensions.md
+++ b/docs/users/extension/getting-started-extensions.md
@@ -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.
diff --git a/docs/users/extension/introduction.md b/docs/users/extension/introduction.md
new file mode 100644
index 000000000..64bc7bc7d
--- /dev/null
+++ b/docs/users/extension/introduction.md
@@ -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 ` | Install an extension from a git URL, local path, or marketplace |
+| `/extensions uninstall ` | Uninstall an extension |
+| `/extensions enable --scope ` | Enable an extension |
+| `/extensions disable --scope ` | Disable an extension |
+| `/extensions update ` | 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 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 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 `/.qwen/extensions`
+
+Extensions exist as a directory that contains a `qwen-extension.json` file. For example:
+
+`/.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 [--scope user|workspace]
+```
+
+**List all settings for an extension:**
+
+```bash
+qwen extensions settings list
+```
+
+**View current values (user and workspace):**
+
+```bash
+qwen extensions settings show
+```
+
+**Remove a setting value:**
+
+```bash
+qwen extensions settings unset [--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). |
diff --git a/docs/users/features/commands.md b/docs/users/features/commands.md
index 5583f3494..1ad7ea1d7 100644
--- a/docs/users/features/commands.md
+++ b/docs/users/features/commands.md
@@ -121,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 |
@@ -135,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` |
-| `/git/commit.toml` | `/git:commit` | `/git:commit Message` |
+| File Location | Generated Command | Example Call |
+| -------------------------- | ----------------- | --------------------- |
+| `~/.qwen/commands/test.md` | `/test` | `/test Parameter` |
+| `/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 |
| ------------- | -------- | ---------------------------------------- | ------------------------------------------ |
@@ -191,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 (`@{...}`)
@@ -212,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
diff --git a/docs/users/features/skills.md b/docs/users/features/skills.md
index 0e55644ab..d5b1be9e6 100644
--- a/docs/users/features/skills.md
+++ b/docs/users/features/skills.md
@@ -157,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:
diff --git a/docs/users/features/sub-agents.md b/docs/users/features/sub-agents.md
index 3497df09f..85ca4aff9 100644
--- a/docs/users/features/sub-agents.md
+++ b/docs/users/features/sub-agents.md
@@ -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
diff --git a/docs/users/reference/keyboard-shortcuts.md b/docs/users/reference/keyboard-shortcuts.md
index b029c4a03..46f3c8c42 100644
--- a/docs/users/reference/keyboard-shortcuts.md
+++ b/docs/users/reference/keyboard-shortcuts.md
@@ -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. |
diff --git a/esbuild.config.js b/esbuild.config.js
index 9f24d0ba5..12ab39d58 100644
--- a/esbuild.config.js
+++ b/esbuild.config.js
@@ -33,7 +33,6 @@ const external = [
'@lydell/node-pty-linux-x64',
'@lydell/node-pty-win32-arm64',
'@lydell/node-pty-win32-x64',
- 'tiktoken',
];
esbuild
diff --git a/package-lock.json b/package-lock.json
index 39d10c131..5641b0bde 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@qwen-code/qwen-code",
- "version": "0.7.1",
+ "version": "0.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qwen-code/qwen-code",
- "version": "0.7.1",
+ "version": "0.8.0",
"workspaces": [
"packages/*"
],
@@ -3876,6 +3876,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",
@@ -10989,6 +11000,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",
@@ -13398,6 +13418,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",
@@ -14755,6 +14788,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",
@@ -15690,12 +15729,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",
@@ -17318,7 +17351,7 @@
},
"packages/cli": {
"name": "@qwen-code/qwen-code",
- "version": "0.7.1",
+ "version": "0.8.0",
"dependencies": {
"@google/genai": "1.30.0",
"@iarna/toml": "^2.2.5",
@@ -17330,7 +17363,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",
@@ -17348,7 +17380,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",
@@ -17372,7 +17403,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",
@@ -17955,11 +17985,12 @@
},
"packages/core": {
"name": "@qwen-code/qwen-code-core",
- "version": "0.7.1",
+ "version": "0.8.0",
"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",
@@ -17979,6 +18010,7 @@
"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",
@@ -17995,10 +18027,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"
@@ -18010,6 +18043,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",
@@ -18600,7 +18635,6 @@
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.25.1",
- "tiktoken": "^1.0.21",
"zod": "^3.25.0"
},
"devDependencies": {
@@ -21416,7 +21450,7 @@
},
"packages/test-utils": {
"name": "@qwen-code/qwen-code-test-utils",
- "version": "0.7.1",
+ "version": "0.8.0",
"dev": true,
"license": "Apache-2.0",
"devDependencies": {
@@ -21428,7 +21462,7 @@
},
"packages/vscode-ide-companion": {
"name": "qwen-code-vscode-ide-companion",
- "version": "0.7.1",
+ "version": "0.8.0",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.25.1",
diff --git a/package.json b/package.json
index c39d1cc47..2ce6e8146 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
- "version": "0.7.1",
+ "version": "0.8.0",
"engines": {
"node": ">=20.0.0"
},
@@ -13,7 +13,7 @@
"url": "git+https://github.com/QwenLM/qwen-code.git"
},
"config": {
- "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.1"
+ "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.8.0"
},
"scripts": {
"start": "cross-env node scripts/start.js",
diff --git a/packages/cli/package.json b/packages/cli/package.json
index f7b26a605..03e5d90e9 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
- "version": "0.7.1",
+ "version": "0.8.0",
"description": "Qwen Code",
"repository": {
"type": "git",
@@ -33,13 +33,13 @@
"dist"
],
"config": {
- "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.1"
+ "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.8.0"
},
"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"
diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts
index 373bf67be..6c40bffee 100644
--- a/packages/cli/src/acp-integration/acpAgent.ts
+++ b/packages/cli/src/acp-integration/acpAgent.ts
@@ -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,
) {}
@@ -196,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;
diff --git a/packages/cli/src/commands/extensions.test.tsx b/packages/cli/src/commands/extensions.test.tsx
new file mode 100644
index 000000000..4499fa1ee
--- /dev/null
+++ b/packages/cli/src/commands/extensions.test.tsx
@@ -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 ');
+ });
+
+ 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',
+ );
+ });
+});
diff --git a/packages/cli/src/commands/extensions.tsx b/packages/cli/src/commands/extensions.tsx
index 12b49e894..a69a1d85b 100644
--- a/packages/cli/src/commands/extensions.tsx
+++ b/packages/cli/src/commands/extensions.tsx
@@ -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 ',
@@ -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: () => {
diff --git a/packages/cli/src/commands/extensions/consent.test.ts b/packages/cli/src/commands/extensions/consent.test.ts
new file mode 100644
index 000000000..3b9808bc4
--- /dev/null
+++ b/packages/cli/src/commands/extensions/consent.test.ts
@@ -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) => {
+ 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;
+
+ 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();
+ });
+});
diff --git a/packages/cli/src/commands/extensions/consent.ts b/packages/cli/src/commands/extensions/consent.ts
new file mode 100644
index 000000000..c3da6a282
--- /dev/null
+++ b/packages/cli/src/commands/extensions/consent.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ return new Promise((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,
+ 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,
+ }),
+ );
+ }
+};
diff --git a/packages/cli/src/commands/extensions/disable.test.ts b/packages/cli/src/commands/extensions/disable.test.ts
new file mode 100644
index 000000000..7bde3ee0a
--- /dev/null
+++ b/packages/cli/src/commands/extensions/disable.test.ts
@@ -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);
+ });
+});
diff --git a/packages/cli/src/commands/extensions/disable.ts b/packages/cli/src/commands/extensions/disable.ts
index 0a88ce08f..92ebd6fa8 100644
--- a/packages/cli/src/commands/extensions/disable.ts
+++ b/packages/cli/src/commands/extensions/disable.ts
@@ -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] ',
- 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,
});
diff --git a/packages/cli/src/commands/extensions/enable.test.ts b/packages/cli/src/commands/extensions/enable.test.ts
new file mode 100644
index 000000000..374918e0a
--- /dev/null
+++ b/packages/cli/src/commands/extensions/enable.test.ts
@@ -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();
+ 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');
+ });
+});
diff --git a/packages/cli/src/commands/extensions/enable.ts b/packages/cli/src/commands/extensions/enable.ts
index 7a77112d2..b36e50ac9 100644
--- a/packages/cli/src/commands/extensions/enable.ts
+++ b/packages/cli/src/commands/extensions/enable.ts
@@ -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] ',
- 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,
});
diff --git a/packages/cli/src/commands/extensions/examples/agent/agents/diary.md b/packages/cli/src/commands/extensions/examples/agent/agents/diary.md
new file mode 100644
index 000000000..8c0c76a91
--- /dev/null
+++ b/packages/cli/src/commands/extensions/examples/agent/agents/diary.md
@@ -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
diff --git a/packages/cli/src/commands/extensions/examples/agent/qwen-extension.json b/packages/cli/src/commands/extensions/examples/agent/qwen-extension.json
new file mode 100644
index 000000000..a9a8e8a68
--- /dev/null
+++ b/packages/cli/src/commands/extensions/examples/agent/qwen-extension.json
@@ -0,0 +1,4 @@
+{
+ "name": "agent-example",
+ "version": "1.0.0"
+}
diff --git a/packages/cli/src/commands/extensions/examples/custom-commands/commands/fs/grep-code.toml b/packages/cli/src/commands/extensions/examples/commands/commands/fs/grep-code.md
similarity index 65%
rename from packages/cli/src/commands/extensions/examples/custom-commands/commands/fs/grep-code.toml
rename to packages/cli/src/commands/extensions/examples/commands/commands/fs/grep-code.md
index 87d957542..cb57c52de 100644
--- a/packages/cli/src/commands/extensions/examples/custom-commands/commands/fs/grep-code.toml
+++ b/packages/cli/src/commands/extensions/examples/commands/commands/fs/grep-code.md
@@ -1,6 +1,3 @@
-prompt = """
Please summarize the findings for the pattern `{{args}}`.
Search Results:
-!{grep -r {{args}} .}
-"""
diff --git a/packages/cli/src/commands/extensions/examples/commands/qwen-extension.json b/packages/cli/src/commands/extensions/examples/commands/qwen-extension.json
new file mode 100644
index 000000000..277a40548
--- /dev/null
+++ b/packages/cli/src/commands/extensions/examples/commands/qwen-extension.json
@@ -0,0 +1,4 @@
+{
+ "name": "commands-example",
+ "version": "1.0.0"
+}
diff --git a/packages/cli/src/commands/extensions/examples/custom-commands/qwen-extension.json b/packages/cli/src/commands/extensions/examples/custom-commands/qwen-extension.json
deleted file mode 100644
index d973ab8fe..000000000
--- a/packages/cli/src/commands/extensions/examples/custom-commands/qwen-extension.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "name": "custom-commands",
- "version": "1.0.0"
-}
diff --git a/packages/cli/src/commands/extensions/examples/exclude-tools/qwen-extension.json b/packages/cli/src/commands/extensions/examples/exclude-tools/qwen-extension.json
deleted file mode 100644
index 5023fb7ad..000000000
--- a/packages/cli/src/commands/extensions/examples/exclude-tools/qwen-extension.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "name": "excludeTools",
- "version": "1.0.0",
- "excludeTools": ["run_shell_command(rm -rf)"]
-}
diff --git a/packages/cli/src/commands/extensions/examples/mcp-server/package.json b/packages/cli/src/commands/extensions/examples/mcp-server/package.json
index d38f7ee99..59c1c45c1 100644
--- a/packages/cli/src/commands/extensions/examples/mcp-server/package.json
+++ b/packages/cli/src/commands/extensions/examples/mcp-server/package.json
@@ -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": {
diff --git a/packages/cli/src/commands/extensions/examples/skills/qwen-extension.json b/packages/cli/src/commands/extensions/examples/skills/qwen-extension.json
new file mode 100644
index 000000000..2674ef9e0
--- /dev/null
+++ b/packages/cli/src/commands/extensions/examples/skills/qwen-extension.json
@@ -0,0 +1,4 @@
+{
+ "name": "skills-example",
+ "version": "1.0.0"
+}
diff --git a/packages/cli/src/commands/extensions/examples/skills/skills/synonyms/SKILL.md b/packages/cli/src/commands/extensions/examples/skills/skills/synonyms/SKILL.md
new file mode 100644
index 000000000..ed2878771
--- /dev/null
+++ b/packages/cli/src/commands/extensions/examples/skills/skills/synonyms/SKILL.md
@@ -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
diff --git a/packages/cli/src/commands/extensions/install.test.ts b/packages/cli/src/commands/extensions/install.test.ts
index 17a41d8d9..bb18392bc 100644
--- a/packages/cli/src/commands/extensions/install.test.ts
+++ b/packages/cli/src/commands/extensions/install.test.ts
@@ -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'),
);
diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts
index 2f1675ff9..6a9ce4929 100644
--- a/packages/cli/src/commands/extensions/install.ts
+++ b/packages/cli/src/commands/extensions/install.ts
@@ -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 ',
- 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,
});
},
};
diff --git a/packages/cli/src/commands/extensions/link.test.ts b/packages/cli/src/commands/extensions/link.test.ts
new file mode 100644
index 000000000..babe4ce90
--- /dev/null
+++ b/packages/cli/src/commands/extensions/link.test.ts
@@ -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);
+ });
+});
diff --git a/packages/cli/src/commands/extensions/link.ts b/packages/cli/src/commands/extensions/link.ts
index 8104e42b9..545899cda 100644
--- a/packages/cli/src/commands/extensions/link.ts
+++ b/packages/cli/src/commands/extensions/link.ts
@@ -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 ',
- 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),
diff --git a/packages/cli/src/commands/extensions/list.test.ts b/packages/cli/src/commands/extensions/list.test.ts
new file mode 100644
index 000000000..8c7a24951
--- /dev/null
+++ b/packages/cli/src/commands/extensions/list.test.ts
@@ -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);
+ });
+});
diff --git a/packages/cli/src/commands/extensions/list.ts b/packages/cli/src/commands/extensions/list.ts
index f6689a3c0..6f5653be3 100644
--- a/packages/cli/src/commands/extensions/list.ts
+++ b/packages/cli/src/commands/extensions/list.ts
@@ -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();
diff --git a/packages/cli/src/commands/extensions/settings.test.ts b/packages/cli/src/commands/extensions/settings.test.ts
new file mode 100644
index 000000000..042965eec
--- /dev/null
+++ b/packages/cli/src/commands/extensions/settings.test.ts
@@ -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)',
+ );
+ });
+});
diff --git a/packages/cli/src/commands/extensions/settings.ts b/packages/cli/src/commands/extensions/settings.ts
new file mode 100644
index 000000000..49baf2cc4
--- /dev/null
+++ b/packages/cli/src/commands/extensions/settings.ts
@@ -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