diff --git a/.github/workflows/release-vscode-companion.yml b/.github/workflows/release-vscode-companion.yml
new file mode 100644
index 000000000..2e0c4b60e
--- /dev/null
+++ b/.github/workflows/release-vscode-companion.yml
@@ -0,0 +1,339 @@
+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:
+ # First job: Determine version and run tests once
+ prepare:
+ runs-on: 'ubuntu-latest'
+ if: |-
+ ${{ github.repository == 'QwenLM/qwen-code' }}
+ permissions:
+ contents: 'read'
+ outputs:
+ release_version: '${{ steps.version.outputs.RELEASE_VERSION }}'
+ release_tag: '${{ steps.version.outputs.RELEASE_TAG }}'
+ vscode_tag: '${{ steps.version.outputs.VSCODE_TAG }}'
+ is_preview: '${{ steps.vars.outputs.is_preview }}'
+ is_dry_run: '${{ steps.vars.outputs.is_dry_run }}'
+
+ 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: '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: '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 }}'
+
+ # Second job: Build platform-specific VSIXes in parallel
+ build:
+ needs: 'prepare'
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ # Platform-specific builds (with node-pty native binaries)
+ - os: 'ubuntu-latest'
+ target: 'linux-x64'
+ universal: false
+ # macOS 15 (x64): use macos-15-intel
+ # Endpoint Badge: macos-latest-large, macos-15-large, or macos-15-intel
+ - os: 'macos-15-intel'
+ target: 'darwin-x64'
+ universal: false
+ # macOS 15 Arm64: use macos-latest
+ # Endpoint Badge: macos-latest, macos-15, or macos-15-xlarge
+ - os: 'macos-latest'
+ target: 'darwin-arm64'
+ universal: false
+ - os: 'windows-latest'
+ target: 'win32-x64'
+ universal: false
+ # Universal fallback (without node-pty, uses child_process)
+ - os: 'ubuntu-latest'
+ target: ''
+ universal: true
+
+ runs-on: '${{ matrix.os }}'
+ permissions:
+ contents: 'read'
+
+ steps:
+ - name: 'Checkout'
+ uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
+ with:
+ ref: '${{ github.event.inputs.ref || github.sha }}'
+ fetch-depth: 0
+
+ - 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'
+ run: |-
+ npm install -g @vscode/vsce
+
+ - name: 'Update package version'
+ env:
+ RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}'
+ shell: 'bash'
+ run: |-
+ npm run release:version -- "${RELEASE_VERSION}"
+
+ - name: 'Prepare VSCode Extension'
+ env:
+ UNIVERSAL_BUILD: '${{ matrix.universal }}'
+ VSCODE_TARGET: '${{ matrix.target }}'
+ run: |
+ # Build and stage the extension + bundled CLI
+ npm --workspace=qwen-code-vscode-ide-companion run prepackage
+
+ - name: 'Package VSIX (platform-specific)'
+ if: '${{ matrix.target != '''' }}'
+ working-directory: 'packages/vscode-ide-companion'
+ run: |-
+ if [[ "${{ needs.prepare.outputs.is_preview }}" == "true" ]]; then
+ vsce package --no-dependencies --pre-release --target ${{ matrix.target }} \
+ --out ../../qwen-code-vscode-companion-${{ needs.prepare.outputs.release_version }}-${{ matrix.target }}.vsix
+ else
+ vsce package --no-dependencies --target ${{ matrix.target }} \
+ --out ../../qwen-code-vscode-companion-${{ needs.prepare.outputs.release_version }}-${{ matrix.target }}.vsix
+ fi
+ shell: 'bash'
+
+ - name: 'Package VSIX (universal)'
+ if: '${{ matrix.target == '''' }}'
+ working-directory: 'packages/vscode-ide-companion'
+ run: |-
+ if [[ "${{ needs.prepare.outputs.is_preview }}" == "true" ]]; then
+ vsce package --no-dependencies --pre-release \
+ --out ../../qwen-code-vscode-companion-${{ needs.prepare.outputs.release_version }}-universal.vsix
+ else
+ vsce package --no-dependencies \
+ --out ../../qwen-code-vscode-companion-${{ needs.prepare.outputs.release_version }}-universal.vsix
+ fi
+ shell: 'bash'
+
+ - name: 'Upload VSIX Artifact'
+ uses: 'actions/upload-artifact@v4'
+ with:
+ name: 'vsix-${{ matrix.target || ''universal'' }}'
+ path: 'qwen-code-vscode-companion-${{ needs.prepare.outputs.release_version }}-*.vsix'
+ if-no-files-found: 'error'
+
+ # Third job: Publish all VSIXes to marketplaces
+ publish:
+ needs:
+ - 'prepare'
+ - 'build'
+ runs-on: 'ubuntu-latest'
+ environment:
+ name: 'production-release'
+ url: '${{ github.server_url }}/${{ github.repository }}/releases/tag/vscode-companion-${{ needs.prepare.outputs.release_tag }}'
+ permissions:
+ contents: 'read'
+ issues: 'write'
+
+ steps:
+ - name: 'Checkout'
+ uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
+ with:
+ ref: '${{ github.event.inputs.ref || github.sha }}'
+
+ - name: 'Download all VSIX artifacts'
+ uses: 'actions/download-artifact@v4'
+ with:
+ pattern: 'vsix-*'
+ path: 'vsix-artifacts'
+ merge-multiple: true
+
+ - name: 'List downloaded artifacts'
+ run: |-
+ echo "Downloaded VSIX files:"
+ ls -la vsix-artifacts/
+
+ - name: 'Install VSCE and OVSX'
+ run: |-
+ npm install -g @vscode/vsce
+ npm install -g ovsx
+
+ - name: 'Publish to Microsoft Marketplace'
+ if: '${{ needs.prepare.outputs.is_dry_run == ''false'' && needs.prepare.outputs.is_preview != ''true'' }}'
+ env:
+ VSCE_PAT: '${{ secrets.VSCE_PAT }}'
+ run: |-
+ echo "Publishing to Microsoft Marketplace..."
+ for vsix in vsix-artifacts/*.vsix; do
+ echo "Publishing: ${vsix}"
+ vsce publish --packagePath "${vsix}" --pat "${VSCE_PAT}"
+ done
+
+ - name: 'Publish to OpenVSX'
+ if: '${{ needs.prepare.outputs.is_dry_run == ''false'' }}'
+ env:
+ OVSX_TOKEN: '${{ secrets.OVSX_TOKEN }}'
+ run: |-
+ echo "Publishing to OpenVSX..."
+ for vsix in vsix-artifacts/*.vsix; do
+ echo "Publishing: ${vsix}"
+ if [[ "${{ needs.prepare.outputs.is_preview }}" == "true" ]]; then
+ ovsx publish "${vsix}" --pat "${OVSX_TOKEN}" --pre-release
+ else
+ ovsx publish "${vsix}" --pat "${OVSX_TOKEN}"
+ fi
+ done
+
+ - name: 'Upload all VSIXes as release artifacts (dry run)'
+ if: '${{ needs.prepare.outputs.is_dry_run == ''true'' }}'
+ uses: 'actions/upload-artifact@v4'
+ with:
+ name: 'all-vsix-packages-${{ needs.prepare.outputs.release_version }}'
+ path: 'vsix-artifacts/*.vsix'
+ if-no-files-found: 'error'
+
+ report-failure:
+ name: 'Create Issue on Failure'
+ needs:
+ - 'prepare'
+ - 'build'
+ - 'publish'
+ if: |-
+ ${{
+ always() &&
+ (
+ needs.build.result == 'failure' ||
+ needs.build.result == 'cancelled' ||
+ needs.publish.result == 'failure' ||
+ needs.publish.result == 'cancelled'
+ )
+ }}
+ runs-on: 'ubuntu-latest'
+ permissions:
+ contents: 'read'
+ issues: 'write'
+ steps:
+ - name: 'Create failure issue'
+ env:
+ GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
+ RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}'
+ DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
+ GH_REPO: '${{ github.repository }}'
+ run: |-
+ gh issue create \
+ --repo "${GH_REPO}" \
+ --title "VSCode IDE Companion Release Failed for ${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/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/extension/_meta.ts b/docs/users/extension/_meta.ts
new file mode 100644
index 000000000..ad072a629
--- /dev/null
+++ b/docs/users/extension/_meta.ts
@@ -0,0 +1,9 @@
+export default {
+ introduction: 'Introduction',
+ 'getting-started-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..1d7160768
--- /dev/null
+++ b/docs/users/extension/introduction.md
@@ -0,0 +1,309 @@
+# Qwen Code Extensions
+
+Qwen Code extensions package prompts, MCP servers, subagents, skills 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.
+
+Extensions and plugins from [Gemini CLI Extensions Gallery](https://geminicli.com/extensions/) and [Claude Code Marketplace](https://claudemarketplaces.com/) can be directly installed into Qwen Code. 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 detail ` | Show details of an extension |
+| `/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 Claude Code Marketplace
+
+Qwen Code also supports plugins from the [Claude Code Marketplace](https://claudemarketplaces.com/). Install from a marketplace and choose a plugin:
+
+```bash
+qwen extensions install
+# or
+qwen extensions install
+```
+
+If you want to install a specific plugin, you can use the format with plugin name:
+
+```bash
+qwen extensions install :
+# or
+qwen extensions install :
+```
+
+For example, to install the `prompts.chat` plugin from the [f/awesome-chatgpt-prompts](https://claudemarketplaces.com/plugins/f-awesome-chatgpt-prompts) marketplace:
+
+```bash
+qwen extensions install f/awesome-chatgpt-prompts:prompts.chat
+# or
+qwen extensions install https://github.com/f/awesome-chatgpt-prompts:prompts.chat
+```
+
+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
+
+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.
+
+> **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 Gemini CLI Extensions
+
+Qwen Code fully supports extensions from the [Gemini CLI Extensions Gallery](https://geminicli.com/extensions/). Simply install them using the git URL:
+
+```bash
+qwen extensions install
+# or
+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 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
+```
+
+## 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/language.md b/docs/users/features/language.md
index e5067a319..22143d03a 100644
--- a/docs/users/features/language.md
+++ b/docs/users/features/language.md
@@ -25,6 +25,7 @@ Use the `/language ui` command:
/language ui en-US # English
/language ui ru-RU # Russian
/language ui de-DE # German
+/language ui ja-JP # Japanese
```
Aliases are also supported:
@@ -34,6 +35,7 @@ Aliases are also supported:
/language ui en # English
/language ui ru # Russian
/language ui de # German
+/language ui ja # Japanese
```
### Auto-detection
@@ -63,6 +65,7 @@ On first startup, if no `output-language.md` file exists, Qwen Code automaticall
- System locale `en` creates a rule for English responses
- System locale `ru` creates a rule for Russian responses
- System locale `de` creates a rule for German responses
+- System locale `ja` creates a rule for Japanese responses
### Manual Setting
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/integration-tests/sdk-typescript/abort-and-lifecycle.test.ts b/integration-tests/sdk-typescript/abort-and-lifecycle.test.ts
index 93005d4b7..d4566fcf3 100644
--- a/integration-tests/sdk-typescript/abort-and-lifecycle.test.ts
+++ b/integration-tests/sdk-typescript/abort-and-lifecycle.test.ts
@@ -11,10 +11,16 @@ import {
AbortError,
isAbortError,
isSDKAssistantMessage,
+ isSDKResultMessage,
type TextBlock,
type ContentBlock,
+ type SDKUserMessage,
} from '@qwen-code/sdk';
-import { SDKTestHelper, createSharedTestOptions } from './test-helper.js';
+import {
+ SDKTestHelper,
+ createSharedTestOptions,
+ createResultWaiter,
+} from './test-helper.js';
const SHARED_TEST_OPTIONS = createSharedTestOptions();
@@ -250,6 +256,171 @@ describe('AbortController and Process Lifecycle (E2E)', () => {
});
});
+ describe('Closed stdin behavior (asyncGenerator prompt)', () => {
+ it('should reject control requests after stdin closes', async () => {
+ const resultWaiter = createResultWaiter(1);
+ let promptDoneResolve: () => void = () => {};
+ const promptDonePromise = new Promise((resolve) => {
+ promptDoneResolve = resolve;
+ });
+
+ async function* createPrompt(): AsyncIterable {
+ yield {
+ type: 'user',
+ session_id: crypto.randomUUID(),
+ message: {
+ role: 'user',
+ content: 'Say "OK".',
+ },
+ parent_tool_use_id: null,
+ };
+
+ await resultWaiter.waitForResult(0);
+ promptDoneResolve();
+ }
+
+ const q = query({
+ prompt: createPrompt(),
+ options: {
+ ...SHARED_TEST_OPTIONS,
+ cwd: testDir,
+ debug: false,
+ },
+ });
+
+ let firstResultReceived = false;
+
+ try {
+ for await (const message of q) {
+ if (isSDKResultMessage(message)) {
+ firstResultReceived = true;
+ resultWaiter.notifyResult();
+ break;
+ }
+ }
+
+ expect(firstResultReceived).toBe(true);
+ await promptDonePromise;
+ q.endInput();
+
+ await expect(q.setPermissionMode('default')).rejects.toThrow(
+ 'Input stream closed',
+ );
+ } finally {
+ await q.close();
+ }
+ });
+
+ it('should handle control responses when stdin closes before replies', async () => {
+ await helper.createFile('test.txt', 'original content');
+
+ let canUseToolCalledResolve: () => void = () => {};
+ const canUseToolCalledPromise = new Promise((resolve, reject) => {
+ canUseToolCalledResolve = resolve;
+ setTimeout(() => {
+ reject(new Error('canUseTool callback not called'));
+ }, 15000);
+ });
+
+ let inputStreamDoneResolve: () => void = () => {};
+ const inputStreamDonePromise = new Promise((resolve, reject) => {
+ inputStreamDoneResolve = resolve;
+ setTimeout(() => {
+ reject(new Error('inputStreamDonePromise timeout'));
+ }, 15000);
+ });
+
+ let firstResultResolve: () => void = () => {};
+ const firstResultPromise = new Promise((resolve) => {
+ firstResultResolve = resolve;
+ });
+
+ let secondResultResolve: () => void = () => {};
+ const secondResultPromise = new Promise((resolve, reject) => {
+ secondResultResolve = resolve;
+ });
+
+ async function* createPrompt(): AsyncIterable {
+ const sessionId = crypto.randomUUID();
+
+ yield {
+ type: 'user',
+ session_id: sessionId,
+ message: {
+ role: 'user',
+ content: 'Say "OK".',
+ },
+ parent_tool_use_id: null,
+ };
+
+ await firstResultPromise;
+
+ yield {
+ type: 'user',
+ session_id: sessionId,
+ message: {
+ role: 'user',
+ content: 'Write "updated" to test.txt.',
+ },
+ parent_tool_use_id: null,
+ };
+ await inputStreamDonePromise;
+ }
+
+ const q = query({
+ prompt: createPrompt(),
+ options: {
+ ...SHARED_TEST_OPTIONS,
+ cwd: testDir,
+ permissionMode: 'default',
+ coreTools: ['read_file', 'write_file'],
+ canUseTool: async (toolName, input) => {
+ inputStreamDoneResolve();
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ canUseToolCalledResolve();
+
+ return {
+ behavior: 'allow',
+ updatedInput: input,
+ };
+ },
+ debug: false,
+ },
+ });
+
+ try {
+ const loop = async () => {
+ let resultCount = 0;
+ for await (const _message of q) {
+ console.log(JSON.stringify(_message, null, 2));
+ // Consume messages until completion.
+ if (isSDKResultMessage(_message)) {
+ resultCount += 1;
+ if (resultCount === 1) {
+ firstResultResolve();
+ }
+ if (resultCount === 2) {
+ secondResultResolve();
+ break;
+ }
+ }
+ }
+ };
+
+ loop();
+
+ await firstResultPromise;
+ await canUseToolCalledPromise;
+ await secondResultPromise;
+
+ const content = await helper.readFile('test.txt');
+ expect(content).toBe('original content');
+ } finally {
+ await q.close();
+ }
+ });
+ });
+
describe('Error Handling and Recovery', () => {
it('should handle invalid executable path', async () => {
try {
diff --git a/integration-tests/sdk-typescript/mcp-server.test.ts b/integration-tests/sdk-typescript/mcp-server.test.ts
index 9b3f21938..cf1de26d4 100644
--- a/integration-tests/sdk-typescript/mcp-server.test.ts
+++ b/integration-tests/sdk-typescript/mcp-server.test.ts
@@ -19,6 +19,7 @@ import {
type SDKMessage,
type ToolUseBlock,
type SDKSystemMessage,
+ type SDKUserMessage,
} from '@qwen-code/sdk';
import {
SDKTestHelper,
@@ -26,6 +27,7 @@ import {
extractText,
findToolUseBlocks,
createSharedTestOptions,
+ createResultWaiter,
} from './test-helper.js';
const SHARED_TEST_OPTIONS = {
@@ -296,6 +298,176 @@ describe('MCP Server Integration (E2E)', () => {
await q.close();
}
});
+
+ it('should support multi-turn asyncGenerator prompt with MCP tools', async () => {
+ const resultWaiter = createResultWaiter(2);
+
+ async function* createMultiTurnPrompt(): AsyncIterable {
+ const sessionId = crypto.randomUUID();
+
+ yield {
+ type: 'user',
+ session_id: sessionId,
+ message: {
+ role: 'user',
+ content: 'Use the add tool to calculate 2 + 3. Give me the result.',
+ },
+ parent_tool_use_id: null,
+ };
+
+ await resultWaiter.waitForResult(0);
+
+ yield {
+ type: 'user',
+ session_id: sessionId,
+ message: {
+ role: 'user',
+ content:
+ 'Now use the multiply tool to calculate 5 * 4. Give me the result.',
+ },
+ parent_tool_use_id: null,
+ };
+
+ await resultWaiter.waitForResult(1);
+ }
+
+ const q = query({
+ prompt: createMultiTurnPrompt(),
+ options: {
+ ...SHARED_TEST_OPTIONS,
+ cwd: testDir,
+ debug: false,
+ mcpServers: {
+ 'test-math-server': {
+ command: 'node',
+ args: [serverScriptPath],
+ },
+ },
+ },
+ });
+
+ const messages: SDKMessage[] = [];
+ let assistantText = '';
+ const toolCalls: string[] = [];
+
+ try {
+ for await (const message of q) {
+ messages.push(message);
+
+ if (isSDKResultMessage(message)) {
+ resultWaiter.notifyResult();
+ }
+ if (isSDKAssistantMessage(message)) {
+ const toolUseBlocks = findToolUseBlocks(message);
+ toolUseBlocks.forEach((block) => {
+ toolCalls.push(block.name);
+ });
+ assistantText += extractText(message.message.content);
+ }
+ }
+
+ expect(toolCalls).toContain('add');
+ expect(toolCalls).toContain('multiply');
+ expect(assistantText).toMatch(/5/);
+ expect(assistantText).toMatch(/20/);
+
+ const lastMessage = messages[messages.length - 1];
+ expect(isSDKResultMessage(lastMessage)).toBe(true);
+ } finally {
+ await q.close();
+ }
+ });
+
+ it('should support multi-turn MCP tools with canUseTool', async () => {
+ const canUseToolCalls: Array<{ toolName: string }> = [];
+ const resultWaiter = createResultWaiter(2);
+
+ async function* createMultiTurnPrompt(): AsyncIterable {
+ const sessionId = crypto.randomUUID();
+
+ yield {
+ type: 'user',
+ session_id: sessionId,
+ message: {
+ role: 'user',
+ content: 'Use the add tool to calculate 9 + 1. Give me the result.',
+ },
+ parent_tool_use_id: null,
+ };
+
+ await resultWaiter.waitForResult(0);
+
+ yield {
+ type: 'user',
+ session_id: sessionId,
+ message: {
+ role: 'user',
+ content:
+ 'Now use the multiply tool to calculate 4 * 3. Give me the result.',
+ },
+ parent_tool_use_id: null,
+ };
+
+ await resultWaiter.waitForResult(1);
+ }
+
+ const q = query({
+ prompt: createMultiTurnPrompt(),
+ options: {
+ ...SHARED_TEST_OPTIONS,
+ cwd: testDir,
+ permissionMode: 'default',
+ canUseTool: async (toolName) => {
+ canUseToolCalls.push({ toolName });
+ return {
+ behavior: 'allow',
+ updatedInput: {},
+ };
+ },
+ debug: false,
+ mcpServers: {
+ 'test-math-server': {
+ command: 'node',
+ args: [serverScriptPath],
+ },
+ },
+ },
+ });
+
+ const messages: SDKMessage[] = [];
+ let assistantText = '';
+ const toolCalls: string[] = [];
+
+ try {
+ for await (const message of q) {
+ messages.push(message);
+
+ if (isSDKResultMessage(message)) {
+ resultWaiter.notifyResult();
+ }
+ if (isSDKAssistantMessage(message)) {
+ const toolUseBlocks = findToolUseBlocks(message);
+ toolUseBlocks.forEach((block) => {
+ toolCalls.push(block.name);
+ });
+ assistantText += extractText(message.message.content);
+ }
+ }
+
+ expect(toolCalls).toContain('add');
+ expect(toolCalls).toContain('multiply');
+ expect(canUseToolCalls.map((call) => call.toolName)).toEqual(
+ expect.arrayContaining(['add', 'multiply']),
+ );
+ expect(assistantText).toMatch(/10/);
+ expect(assistantText).toMatch(/12/);
+
+ const lastMessage = messages[messages.length - 1];
+ expect(isSDKResultMessage(lastMessage)).toBe(true);
+ } finally {
+ await q.close();
+ }
+ });
});
describe('MCP Tool Message Flow', () => {
diff --git a/integration-tests/sdk-typescript/multi-turn.test.ts b/integration-tests/sdk-typescript/multi-turn.test.ts
index c1b96cc7c..4cf845fc5 100644
--- a/integration-tests/sdk-typescript/multi-turn.test.ts
+++ b/integration-tests/sdk-typescript/multi-turn.test.ts
@@ -22,7 +22,11 @@ import {
type ControlMessage,
type ToolUseBlock,
} from '@qwen-code/sdk';
-import { SDKTestHelper, createSharedTestOptions } from './test-helper.js';
+import {
+ SDKTestHelper,
+ createSharedTestOptions,
+ createResultWaiter,
+} from './test-helper.js';
const SHARED_TEST_OPTIONS = createSharedTestOptions();
@@ -76,6 +80,8 @@ describe('Multi-Turn Conversations (E2E)', () => {
describe('AsyncIterable Prompt Support', () => {
it('should handle multi-turn conversation using AsyncIterable prompt', async () => {
+ const resultWaiter = createResultWaiter(3);
+
// Create multi-turn conversation generator
async function* createMultiTurnConversation(): AsyncIterable {
const sessionId = crypto.randomUUID();
@@ -90,7 +96,7 @@ describe('Multi-Turn Conversations (E2E)', () => {
parent_tool_use_id: null,
} as SDKUserMessage;
- await new Promise((resolve) => setTimeout(resolve, 100));
+ await resultWaiter.waitForResult(0);
yield {
type: 'user',
@@ -102,7 +108,7 @@ describe('Multi-Turn Conversations (E2E)', () => {
parent_tool_use_id: null,
} as SDKUserMessage;
- await new Promise((resolve) => setTimeout(resolve, 100));
+ await resultWaiter.waitForResult(1);
yield {
type: 'user',
@@ -113,6 +119,8 @@ describe('Multi-Turn Conversations (E2E)', () => {
},
parent_tool_use_id: null,
} as SDKUserMessage;
+
+ await resultWaiter.waitForResult(2);
}
// Create multi-turn query using AsyncIterable prompt
@@ -133,6 +141,9 @@ describe('Multi-Turn Conversations (E2E)', () => {
for await (const message of q) {
messages.push(message);
+ if (isSDKResultMessage(message)) {
+ resultWaiter.notifyResult();
+ }
if (isSDKAssistantMessage(message)) {
assistantMessages.push(message);
const text = extractText(message.message.content);
@@ -153,6 +164,8 @@ describe('Multi-Turn Conversations (E2E)', () => {
});
it('should maintain session context across turns', async () => {
+ const resultWaiter = createResultWaiter(2);
+
async function* createContextualConversation(): AsyncIterable {
const sessionId = crypto.randomUUID();
@@ -162,12 +175,12 @@ describe('Multi-Turn Conversations (E2E)', () => {
message: {
role: 'user',
content:
- 'Suppose we have 3 rabbits and 4 carrots. How many animals are there?',
+ 'Suppose we have 3 rabbits and 4 carrots. Identify: How many **animals** are there?',
},
parent_tool_use_id: null,
} as SDKUserMessage;
- await new Promise((resolve) => setTimeout(resolve, 200));
+ await resultWaiter.waitForResult(0);
yield {
type: 'user',
@@ -178,6 +191,8 @@ describe('Multi-Turn Conversations (E2E)', () => {
},
parent_tool_use_id: null,
} as SDKUserMessage;
+
+ await resultWaiter.waitForResult(1);
}
const q = query({
@@ -193,6 +208,9 @@ describe('Multi-Turn Conversations (E2E)', () => {
try {
for await (const message of q) {
+ if (isSDKResultMessage(message)) {
+ resultWaiter.notifyResult();
+ }
if (isSDKAssistantMessage(message)) {
assistantMessages.push(message);
}
@@ -213,6 +231,8 @@ describe('Multi-Turn Conversations (E2E)', () => {
describe('Tool Usage in Multi-Turn', () => {
it('should handle tool usage across multiple turns', async () => {
+ const resultWaiter = createResultWaiter(2);
+
async function* createToolConversation(): AsyncIterable {
const sessionId = crypto.randomUUID();
@@ -226,7 +246,7 @@ describe('Multi-Turn Conversations (E2E)', () => {
parent_tool_use_id: null,
} as SDKUserMessage;
- await new Promise((resolve) => setTimeout(resolve, 200));
+ await resultWaiter.waitForResult(0);
yield {
type: 'user',
@@ -237,6 +257,8 @@ describe('Multi-Turn Conversations (E2E)', () => {
},
parent_tool_use_id: null,
} as SDKUserMessage;
+
+ await resultWaiter.waitForResult(1);
}
const q = query({
@@ -257,6 +279,9 @@ describe('Multi-Turn Conversations (E2E)', () => {
for await (const message of q) {
messages.push(message);
+ if (isSDKResultMessage(message)) {
+ resultWaiter.notifyResult();
+ }
if (isSDKAssistantMessage(message)) {
assistantMessages.push(message);
const hasToolUseBlock = message.message.content.some(
@@ -286,6 +311,8 @@ describe('Multi-Turn Conversations (E2E)', () => {
describe('Message Flow and Sequencing', () => {
it('should process messages in correct sequence', async () => {
+ const resultWaiter = createResultWaiter(2);
+
async function* createSequentialConversation(): AsyncIterable {
const sessionId = crypto.randomUUID();
@@ -299,7 +326,7 @@ describe('Multi-Turn Conversations (E2E)', () => {
parent_tool_use_id: null,
} as SDKUserMessage;
- await new Promise((resolve) => setTimeout(resolve, 100));
+ await resultWaiter.waitForResult(0);
yield {
type: 'user',
@@ -310,6 +337,8 @@ describe('Multi-Turn Conversations (E2E)', () => {
},
parent_tool_use_id: null,
} as SDKUserMessage;
+
+ await resultWaiter.waitForResult(1);
}
const q = query({
@@ -329,6 +358,9 @@ describe('Multi-Turn Conversations (E2E)', () => {
const messageType = getMessageType(message);
messageSequence.push(messageType);
+ if (isSDKResultMessage(message)) {
+ resultWaiter.notifyResult();
+ }
if (isSDKAssistantMessage(message)) {
const text = extractText(message.message.content);
assistantResponses.push(text);
@@ -351,6 +383,8 @@ describe('Multi-Turn Conversations (E2E)', () => {
});
it('should handle conversation completion correctly', async () => {
+ const resultWaiter = createResultWaiter(2);
+
async function* createSimpleConversation(): AsyncIterable {
const sessionId = crypto.randomUUID();
@@ -364,7 +398,7 @@ describe('Multi-Turn Conversations (E2E)', () => {
parent_tool_use_id: null,
} as SDKUserMessage;
- await new Promise((resolve) => setTimeout(resolve, 100));
+ await resultWaiter.waitForResult(0);
yield {
type: 'user',
@@ -375,6 +409,8 @@ describe('Multi-Turn Conversations (E2E)', () => {
},
parent_tool_use_id: null,
} as SDKUserMessage;
+
+ await resultWaiter.waitForResult(1);
}
const q = query({
@@ -394,6 +430,7 @@ describe('Multi-Turn Conversations (E2E)', () => {
messageCount++;
if (isSDKResultMessage(message)) {
+ resultWaiter.notifyResult();
completedNaturally = true;
expect(message.subtype).toBe('success');
}
@@ -441,6 +478,8 @@ describe('Multi-Turn Conversations (E2E)', () => {
});
it('should handle conversation with delays', async () => {
+ const resultWaiter = createResultWaiter(2);
+
async function* createDelayedConversation(): AsyncIterable {
const sessionId = crypto.randomUUID();
@@ -455,7 +494,7 @@ describe('Multi-Turn Conversations (E2E)', () => {
} as SDKUserMessage;
// Longer delay to test patience
- await new Promise((resolve) => setTimeout(resolve, 500));
+ await resultWaiter.waitForResult(0);
yield {
type: 'user',
@@ -466,6 +505,8 @@ describe('Multi-Turn Conversations (E2E)', () => {
},
parent_tool_use_id: null,
} as SDKUserMessage;
+
+ await resultWaiter.waitForResult(1);
}
const q = query({
@@ -481,6 +522,9 @@ describe('Multi-Turn Conversations (E2E)', () => {
try {
for await (const message of q) {
+ if (isSDKResultMessage(message)) {
+ resultWaiter.notifyResult();
+ }
if (isSDKAssistantMessage(message)) {
assistantMessages.push(message);
}
@@ -495,6 +539,8 @@ describe('Multi-Turn Conversations (E2E)', () => {
describe('Partial Messages in Multi-Turn', () => {
it('should receive partial messages when includePartialMessages is enabled', async () => {
+ const resultWaiter = createResultWaiter(2);
+
async function* createMultiTurnConversation(): AsyncIterable {
const sessionId = crypto.randomUUID();
@@ -508,7 +554,7 @@ describe('Multi-Turn Conversations (E2E)', () => {
parent_tool_use_id: null,
} as SDKUserMessage;
- await new Promise((resolve) => setTimeout(resolve, 100));
+ await resultWaiter.waitForResult(0);
yield {
type: 'user',
@@ -519,6 +565,8 @@ describe('Multi-Turn Conversations (E2E)', () => {
},
parent_tool_use_id: null,
} as SDKUserMessage;
+
+ await resultWaiter.waitForResult(1);
}
const q = query({
@@ -539,6 +587,9 @@ describe('Multi-Turn Conversations (E2E)', () => {
for await (const message of q) {
messages.push(message);
+ if (isSDKResultMessage(message)) {
+ resultWaiter.notifyResult();
+ }
if (isSDKPartialAssistantMessage(message)) {
partialMessageCount++;
}
diff --git a/integration-tests/sdk-typescript/permission-control.test.ts b/integration-tests/sdk-typescript/permission-control.test.ts
index eee344755..4c253dc28 100644
--- a/integration-tests/sdk-typescript/permission-control.test.ts
+++ b/integration-tests/sdk-typescript/permission-control.test.ts
@@ -31,6 +31,7 @@ import {
hasErrorToolResults,
findSystemMessage,
findToolCalls,
+ createResultWaiter,
} from './test-helper.js';
const TEST_TIMEOUT = 30000;
@@ -44,6 +45,7 @@ const SHARED_TEST_OPTIONS = createSharedTestOptions();
function createStreamingInputWithControlPoint(
firstMessage: string,
secondMessage: string,
+ resultWaiter: { waitForResult: (index: number) => Promise },
): {
generator: AsyncIterable;
resume: () => void;
@@ -66,7 +68,7 @@ function createStreamingInputWithControlPoint(
parent_tool_use_id: null,
} as SDKUserMessage;
- await new Promise((resolve) => setTimeout(resolve, 200));
+ await resultWaiter.waitForResult(0);
await resumePromise;
@@ -81,6 +83,8 @@ function createStreamingInputWithControlPoint(
},
parent_tool_use_id: null,
} as SDKUserMessage;
+
+ await resultWaiter.waitForResult(1);
})();
const resume = () => {
@@ -320,9 +324,11 @@ describe('Permission Control (E2E)', () => {
describe('setPermissionMode API', () => {
it('should change permission mode from default to yolo', async () => {
+ const resultWaiter = createResultWaiter(2);
const { generator, resume } = createStreamingInputWithControlPoint(
'What is 1 + 1?',
'What is 2 + 2?',
+ resultWaiter,
);
const q = query({
@@ -361,6 +367,9 @@ describe('Permission Control (E2E)', () => {
resolvers.second?.();
}
}
+ if (isSDKResultMessage(message)) {
+ resultWaiter.notifyResult();
+ }
}
})();
@@ -397,9 +406,11 @@ describe('Permission Control (E2E)', () => {
});
it('should change permission mode from yolo to plan', async () => {
+ const resultWaiter = createResultWaiter(2);
const { generator, resume } = createStreamingInputWithControlPoint(
'What is 3 + 3?',
'What is 4 + 4?',
+ resultWaiter,
);
const q = query({
@@ -437,6 +448,9 @@ describe('Permission Control (E2E)', () => {
resolvers.second?.();
}
}
+ if (isSDKResultMessage(message)) {
+ resultWaiter.notifyResult();
+ }
}
})();
@@ -473,9 +487,11 @@ describe('Permission Control (E2E)', () => {
});
it('should change permission mode to auto-edit', async () => {
+ const resultWaiter = createResultWaiter(2);
const { generator, resume } = createStreamingInputWithControlPoint(
'What is 5 + 5?',
'What is 6 + 6?',
+ resultWaiter,
);
const q = query({
@@ -513,6 +529,9 @@ describe('Permission Control (E2E)', () => {
resolvers.second?.();
}
}
+ if (isSDKResultMessage(message)) {
+ resultWaiter.notifyResult();
+ }
}
})();
@@ -584,9 +603,11 @@ describe('Permission Control (E2E)', () => {
input: Record;
}> = [];
+ const resultWaiter = createResultWaiter(2);
const { generator, resume } = createStreamingInputWithControlPoint(
'Create a file named first.txt',
'Create a file named second.txt',
+ resultWaiter,
);
const q = query({
@@ -630,6 +651,7 @@ describe('Permission Control (E2E)', () => {
secondResponseReceived = true;
resolvers.second?.();
}
+ resultWaiter.notifyResult();
}
}
})();
diff --git a/integration-tests/sdk-typescript/system-control.test.ts b/integration-tests/sdk-typescript/system-control.test.ts
index a977e6471..0ae28c4c5 100644
--- a/integration-tests/sdk-typescript/system-control.test.ts
+++ b/integration-tests/sdk-typescript/system-control.test.ts
@@ -8,9 +8,14 @@ import {
query,
isSDKAssistantMessage,
isSDKSystemMessage,
+ isSDKResultMessage,
type SDKUserMessage,
} from '@qwen-code/sdk';
-import { SDKTestHelper, createSharedTestOptions } from './test-helper.js';
+import {
+ SDKTestHelper,
+ createSharedTestOptions,
+ createResultWaiter,
+} from './test-helper.js';
const SHARED_TEST_OPTIONS = createSharedTestOptions();
@@ -26,6 +31,7 @@ const SHARED_TEST_OPTIONS = createSharedTestOptions();
function createStreamingInputWithControlPoint(
firstMessage: string,
secondMessage: string,
+ resultWaiter: { waitForResult: (index: number) => Promise },
): {
generator: AsyncIterable;
resume: () => void;
@@ -48,7 +54,7 @@ function createStreamingInputWithControlPoint(
parent_tool_use_id: null,
} as SDKUserMessage;
- await new Promise((resolve) => setTimeout(resolve, 200));
+ await resultWaiter.waitForResult(0);
await resumePromise;
@@ -63,6 +69,8 @@ function createStreamingInputWithControlPoint(
},
parent_tool_use_id: null,
} as SDKUserMessage;
+
+ await resultWaiter.waitForResult(1);
})();
const resume = () => {
@@ -89,9 +97,11 @@ describe('System Control (E2E)', () => {
describe('setModel API', () => {
it('should change model dynamically during streaming input', async () => {
+ const resultWaiter = createResultWaiter(2);
const { generator, resume } = createStreamingInputWithControlPoint(
'Tell me the model name.',
'Tell me the model name now again.',
+ resultWaiter,
);
const q = query({
@@ -126,6 +136,9 @@ describe('System Control (E2E)', () => {
if (isSDKSystemMessage(message)) {
systemMessages.push({ model: message.model });
}
+ if (isSDKResultMessage(message)) {
+ resultWaiter.notifyResult();
+ }
if (isSDKAssistantMessage(message)) {
if (!firstResponseReceived) {
firstResponseReceived = true;
@@ -181,6 +194,7 @@ describe('System Control (E2E)', () => {
it('should handle multiple model changes in sequence', async () => {
const sessionId = crypto.randomUUID();
+ const resultWaiter = createResultWaiter(3);
let resumeResolve1: (() => void) | null = null;
let resumeResolve2: (() => void) | null = null;
const resumePromise1 = new Promise((resolve) => {
@@ -198,7 +212,7 @@ describe('System Control (E2E)', () => {
parent_tool_use_id: null,
} as SDKUserMessage;
- await new Promise((resolve) => setTimeout(resolve, 200));
+ await resultWaiter.waitForResult(0);
await resumePromise1;
await new Promise((resolve) => setTimeout(resolve, 200));
@@ -209,7 +223,7 @@ describe('System Control (E2E)', () => {
parent_tool_use_id: null,
} as SDKUserMessage;
- await new Promise((resolve) => setTimeout(resolve, 200));
+ await resultWaiter.waitForResult(1);
await resumePromise2;
await new Promise((resolve) => setTimeout(resolve, 200));
@@ -219,6 +233,8 @@ describe('System Control (E2E)', () => {
message: { role: 'user', content: 'Third message' },
parent_tool_use_id: null,
} as SDKUserMessage;
+
+ await resultWaiter.waitForResult(2);
})();
const q = query({
@@ -246,6 +262,9 @@ describe('System Control (E2E)', () => {
if (isSDKSystemMessage(message)) {
systemMessages.push({ model: message.model });
}
+ if (isSDKResultMessage(message)) {
+ resultWaiter.notifyResult();
+ }
if (isSDKAssistantMessage(message)) {
if (responseCount < resolvers.length) {
resolvers[responseCount]?.();
@@ -318,6 +337,7 @@ describe('System Control (E2E)', () => {
describe('supportedCommands API', () => {
it('should return list of supported slash commands', async () => {
const sessionId = crypto.randomUUID();
+ const resultWaiter = createResultWaiter(1);
const generator = (async function* () {
yield {
type: 'user',
@@ -325,6 +345,8 @@ describe('System Control (E2E)', () => {
message: { role: 'user', content: 'Hello' },
parent_tool_use_id: null,
} as SDKUserMessage;
+
+ await resultWaiter.waitForResult(0);
})();
const q = query({
@@ -343,6 +365,9 @@ describe('System Control (E2E)', () => {
const messageConsumer = (async () => {
try {
for await (const _message of q) {
+ if (isSDKResultMessage(_message)) {
+ resultWaiter.notifyResult();
+ }
// Just consume messages
}
} catch (error) {
diff --git a/integration-tests/sdk-typescript/test-helper.ts b/integration-tests/sdk-typescript/test-helper.ts
index d7efc026c..07f44f890 100644
--- a/integration-tests/sdk-typescript/test-helper.ts
+++ b/integration-tests/sdk-typescript/test-helper.ts
@@ -655,6 +655,29 @@ export function hasErrorToolResults(messages: SDKMessage[]): boolean {
// Streaming Input Utilities
// ============================================================================
+export function createResultWaiter(expectedResults: number): {
+ waitForResult: (index: number) => Promise;
+ notifyResult: () => void;
+} {
+ const resolvers: Array<() => void> = [];
+ const promises = Array.from({ length: expectedResults }, () => {
+ return new Promise((resolve) => {
+ resolvers.push(resolve);
+ });
+ });
+ let resolvedCount = 0;
+
+ return {
+ waitForResult: (index: number) => promises[index],
+ notifyResult: () => {
+ if (resolvedCount < resolvers.length) {
+ resolvers[resolvedCount]?.();
+ resolvedCount += 1;
+ }
+ },
+ };
+}
+
/**
* Create a simple streaming input from an array of message contents
*/
diff --git a/integration-tests/sdk-typescript/tool-control.test.ts b/integration-tests/sdk-typescript/tool-control.test.ts
index 549f820c0..aecb98ae6 100644
--- a/integration-tests/sdk-typescript/tool-control.test.ts
+++ b/integration-tests/sdk-typescript/tool-control.test.ts
@@ -12,7 +12,13 @@
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
-import { query, isSDKAssistantMessage, type SDKMessage } from '@qwen-code/sdk';
+import {
+ query,
+ isSDKAssistantMessage,
+ isSDKResultMessage,
+ type SDKMessage,
+ type SDKUserMessage,
+} from '@qwen-code/sdk';
import {
SDKTestHelper,
extractText,
@@ -20,6 +26,7 @@ import {
findToolResults,
assertSuccessfulCompletion,
createSharedTestOptions,
+ createResultWaiter,
} from './test-helper.js';
const SHARED_TEST_OPTIONS = createSharedTestOptions();
@@ -739,4 +746,239 @@ describe('Tool Control Parameters (E2E)', () => {
TEST_TIMEOUT,
);
});
+
+ describe('canUseTool with asyncGenerator prompt', () => {
+ it(
+ 'should invoke canUseTool callback when using asyncGenerator as prompt',
+ async () => {
+ await helper.createFile('test.txt', 'original content');
+
+ const resultWaiter = createResultWaiter(1);
+ const canUseToolCalls: Array<{
+ toolName: string;
+ input: Record;
+ }> = [];
+
+ // Create an async generator that yields a single message
+ async function* createPrompt(): AsyncIterable {
+ yield {
+ type: 'user',
+ session_id: crypto.randomUUID(),
+ message: {
+ role: 'user',
+ content: 'Read test.txt and then write "updated" to it.',
+ },
+ parent_tool_use_id: null,
+ };
+
+ await resultWaiter.waitForResult(0);
+ }
+
+ const q = query({
+ prompt: createPrompt(),
+ options: {
+ ...SHARED_TEST_OPTIONS,
+ cwd: testDir,
+ permissionMode: 'default',
+ coreTools: ['read_file', 'write_file'],
+ allowedTools: [],
+ canUseTool: async (toolName, input) => {
+ canUseToolCalls.push({ toolName, input });
+ return {
+ behavior: 'allow',
+ updatedInput: input,
+ };
+ },
+ debug: false,
+ },
+ });
+
+ const messages: SDKMessage[] = [];
+
+ try {
+ for await (const message of q) {
+ messages.push(message);
+ if (isSDKResultMessage(message)) {
+ resultWaiter.notifyResult();
+ }
+ }
+
+ const toolCalls = findToolCalls(messages);
+ const toolNames = toolCalls.map((tc) => tc.toolUse.name);
+
+ // Both tools should have been executed
+ expect(toolNames).toContain('read_file');
+ expect(toolNames).toContain('write_file');
+
+ const toolsCalledInCallback = canUseToolCalls.map(
+ (call) => call.toolName,
+ );
+ expect(toolsCalledInCallback).toContain('write_file');
+
+ const writeFileResults = findToolResults(messages, 'write_file');
+ expect(writeFileResults.length).toBeGreaterThan(0);
+
+ // Verify file was modified
+ const content = await helper.readFile('test.txt');
+ expect(content).toBe('updated');
+ } finally {
+ await q.close();
+ }
+ },
+ TEST_TIMEOUT,
+ );
+
+ it(
+ 'should deny tool when canUseTool returns deny with asyncGenerator prompt',
+ async () => {
+ await helper.createFile('test.txt', 'original content');
+
+ const resultWaiter = createResultWaiter(1);
+ // Create an async generator that yields a single message
+ async function* createPrompt(): AsyncIterable {
+ yield {
+ type: 'user',
+ session_id: crypto.randomUUID(),
+ message: {
+ role: 'user',
+ content: 'Write "modified" to test.txt.',
+ },
+ parent_tool_use_id: null,
+ };
+ await resultWaiter.waitForResult(0);
+ }
+
+ const q = query({
+ prompt: createPrompt(),
+ options: {
+ ...SHARED_TEST_OPTIONS,
+ cwd: testDir,
+ permissionMode: 'default',
+ coreTools: ['read_file', 'write_file'],
+ canUseTool: async (toolName) => {
+ if (toolName === 'write_file') {
+ return {
+ behavior: 'deny',
+ message: 'Write operations are not allowed',
+ };
+ }
+ return { behavior: 'allow', updatedInput: {} };
+ },
+ debug: false,
+ },
+ });
+
+ const messages: SDKMessage[] = [];
+
+ try {
+ for await (const message of q) {
+ messages.push(message);
+ if (isSDKResultMessage(message)) {
+ resultWaiter.notifyResult();
+ }
+ }
+
+ // write_file should have been attempted but stream was closed
+ const writeFileResults = findToolResults(messages, 'write_file');
+ expect(writeFileResults.length).toBeGreaterThan(0);
+ for (const result of writeFileResults) {
+ expect(result.content).toContain(
+ '[Operation Cancelled] Reason: Write operations are not allowed',
+ );
+ }
+
+ // File content should remain unchanged (because write was denied)
+ const content = await helper.readFile('test.txt');
+ expect(content).toBe('original content');
+ } finally {
+ await q.close();
+ }
+ },
+ TEST_TIMEOUT,
+ );
+
+ it(
+ 'should support multi-turn conversation with canUseTool using asyncGenerator',
+ async () => {
+ await helper.createFile('data.txt', 'initial data');
+
+ const resultWaiter = createResultWaiter(2);
+ const canUseToolCalls: string[] = [];
+
+ // Create an async generator that yields multiple messages
+ async function* createMultiTurnPrompt(): AsyncIterable {
+ const sessionId = crypto.randomUUID();
+
+ yield {
+ type: 'user',
+ session_id: sessionId,
+ message: {
+ role: 'user',
+ content: 'Read data.txt and tell me what it contains.',
+ },
+ parent_tool_use_id: null,
+ };
+
+ await resultWaiter.waitForResult(0);
+
+ yield {
+ type: 'user',
+ session_id: sessionId,
+ message: {
+ role: 'user',
+ content: 'Now append " - updated" to the file content.',
+ },
+ parent_tool_use_id: null,
+ };
+
+ await resultWaiter.waitForResult(1);
+ }
+
+ const q = query({
+ prompt: createMultiTurnPrompt(),
+ options: {
+ ...SHARED_TEST_OPTIONS,
+ cwd: testDir,
+ permissionMode: 'default',
+ coreTools: ['read_file', 'write_file'],
+ canUseTool: async (toolName) => {
+ canUseToolCalls.push(toolName);
+ return { behavior: 'allow', updatedInput: {} };
+ },
+ debug: false,
+ },
+ });
+
+ const messages: SDKMessage[] = [];
+
+ try {
+ for await (const message of q) {
+ messages.push(message);
+ if (isSDKResultMessage(message)) {
+ resultWaiter.notifyResult();
+ }
+ }
+
+ const toolCalls = findToolCalls(messages);
+ const toolNames = toolCalls.map((tc) => tc.toolUse.name);
+
+ // Should have read_file and write_file calls
+ expect(toolNames).toContain('read_file');
+ expect(toolNames).toContain('write_file');
+
+ expect(canUseToolCalls).toContain('write_file');
+
+ const writeFileResults = findToolResults(messages, 'write_file');
+ expect(writeFileResults.length).toBeGreaterThan(0);
+
+ const content = await helper.readFile('data.txt');
+ expect(content).toContain('initial data');
+ expect(content).toContain(' - updated');
+ } finally {
+ await q.close();
+ }
+ },
+ TEST_TIMEOUT,
+ );
+ });
});
diff --git a/package-lock.json b/package-lock.json
index a7f6b5fbb..36b34d377 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@qwen-code/qwen-code",
- "version": "0.8.0",
+ "version": "0.8.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qwen-code/qwen-code",
- "version": "0.8.0",
+ "version": "0.8.2",
"workspaces": [
"packages/*"
],
@@ -63,8 +63,7 @@
"@lydell/node-pty-darwin-x64": "1.1.0",
"@lydell/node-pty-linux-x64": "1.1.0",
"@lydell/node-pty-win32-arm64": "1.1.0",
- "@lydell/node-pty-win32-x64": "1.1.0",
- "node-pty": "^1.0.0"
+ "@lydell/node-pty-win32-x64": "1.1.0"
}
},
"node_modules/@alcalzone/ansi-tokenize": {
@@ -3875,6 +3874,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",
@@ -10981,6 +10991,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",
@@ -11905,13 +11924,6 @@
"thenify-all": "^1.0.0"
}
},
- "node_modules/nan": {
- "version": "2.23.0",
- "resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz",
- "integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==",
- "license": "MIT",
- "optional": true
- },
"node_modules/nano-spawn": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-1.0.3.tgz",
@@ -12059,17 +12071,6 @@
"webidl-conversions": "^3.0.0"
}
},
- "node_modules/node-pty": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz",
- "integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==",
- "hasInstallScript": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "nan": "^2.17.0"
- }
- },
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
@@ -13390,6 +13391,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",
@@ -14747,6 +14761,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",
@@ -17304,7 +17324,7 @@
},
"packages/cli": {
"name": "@qwen-code/qwen-code",
- "version": "0.8.0",
+ "version": "0.8.2",
"dependencies": {
"@google/genai": "1.30.0",
"@iarna/toml": "^2.2.5",
@@ -17316,7 +17336,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",
@@ -17326,6 +17345,7 @@
"ink-spinner": "^5.0.0",
"lowlight": "^3.3.0",
"open": "^10.1.2",
+ "prompts": "^2.4.2",
"qrcode-terminal": "^0.12.0",
"react": "^19.1.0",
"read-package-up": "^11.0.0",
@@ -17334,7 +17354,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",
@@ -17354,11 +17373,11 @@
"@types/diff": "^7.0.2",
"@types/dotenv": "^6.1.1",
"@types/node": "^20.11.24",
+ "@types/prompts": "^2.4.9",
"@types/react": "^19.1.8",
"@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",
@@ -17941,11 +17960,12 @@
},
"packages/core": {
"name": "@qwen-code/qwen-code-core",
- "version": "0.8.0",
+ "version": "0.8.2",
"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",
@@ -17965,6 +17985,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",
@@ -17981,9 +18002,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",
+ "tar": "^7.5.2",
"undici": "^6.22.0",
"uuid": "^9.0.1",
"ws": "^8.18.0"
@@ -17995,6 +18018,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",
@@ -18009,8 +18034,7 @@
"@lydell/node-pty-darwin-x64": "1.1.0",
"@lydell/node-pty-linux-x64": "1.1.0",
"@lydell/node-pty-win32-arm64": "1.1.0",
- "@lydell/node-pty-win32-x64": "1.1.0",
- "node-pty": "^1.0.0"
+ "@lydell/node-pty-win32-x64": "1.1.0"
}
},
"packages/core/node_modules/@google/genai": {
@@ -18581,7 +18605,7 @@
},
"packages/sdk-typescript": {
"name": "@qwen-code/sdk",
- "version": "0.1.3",
+ "version": "0.1.4",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.25.1",
@@ -21400,7 +21424,7 @@
},
"packages/test-utils": {
"name": "@qwen-code/qwen-code-test-utils",
- "version": "0.8.0",
+ "version": "0.8.2",
"dev": true,
"license": "Apache-2.0",
"devDependencies": {
@@ -21412,7 +21436,7 @@
},
"packages/vscode-ide-companion": {
"name": "qwen-code-vscode-ide-companion",
- "version": "0.8.0",
+ "version": "0.8.2",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.25.1",
diff --git a/package.json b/package.json
index a9ab15472..076ab33e4 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
- "version": "0.8.0",
+ "version": "0.8.2",
"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.8.0"
+ "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.8.2"
},
"scripts": {
"start": "cross-env node scripts/start.js",
@@ -119,8 +119,7 @@
"@lydell/node-pty-darwin-x64": "1.1.0",
"@lydell/node-pty-linux-x64": "1.1.0",
"@lydell/node-pty-win32-arm64": "1.1.0",
- "@lydell/node-pty-win32-x64": "1.1.0",
- "node-pty": "^1.0.0"
+ "@lydell/node-pty-win32-x64": "1.1.0"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
diff --git a/packages/cli/package.json b/packages/cli/package.json
index e31c61dc9..20c0d54e8 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
- "version": "0.8.0",
+ "version": "0.8.2",
"description": "Qwen Code",
"repository": {
"type": "git",
@@ -33,7 +33,7 @@
"dist"
],
"config": {
- "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.8.0"
+ "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.8.2"
},
"dependencies": {
"@google/genai": "1.30.0",
@@ -46,7 +46,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",
@@ -56,6 +55,7 @@
"ink-spinner": "^5.0.0",
"lowlight": "^3.3.0",
"open": "^10.1.2",
+ "prompts": "^2.4.2",
"qrcode-terminal": "^0.12.0",
"react": "^19.1.0",
"read-package-up": "^11.0.0",
@@ -64,7 +64,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",
@@ -85,8 +84,8 @@
"@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",
+ "@types/prompts": "^2.4.9",
"archiver": "^7.0.1",
"ink-testing-library": "^4.0.0",
"jsdom": "^26.1.0",
diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts
index 373bf67be..a33091586 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;
@@ -304,7 +290,7 @@ class GeminiAgent {
}
private async ensureAuthenticated(config: Config): Promise {
- const selectedType = this.settings.merged.security?.auth?.selectedType;
+ const selectedType = config.getModelsConfig().getCurrentAuthType();
if (!selectedType) {
throw acp.RequestError.authRequired(
'Use Qwen Code CLI to authenticate first.',
diff --git a/packages/cli/src/acp-integration/schema.ts b/packages/cli/src/acp-integration/schema.ts
index 4278f0dd4..8e81b140d 100644
--- a/packages/cli/src/acp-integration/schema.ts
+++ b/packages/cli/src/acp-integration/schema.ts
@@ -366,6 +366,9 @@ export type Usage = z.infer;
export const sessionUpdateMetaSchema = z.object({
usage: usageSchema.optional().nullable(),
durationMs: z.number().optional().nullable(),
+ toolName: z.string().optional().nullable(),
+ parentToolCallId: z.string().optional().nullable(),
+ subagentType: z.string().optional().nullable(),
});
export type SessionUpdateMeta = z.infer;
@@ -573,6 +576,7 @@ export const sessionUpdateSchema = z.union([
kind: toolKindSchema,
locations: z.array(toolCallLocationSchema).optional(),
rawInput: z.unknown().optional(),
+ _meta: sessionUpdateMetaSchema.optional().nullable(),
sessionUpdate: z.literal('tool_call'),
status: toolCallStatusSchema,
title: z.string(),
@@ -584,6 +588,7 @@ export const sessionUpdateSchema = z.union([
locations: z.array(toolCallLocationSchema).optional().nullable(),
rawInput: z.unknown().optional(),
rawOutput: z.unknown().optional(),
+ _meta: sessionUpdateMetaSchema.optional().nullable(),
sessionUpdate: z.literal('tool_call_update'),
status: toolCallStatusSchema.optional().nullable(),
title: z.string().optional().nullable(),
diff --git a/packages/cli/src/acp-integration/session/HistoryReplayer.test.ts b/packages/cli/src/acp-integration/session/HistoryReplayer.test.ts
index c9cf65fb8..ef750f539 100644
--- a/packages/cli/src/acp-integration/session/HistoryReplayer.test.ts
+++ b/packages/cli/src/acp-integration/session/HistoryReplayer.test.ts
@@ -228,6 +228,7 @@ describe('HistoryReplayer', () => {
status: 'in_progress',
title: 'read_file',
rawInput: { path: '/test.ts' },
+ _meta: { toolName: 'read_file' },
}),
);
});
@@ -280,6 +281,7 @@ describe('HistoryReplayer', () => {
],
// resultDisplay is included as rawOutput
rawOutput: 'File contents here',
+ _meta: { toolName: 'read_file' },
});
});
diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts
index af98fe25c..5f37e1103 100644
--- a/packages/cli/src/acp-integration/session/Session.test.ts
+++ b/packages/cli/src/acp-integration/session/Session.test.ts
@@ -5,6 +5,9 @@
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as fs from 'node:fs/promises';
+import * as os from 'node:os';
+import * as path from 'node:path';
import { Session } from './Session.js';
import type { Config, GeminiChat } from '@qwen-code/qwen-code-core';
import { ApprovalMode } from '@qwen-code/qwen-code-core';
@@ -38,10 +41,27 @@ describe('Session', () => {
addHistory: vi.fn(),
} as unknown as GeminiChat;
+ const toolRegistry = { getTool: vi.fn() };
+ const fileService = { shouldGitIgnoreFile: vi.fn().mockReturnValue(false) };
+
mockConfig = {
setApprovalMode: vi.fn(),
setModel: setModelSpy,
getModel: vi.fn().mockImplementation(() => currentModel),
+ getSessionId: vi.fn().mockReturnValue('test-session-id'),
+ getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
+ getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
+ getContentGeneratorConfig: vi.fn().mockReturnValue(undefined),
+ getChatRecordingService: vi.fn().mockReturnValue({
+ recordUserMessage: vi.fn(),
+ recordUiTelemetryEvent: vi.fn(),
+ }),
+ getToolRegistry: vi.fn().mockReturnValue(toolRegistry),
+ getFileService: vi.fn().mockReturnValue(fileService),
+ getFileFilteringRespectGitIgnore: vi.fn().mockReturnValue(true),
+ getEnableRecursiveFileSearch: vi.fn().mockReturnValue(false),
+ getTargetDir: vi.fn().mockReturnValue(process.cwd()),
+ getDebugMode: vi.fn().mockReturnValue(false),
} as unknown as Config;
mockClient = {
@@ -171,4 +191,61 @@ describe('Session', () => {
consoleErrorSpy.mockRestore();
});
});
+
+ describe('prompt', () => {
+ it('passes resolved paths to read_many_files tool', async () => {
+ const tempDir = await fs.mkdtemp(
+ path.join(os.tmpdir(), 'qwen-acp-session-'),
+ );
+ const fileName = 'README.md';
+ const filePath = path.join(tempDir, fileName);
+
+ try {
+ await fs.writeFile(filePath, '# Test\n', 'utf8');
+
+ const readManyFilesTool = {
+ buildAndExecute: vi.fn().mockResolvedValue({
+ llmContent: 'file content',
+ returnDisplay: 'ok',
+ }),
+ };
+ const toolRegistry = {
+ getTool: vi.fn((name: string) =>
+ name === 'read_many_files' ? readManyFilesTool : undefined,
+ ),
+ };
+ const fileService = {
+ shouldGitIgnoreFile: vi.fn().mockReturnValue(false),
+ };
+
+ mockConfig.getTargetDir = vi.fn().mockReturnValue(tempDir);
+ mockConfig.getToolRegistry = vi.fn().mockReturnValue(toolRegistry);
+ mockConfig.getFileService = vi.fn().mockReturnValue(fileService);
+ mockChat.sendMessageStream = vi
+ .fn()
+ .mockResolvedValue((async function* () {})());
+
+ const promptRequest: acp.PromptRequest = {
+ sessionId: 'test-session-id',
+ prompt: [
+ { type: 'text', text: 'Check this file' },
+ {
+ type: 'resource_link',
+ name: fileName,
+ uri: `file://${fileName}`,
+ },
+ ],
+ };
+
+ await session.prompt(promptRequest);
+
+ expect(readManyFilesTool.buildAndExecute).toHaveBeenCalledWith(
+ { paths: [fileName] },
+ expect.any(AbortSignal),
+ );
+ } finally {
+ await fs.rm(tempDir, { recursive: true, force: true });
+ }
+ });
+ });
});
diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts
index 5348d78df..48d91fd0e 100644
--- a/packages/cli/src/acp-integration/session/Session.ts
+++ b/packages/cli/src/acp-integration/session/Session.ts
@@ -474,8 +474,17 @@ export class Session implements SessionContext {
}
).eventEmitter;
+ // Extract subagent metadata from TaskTool call
+ const parentToolCallId = callId;
+ const subagentType = (args['subagent_type'] as string) ?? '';
+
// Create a SubAgentTracker for this tool execution
- const subAgentTracker = new SubAgentTracker(this, this.client);
+ const subAgentTracker = new SubAgentTracker(
+ this,
+ this.client,
+ parentToolCallId,
+ subagentType,
+ );
// Set up sub-agent tool tracking
subAgentCleanupFunctions = subAgentTracker.setup(
@@ -647,7 +656,11 @@ export class Session implements SessionContext {
const error = e instanceof Error ? e : new Error(String(e));
// Use ToolCallEmitter for error handling
- await this.toolCallEmitter.emitError(callId, error);
+ await this.toolCallEmitter.emitError(
+ callId,
+ fc.name ?? 'unknown_tool',
+ error,
+ );
// Record tool error for session management
const errorParts = [
@@ -979,7 +992,7 @@ export class Session implements SessionContext {
if (pathSpecsToRead.length > 0) {
const readResult = await readManyFilesTool.buildAndExecute(
{
- paths_with_line_ranges: pathSpecsToRead,
+ paths: pathSpecsToRead,
},
abortSignal,
);
diff --git a/packages/cli/src/acp-integration/session/SubAgentTracker.test.ts b/packages/cli/src/acp-integration/session/SubAgentTracker.test.ts
index f2bb7cc50..96b8bd998 100644
--- a/packages/cli/src/acp-integration/session/SubAgentTracker.test.ts
+++ b/packages/cli/src/acp-integration/session/SubAgentTracker.test.ts
@@ -14,6 +14,7 @@ import type {
SubAgentToolCallEvent,
SubAgentToolResultEvent,
SubAgentApprovalRequestEvent,
+ SubAgentStreamTextEvent,
ToolEditConfirmationDetails,
ToolInfoConfirmationDetails,
} from '@qwen-code/qwen-code-core';
@@ -101,6 +102,18 @@ function createInfoConfirmation(
};
}
+// Helper to create a mock SubAgentStreamTextEvent with required fields
+function createStreamTextEvent(
+ overrides: Partial & { text: string },
+): SubAgentStreamTextEvent {
+ return {
+ subagentId: 'test-subagent',
+ round: 1,
+ timestamp: Date.now(),
+ ...overrides,
+ };
+}
+
describe('SubAgentTracker', () => {
let mockContext: SessionContext;
let mockClient: acp.Client;
@@ -132,7 +145,12 @@ describe('SubAgentTracker', () => {
requestPermission: requestPermissionSpy,
} as unknown as acp.Client;
- tracker = new SubAgentTracker(mockContext, mockClient);
+ tracker = new SubAgentTracker(
+ mockContext,
+ mockClient,
+ 'parent-call-123',
+ 'test-subagent',
+ );
eventEmitter = new EventEmitter() as unknown as SubAgentEventEmitter;
abortController = new AbortController();
});
@@ -162,6 +180,10 @@ describe('SubAgentTracker', () => {
SubAgentEventType.TOOL_WAITING_APPROVAL,
expect.any(Function),
);
+ expect(onSpy).toHaveBeenCalledWith(
+ SubAgentEventType.STREAM_TEXT,
+ expect.any(Function),
+ );
});
it('should remove event listeners on cleanup', () => {
@@ -182,6 +204,10 @@ describe('SubAgentTracker', () => {
SubAgentEventType.TOOL_WAITING_APPROVAL,
expect.any(Function),
);
+ expect(offSpy).toHaveBeenCalledWith(
+ SubAgentEventType.STREAM_TEXT,
+ expect.any(Function),
+ );
});
});
@@ -214,6 +240,11 @@ describe('SubAgentTracker', () => {
locations: [],
kind: 'other',
rawInput: { path: '/test.ts' },
+ _meta: expect.objectContaining({
+ toolName: 'read_file',
+ parentToolCallId: 'parent-call-123',
+ subagentType: 'test-subagent',
+ }),
}),
);
});
@@ -283,6 +314,11 @@ describe('SubAgentTracker', () => {
sessionUpdate: 'tool_call_update',
toolCallId: 'call-123',
status: 'completed',
+ _meta: expect.objectContaining({
+ toolName: 'read_file',
+ parentToolCallId: 'parent-call-123',
+ subagentType: 'test-subagent',
+ }),
}),
);
});
@@ -305,6 +341,11 @@ describe('SubAgentTracker', () => {
expect.objectContaining({
sessionUpdate: 'tool_call_update',
status: 'failed',
+ _meta: expect.objectContaining({
+ toolName: 'read_file',
+ parentToolCallId: 'parent-call-123',
+ subagentType: 'test-subagent',
+ }),
}),
);
});
@@ -522,4 +563,163 @@ describe('SubAgentTracker', () => {
);
});
});
+
+ describe('stream text handling', () => {
+ it('should emit agent_message_chunk on STREAM_TEXT event', async () => {
+ tracker.setup(eventEmitter, abortController.signal);
+
+ const event = createStreamTextEvent({
+ text: 'Hello, this is a response from the model.',
+ });
+
+ eventEmitter.emit(SubAgentEventType.STREAM_TEXT, event);
+
+ await vi.waitFor(() => {
+ expect(sendUpdateSpy).toHaveBeenCalled();
+ });
+
+ expect(sendUpdateSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ sessionUpdate: 'agent_message_chunk',
+ content: {
+ type: 'text',
+ text: 'Hello, this is a response from the model.',
+ },
+ }),
+ );
+ });
+
+ it('should emit multiple chunks for multiple STREAM_TEXT events', async () => {
+ tracker.setup(eventEmitter, abortController.signal);
+
+ eventEmitter.emit(
+ SubAgentEventType.STREAM_TEXT,
+ createStreamTextEvent({ text: 'First chunk ' }),
+ );
+ eventEmitter.emit(
+ SubAgentEventType.STREAM_TEXT,
+ createStreamTextEvent({ text: 'Second chunk ' }),
+ );
+ eventEmitter.emit(
+ SubAgentEventType.STREAM_TEXT,
+ createStreamTextEvent({ text: 'Third chunk' }),
+ );
+
+ await vi.waitFor(() => {
+ expect(sendUpdateSpy).toHaveBeenCalledTimes(3);
+ });
+
+ expect(sendUpdateSpy).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({
+ sessionUpdate: 'agent_message_chunk',
+ content: { type: 'text', text: 'First chunk ' },
+ }),
+ );
+ expect(sendUpdateSpy).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({
+ sessionUpdate: 'agent_message_chunk',
+ content: { type: 'text', text: 'Second chunk ' },
+ }),
+ );
+ expect(sendUpdateSpy).toHaveBeenNthCalledWith(
+ 3,
+ expect.objectContaining({
+ sessionUpdate: 'agent_message_chunk',
+ content: { type: 'text', text: 'Third chunk' },
+ }),
+ );
+ });
+
+ it('should not emit when aborted', async () => {
+ tracker.setup(eventEmitter, abortController.signal);
+ abortController.abort();
+
+ const event = createStreamTextEvent({
+ text: 'This should not be emitted',
+ });
+
+ eventEmitter.emit(SubAgentEventType.STREAM_TEXT, event);
+
+ await new Promise((resolve) => setTimeout(resolve, 10));
+
+ expect(sendUpdateSpy).not.toHaveBeenCalled();
+ });
+
+ it('should emit agent_thought_chunk when thought flag is true', async () => {
+ tracker.setup(eventEmitter, abortController.signal);
+
+ const event = createStreamTextEvent({
+ text: 'Let me think about this...',
+ thought: true,
+ });
+
+ eventEmitter.emit(SubAgentEventType.STREAM_TEXT, event);
+
+ await vi.waitFor(() => {
+ expect(sendUpdateSpy).toHaveBeenCalled();
+ });
+
+ expect(sendUpdateSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ sessionUpdate: 'agent_thought_chunk',
+ content: {
+ type: 'text',
+ text: 'Let me think about this...',
+ },
+ }),
+ );
+ });
+
+ it('should emit agent_message_chunk when thought flag is false', async () => {
+ tracker.setup(eventEmitter, abortController.signal);
+
+ const event = createStreamTextEvent({
+ text: 'Here is the answer.',
+ thought: false,
+ });
+
+ eventEmitter.emit(SubAgentEventType.STREAM_TEXT, event);
+
+ await vi.waitFor(() => {
+ expect(sendUpdateSpy).toHaveBeenCalled();
+ });
+
+ expect(sendUpdateSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ sessionUpdate: 'agent_message_chunk',
+ content: {
+ type: 'text',
+ text: 'Here is the answer.',
+ },
+ }),
+ );
+ });
+
+ it('should emit agent_message_chunk when thought flag is undefined', async () => {
+ tracker.setup(eventEmitter, abortController.signal);
+
+ // Event without thought flag (undefined)
+ const event = createStreamTextEvent({
+ text: 'Default behavior text.',
+ });
+
+ eventEmitter.emit(SubAgentEventType.STREAM_TEXT, event);
+
+ await vi.waitFor(() => {
+ expect(sendUpdateSpy).toHaveBeenCalled();
+ });
+
+ expect(sendUpdateSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ sessionUpdate: 'agent_message_chunk',
+ content: {
+ type: 'text',
+ text: 'Default behavior text.',
+ },
+ }),
+ );
+ });
+ });
});
diff --git a/packages/cli/src/acp-integration/session/SubAgentTracker.ts b/packages/cli/src/acp-integration/session/SubAgentTracker.ts
index 1e745b925..4643fe776 100644
--- a/packages/cli/src/acp-integration/session/SubAgentTracker.ts
+++ b/packages/cli/src/acp-integration/session/SubAgentTracker.ts
@@ -10,6 +10,7 @@ import type {
SubAgentToolResultEvent,
SubAgentApprovalRequestEvent,
SubAgentUsageEvent,
+ SubAgentStreamTextEvent,
ToolCallConfirmationDetails,
AnyDeclarativeTool,
AnyToolInvocation,
@@ -77,11 +78,23 @@ export class SubAgentTracker {
constructor(
private readonly ctx: SessionContext,
private readonly client: acp.Client,
+ private readonly parentToolCallId: string,
+ private readonly subagentType: string,
) {
this.toolCallEmitter = new ToolCallEmitter(ctx);
this.messageEmitter = new MessageEmitter(ctx);
}
+ /**
+ * Gets the subagent metadata to attach to all events.
+ */
+ private getSubagentMeta() {
+ return {
+ parentToolCallId: this.parentToolCallId,
+ subagentType: this.subagentType,
+ };
+ }
+
/**
* Sets up event listeners for a sub-agent's tool events.
*
@@ -97,11 +110,13 @@ export class SubAgentTracker {
const onToolResult = this.createToolResultHandler(abortSignal);
const onApproval = this.createApprovalHandler(abortSignal);
const onUsageMetadata = this.createUsageMetadataHandler(abortSignal);
+ const onStreamText = this.createStreamTextHandler(abortSignal);
eventEmitter.on(SubAgentEventType.TOOL_CALL, onToolCall);
eventEmitter.on(SubAgentEventType.TOOL_RESULT, onToolResult);
eventEmitter.on(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval);
eventEmitter.on(SubAgentEventType.USAGE_METADATA, onUsageMetadata);
+ eventEmitter.on(SubAgentEventType.STREAM_TEXT, onStreamText);
return [
() => {
@@ -109,6 +124,7 @@ export class SubAgentTracker {
eventEmitter.off(SubAgentEventType.TOOL_RESULT, onToolResult);
eventEmitter.off(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval);
eventEmitter.off(SubAgentEventType.USAGE_METADATA, onUsageMetadata);
+ eventEmitter.off(SubAgentEventType.STREAM_TEXT, onStreamText);
// Clean up any remaining states
this.toolStates.clear();
},
@@ -151,6 +167,7 @@ export class SubAgentTracker {
toolName: event.name,
callId: event.callId,
args: event.args,
+ subagentMeta: this.getSubagentMeta(),
});
};
}
@@ -175,6 +192,7 @@ export class SubAgentTracker {
message: event.responseParts ?? [],
resultDisplay: event.resultDisplay,
args: state?.args,
+ subagentMeta: this.getSubagentMeta(),
});
// Clean up state
@@ -269,7 +287,32 @@ export class SubAgentTracker {
const event = args[0] as SubAgentUsageEvent;
if (abortSignal.aborted) return;
- this.messageEmitter.emitUsageMetadata(event.usage, '', event.durationMs);
+ this.messageEmitter.emitUsageMetadata(
+ event.usage,
+ '',
+ event.durationMs,
+ this.getSubagentMeta(),
+ );
+ };
+ }
+
+ /**
+ * Creates a handler for stream text events.
+ * Emits agent message or thought chunks for text content from subagent model responses.
+ */
+ private createStreamTextHandler(
+ abortSignal: AbortSignal,
+ ): (...args: unknown[]) => void {
+ return (...args: unknown[]) => {
+ const event = args[0] as SubAgentStreamTextEvent;
+ if (abortSignal.aborted) return;
+
+ // Emit streamed text as agent message or thought based on the flag
+ void this.messageEmitter.emitMessage(
+ event.text,
+ 'assistant',
+ event.thought ?? false,
+ );
};
}
diff --git a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts
index 39cdf6a72..edf943b21 100644
--- a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts
+++ b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts
@@ -53,6 +53,7 @@ export class MessageEmitter extends BaseEmitter {
usageMetadata: GenerateContentResponseUsageMetadata,
text: string = '',
durationMs?: number,
+ subagentMeta?: import('../types.js').SubagentMeta,
): Promise {
const usage: Usage = {
promptTokens: usageMetadata.promptTokenCount,
@@ -63,7 +64,9 @@ export class MessageEmitter extends BaseEmitter {
};
const meta =
- typeof durationMs === 'number' ? { usage, durationMs } : { usage };
+ typeof durationMs === 'number'
+ ? { usage, durationMs, ...subagentMeta }
+ : { usage, ...subagentMeta };
await this.sendUpdate({
sessionUpdate: 'agent_message_chunk',
diff --git a/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.test.ts b/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.test.ts
index 4616b8592..9bfeb4fcb 100644
--- a/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.test.ts
+++ b/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.test.ts
@@ -77,6 +77,7 @@ describe('ToolCallEmitter', () => {
locations: [],
kind: 'other',
rawInput: { arg1: 'value1' },
+ _meta: { toolName: 'unknown_tool' },
});
});
@@ -100,6 +101,7 @@ describe('ToolCallEmitter', () => {
locations: [{ path: '/test/file.ts', line: 10 }],
kind: 'edit',
rawInput: { path: '/test.ts' },
+ _meta: { toolName: 'edit_file' },
});
});
@@ -123,6 +125,7 @@ describe('ToolCallEmitter', () => {
expect(sendUpdateSpy).toHaveBeenCalledWith(
expect.objectContaining({
rawInput: {},
+ _meta: { toolName: 'test_tool' },
}),
);
});
@@ -150,6 +153,7 @@ describe('ToolCallEmitter', () => {
locations: [], // Fallback to empty
kind: 'other', // Fallback to other
rawInput: { invalid: true },
+ _meta: { toolName: 'failing_tool' },
});
});
});
@@ -170,6 +174,7 @@ describe('ToolCallEmitter', () => {
toolCallId: 'call-123',
status: 'completed',
rawOutput: 'Tool completed successfully',
+ _meta: { toolName: 'test_tool' },
}),
);
});
@@ -193,6 +198,7 @@ describe('ToolCallEmitter', () => {
content: { type: 'text', text: 'Something went wrong' },
},
],
+ _meta: { toolName: 'test_tool' },
});
});
@@ -222,6 +228,7 @@ describe('ToolCallEmitter', () => {
newText: 'new content',
},
],
+ _meta: { toolName: 'edit_file' },
}),
);
});
@@ -247,6 +254,7 @@ describe('ToolCallEmitter', () => {
},
],
rawOutput: 'raw output',
+ _meta: { toolName: 'test_tool' },
}),
);
});
@@ -264,6 +272,7 @@ describe('ToolCallEmitter', () => {
toolCallId: 'call-empty',
status: 'completed',
content: [],
+ _meta: { toolName: 'test_tool' },
});
});
@@ -343,7 +352,7 @@ describe('ToolCallEmitter', () => {
it('should emit tool_call_update with failed status and error message', async () => {
const error = new Error('Connection timeout');
- await emitter.emitError('call-123', error);
+ await emitter.emitError('call-123', 'test_tool', error);
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'tool_call_update',
@@ -355,6 +364,7 @@ describe('ToolCallEmitter', () => {
content: { type: 'text', text: 'Connection timeout' },
},
],
+ _meta: { toolName: 'test_tool' },
});
});
});
@@ -498,6 +508,7 @@ describe('ToolCallEmitter', () => {
},
],
rawOutput: { unknownField: 'value', nested: { data: 123 } },
+ _meta: { toolName: 'test_tool' },
}),
);
});
@@ -519,6 +530,7 @@ describe('ToolCallEmitter', () => {
toolCallId: 'call-extra',
status: 'completed',
rawOutput: 'Result text',
+ _meta: { toolName: 'test_tool' },
}),
);
});
@@ -533,6 +545,7 @@ describe('ToolCallEmitter', () => {
const call = sendUpdateSpy.mock.calls[0][0];
expect(call.rawOutput).toBeUndefined();
+ expect(call._meta).toEqual({ toolName: 'test_tool' });
});
});
@@ -623,6 +636,7 @@ describe('ToolCallEmitter', () => {
content: { type: 'text', text: 'Text content from message' },
},
],
+ _meta: { toolName: 'test_tool' },
});
});
@@ -654,6 +668,7 @@ describe('ToolCallEmitter', () => {
},
],
rawOutput: 'raw result',
+ _meta: { toolName: 'test_tool' },
}),
);
});
diff --git a/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts b/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts
index 9859ed78e..e925567a7 100644
--- a/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts
+++ b/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts
@@ -11,6 +11,7 @@ import type {
ToolCallStartParams,
ToolCallResultParams,
ResolvedToolMetadata,
+ SubagentMeta,
} from '../types.js';
import type * as acp from '../../acp.js';
import type { Part } from '@google/genai';
@@ -65,6 +66,10 @@ export class ToolCallEmitter extends BaseEmitter {
locations,
kind,
rawInput: params.args ?? {},
+ _meta: {
+ toolName: params.toolName,
+ ...params.subagentMeta,
+ },
});
return true;
@@ -120,6 +125,10 @@ export class ToolCallEmitter extends BaseEmitter {
toolCallId: params.callId,
status: params.success ? 'completed' : 'failed',
content: contentArray,
+ _meta: {
+ toolName: params.toolName,
+ ...params.subagentMeta,
+ },
};
// Add rawOutput from resultDisplay
@@ -135,9 +144,16 @@ export class ToolCallEmitter extends BaseEmitter {
* Use this for explicit error handling when not using emitResult.
*
* @param callId - The tool call ID
+ * @param toolName - The tool name
* @param error - The error that occurred
+ * @param subagentMeta - Optional subagent metadata
*/
- async emitError(callId: string, error: Error): Promise {
+ async emitError(
+ callId: string,
+ toolName: string,
+ error: Error,
+ subagentMeta?: SubagentMeta,
+ ): Promise {
await this.sendUpdate({
sessionUpdate: 'tool_call_update',
toolCallId: callId,
@@ -145,6 +161,10 @@ export class ToolCallEmitter extends BaseEmitter {
content: [
{ type: 'content', content: { type: 'text', text: error.message } },
],
+ _meta: {
+ toolName,
+ ...subagentMeta,
+ },
});
}
diff --git a/packages/cli/src/acp-integration/session/types.ts b/packages/cli/src/acp-integration/session/types.ts
index 7812fb036..64cd262aa 100644
--- a/packages/cli/src/acp-integration/session/types.ts
+++ b/packages/cli/src/acp-integration/session/types.ts
@@ -25,6 +25,16 @@ export interface SessionContext extends SessionUpdateSender {
readonly config: Config;
}
+/**
+ * Subagent metadata for tracking parent tool call context.
+ */
+export interface SubagentMeta {
+ /** ID of the parent TaskTool call that created this subagent */
+ parentToolCallId?: string;
+ /** Type of subagent (from TaskParams.subagent_type) */
+ subagentType?: string;
+}
+
/**
* Parameters for emitting a tool call start event.
*/
@@ -37,6 +47,8 @@ export interface ToolCallStartParams {
args?: Record;
/** Status of the tool call */
status?: 'pending' | 'in_progress' | 'completed' | 'failed';
+ /** Optional subagent metadata */
+ subagentMeta?: SubagentMeta;
}
/**
@@ -57,6 +69,8 @@ export interface ToolCallResultParams {
error?: Error;
/** Original args (fallback for TodoWriteTool todos extraction) */
args?: Record;
+ /** Optional subagent metadata */
+ subagentMeta?: SubagentMeta;
}
/**
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..7d48a7c8c
--- /dev/null
+++ b/packages/cli/src/commands/extensions/consent.test.ts
@@ -0,0 +1,322 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import {
+ extensionConsentString,
+ requestConsentOrFail,
+ requestChoicePluginNonInteractive,
+} from './consent.js';
+import type {
+ ExtensionConfig,
+ ClaudeMarketplaceConfig,
+} from '@qwen-code/qwen-code-core';
+import prompts from 'prompts';
+
+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;
+ }),
+}));
+
+vi.mock('prompts');
+
+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();
+ });
+});
+
+describe('requestChoicePluginNonInteractive', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should throw error when plugins array is empty', async () => {
+ const marketplace: ClaudeMarketplaceConfig = {
+ name: 'test-marketplace',
+ owner: { name: 'Test Owner', email: 'test@example.com' },
+ plugins: [],
+ };
+
+ await expect(
+ requestChoicePluginNonInteractive(marketplace),
+ ).rejects.toThrow('No plugins available in this marketplace.');
+ });
+
+ it('should return selected plugin name', async () => {
+ const marketplace: ClaudeMarketplaceConfig = {
+ name: 'test-marketplace',
+ owner: { name: 'Test Owner', email: 'test@example.com' },
+ plugins: [
+ {
+ name: 'plugin1',
+ description: 'Plugin 1',
+ version: '1.0.0',
+ source: 'src1',
+ },
+ {
+ name: 'plugin2',
+ description: 'Plugin 2',
+ version: '1.0.0',
+ source: 'src2',
+ },
+ ],
+ };
+
+ vi.mocked(prompts).mockResolvedValueOnce({ plugin: 'plugin2' });
+
+ const result = await requestChoicePluginNonInteractive(marketplace);
+
+ expect(result).toBe('plugin2');
+ expect(prompts).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'select',
+ name: 'plugin',
+ choices: expect.arrayContaining([
+ expect.objectContaining({ value: 'plugin1' }),
+ expect.objectContaining({ value: 'plugin2' }),
+ ]),
+ }),
+ );
+ });
+
+ it('should throw error when selection is cancelled', async () => {
+ const marketplace: ClaudeMarketplaceConfig = {
+ name: 'test-marketplace',
+ owner: { name: 'Test Owner', email: 'test@example.com' },
+ plugins: [{ name: 'plugin1', version: '1.0.0', source: 'src1' }],
+ };
+
+ vi.mocked(prompts).mockResolvedValueOnce({ plugin: undefined });
+
+ await expect(
+ requestChoicePluginNonInteractive(marketplace),
+ ).rejects.toThrow('Plugin selection cancelled.');
+ });
+});
diff --git a/packages/cli/src/commands/extensions/consent.ts b/packages/cli/src/commands/extensions/consent.ts
new file mode 100644
index 000000000..cfff6e5b7
--- /dev/null
+++ b/packages/cli/src/commands/extensions/consent.ts
@@ -0,0 +1,256 @@
+import type {
+ ClaudeMarketplaceConfig,
+ ExtensionConfig,
+ ExtensionRequestOptions,
+ SkillConfig,
+ SubagentConfig,
+} from '@qwen-code/qwen-code-core';
+import type { ConfirmationRequest } from '../../ui/types.js';
+import chalk from 'chalk';
+import prompts from 'prompts';
+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 plugin selection from the user in non-interactive mode.
+ * Displays an interactive list with arrow key navigation.
+ *
+ * This should not be called from interactive mode as it will break the CLI.
+ *
+ * @param marketplace The marketplace config containing available plugins.
+ * @returns The name of the selected plugin.
+ */
+export async function requestChoicePluginNonInteractive(
+ marketplace: ClaudeMarketplaceConfig,
+): Promise {
+ const plugins = marketplace.plugins;
+
+ if (plugins.length === 0) {
+ throw new Error(t('No plugins available in this marketplace.'));
+ }
+
+ // Build choices for prompts select
+
+ const choices = plugins.map((plugin) => ({
+ title: chalk.green(chalk.bold(`[${plugin.name}]`)),
+ value: plugin.name,
+ }));
+
+ const response = await prompts({
+ type: 'select',
+ name: 'plugin',
+ message: t('Select a plugin to install from marketplace "{{name}}":', {
+ name: marketplace.name,
+ }),
+ choices,
+ initial: 0,
+ });
+
+ // Handle cancellation (Ctrl+C)
+ if (response.plugin === undefined) {
+ throw new Error(t('Plugin selection cancelled.'));
+ }
+
+ return response.plugin;
+}
+
+/**
+ * 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..f002d1a12 100644
--- a/packages/cli/src/commands/extensions/install.test.ts
+++ b/packages/cli/src/commands/extensions/install.test.ts
@@ -4,30 +4,52 @@
* 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,
+ requestChoicePluginNonInteractive: vi.fn(),
+}));
+
+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 +73,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 +99,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 +115,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 +131,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 +143,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 +159,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 +175,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..f7fda09df 100644
--- a/packages/cli/src/commands/extensions/install.ts
+++ b/packages/cli/src/commands/extensions/install.ts
@@ -5,58 +5,74 @@
*/
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,
+ requestChoicePluginNonInteractive,
+} 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,
+ requestChoicePlugin: requestChoicePluginNonInteractive,
+ });
+ 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 +81,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 +123,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