Merge branch 'main' into feat/gemini-3-integration

This commit is contained in:
tanzhenxin
2025-12-22 10:08:15 +08:00
26 changed files with 625 additions and 852 deletions

View File

@@ -121,6 +121,11 @@ jobs:
IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}' IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}'
MANUAL_VERSION: '${{ inputs.version }}' MANUAL_VERSION: '${{ inputs.version }}'
- name: 'Build CLI Bundle'
run: |
npm run build
npm run bundle
- name: 'Run Tests' - name: 'Run Tests'
if: |- if: |-
${{ github.event.inputs.force_skip_tests != 'true' }} ${{ github.event.inputs.force_skip_tests != 'true' }}
@@ -132,13 +137,6 @@ jobs:
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}' OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}' OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
- name: 'Build CLI for Integration Tests'
if: |-
${{ github.event.inputs.force_skip_tests != 'true' }}
run: |
npm run build
npm run bundle
- name: 'Run SDK Integration Tests' - name: 'Run SDK Integration Tests'
if: |- if: |-
${{ github.event.inputs.force_skip_tests != 'true' }} ${{ github.event.inputs.force_skip_tests != 'true' }}

View File

@@ -133,8 +133,8 @@ jobs:
${{ github.event.inputs.force_skip_tests != 'true' }} ${{ github.event.inputs.force_skip_tests != 'true' }}
run: | run: |
npm run preflight npm run preflight
npm run test:integration:sandbox:none npm run test:integration:cli:sandbox:none
npm run test:integration:sandbox:docker npm run test:integration:cli:sandbox:docker
env: env:
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}' OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}' OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'

View File

@@ -5,6 +5,7 @@ Welcome to the Qwen Code documentation. Qwen Code is an agentic coding tool that
## Documentation Sections ## Documentation Sections
### [User Guide](./users/overview) ### [User Guide](./users/overview)
Learn how to use Qwen Code as an end user. This section covers: Learn how to use Qwen Code as an end user. This section covers:
- Basic installation and setup - Basic installation and setup

View File

@@ -69,7 +69,7 @@ Settings are organized into categories. All settings should be placed within the
| Setting | Type | Description | Default | | Setting | Type | Description | Default |
| ---------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | | ---------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `ui.theme` | string | The color theme for the UI. See [Themes](../configuration/themes) for available options. | `undefined` | | `ui.theme` | string | The color theme for the UI. See [Themes](../configuration/themes) for available options. | `undefined` |
| `ui.customThemes` | object | Custom theme definitions. | `{}` | | `ui.customThemes` | object | Custom theme definitions. | `{}` |
| `ui.hideWindowTitle` | boolean | Hide the window title bar. | `false` | | `ui.hideWindowTitle` | boolean | Hide the window title bar. | `false` |
| `ui.hideTips` | boolean | Hide helpful tips in the UI. | `false` | | `ui.hideTips` | boolean | Hide helpful tips in the UI. | `false` |
@@ -357,38 +357,38 @@ Arguments passed directly when running the CLI can override other configurations
### Command-Line Arguments Table ### Command-Line Arguments Table
| Argument | Alias | Description | Possible Values | Notes | | Argument | Alias | Description | Possible Values | Notes |
| ---------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--model` | `-m` | Specifies the Qwen model to use for this session. | Model name | Example: `npm start -- --model qwen3-coder-plus` | | `--model` | `-m` | Specifies the Qwen model to use for this session. | Model name | Example: `npm start -- --model qwen3-coder-plus` |
| `--prompt` | `-p` | Used to pass a prompt directly to the command. This invokes Qwen Code in a non-interactive mode. | Your prompt text | For scripting examples, use the `--output-format json` flag to get structured output. | | `--prompt` | `-p` | Used to pass a prompt directly to the command. This invokes Qwen Code in a non-interactive mode. | Your prompt text | For scripting examples, use the `--output-format json` flag to get structured output. |
| `--prompt-interactive` | `-i` | Starts an interactive session with the provided prompt as the initial input. | Your prompt text | The prompt is processed within the interactive session, not before it. Cannot be used when piping input from stdin. Example: `qwen -i "explain this code"` | | `--prompt-interactive` | `-i` | Starts an interactive session with the provided prompt as the initial input. | Your prompt text | The prompt is processed within the interactive session, not before it. Cannot be used when piping input from stdin. Example: `qwen -i "explain this code"` |
| `--output-format` | `-o` | Specifies the format of the CLI output for non-interactive mode. | `text`, `json`, `stream-json` | `text`: (Default) The standard human-readable output. `json`: A machine-readable JSON output emitted at the end of execution. `stream-json`: Streaming JSON messages emitted as they occur during execution. For structured output and scripting, use the `--output-format json` or `--output-format stream-json` flag. See [Headless Mode](../features/headless) for detailed information. | | `--output-format` | `-o` | Specifies the format of the CLI output for non-interactive mode. | `text`, `json`, `stream-json` | `text`: (Default) The standard human-readable output. `json`: A machine-readable JSON output emitted at the end of execution. `stream-json`: Streaming JSON messages emitted as they occur during execution. For structured output and scripting, use the `--output-format json` or `--output-format stream-json` flag. See [Headless Mode](../features/headless) for detailed information. |
| `--input-format` | | Specifies the format consumed from standard input. | `text`, `stream-json` | `text`: (Default) Standard text input from stdin or command-line arguments. `stream-json`: JSON message protocol via stdin for bidirectional communication. Requirement: `--input-format stream-json` requires `--output-format stream-json` to be set. When using `stream-json`, stdin is reserved for protocol messages. See [Headless Mode](../features/headless) for detailed information. | | `--input-format` | | Specifies the format consumed from standard input. | `text`, `stream-json` | `text`: (Default) Standard text input from stdin or command-line arguments. `stream-json`: JSON message protocol via stdin for bidirectional communication. Requirement: `--input-format stream-json` requires `--output-format stream-json` to be set. When using `stream-json`, stdin is reserved for protocol messages. See [Headless Mode](../features/headless) for detailed information. |
| `--include-partial-messages` | | Include partial assistant messages when using `stream-json` output format. When enabled, emits stream events (message_start, content_block_delta, etc.) as they occur during streaming. | | Default: `false`. Requirement: Requires `--output-format stream-json` to be set. See [Headless Mode](../features/headless) for detailed information about stream events. | | `--include-partial-messages` | | Include partial assistant messages when using `stream-json` output format. When enabled, emits stream events (message_start, content_block_delta, etc.) as they occur during streaming. | | Default: `false`. Requirement: Requires `--output-format stream-json` to be set. See [Headless Mode](../features/headless) for detailed information about stream events. |
| `--sandbox` | `-s` | Enables sandbox mode for this session. | | | | `--sandbox` | `-s` | Enables sandbox mode for this session. | | |
| `--sandbox-image` | | Sets the sandbox image URI. | | | | `--sandbox-image` | | Sets the sandbox image URI. | | |
| `--debug` | `-d` | Enables debug mode for this session, providing more verbose output. | | | | `--debug` | `-d` | Enables debug mode for this session, providing more verbose output. | | |
| `--all-files` | `-a` | If set, recursively includes all files within the current directory as context for the prompt. | | | | `--all-files` | `-a` | If set, recursively includes all files within the current directory as context for the prompt. | | |
| `--help` | `-h` | Displays help information about command-line arguments. | | | | `--help` | `-h` | Displays help information about command-line arguments. | | |
| `--show-memory-usage` | | Displays the current memory usage. | | | | `--show-memory-usage` | | Displays the current memory usage. | | |
| `--yolo` | | Enables YOLO mode, which automatically approves all tool calls. | | | | `--yolo` | | Enables YOLO mode, which automatically approves all tool calls. | | |
| `--approval-mode` | | Sets the approval mode for tool calls. | `plan`, `default`, `auto-edit`, `yolo` | Supported modes: `plan`: Analyze only—do not modify files or execute commands. `default`: Require approval for file edits or shell commands (default behavior). `auto-edit`: Automatically approve edit tools (edit, write_file) while prompting for others. `yolo`: Automatically approve all tool calls (equivalent to `--yolo`). Cannot be used together with `--yolo`. Use `--approval-mode=yolo` instead of `--yolo` for the new unified approach. Example: `qwen --approval-mode auto-edit`<br>See more about [Approval Mode](../features/approval-mode). | | `--approval-mode` | | Sets the approval mode for tool calls. | `plan`, `default`, `auto-edit`, `yolo` | Supported modes: `plan`: Analyze only—do not modify files or execute commands. `default`: Require approval for file edits or shell commands (default behavior). `auto-edit`: Automatically approve edit tools (edit, write_file) while prompting for others. `yolo`: Automatically approve all tool calls (equivalent to `--yolo`). Cannot be used together with `--yolo`. Use `--approval-mode=yolo` instead of `--yolo` for the new unified approach. Example: `qwen --approval-mode auto-edit`<br>See more about [Approval Mode](../features/approval-mode). |
| `--allowed-tools` | | A comma-separated list of tool names that will bypass the confirmation dialog. | Tool names | Example: `qwen --allowed-tools "Shell(git status)"` | | `--allowed-tools` | | A comma-separated list of tool names that will bypass the confirmation dialog. | Tool names | Example: `qwen --allowed-tools "Shell(git status)"` |
| `--telemetry` | | Enables [telemetry](/developers/development/telemetry). | | | | `--telemetry` | | Enables [telemetry](/developers/development/telemetry). | | |
| `--telemetry-target` | | Sets the telemetry target. | | See [telemetry](/developers/development/telemetry) for more information. | | `--telemetry-target` | | Sets the telemetry target. | | See [telemetry](/developers/development/telemetry) for more information. |
| `--telemetry-otlp-endpoint` | | Sets the OTLP endpoint for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. | | `--telemetry-otlp-endpoint` | | Sets the OTLP endpoint for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. |
| `--telemetry-otlp-protocol` | | Sets the OTLP protocol for telemetry (`grpc` or `http`). | | Defaults to `grpc`. See [telemetry](../../developers/development/telemetry) for more information. | | `--telemetry-otlp-protocol` | | Sets the OTLP protocol for telemetry (`grpc` or `http`). | | Defaults to `grpc`. See [telemetry](../../developers/development/telemetry) for more information. |
| `--telemetry-log-prompts` | | Enables logging of prompts for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. | | `--telemetry-log-prompts` | | Enables logging of prompts for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. |
| `--checkpointing` | | Enables [checkpointing](../features/checkpointing). | | | | `--checkpointing` | | Enables [checkpointing](../features/checkpointing). | | |
| `--extensions` | `-e` | Specifies a list of extensions to use for the session. | Extension names | If not provided, all available extensions are used. Use the special term `qwen -e none` to disable all extensions. Example: `qwen -e my-extension -e my-other-extension` | | `--extensions` | `-e` | Specifies a list of extensions to use for the session. | Extension names | If not provided, all available extensions are used. Use the special term `qwen -e none` to disable all extensions. Example: `qwen -e my-extension -e my-other-extension` |
| `--list-extensions` | `-l` | Lists all available extensions and exits. | | | | `--list-extensions` | `-l` | Lists all available extensions and exits. | | |
| `--proxy` | | Sets the proxy for the CLI. | Proxy URL | Example: `--proxy http://localhost:7890`. | | `--proxy` | | Sets the proxy for the CLI. | Proxy URL | Example: `--proxy http://localhost:7890`. |
| `--include-directories` | | Includes additional directories in the workspace for multi-directory support. | Directory paths | Can be specified multiple times or as comma-separated values. 5 directories can be added at maximum. Example: `--include-directories /path/to/project1,/path/to/project2` or `--include-directories /path/to/project1 --include-directories /path/to/project2` | | `--include-directories` | | Includes additional directories in the workspace for multi-directory support. | Directory paths | Can be specified multiple times or as comma-separated values. 5 directories can be added at maximum. Example: `--include-directories /path/to/project1,/path/to/project2` or `--include-directories /path/to/project1 --include-directories /path/to/project2` |
| `--screen-reader` | | Enables screen reader mode, which adjusts the TUI for better compatibility with screen readers. | | | | `--screen-reader` | | Enables screen reader mode, which adjusts the TUI for better compatibility with screen readers. | | |
| `--version` | | Displays the version of the CLI. | | | | `--version` | | Displays the version of the CLI. | | |
| `--openai-logging` | | Enables logging of OpenAI API calls for debugging and analysis. | | This flag overrides the `enableOpenAILogging` setting in `settings.json`. | | `--openai-logging` | | Enables logging of OpenAI API calls for debugging and analysis. | | This flag overrides the `enableOpenAILogging` setting in `settings.json`. |
| `--openai-logging-dir` | | Sets a custom directory path for OpenAI API logs. | Directory path | This flag overrides the `openAILoggingDir` setting in `settings.json`. Supports absolute paths, relative paths, and `~` expansion. Example: `qwen --openai-logging-dir "~/qwen-logs" --openai-logging` | | `--openai-logging-dir` | | Sets a custom directory path for OpenAI API logs. | Directory path | This flag overrides the `openAILoggingDir` setting in `settings.json`. Supports absolute paths, relative paths, and `~` expansion. Example: `qwen --openai-logging-dir "~/qwen-logs" --openai-logging` |
| `--tavily-api-key` | | Sets the Tavily API key for web search functionality for this session. | API key | Example: `qwen --tavily-api-key tvly-your-api-key-here` | | `--tavily-api-key` | | Sets the Tavily API key for web search functionality for this session. | API key | Example: `qwen --tavily-api-key tvly-your-api-key-here` |
## Context Files (Hierarchical Instructional Context) ## Context Files (Hierarchical Instructional Context)

View File

@@ -16,16 +16,15 @@ The plugin **MUST** run a local HTTP server that implements the **Model Context
- **Endpoint:** The server should expose a single endpoint (e.g., `/mcp`) for all MCP communication. - **Endpoint:** The server should expose a single endpoint (e.g., `/mcp`) for all MCP communication.
- **Port:** The server **MUST** listen on a dynamically assigned port (i.e., listen on port `0`). - **Port:** The server **MUST** listen on a dynamically assigned port (i.e., listen on port `0`).
### 2. Discovery Mechanism: The Port File ### 2. Discovery Mechanism: The Lock File
For Qwen Code to connect, it needs to discover which IDE instance it's running in and what port your server is using. The plugin **MUST** facilitate this by creating a "discovery file." For Qwen Code to connect, it needs to discover what port your server is using. The plugin **MUST** facilitate this by creating a "lock file" and setting the port environment variable.
- **How the CLI Finds the File:** The CLI determines the Process ID (PID) of the IDE it's running in by traversing the process tree. It then looks for a discovery file that contains this PID in its name. - **How the CLI Finds the File:** The CLI reads the port from `QWEN_CODE_IDE_SERVER_PORT`, then reads `~/.qwen/ide/<PORT>.lock`. (Legacy fallbacks exist for older extensions; see note below.)
- **File Location:** The file must be created in a specific directory: `os.tmpdir()/qwen/ide/`. Your plugin must create this directory if it doesn't exist. - **File Location:** The file must be created in a specific directory: `~/.qwen/ide/`. Your plugin must create this directory if it doesn't exist.
- **File Naming Convention:** The filename is critical and **MUST** follow the pattern: - **File Naming Convention:** The filename is critical and **MUST** follow the pattern:
`qwen-code-ide-server-${PID}-${PORT}.json` `<PORT>.lock`
- `${PID}`: The process ID of the parent IDE process. Your plugin must determine this PID and include it in the filename. - `<PORT>`: The port your MCP server is listening on.
- `${PORT}`: The port your MCP server is listening on.
- **File Content & Workspace Validation:** The file **MUST** contain a JSON object with the following structure: - **File Content & Workspace Validation:** The file **MUST** contain a JSON object with the following structure:
```json ```json
@@ -33,21 +32,20 @@ For Qwen Code to connect, it needs to discover which IDE instance it's running i
"port": 12345, "port": 12345,
"workspacePath": "/path/to/project1:/path/to/project2", "workspacePath": "/path/to/project1:/path/to/project2",
"authToken": "a-very-secret-token", "authToken": "a-very-secret-token",
"ideInfo": { "ppid": 1234,
"name": "vscode", "ideName": "VS Code"
"displayName": "VS Code"
}
} }
``` ```
- `port` (number, required): The port of the MCP server. - `port` (number, required): The port of the MCP server.
- `workspacePath` (string, required): A list of all open workspace root paths, delimited by the OS-specific path separator (`:` for Linux/macOS, `;` for Windows). The CLI uses this path to ensure it's running in the same project folder that's open in the IDE. If the CLI's current working directory is not a sub-directory of `workspacePath`, the connection will be rejected. Your plugin **MUST** provide the correct, absolute path(s) to the root of the open workspace(s). - `workspacePath` (string, required): A list of all open workspace root paths, delimited by the OS-specific path separator (`:` for Linux/macOS, `;` for Windows). The CLI uses this path to ensure it's running in the same project folder that's open in the IDE. If the CLI's current working directory is not a sub-directory of `workspacePath`, the connection will be rejected. Your plugin **MUST** provide the correct, absolute path(s) to the root of the open workspace(s).
- `authToken` (string, required): A secret token for securing the connection. The CLI will include this token in an `Authorization: Bearer <token>` header on all requests. - `authToken` (string, required): A secret token for securing the connection. The CLI will include this token in an `Authorization: Bearer <token>` header on all requests.
- `ideInfo` (object, required): Information about the IDE. - `ppid` (number, required): The parent process ID of the IDE process.
- `name` (string, required): A short, lowercase identifier for the IDE (e.g., `vscode`, `jetbrains`). - `ideName` (string, required): A user-friendly name for the IDE (e.g., `VS Code`, `JetBrains IDE`).
- `displayName` (string, required): A user-friendly name for the IDE (e.g., `VS Code`, `JetBrains IDE`).
- **Authentication:** To secure the connection, the plugin **MUST** generate a unique, secret token and include it in the discovery file. The CLI will then include this token in the `Authorization` header for all requests to the MCP server (e.g., `Authorization: Bearer a-very-secret-token`). Your server **MUST** validate this token on every request and reject any that are unauthorized. - **Authentication:** To secure the connection, the plugin **MUST** generate a unique, secret token and include it in the discovery file. The CLI will then include this token in the `Authorization` header for all requests to the MCP server (e.g., `Authorization: Bearer a-very-secret-token`). Your server **MUST** validate this token on every request and reject any that are unauthorized.
- **Tie-Breaking with Environment Variables (Recommended):** For the most reliable experience, your plugin **SHOULD** both create the discovery file and set the `QWEN_CODE_IDE_SERVER_PORT` environment variable in the integrated terminal. The file serves as the primary discovery mechanism, but the environment variable is crucial for tie-breaking. If a user has multiple IDE windows open for the same workspace, the CLI uses the `QWEN_CODE_IDE_SERVER_PORT` variable to identify and connect to the correct window's server. - **Environment Variables (Required):** Your plugin **MUST** set `QWEN_CODE_IDE_SERVER_PORT` in the integrated terminal so the CLI can locate the correct `<PORT>.lock` file.
**Legacy note:** For extensions older than v0.5.1, Qwen Code may fall back to reading JSON files in the system temp directory named `qwen-code-ide-server-<PID>.json` or `qwen-code-ide-server-<PORT>.json`. New integrations should not rely on these legacy files.
## II. The Context Interface ## II. The Context Interface

42
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "@qwen-code/qwen-code", "name": "@qwen-code/qwen-code",
"version": "0.5.1", "version": "0.6.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@qwen-code/qwen-code", "name": "@qwen-code/qwen-code",
"version": "0.5.1", "version": "0.6.0",
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
], ],
@@ -16085,6 +16085,7 @@
"version": "7.15.0", "version": "7.15.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.15.0.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-7.15.0.tgz",
"integrity": "sha512-7oZJCPvvMvTd0OlqWsIxTuItTpJBpU1tcbVl24FMn3xt3+VSunwUasmfPJRE57oNO1KsZ4PgA1xTdAX4hq8NyQ==", "integrity": "sha512-7oZJCPvvMvTd0OlqWsIxTuItTpJBpU1tcbVl24FMn3xt3+VSunwUasmfPJRE57oNO1KsZ4PgA1xTdAX4hq8NyQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=20.18.1" "node": ">=20.18.1"
@@ -17193,7 +17194,7 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@qwen-code/qwen-code", "name": "@qwen-code/qwen-code",
"version": "0.5.1", "version": "0.6.0",
"dependencies": { "dependencies": {
"@google/genai": "1.30.0", "@google/genai": "1.30.0",
"@iarna/toml": "^2.2.5", "@iarna/toml": "^2.2.5",
@@ -17224,7 +17225,7 @@
"strip-ansi": "^7.1.0", "strip-ansi": "^7.1.0",
"strip-json-comments": "^3.1.1", "strip-json-comments": "^3.1.1",
"tar": "^7.5.2", "tar": "^7.5.2",
"undici": "^7.10.0", "undici": "^6.22.0",
"update-notifier": "^7.3.1", "update-notifier": "^7.3.1",
"wrap-ansi": "9.0.2", "wrap-ansi": "9.0.2",
"yargs": "^17.7.2", "yargs": "^17.7.2",
@@ -17819,9 +17820,18 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"packages/cli/node_modules/undici": {
"version": "6.22.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.22.0.tgz",
"integrity": "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==",
"license": "MIT",
"engines": {
"node": ">=18.17"
}
},
"packages/core": { "packages/core": {
"name": "@qwen-code/qwen-code-core", "name": "@qwen-code/qwen-code-core",
"version": "0.5.1", "version": "0.6.0",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@google/genai": "1.30.0", "@google/genai": "1.30.0",
@@ -17863,7 +17873,7 @@
"simple-git": "^3.28.0", "simple-git": "^3.28.0",
"strip-ansi": "^7.1.0", "strip-ansi": "^7.1.0",
"tiktoken": "^1.0.21", "tiktoken": "^1.0.21",
"undici": "^7.10.0", "undici": "^6.22.0",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"ws": "^8.18.0" "ws": "^8.18.0"
}, },
@@ -18440,6 +18450,15 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"packages/core/node_modules/undici": {
"version": "6.22.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.22.0.tgz",
"integrity": "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==",
"license": "MIT",
"engines": {
"node": ">=18.17"
}
},
"packages/core/node_modules/zod-to-json-schema": { "packages/core/node_modules/zod-to-json-schema": {
"version": "3.25.0", "version": "3.25.0",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz", "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz",
@@ -18451,10 +18470,11 @@
}, },
"packages/sdk-typescript": { "packages/sdk-typescript": {
"name": "@qwen-code/sdk", "name": "@qwen-code/sdk",
"version": "0.5.1", "version": "0.6.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.25.1", "@modelcontextprotocol/sdk": "^1.25.1",
"tiktoken": "^1.0.21",
"zod": "^3.25.0" "zod": "^3.25.0"
}, },
"devDependencies": { "devDependencies": {
@@ -21270,7 +21290,7 @@
}, },
"packages/test-utils": { "packages/test-utils": {
"name": "@qwen-code/qwen-code-test-utils", "name": "@qwen-code/qwen-code-test-utils",
"version": "0.5.1", "version": "0.6.0",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"devDependencies": { "devDependencies": {
@@ -21282,7 +21302,7 @@
}, },
"packages/vscode-ide-companion": { "packages/vscode-ide-companion": {
"name": "qwen-code-vscode-ide-companion", "name": "qwen-code-vscode-ide-companion",
"version": "0.5.1", "version": "0.6.0",
"license": "LICENSE", "license": "LICENSE",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.25.1", "@modelcontextprotocol/sdk": "^1.25.1",
@@ -21302,7 +21322,7 @@
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"@types/semver": "^7.7.1", "@types/semver": "^7.7.1",
"@types/vscode": "^1.99.0", "@types/vscode": "^1.85.0",
"@typescript-eslint/eslint-plugin": "^8.31.1", "@typescript-eslint/eslint-plugin": "^8.31.1",
"@typescript-eslint/parser": "^8.31.1", "@typescript-eslint/parser": "^8.31.1",
"@vscode/vsce": "^3.6.0", "@vscode/vsce": "^3.6.0",
@@ -21317,7 +21337,7 @@
"vitest": "^3.2.4" "vitest": "^3.2.4"
}, },
"engines": { "engines": {
"vscode": "^1.99.0" "vscode": "^1.85.0"
} }
}, },
"packages/vscode-ide-companion/node_modules/@modelcontextprotocol/sdk": { "packages/vscode-ide-companion/node_modules/@modelcontextprotocol/sdk": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@qwen-code/qwen-code", "name": "@qwen-code/qwen-code",
"version": "0.5.1", "version": "0.6.0",
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
}, },
@@ -13,7 +13,7 @@
"url": "git+https://github.com/QwenLM/qwen-code.git" "url": "git+https://github.com/QwenLM/qwen-code.git"
}, },
"config": { "config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.5.1" "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.0"
}, },
"scripts": { "scripts": {
"start": "cross-env node scripts/start.js", "start": "cross-env node scripts/start.js",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@qwen-code/qwen-code", "name": "@qwen-code/qwen-code",
"version": "0.5.1", "version": "0.6.0",
"description": "Qwen Code", "description": "Qwen Code",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -33,7 +33,7 @@
"dist" "dist"
], ],
"config": { "config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.5.1" "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.0"
}, },
"dependencies": { "dependencies": {
"@google/genai": "1.30.0", "@google/genai": "1.30.0",
@@ -64,7 +64,7 @@
"strip-ansi": "^7.1.0", "strip-ansi": "^7.1.0",
"strip-json-comments": "^3.1.1", "strip-json-comments": "^3.1.1",
"tar": "^7.5.2", "tar": "^7.5.2",
"undici": "^7.10.0", "undici": "^6.22.0",
"extract-zip": "^2.0.1", "extract-zip": "^2.0.1",
"update-notifier": "^7.3.1", "update-notifier": "^7.3.1",
"wrap-ansi": "9.0.2", "wrap-ansi": "9.0.2",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@qwen-code/qwen-code-core", "name": "@qwen-code/qwen-code-core",
"version": "0.5.1", "version": "0.6.0",
"description": "Qwen Code Core", "description": "Qwen Code Core",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -62,7 +62,7 @@
"simple-git": "^3.28.0", "simple-git": "^3.28.0",
"strip-ansi": "^7.1.0", "strip-ansi": "^7.1.0",
"tiktoken": "^1.0.21", "tiktoken": "^1.0.21",
"undici": "^7.10.0", "undici": "^6.22.0",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"ws": "^8.18.0" "ws": "^8.18.0"
}, },

View File

@@ -15,6 +15,7 @@ export const OAUTH_FILE = 'oauth_creds.json';
const TMP_DIR_NAME = 'tmp'; const TMP_DIR_NAME = 'tmp';
const BIN_DIR_NAME = 'bin'; const BIN_DIR_NAME = 'bin';
const PROJECT_DIR_NAME = 'projects'; const PROJECT_DIR_NAME = 'projects';
const IDE_DIR_NAME = 'ide';
export class Storage { export class Storage {
private readonly targetDir: string; private readonly targetDir: string;
@@ -59,6 +60,10 @@ export class Storage {
return path.join(Storage.getGlobalQwenDir(), TMP_DIR_NAME); return path.join(Storage.getGlobalQwenDir(), TMP_DIR_NAME);
} }
static getGlobalIdeDir(): string {
return path.join(Storage.getGlobalQwenDir(), IDE_DIR_NAME);
}
static getGlobalBinDir(): string { static getGlobalBinDir(): string {
return path.join(Storage.getGlobalQwenDir(), BIN_DIR_NAME); return path.join(Storage.getGlobalQwenDir(), BIN_DIR_NAME);
} }

View File

@@ -32,6 +32,7 @@ vi.mock('node:fs', async (importOriginal) => {
...actual.promises, ...actual.promises,
readFile: vi.fn(), readFile: vi.fn(),
readdir: vi.fn(), readdir: vi.fn(),
stat: vi.fn(),
}, },
realpathSync: (p: string) => p, realpathSync: (p: string) => p,
existsSync: () => false, existsSync: () => false,
@@ -68,6 +69,7 @@ describe('IdeClient', () => {
command: 'test-ide', command: 'test-ide',
}); });
vi.mocked(os.tmpdir).mockReturnValue('/tmp'); vi.mocked(os.tmpdir).mockReturnValue('/tmp');
vi.mocked(os.homedir).mockReturnValue('/home/test');
// Mock MCP client and transports // Mock MCP client and transports
mockClient = { mockClient = {
@@ -97,19 +99,15 @@ describe('IdeClient', () => {
describe('connect', () => { describe('connect', () => {
it('should connect using HTTP when port is provided in config file', async () => { it('should connect using HTTP when port is provided in config file', async () => {
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '8080';
const config = { port: '8080' }; const config = { port: '8080' };
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([]);
const ideClient = await IdeClient.getInstance(); const ideClient = await IdeClient.getInstance();
await ideClient.connect(); await ideClient.connect();
expect(fs.promises.readFile).toHaveBeenCalledWith( expect(fs.promises.readFile).toHaveBeenCalledWith(
path.join('/tmp', 'qwen-code-ide-server-12345.json'), path.join('/home/test', '.qwen', 'ide', '8080.lock'),
'utf8', 'utf8',
); );
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith( expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
@@ -120,16 +118,13 @@ describe('IdeClient', () => {
expect(ideClient.getConnectionStatus().status).toBe( expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Connected, IDEConnectionStatus.Connected,
); );
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
}); });
it('should connect using stdio when stdio config is provided in file', async () => { it('should connect using stdio when stdio config is provided in file', async () => {
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '8080';
const config = { stdio: { command: 'test-cmd', args: ['--foo'] } }; const config = { stdio: { command: 'test-cmd', args: ['--foo'] } };
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([]);
const ideClient = await IdeClient.getInstance(); const ideClient = await IdeClient.getInstance();
await ideClient.connect(); await ideClient.connect();
@@ -142,19 +137,16 @@ describe('IdeClient', () => {
expect(ideClient.getConnectionStatus().status).toBe( expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Connected, IDEConnectionStatus.Connected,
); );
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
}); });
it('should prioritize port over stdio when both are in config file', async () => { it('should prioritize port over stdio when both are in config file', async () => {
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '8080';
const config = { const config = {
port: '8080', port: '8080',
stdio: { command: 'test-cmd', args: ['--foo'] }, stdio: { command: 'test-cmd', args: ['--foo'] },
}; };
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([]);
const ideClient = await IdeClient.getInstance(); const ideClient = await IdeClient.getInstance();
await ideClient.connect(); await ideClient.connect();
@@ -164,6 +156,7 @@ describe('IdeClient', () => {
expect(ideClient.getConnectionStatus().status).toBe( expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Connected, IDEConnectionStatus.Connected,
); );
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
}); });
it('should connect using HTTP when port is provided in environment variables', async () => { it('should connect using HTTP when port is provided in environment variables', async () => {
@@ -263,7 +256,8 @@ describe('IdeClient', () => {
}); });
describe('getConnectionConfigFromFile', () => { describe('getConnectionConfigFromFile', () => {
it('should return config from the specific pid file if it exists', async () => { it('should return config from the env port lock file if it exists', async () => {
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '1234';
const config = { port: '1234', workspacePath: '/test/workspace' }; const config = { port: '1234', workspacePath: '/test/workspace' };
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
@@ -277,18 +271,14 @@ describe('IdeClient', () => {
expect(result).toEqual(config); expect(result).toEqual(config);
expect(fs.promises.readFile).toHaveBeenCalledWith( expect(fs.promises.readFile).toHaveBeenCalledWith(
path.join('/tmp', 'qwen-code-ide-server-12345.json'), path.join('/home/test', '.qwen', 'ide', '1234.lock'),
'utf8', 'utf8',
); );
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
}); });
it('should return undefined if no config files are found', async () => { it('should return undefined if no config files are found', async () => {
vi.mocked(fs.promises.readFile).mockRejectedValue(new Error('not found')); vi.mocked(fs.promises.readFile).mockRejectedValue(new Error('not found'));
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([]);
const ideClient = await IdeClient.getInstance(); const ideClient = await IdeClient.getInstance();
const result = await ( const result = await (
@@ -300,20 +290,15 @@ describe('IdeClient', () => {
expect(result).toBeUndefined(); expect(result).toBeUndefined();
}); });
it('should find and parse a single config file with the new naming scheme', async () => { it('should read legacy pid config when available', async () => {
const config = { port: '5678', workspacePath: '/test/workspace' }; const config = {
vi.mocked(fs.promises.readFile).mockRejectedValueOnce( port: '5678',
new Error('not found'), workspacePath: '/test/workspace',
); // For old path ppid: 12345,
( };
vi.mocked(fs.promises.readdir) as Mock< vi.mocked(fs.promises.readFile).mockResolvedValueOnce(
(path: fs.PathLike) => Promise<string[]> JSON.stringify(config),
> );
).mockResolvedValue(['qwen-code-ide-server-12345-123.json']);
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
isValid: true,
});
const ideClient = await IdeClient.getInstance(); const ideClient = await IdeClient.getInstance();
const result = await ( const result = await (
@@ -324,110 +309,18 @@ describe('IdeClient', () => {
expect(result).toEqual(config); expect(result).toEqual(config);
expect(fs.promises.readFile).toHaveBeenCalledWith( expect(fs.promises.readFile).toHaveBeenCalledWith(
path.join('/tmp/gemini/ide', 'qwen-code-ide-server-12345-123.json'), path.join('/tmp', 'qwen-code-ide-server-12345.json'),
'utf8', 'utf8',
); );
}); });
it('should filter out configs with invalid workspace paths', async () => { it('should fall back to legacy port file when pid file is missing', async () => {
const validConfig = {
port: '5678',
workspacePath: '/test/workspace',
};
const invalidConfig = {
port: '1111',
workspacePath: '/invalid/workspace',
};
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([
'qwen-code-ide-server-12345-111.json',
'qwen-code-ide-server-12345-222.json',
]);
vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce(JSON.stringify(invalidConfig))
.mockResolvedValueOnce(JSON.stringify(validConfig));
const validateSpy = vi
.spyOn(IdeClient, 'validateWorkspacePath')
.mockReturnValueOnce({ isValid: false })
.mockReturnValueOnce({ isValid: true });
const ideClient = await IdeClient.getInstance();
const result = await (
ideClient as unknown as {
getConnectionConfigFromFile: () => Promise<unknown>;
}
).getConnectionConfigFromFile();
expect(result).toEqual(validConfig);
expect(validateSpy).toHaveBeenCalledWith(
'/invalid/workspace',
'/test/workspace/sub-dir',
);
expect(validateSpy).toHaveBeenCalledWith(
'/test/workspace',
'/test/workspace/sub-dir',
);
});
it('should return the first valid config when multiple workspaces are valid', async () => {
const config1 = { port: '1111', workspacePath: '/test/workspace' };
const config2 = { port: '2222', workspacePath: '/test/workspace2' };
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([
'qwen-code-ide-server-12345-111.json',
'qwen-code-ide-server-12345-222.json',
]);
vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce(JSON.stringify(config1))
.mockResolvedValueOnce(JSON.stringify(config2));
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
isValid: true,
});
const ideClient = await IdeClient.getInstance();
const result = await (
ideClient as unknown as {
getConnectionConfigFromFile: () => Promise<unknown>;
}
).getConnectionConfigFromFile();
expect(result).toEqual(config1);
});
it('should prioritize the config matching the port from the environment variable', async () => {
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '2222'; process.env['QWEN_CODE_IDE_SERVER_PORT'] = '2222';
const config1 = { port: '1111', workspacePath: '/test/workspace' };
const config2 = { port: '2222', workspacePath: '/test/workspace2' }; const config2 = { port: '2222', workspacePath: '/test/workspace2' };
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([
'qwen-code-ide-server-12345-111.json',
'qwen-code-ide-server-12345-222.json',
]);
vi.mocked(fs.promises.readFile) vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce(JSON.stringify(config1)) .mockRejectedValueOnce(new Error('not found')) // ~/.qwen/ide/<port>.lock
.mockRejectedValueOnce(new Error('not found')) // legacy pid file
.mockResolvedValueOnce(JSON.stringify(config2)); .mockResolvedValueOnce(JSON.stringify(config2));
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
isValid: true,
});
const ideClient = await IdeClient.getInstance(); const ideClient = await IdeClient.getInstance();
const result = await ( const result = await (
@@ -437,28 +330,23 @@ describe('IdeClient', () => {
).getConnectionConfigFromFile(); ).getConnectionConfigFromFile();
expect(result).toEqual(config2); expect(result).toEqual(config2);
expect(fs.promises.readFile).toHaveBeenCalledWith(
path.join('/tmp', 'qwen-code-ide-server-12345.json'),
'utf8',
);
expect(fs.promises.readFile).toHaveBeenCalledWith(
path.join('/tmp', 'qwen-code-ide-server-2222.json'),
'utf8',
);
delete process.env['QWEN_CODE_IDE_SERVER_PORT']; delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
}); });
it('should handle invalid JSON in one of the config files', async () => { it('should fall back to legacy config when env lock file has invalid JSON', async () => {
const validConfig = { port: '2222', workspacePath: '/test/workspace' }; process.env['QWEN_CODE_IDE_SERVER_PORT'] = '3333';
vi.mocked(fs.promises.readFile).mockRejectedValueOnce( const config = { port: '1111', workspacePath: '/test/workspace' };
new Error('not found'),
);
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([
'qwen-code-ide-server-12345-111.json',
'qwen-code-ide-server-12345-222.json',
]);
vi.mocked(fs.promises.readFile) vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce('invalid json') .mockResolvedValueOnce('invalid json')
.mockResolvedValueOnce(JSON.stringify(validConfig)); .mockResolvedValueOnce(JSON.stringify(config));
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
isValid: true,
});
const ideClient = await IdeClient.getInstance(); const ideClient = await IdeClient.getInstance();
const result = await ( const result = await (
@@ -467,96 +355,7 @@ describe('IdeClient', () => {
} }
).getConnectionConfigFromFile(); ).getConnectionConfigFromFile();
expect(result).toEqual(validConfig); expect(result).toEqual(config);
});
it('should return undefined if readdir throws an error', async () => {
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
vi.mocked(fs.promises.readdir).mockRejectedValue(
new Error('readdir failed'),
);
const ideClient = await IdeClient.getInstance();
const result = await (
ideClient as unknown as {
getConnectionConfigFromFile: () => Promise<unknown>;
}
).getConnectionConfigFromFile();
expect(result).toBeUndefined();
});
it('should ignore files with invalid names', async () => {
const validConfig = { port: '3333', workspacePath: '/test/workspace' };
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([
'qwen-code-ide-server-12345-111.json', // valid
'not-a-config-file.txt', // invalid
'qwen-code-ide-server-asdf.json', // invalid
]);
vi.mocked(fs.promises.readFile).mockResolvedValueOnce(
JSON.stringify(validConfig),
);
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
isValid: true,
});
const ideClient = await IdeClient.getInstance();
const result = await (
ideClient as unknown as {
getConnectionConfigFromFile: () => Promise<unknown>;
}
).getConnectionConfigFromFile();
expect(result).toEqual(validConfig);
expect(fs.promises.readFile).toHaveBeenCalledWith(
path.join('/tmp/gemini/ide', 'qwen-code-ide-server-12345-111.json'),
'utf8',
);
expect(fs.promises.readFile).not.toHaveBeenCalledWith(
path.join('/tmp/gemini/ide', 'not-a-config-file.txt'),
'utf8',
);
});
it('should match env port string to a number port in the config', async () => {
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '3333';
const config1 = { port: 1111, workspacePath: '/test/workspace' };
const config2 = { port: 3333, workspacePath: '/test/workspace2' };
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([
'qwen-code-ide-server-12345-111.json',
'qwen-code-ide-server-12345-222.json',
]);
vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce(JSON.stringify(config1))
.mockResolvedValueOnce(JSON.stringify(config2));
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
isValid: true,
});
const ideClient = await IdeClient.getInstance();
const result = await (
ideClient as unknown as {
getConnectionConfigFromFile: () => Promise<unknown>;
}
).getConnectionConfigFromFile();
expect(result).toEqual(config2);
delete process.env['QWEN_CODE_IDE_SERVER_PORT']; delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
}); });
}); });

View File

@@ -8,6 +8,7 @@ import * as fs from 'node:fs';
import { isSubpath } from '../utils/paths.js'; import { isSubpath } from '../utils/paths.js';
import { detectIde, type IdeInfo } from '../ide/detect-ide.js'; import { detectIde, type IdeInfo } from '../ide/detect-ide.js';
import { ideContextStore } from './ideContext.js'; import { ideContextStore } from './ideContext.js';
import { Storage } from '../config/storage.js';
import { import {
IdeContextNotificationSchema, IdeContextNotificationSchema,
IdeDiffAcceptedNotificationSchema, IdeDiffAcceptedNotificationSchema,
@@ -572,98 +573,103 @@ export class IdeClient {
| (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo }) | (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo })
| undefined | undefined
> { > {
if (!this.ideProcessInfo) {
return undefined;
}
// For backwards compatability
try {
const portFile = path.join(
os.tmpdir(),
`qwen-code-ide-server-${this.ideProcessInfo.pid}.json`,
);
const portFileContents = await fs.promises.readFile(portFile, 'utf8');
return JSON.parse(portFileContents);
} catch (_) {
// For newer extension versions, the file name matches the pattern
// /^qwen-code-ide-server-${pid}-\d+\.json$/. If multiple IDE
// windows are open, multiple files matching the pattern are expected to
// exist.
}
const portFileDir = path.join(os.tmpdir(), 'gemini', 'ide');
let portFiles;
try {
portFiles = await fs.promises.readdir(portFileDir);
} catch (e) {
logger.debug('Failed to read IDE connection directory:', e);
return undefined;
}
if (!portFiles) {
return undefined;
}
const fileRegex = new RegExp(
`^qwen-code-ide-server-${this.ideProcessInfo.pid}-\\d+\\.json$`,
);
const matchingFiles = portFiles
.filter((file) => fileRegex.test(file))
.sort();
if (matchingFiles.length === 0) {
return undefined;
}
let fileContents: string[];
try {
fileContents = await Promise.all(
matchingFiles.map((file) =>
fs.promises.readFile(path.join(portFileDir, file), 'utf8'),
),
);
} catch (e) {
logger.debug('Failed to read IDE connection config file(s):', e);
return undefined;
}
const parsedContents = fileContents.map((content) => {
try {
return JSON.parse(content);
} catch (e) {
logger.debug('Failed to parse JSON from config file: ', e);
return undefined;
}
});
const validWorkspaces = parsedContents.filter((content) => {
if (!content) {
return false;
}
const { isValid } = IdeClient.validateWorkspacePath(
content.workspacePath,
process.cwd(),
);
return isValid;
});
if (validWorkspaces.length === 0) {
return undefined;
}
if (validWorkspaces.length === 1) {
return validWorkspaces[0];
}
const portFromEnv = this.getPortFromEnv(); const portFromEnv = this.getPortFromEnv();
if (portFromEnv) { if (portFromEnv) {
const matchingPort = validWorkspaces.find( try {
(content) => String(content.port) === portFromEnv, const ideDir = Storage.getGlobalIdeDir();
); const lockFile = path.join(ideDir, `${portFromEnv}.lock`);
if (matchingPort) { const lockFileContents = await fs.promises.readFile(lockFile, 'utf8');
return matchingPort; return JSON.parse(lockFileContents);
} catch (_) {
// Fall through to legacy discovery.
} }
} }
return validWorkspaces[0]; // Legacy discovery for VSCode extension < v0.5.1.
return this.getLegacyConnectionConfig(portFromEnv);
}
// Legacy connection files were written in the global temp directory.
private async getLegacyConnectionConfig(
portFromEnv?: string,
): Promise<
| (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo })
| undefined
> {
if (this.ideProcessInfo) {
try {
const portFile = path.join(
os.tmpdir(),
`qwen-code-ide-server-${this.ideProcessInfo.pid}.json`,
);
const portFileContents = await fs.promises.readFile(portFile, 'utf8');
return JSON.parse(portFileContents);
} catch (_) {
// For older/newer extension versions, the file name matches the pattern
// /^qwen-code-ide-server-${pid}-\d+\.json$/. If multiple IDE
// windows are open, multiple files matching the pattern are expected to
// exist.
}
}
if (portFromEnv) {
try {
const portFile = path.join(
os.tmpdir(),
`qwen-code-ide-server-${portFromEnv}.json`,
);
const portFileContents = await fs.promises.readFile(portFile, 'utf8');
return JSON.parse(portFileContents);
} catch (_) {
// Ignore and fall through.
}
}
return undefined;
}
protected async getAllConnectionConfigs(
ideDir: string,
): Promise<
ConnectionConfig & Array<{ workspacePath?: string; ideInfo?: IdeInfo }>
> {
const fileRegex = new RegExp('^\\d+\\.lock$');
let lockFiles: string[];
try {
lockFiles = (await fs.promises.readdir(ideDir)).filter((file) =>
fileRegex.test(file),
);
} catch (e) {
logger.debug('Failed to read IDE connection directory:', e);
return [];
}
const fileContents = await Promise.all(
lockFiles.map(async (file) => {
const fullPath = path.join(ideDir, file);
try {
const stat = await fs.promises.stat(fullPath);
const content = await fs.promises.readFile(fullPath, 'utf8');
try {
const parsed = JSON.parse(content);
return { file, mtimeMs: stat.mtimeMs, parsed };
} catch (e) {
logger.debug('Failed to parse JSON from lock file: ', e);
return { file, mtimeMs: stat.mtimeMs, parsed: undefined };
}
} catch (e) {
// If we can't stat/read the file, treat it as very old so it doesn't
// win ties, and skip parsing by returning undefined content.
logger.debug('Failed to read/stat IDE lock file:', e);
return { file, mtimeMs: -Infinity, parsed: undefined };
}
}),
);
return fileContents
.filter(({ parsed }) => parsed !== undefined)
.sort((a, b) => b.mtimeMs - a.mtimeMs)
.map(({ parsed }) => parsed);
} }
private createProxyAwareFetch() { private createProxyAwareFetch() {

View File

@@ -50,7 +50,7 @@ describe('getIdeProcessInfo', () => {
expect(result).toEqual({ pid: 700, command: '/usr/lib/vscode/code' }); expect(result).toEqual({ pid: 700, command: '/usr/lib/vscode/code' });
}); });
it('should return parent process info if grandparent lookup fails', async () => { it('should return shell process info if grandparent lookup fails', async () => {
(os.platform as Mock).mockReturnValue('linux'); (os.platform as Mock).mockReturnValue('linux');
mockedExec mockedExec
.mockResolvedValueOnce({ stdout: '800 /bin/bash' }) // pid 1000 -> ppid 800 (shell) .mockResolvedValueOnce({ stdout: '800 /bin/bash' }) // pid 1000 -> ppid 800 (shell)
@@ -63,134 +63,96 @@ describe('getIdeProcessInfo', () => {
}); });
describe('on Windows', () => { describe('on Windows', () => {
it('should traverse up and find the great-grandchild of the root process', async () => { it('should return great-grandparent process using heuristic', async () => {
(os.platform as Mock).mockReturnValue('win32'); (os.platform as Mock).mockReturnValue('win32');
const processInfoMap = new Map([
[ const processes = [
1000, {
{ ProcessId: 1000,
stdout: ParentProcessId: 900,
'{"Name":"node.exe","ParentProcessId":900,"CommandLine":"node.exe"}', Name: 'node.exe',
}, CommandLine: 'node.exe',
], },
[ {
900, ProcessId: 900,
{ ParentProcessId: 800,
stdout: Name: 'powershell.exe',
'{"Name":"powershell.exe","ParentProcessId":800,"CommandLine":"powershell.exe"}', CommandLine: 'powershell.exe',
}, },
], {
[ ProcessId: 800,
800, ParentProcessId: 700,
{ Name: 'code.exe',
stdout: CommandLine: 'code.exe',
'{"Name":"code.exe","ParentProcessId":700,"CommandLine":"code.exe"}', },
}, {
], ProcessId: 700,
[ ParentProcessId: 0,
700, Name: 'wininit.exe',
{ CommandLine: 'wininit.exe',
stdout: },
'{"Name":"wininit.exe","ParentProcessId":0,"CommandLine":"wininit.exe"}', ];
},
], mockedExec.mockImplementation((file: string, _args: string[]) => {
]); if (file === 'powershell') {
mockedExec.mockImplementation((command: string) => { return Promise.resolve({ stdout: JSON.stringify(processes) });
const pidMatch = command.match(/ProcessId=(\d+)/);
if (pidMatch) {
const pid = parseInt(pidMatch[1], 10);
return Promise.resolve(processInfoMap.get(pid));
} }
return Promise.reject(new Error('Invalid command for mock')); return Promise.resolve({ stdout: '' });
}); });
const result = await getIdeProcessInfo(); const result = await getIdeProcessInfo();
// Process chain: 1000 (node.exe) -> 900 (powershell.exe) -> 800 (code.exe) -> 700 (wininit.exe)
// ancestors = [1000, 900, 800, 700], length = 4
// Heuristic: return ancestors[length-3] = ancestors[1] = 900 (powershell.exe)
expect(result).toEqual({ pid: 900, command: 'powershell.exe' }); expect(result).toEqual({ pid: 900, command: 'powershell.exe' });
}); });
it('should handle non-existent process gracefully', async () => { it('should handle empty process list gracefully', async () => {
(os.platform as Mock).mockReturnValue('win32'); (os.platform as Mock).mockReturnValue('win32');
mockedExec mockedExec.mockResolvedValue({ stdout: '[]' });
.mockResolvedValueOnce({ stdout: '' }) // Non-existent PID returns empty due to -ErrorAction SilentlyContinue
.mockResolvedValueOnce({
stdout:
'{"Name":"fallback.exe","ParentProcessId":0,"CommandLine":"fallback.exe"}',
}); // Fallback call
const result = await getIdeProcessInfo(); const result = await getIdeProcessInfo();
expect(result).toEqual({ pid: 1000, command: 'fallback.exe' }); // Should return current pid and empty command because process not found in map
expect(result).toEqual({ pid: 1000, command: '' });
}); });
it('should handle malformed JSON output gracefully', async () => { it('should handle malformed JSON output gracefully', async () => {
(os.platform as Mock).mockReturnValue('win32'); (os.platform as Mock).mockReturnValue('win32');
mockedExec mockedExec.mockResolvedValue({ stdout: '{"invalid":json}' });
.mockResolvedValueOnce({ stdout: '{"invalid":json}' }) // Malformed JSON
.mockResolvedValueOnce({
stdout:
'{"Name":"fallback.exe","ParentProcessId":0,"CommandLine":"fallback.exe"}',
}); // Fallback call
const result = await getIdeProcessInfo(); const result = await getIdeProcessInfo();
expect(result).toEqual({ pid: 1000, command: 'fallback.exe' }); expect(result).toEqual({ pid: 1000, command: '' });
}); });
it('should handle PowerShell errors without crashing the process chain', async () => { it('should return last ancestor if chain is too short', async () => {
(os.platform as Mock).mockReturnValue('win32'); (os.platform as Mock).mockReturnValue('win32');
const processInfoMap = new Map([
[1000, { stdout: '' }], // First process doesn't exist (empty due to -ErrorAction)
[
1001,
{
stdout:
'{"Name":"parent.exe","ParentProcessId":800,"CommandLine":"parent.exe"}',
},
],
[
800,
{
stdout:
'{"Name":"ide.exe","ParentProcessId":0,"CommandLine":"ide.exe"}',
},
],
]);
// Mock the process.pid to test traversal with missing processes const processes = [
Object.defineProperty(process, 'pid', { {
value: 1001, ProcessId: 1000,
configurable: true, ParentProcessId: 900,
}); Name: 'node.exe',
CommandLine: 'node.exe',
},
{
ProcessId: 900,
ParentProcessId: 0,
Name: 'explorer.exe',
CommandLine: 'explorer.exe',
},
];
mockedExec.mockImplementation((command: string) => { mockedExec.mockImplementation((file: string, _args: string[]) => {
const pidMatch = command.match(/ProcessId=(\d+)/); if (file === 'powershell') {
if (pidMatch) { return Promise.resolve({ stdout: JSON.stringify(processes) });
const pid = parseInt(pidMatch[1], 10);
return Promise.resolve(processInfoMap.get(pid) || { stdout: '' });
} }
return Promise.reject(new Error('Invalid command for mock')); return Promise.resolve({ stdout: '' });
}); });
const result = await getIdeProcessInfo(); const result = await getIdeProcessInfo();
// Should return the current process command since traversal continues despite missing processes // ancestors = [1000, 900], length = 2 (< 3)
expect(result).toEqual({ pid: 1001, command: 'parent.exe' }); // Heuristic: return ancestors[length-1] = ancestors[1] = 900 (explorer.exe)
expect(result).toEqual({ pid: 900, command: 'explorer.exe' });
// Reset process.pid
Object.defineProperty(process, 'pid', {
value: 1000,
configurable: true,
});
});
it('should handle partial JSON data with defaults', async () => {
(os.platform as Mock).mockReturnValue('win32');
mockedExec
.mockResolvedValueOnce({ stdout: '{"Name":"partial.exe"}' }) // Missing ParentProcessId, defaults to 0
.mockResolvedValueOnce({
stdout:
'{"Name":"root.exe","ParentProcessId":0,"CommandLine":"root.exe"}',
}); // Get grandparent info
const result = await getIdeProcessInfo();
expect(result).toEqual({ pid: 1000, command: 'root.exe' });
}); });
}); });
}); });

View File

@@ -4,74 +4,28 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { exec } from 'node:child_process'; import { exec, execFile } from 'node:child_process';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
const execAsync = promisify(exec); const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
const MAX_TRAVERSAL_DEPTH = 32; const MAX_TRAVERSAL_DEPTH = 32;
/**
* Fetches the parent process ID, name, and command for a given process ID.
*
* @param pid The process ID to inspect.
* @returns A promise that resolves to the parent's PID, name, and command.
*/
async function getProcessInfo(pid: number): Promise<{ async function getProcessInfo(pid: number): Promise<{
parentPid: number; parentPid: number;
name: string; name: string;
command: string; command: string;
}> { }> {
try { // Only used for Unix systems (macOS and Linux)
const platform = os.platform(); const { stdout } = await execAsync(`ps -p ${pid} -o ppid=,comm=`);
if (platform === 'win32') { const [ppidStr, ...commandParts] = stdout.trim().split(/\s+/);
const powershellCommand = [ const parentPid = parseInt(ppidStr, 10);
'$p = Get-CimInstance Win32_Process', const command = commandParts.join(' ');
`-Filter 'ProcessId=${pid}'`, return { parentPid, name: path.basename(command), command };
'-ErrorAction SilentlyContinue;',
'if ($p) {',
'@{Name=$p.Name;ParentProcessId=$p.ParentProcessId;CommandLine=$p.CommandLine}',
'| ConvertTo-Json',
'}',
].join(' ');
const { stdout } = await execAsync(`powershell "${powershellCommand}"`);
const output = stdout.trim();
if (!output) return { parentPid: 0, name: '', command: '' };
const {
Name = '',
ParentProcessId = 0,
CommandLine = '',
} = JSON.parse(output);
return {
parentPid: ParentProcessId,
name: Name,
command: CommandLine ?? '',
};
} else {
const command = `ps -o ppid=,command= -p ${pid}`;
const { stdout } = await execAsync(command);
const trimmedStdout = stdout.trim();
if (!trimmedStdout) {
return { parentPid: 0, name: '', command: '' };
}
const ppidString = trimmedStdout.split(/\s+/)[0];
const parentPid = parseInt(ppidString, 10);
const fullCommand = trimmedStdout.substring(ppidString.length).trim();
const processName = path.basename(fullCommand.split(' ')[0]);
return {
parentPid: isNaN(parentPid) ? 1 : parentPid,
name: processName,
command: fullCommand,
};
}
} catch (_e) {
console.debug(`Failed to get process info for pid ${pid}:`, _e);
return { parentPid: 0, name: '', command: '' };
}
} }
/** /**
* Finds the IDE process info on Unix-like systems. * Finds the IDE process info on Unix-like systems.
* *
@@ -106,15 +60,15 @@ async function getIdeProcessInfoForUnix(): Promise<{
} catch { } catch {
// Ignore if getting grandparent fails, we'll just use the parent pid. // Ignore if getting grandparent fails, we'll just use the parent pid.
} }
const { command } = await getProcessInfo(idePid); const { command: ideCommand } = await getProcessInfo(idePid);
return { pid: idePid, command }; return { pid: idePid, command: ideCommand };
} }
if (parentPid <= 1) { if (parentPid <= 1) {
break; // Reached the root break; // Reached the root
} }
currentPid = parentPid; currentPid = parentPid;
} catch { } catch (_e) {
// Process in chain died // Process in chain died
break; break;
} }
@@ -124,50 +78,104 @@ async function getIdeProcessInfoForUnix(): Promise<{
return { pid: currentPid, command }; return { pid: currentPid, command };
} }
interface ProcessInfo {
pid: number;
parentPid: number;
name: string;
command: string;
}
interface RawProcessInfo {
ProcessId?: number;
ParentProcessId?: number;
Name?: string;
CommandLine?: string;
}
/** /**
* Finds the IDE process info on Windows. * Fetches the entire process table on Windows.
*
* The strategy is to find the great-grandchild of the root process.
*
* @returns A promise that resolves to the PID and command of the IDE process.
*/ */
async function getProcessTableWindows(): Promise<Map<number, ProcessInfo>> {
const processMap = new Map<number, ProcessInfo>();
try {
const powershellCommand =
'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,CommandLine | ConvertTo-Json -Compress';
const { stdout } = await execFileAsync(
'powershell',
['-NoProfile', '-NonInteractive', '-Command', powershellCommand],
{ maxBuffer: 10 * 1024 * 1024 },
);
if (!stdout.trim()) {
return processMap;
}
let processes: RawProcessInfo | RawProcessInfo[];
try {
processes = JSON.parse(stdout);
} catch (_e) {
return processMap;
}
if (!Array.isArray(processes)) {
processes = [processes];
}
for (const p of processes) {
if (p && typeof p.ProcessId === 'number') {
processMap.set(p.ProcessId, {
pid: p.ProcessId,
parentPid: p.ParentProcessId || 0,
name: p.Name || '',
command: p.CommandLine || '',
});
}
}
} catch (_e) {
// Fallback or error handling if PowerShell fails
}
return processMap;
}
async function getIdeProcessInfoForWindows(): Promise<{ async function getIdeProcessInfoForWindows(): Promise<{
pid: number; pid: number;
command: string; command: string;
}> { }> {
let currentPid = process.pid; // Fetch the entire process table in one go.
let previousPid = process.pid; const processMap = await getProcessTableWindows();
for (let i = 0; i < MAX_TRAVERSAL_DEPTH; i++) { const myPid = process.pid;
try { const myProc = processMap.get(myPid);
const { parentPid } = await getProcessInfo(currentPid);
if (parentPid > 0) { if (!myProc) {
try { // Fallback: return current process info if snapshot fails
const { parentPid: grandParentPid } = await getProcessInfo(parentPid); return { pid: myPid, command: '' };
if (grandParentPid === 0) { }
// We've found the grandchild of the root (`currentPid`). The IDE
// process is its child, which we've stored in `previousPid`.
const { command } = await getProcessInfo(previousPid);
return { pid: previousPid, command };
}
} catch {
// getting grandparent failed, proceed
}
}
if (parentPid <= 0) { // Perform tree traversal in memory
break; // Reached the root const ancestors: ProcessInfo[] = [];
} let curr: ProcessInfo | undefined = myProc;
previousPid = currentPid;
currentPid = parentPid; for (let i = 0; i < MAX_TRAVERSAL_DEPTH && curr; i++) {
} catch { ancestors.push(curr);
// Process in chain died
if (curr.parentPid === 0 || !processMap.has(curr.parentPid)) {
// Parent process not in map, stop traversal
break; break;
} }
curr = processMap.get(curr.parentPid);
} }
const { command } = await getProcessInfo(currentPid);
return { pid: currentPid, command }; // Use heuristic: return the great-grandparent (ancestors[length-3])
if (ancestors.length >= 3) {
const target = ancestors[ancestors.length - 3];
return { pid: target.pid, command: target.command };
} else if (ancestors.length > 0) {
const target = ancestors[ancestors.length - 1];
return { pid: target.pid, command: target.command };
}
return { pid: myPid, command: myProc.command };
} }
/** /**

View File

@@ -13,9 +13,8 @@ npm install @qwen-code/sdk
## Requirements ## Requirements
- Node.js >= 20.0.0 - Node.js >= 20.0.0
- [Qwen Code](https://github.com/QwenLM/qwen-code) >= 0.4.0 (stable) installed and accessible in PATH
> **Note for nvm users**: If you use nvm to manage Node.js versions, the SDK may not be able to auto-detect the Qwen Code executable. You should explicitly set the `pathToQwenExecutable` option to the full path of the `qwen` binary. > From v0.1.1, the CLI is bundled with the SDK. So no standalone CLI installation is needed.
## Quick Start ## Quick Start
@@ -372,6 +371,23 @@ try {
} }
``` ```
## FAQ / Troubleshooting
### Version 0.1.0 Requirements
If you're using SDK version **0.1.0**, please note the following requirements:
#### Qwen Code Installation Required
Version 0.1.0 requires [Qwen Code](https://github.com/QwenLM/qwen-code) **>= 0.4.0** to be installed separately and accessible in your PATH.
```bash
# Install Qwen Code globally
npm install -g qwen-code@^0.4.0
```
**Note**: From version **0.1.1** onwards, the CLI is bundled with the SDK, so no separate Qwen Code installation is needed.
## License ## License
Apache-2.0 - see [LICENSE](./LICENSE) for details. Apache-2.0 - see [LICENSE](./LICENSE) for details.

View File

@@ -1,6 +1,6 @@
{ {
"name": "@qwen-code/sdk", "name": "@qwen-code/sdk",
"version": "0.5.1", "version": "0.6.0",
"description": "TypeScript SDK for programmatic access to qwen-code CLI", "description": "TypeScript SDK for programmatic access to qwen-code CLI",
"main": "./dist/index.cjs", "main": "./dist/index.cjs",
"module": "./dist/index.mjs", "module": "./dist/index.mjs",
@@ -46,7 +46,8 @@
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.25.1", "@modelcontextprotocol/sdk": "^1.25.1",
"zod": "^3.25.0" "zod": "^3.25.0",
"tiktoken": "^1.0.21"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.14.0", "@types/node": "^20.14.0",

View File

@@ -91,3 +91,35 @@ if (existsSync(licenseSource)) {
console.warn('Could not copy LICENSE:', error.message); console.warn('Could not copy LICENSE:', error.message);
} }
} }
console.log('Bundling CLI into SDK package...');
const repoRoot = join(rootDir, '..', '..');
const rootDistDir = join(repoRoot, 'dist');
if (!existsSync(rootDistDir) || !existsSync(join(rootDistDir, 'cli.js'))) {
console.log('Building CLI bundle...');
try {
execSync('npm run bundle', { stdio: 'inherit', cwd: repoRoot });
} catch (error) {
console.error('Failed to build CLI bundle:', error.message);
throw error;
}
}
const cliDistDir = join(rootDir, 'dist', 'cli');
mkdirSync(cliDistDir, { recursive: true });
console.log('Copying CLI bundle...');
cpSync(join(rootDistDir, 'cli.js'), join(cliDistDir, 'cli.js'));
const vendorSource = join(rootDistDir, 'vendor');
if (existsSync(vendorSource)) {
cpSync(vendorSource, join(cliDistDir, 'vendor'), { recursive: true });
}
const localesSource = join(rootDistDir, 'locales');
if (existsSync(localesSource)) {
cpSync(localesSource, join(cliDistDir, 'locales'), { recursive: true });
}
console.log('CLI bundle copied successfully to SDK package');

View File

@@ -2,24 +2,16 @@
* CLI path auto-detection and subprocess spawning utilities * CLI path auto-detection and subprocess spawning utilities
* *
* Supports multiple execution modes: * Supports multiple execution modes:
* 1. Native binary: 'qwen' (production) * 1. Bundled CLI: Node.js bundle included in the SDK package (default)
* 2. Node.js bundle: 'node /path/to/cli.js' (production validation) * 2. Node.js bundle: 'node /path/to/cli.js' (custom path)
* 3. Bun bundle: 'bun /path/to/cli.js' (alternative runtime) * 3. Bun bundle: 'bun /path/to/cli.js' (alternative runtime)
* 4. TypeScript source: 'tsx /path/to/index.ts' (development) * 4. TypeScript source: 'tsx /path/to/index.ts' (development)
*
* Auto-detection locations for native binary:
* 1. QWEN_CODE_CLI_PATH environment variable
* 2. ~/.volta/bin/qwen
* 3. ~/.npm-global/bin/qwen
* 4. /usr/local/bin/qwen
* 5. ~/.local/bin/qwen
* 6. ~/node_modules/.bin/qwen
* 7. ~/.yarn/bin/qwen
*/ */
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import * as path from 'node:path'; import * as path from 'node:path';
import { execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
/** /**
* Executable types supported by the SDK * Executable types supported by the SDK
@@ -40,49 +32,38 @@ export type SpawnInfo = {
originalInput: string; originalInput: string;
}; };
export function findNativeCliPath(): string { function getBundledCliPath(): string | null {
const homeDir = process.env['HOME'] || process.env['USERPROFILE'] || ''; try {
const currentFile =
typeof __filename !== 'undefined'
? __filename
: fileURLToPath(import.meta.url);
const candidates: Array<string | undefined> = [ const currentDir = path.dirname(currentFile);
// 1. Environment variable (highest priority)
process.env['QWEN_CODE_CLI_PATH'],
// 2. Volta bin const bundledCliPath = path.join(currentDir, 'cli', 'cli.js');
path.join(homeDir, '.volta', 'bin', 'qwen'),
// 3. Global npm installations if (fs.existsSync(bundledCliPath)) {
path.join(homeDir, '.npm-global', 'bin', 'qwen'), return bundledCliPath;
// 4. Common Unix binary locations
'/usr/local/bin/qwen',
// 5. User local bin
path.join(homeDir, '.local', 'bin', 'qwen'),
// 6. Node modules bin in home directory
path.join(homeDir, 'node_modules', '.bin', 'qwen'),
// 7. Yarn global bin
path.join(homeDir, '.yarn', 'bin', 'qwen'),
];
// Find first existing candidate
for (const candidate of candidates) {
if (candidate && fs.existsSync(candidate)) {
return path.resolve(candidate);
} }
return null;
} catch {
return null;
}
}
export function findNativeCliPath(): string {
const bundledCli = getBundledCliPath();
if (bundledCli) {
return bundledCli;
} }
// Not found - throw helpful error
throw new Error( throw new Error(
'qwen CLI not found. Please:\n' + 'Bundled qwen CLI not found. The CLI should be included in the SDK package.\n' +
' 1. Install qwen globally: npm install -g qwen\n' + 'If you need to use a custom CLI, provide explicit executable:\n' +
' 2. Or provide explicit executable: query({ pathToQwenExecutable: "/path/to/qwen" })\n' + ' query({ pathToQwenExecutable: "/path/to/cli.js" })\n' +
' 3. Or set environment variable: QWEN_CODE_CLI_PATH="/path/to/qwen"\n' +
'\n' +
'For development/testing, you can also use:\n' +
' • TypeScript source: query({ pathToQwenExecutable: "/path/to/index.ts" })\n' + ' • TypeScript source: query({ pathToQwenExecutable: "/path/to/index.ts" })\n' +
' • Node.js bundle: query({ pathToQwenExecutable: "/path/to/cli.js" })\n' +
' • Force specific runtime: query({ pathToQwenExecutable: "bun:/path/to/cli.js" })', ' • Force specific runtime: query({ pathToQwenExecutable: "bun:/path/to/cli.js" })',
); );
} }

View File

@@ -38,6 +38,8 @@ describe('CLI Path Utilities', () => {
mockFs.statSync.mockReturnValue({ mockFs.statSync.mockReturnValue({
isFile: () => true, isFile: () => true,
} as ReturnType<typeof import('fs').statSync>); } as ReturnType<typeof import('fs').statSync>);
// Default: return true for existsSync (can be overridden in specific tests)
mockFs.existsSync.mockReturnValue(true);
}); });
afterEach(() => { afterEach(() => {
@@ -50,28 +52,26 @@ describe('CLI Path Utilities', () => {
describe('parseExecutableSpec', () => { describe('parseExecutableSpec', () => {
describe('auto-detection (no spec provided)', () => { describe('auto-detection (no spec provided)', () => {
it('should auto-detect native CLI when no spec provided', () => { it('should auto-detect bundled CLI when no spec provided', () => {
// Mock environment variable // Mock existsSync to return true for bundled CLI
const originalEnv = process.env['QWEN_CODE_CLI_PATH']; mockFs.existsSync.mockImplementation((p) => {
process.env['QWEN_CODE_CLI_PATH'] = '/usr/local/bin/qwen'; const pathStr = p.toString();
mockFs.existsSync.mockReturnValue(true); return (
pathStr.includes('cli/cli.js') || pathStr.includes('cli\\cli.js')
);
});
const result = parseExecutableSpec(); const result = parseExecutableSpec();
expect(result).toEqual({ expect(result.executablePath).toContain('cli.js');
executablePath: path.resolve('/usr/local/bin/qwen'), expect(result.isExplicitRuntime).toBe(false);
isExplicitRuntime: false,
});
// Restore env
process.env['QWEN_CODE_CLI_PATH'] = originalEnv;
}); });
it('should throw when auto-detection fails', () => { it('should throw when bundled CLI not found', () => {
mockFs.existsSync.mockReturnValue(false); mockFs.existsSync.mockReturnValue(false);
expect(() => parseExecutableSpec()).toThrow( expect(() => parseExecutableSpec()).toThrow(
'qwen CLI not found. Please:', 'Bundled qwen CLI not found',
); );
}); });
}); });
@@ -361,65 +361,44 @@ describe('CLI Path Utilities', () => {
}); });
describe('auto-detection fallback', () => { describe('auto-detection fallback', () => {
it('should auto-detect when no spec provided', () => { it('should auto-detect bundled CLI when no spec provided', () => {
// Mock environment variable // Mock existsSync to return true for bundled CLI
const originalEnv = process.env['QWEN_CODE_CLI_PATH']; mockFs.existsSync.mockImplementation((p) => {
process.env['QWEN_CODE_CLI_PATH'] = '/usr/local/bin/qwen'; const pathStr = p.toString();
return (
pathStr.includes('cli/cli.js') || pathStr.includes('cli\\cli.js')
);
});
const result = prepareSpawnInfo(); const result = prepareSpawnInfo();
expect(result).toEqual({ expect(result.command).toBe(process.execPath);
command: path.resolve('/usr/local/bin/qwen'), expect(result.args[0]).toContain('cli.js');
args: [], expect(result.type).toBe('node');
type: 'native', expect(result.originalInput).toBe('');
originalInput: '',
});
// Restore env
process.env['QWEN_CODE_CLI_PATH'] = originalEnv;
}); });
}); });
}); });
describe('findNativeCliPath', () => { describe('findNativeCliPath', () => {
it('should find CLI from environment variable', () => { it('should find bundled CLI', () => {
const originalEnv = process.env['QWEN_CODE_CLI_PATH']; // Mock existsSync to return true for bundled CLI
process.env['QWEN_CODE_CLI_PATH'] = '/custom/path/to/qwen';
mockFs.existsSync.mockReturnValue(true);
const result = findNativeCliPath();
expect(result).toBe(path.resolve('/custom/path/to/qwen'));
process.env['QWEN_CODE_CLI_PATH'] = originalEnv;
});
it('should search common installation locations', () => {
const originalEnv = process.env['QWEN_CODE_CLI_PATH'];
delete process.env['QWEN_CODE_CLI_PATH'];
// Mock fs.existsSync to return true for volta bin
// Use path.join to match platform-specific path separators
const voltaBinPath = path.join('.volta', 'bin', 'qwen');
mockFs.existsSync.mockImplementation((p) => { mockFs.existsSync.mockImplementation((p) => {
return p.toString().includes(voltaBinPath); const pathStr = p.toString();
return (
pathStr.includes('cli/cli.js') || pathStr.includes('cli\\cli.js')
);
}); });
const result = findNativeCliPath(); const result = findNativeCliPath();
expect(result).toContain(voltaBinPath); expect(result).toContain('cli.js');
process.env['QWEN_CODE_CLI_PATH'] = originalEnv;
}); });
it('should throw descriptive error when CLI not found', () => { it('should throw descriptive error when bundled CLI not found', () => {
const originalEnv = process.env['QWEN_CODE_CLI_PATH'];
delete process.env['QWEN_CODE_CLI_PATH'];
mockFs.existsSync.mockReturnValue(false); mockFs.existsSync.mockReturnValue(false);
expect(() => findNativeCliPath()).toThrow('qwen CLI not found. Please:'); expect(() => findNativeCliPath()).toThrow('Bundled qwen CLI not found');
process.env['QWEN_CODE_CLI_PATH'] = originalEnv;
}); });
}); });
@@ -634,13 +613,10 @@ describe('CLI Path Utilities', () => {
mockFs.existsSync.mockReturnValue(false); mockFs.existsSync.mockReturnValue(false);
expect(() => parseExecutableSpec('/missing/file')).toThrow( expect(() => parseExecutableSpec('/missing/file')).toThrow(
'Set QWEN_CODE_CLI_PATH environment variable', 'Executable file not found at',
); );
expect(() => parseExecutableSpec('/missing/file')).toThrow( expect(() => parseExecutableSpec('/missing/file')).toThrow(
'Install qwen globally: npm install -g qwen', 'Please check the file path and ensure the file exists',
);
expect(() => parseExecutableSpec('/missing/file')).toThrow(
'Force specific runtime: bun:/path/to/cli.js or tsx:/path/to/index.ts',
); );
}); });
}); });

View File

@@ -1,6 +1,6 @@
{ {
"name": "@qwen-code/qwen-code-test-utils", "name": "@qwen-code/qwen-code-test-utils",
"version": "0.5.1", "version": "0.6.0",
"private": true, "private": true,
"main": "src/index.ts", "main": "src/index.ts",
"license": "Apache-2.0", "license": "Apache-2.0",

View File

@@ -2,7 +2,7 @@
"name": "qwen-code-vscode-ide-companion", "name": "qwen-code-vscode-ide-companion",
"displayName": "Qwen Code Companion", "displayName": "Qwen Code Companion",
"description": "Enable Qwen Code with direct access to your VS Code workspace.", "description": "Enable Qwen Code with direct access to your VS Code workspace.",
"version": "0.5.1", "version": "0.6.0",
"publisher": "qwenlm", "publisher": "qwenlm",
"icon": "assets/icon.png", "icon": "assets/icon.png",
"repository": { "repository": {
@@ -11,7 +11,7 @@
"directory": "packages/vscode-ide-companion" "directory": "packages/vscode-ide-companion"
}, },
"engines": { "engines": {
"vscode": "^1.99.0" "vscode": "^1.85.0"
}, },
"license": "LICENSE", "license": "LICENSE",
"preview": true, "preview": true,
@@ -137,7 +137,7 @@
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"@types/semver": "^7.7.1", "@types/semver": "^7.7.1",
"@types/vscode": "^1.99.0", "@types/vscode": "^1.85.0",
"@typescript-eslint/eslint-plugin": "^8.31.1", "@typescript-eslint/eslint-plugin": "^8.31.1",
"@typescript-eslint/parser": "^8.31.1", "@typescript-eslint/parser": "^8.31.1",
"@vscode/vsce": "^3.6.0", "@vscode/vsce": "^3.6.0",

View File

@@ -27,13 +27,14 @@ vi.mock('node:fs/promises', () => ({
writeFile: vi.fn(() => Promise.resolve(undefined)), writeFile: vi.fn(() => Promise.resolve(undefined)),
unlink: vi.fn(() => Promise.resolve(undefined)), unlink: vi.fn(() => Promise.resolve(undefined)),
chmod: vi.fn(() => Promise.resolve(undefined)), chmod: vi.fn(() => Promise.resolve(undefined)),
mkdir: vi.fn(() => Promise.resolve(undefined)),
})); }));
vi.mock('node:os', async (importOriginal) => { vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof os>(); const actual = await importOriginal<typeof os>();
return { return {
...actual, ...actual,
tmpdir: vi.fn(() => '/tmp'), homedir: vi.fn(() => '/home/test'),
}; };
}); });
@@ -128,30 +129,24 @@ describe('IDEServer', () => {
); );
const port = getPortFromMock(replaceMock); const port = getPortFromMock(replaceMock);
const expectedPortFile = path.join( const expectedLockFile = path.join(
'/tmp', '/home/test',
`qwen-code-ide-server-${port}.json`, '.qwen',
); 'ide',
const expectedPpidPortFile = path.join( `${port}.lock`,
'/tmp',
`qwen-code-ide-server-${process.ppid}.json`,
); );
const expectedContent = JSON.stringify({ const expectedContent = JSON.stringify({
port: parseInt(port, 10), port: parseInt(port, 10),
workspacePath: expectedWorkspacePaths, workspacePath: expectedWorkspacePaths,
ppid: process.ppid, ppid: process.ppid,
authToken: 'test-auth-token', authToken: 'test-auth-token',
ideName: 'VS Code',
}); });
expect(fs.writeFile).toHaveBeenCalledWith( expect(fs.writeFile).toHaveBeenCalledWith(
expectedPortFile, expectedLockFile,
expectedContent, expectedContent,
); );
expect(fs.writeFile).toHaveBeenCalledWith( expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
expectedPpidPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
}); });
it('should set a single folder path', async () => { it('should set a single folder path', async () => {
@@ -166,30 +161,24 @@ describe('IDEServer', () => {
); );
const port = getPortFromMock(replaceMock); const port = getPortFromMock(replaceMock);
const expectedPortFile = path.join( const expectedLockFile = path.join(
'/tmp', '/home/test',
`qwen-code-ide-server-${port}.json`, '.qwen',
); 'ide',
const expectedPpidPortFile = path.join( `${port}.lock`,
'/tmp',
`qwen-code-ide-server-${process.ppid}.json`,
); );
const expectedContent = JSON.stringify({ const expectedContent = JSON.stringify({
port: parseInt(port, 10), port: parseInt(port, 10),
workspacePath: '/foo/bar', workspacePath: '/foo/bar',
ppid: process.ppid, ppid: process.ppid,
authToken: 'test-auth-token', authToken: 'test-auth-token',
ideName: 'VS Code',
}); });
expect(fs.writeFile).toHaveBeenCalledWith( expect(fs.writeFile).toHaveBeenCalledWith(
expectedPortFile, expectedLockFile,
expectedContent, expectedContent,
); );
expect(fs.writeFile).toHaveBeenCalledWith( expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
expectedPpidPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
}); });
it('should set an empty string if no folders are open', async () => { it('should set an empty string if no folders are open', async () => {
@@ -204,30 +193,24 @@ describe('IDEServer', () => {
); );
const port = getPortFromMock(replaceMock); const port = getPortFromMock(replaceMock);
const expectedPortFile = path.join( const expectedLockFile = path.join(
'/tmp', '/home/test',
`qwen-code-ide-server-${port}.json`, '.qwen',
); 'ide',
const expectedPpidPortFile = path.join( `${port}.lock`,
'/tmp',
`qwen-code-ide-server-${process.ppid}.json`,
); );
const expectedContent = JSON.stringify({ const expectedContent = JSON.stringify({
port: parseInt(port, 10), port: parseInt(port, 10),
workspacePath: '', workspacePath: '',
ppid: process.ppid, ppid: process.ppid,
authToken: 'test-auth-token', authToken: 'test-auth-token',
ideName: 'VS Code',
}); });
expect(fs.writeFile).toHaveBeenCalledWith( expect(fs.writeFile).toHaveBeenCalledWith(
expectedPortFile, expectedLockFile,
expectedContent, expectedContent,
); );
expect(fs.writeFile).toHaveBeenCalledWith( expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
expectedPpidPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
}); });
it('should update the path when workspace folders change', async () => { it('should update the path when workspace folders change', async () => {
@@ -256,30 +239,24 @@ describe('IDEServer', () => {
); );
const port = getPortFromMock(replaceMock); const port = getPortFromMock(replaceMock);
const expectedPortFile = path.join( const expectedLockFile = path.join(
'/tmp', '/home/test',
`qwen-code-ide-server-${port}.json`, '.qwen',
); 'ide',
const expectedPpidPortFile = path.join( `${port}.lock`,
'/tmp',
`qwen-code-ide-server-${process.ppid}.json`,
); );
const expectedContent = JSON.stringify({ const expectedContent = JSON.stringify({
port: parseInt(port, 10), port: parseInt(port, 10),
workspacePath: expectedWorkspacePaths, workspacePath: expectedWorkspacePaths,
ppid: process.ppid, ppid: process.ppid,
authToken: 'test-auth-token', authToken: 'test-auth-token',
ideName: 'VS Code',
}); });
expect(fs.writeFile).toHaveBeenCalledWith( expect(fs.writeFile).toHaveBeenCalledWith(
expectedPortFile, expectedLockFile,
expectedContent, expectedContent,
); );
expect(fs.writeFile).toHaveBeenCalledWith( expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
expectedPpidPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
// Simulate removing a folder // Simulate removing a folder
vscodeMock.workspace.workspaceFolders = [{ uri: { fsPath: '/baz/qux' } }]; vscodeMock.workspace.workspaceFolders = [{ uri: { fsPath: '/baz/qux' } }];
@@ -294,36 +271,26 @@ describe('IDEServer', () => {
workspacePath: '/baz/qux', workspacePath: '/baz/qux',
ppid: process.ppid, ppid: process.ppid,
authToken: 'test-auth-token', authToken: 'test-auth-token',
ideName: 'VS Code',
}); });
expect(fs.writeFile).toHaveBeenCalledWith( expect(fs.writeFile).toHaveBeenCalledWith(
expectedPortFile, expectedLockFile,
expectedContent2, expectedContent2,
); );
expect(fs.writeFile).toHaveBeenCalledWith( expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
expectedPpidPortFile,
expectedContent2,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
}); });
it('should clear env vars and delete port file on stop', async () => { it('should clear env vars and delete lock file on stop', async () => {
await ideServer.start(mockContext); await ideServer.start(mockContext);
const replaceMock = mockContext.environmentVariableCollection.replace; const replaceMock = mockContext.environmentVariableCollection.replace;
const port = getPortFromMock(replaceMock); const port = getPortFromMock(replaceMock);
const portFile = path.join('/tmp', `qwen-code-ide-server-${port}.json`); const lockFile = path.join('/home/test', '.qwen', 'ide', `${port}.lock`);
const ppidPortFile = path.join( expect(fs.writeFile).toHaveBeenCalledWith(lockFile, expect.any(String));
'/tmp',
`qwen-code-ide-server-${process.ppid}.json`,
);
expect(fs.writeFile).toHaveBeenCalledWith(portFile, expect.any(String));
expect(fs.writeFile).toHaveBeenCalledWith(ppidPortFile, expect.any(String));
await ideServer.stop(); await ideServer.stop();
expect(mockContext.environmentVariableCollection.clear).toHaveBeenCalled(); expect(mockContext.environmentVariableCollection.clear).toHaveBeenCalled();
expect(fs.unlink).toHaveBeenCalledWith(portFile); expect(fs.unlink).toHaveBeenCalledWith(lockFile);
expect(fs.unlink).toHaveBeenCalledWith(ppidPortFile);
}); });
it.skipIf(process.platform !== 'win32')( it.skipIf(process.platform !== 'win32')(
@@ -344,30 +311,24 @@ describe('IDEServer', () => {
); );
const port = getPortFromMock(replaceMock); const port = getPortFromMock(replaceMock);
const expectedPortFile = path.join( const expectedLockFile = path.join(
'/tmp', '/home/test',
`qwen-code-ide-server-${port}.json`, '.qwen',
); 'ide',
const expectedPpidPortFile = path.join( `${port}.lock`,
'/tmp',
`qwen-code-ide-server-${process.ppid}.json`,
); );
const expectedContent = JSON.stringify({ const expectedContent = JSON.stringify({
port: parseInt(port, 10), port: parseInt(port, 10),
workspacePath: expectedWorkspacePaths, workspacePath: expectedWorkspacePaths,
ppid: process.ppid, ppid: process.ppid,
authToken: 'test-auth-token', authToken: 'test-auth-token',
ideName: 'VS Code',
}); });
expect(fs.writeFile).toHaveBeenCalledWith( expect(fs.writeFile).toHaveBeenCalledWith(
expectedPortFile, expectedLockFile,
expectedContent, expectedContent,
); );
expect(fs.writeFile).toHaveBeenCalledWith( expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
expectedPpidPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
}, },
); );
@@ -379,7 +340,7 @@ describe('IDEServer', () => {
port = (ideServer as unknown as { port: number }).port; port = (ideServer as unknown as { port: number }).port;
}); });
it('should allow request without auth token for backwards compatibility', async () => { it('should reject request without auth token', async () => {
const response = await fetch(`http://localhost:${port}/mcp`, { const response = await fetch(`http://localhost:${port}/mcp`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -390,7 +351,9 @@ describe('IDEServer', () => {
id: 1, id: 1,
}), }),
}); });
expect(response.status).not.toBe(401); expect(response.status).toBe(401);
const body = await response.text();
expect(body).toBe('Unauthorized');
}); });
it('should allow request with valid auth token', async () => { it('should allow request with valid auth token', async () => {
@@ -550,6 +513,7 @@ describe('IDEServer HTTP endpoints', () => {
headers: { headers: {
Host: `localhost:${port}`, Host: `localhost:${port}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: 'Bearer test-auth-token',
}, },
}, },
JSON.stringify({ jsonrpc: '2.0', method: 'initialize' }), JSON.stringify({ jsonrpc: '2.0', method: 'initialize' }),

View File

@@ -10,6 +10,7 @@ import {
IdeContextNotificationSchema, IdeContextNotificationSchema,
OpenDiffRequestSchema, OpenDiffRequestSchema,
} from '@qwen-code/qwen-code-core/src/ide/types.js'; } from '@qwen-code/qwen-code-core/src/ide/types.js';
import { detectIdeFromEnv } from '@qwen-code/qwen-code-core/src/ide/detect-ide.js';
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
@@ -38,12 +39,24 @@ class CORSError extends Error {
const MCP_SESSION_ID_HEADER = 'mcp-session-id'; const MCP_SESSION_ID_HEADER = 'mcp-session-id';
const IDE_SERVER_PORT_ENV_VAR = 'QWEN_CODE_IDE_SERVER_PORT'; const IDE_SERVER_PORT_ENV_VAR = 'QWEN_CODE_IDE_SERVER_PORT';
const IDE_WORKSPACE_PATH_ENV_VAR = 'QWEN_CODE_IDE_WORKSPACE_PATH'; const IDE_WORKSPACE_PATH_ENV_VAR = 'QWEN_CODE_IDE_WORKSPACE_PATH';
const QWEN_DIR = '.qwen';
const IDE_DIR = 'ide';
async function getGlobalIdeDir(): Promise<string> {
const homeDir = os.homedir();
// Prefer home dir, but fall back to tmpdir if unavailable (matches core Storage behavior).
const baseDir = homeDir
? path.join(homeDir, QWEN_DIR)
: path.join(os.tmpdir(), QWEN_DIR);
const ideDir = path.join(baseDir, IDE_DIR);
await fs.mkdir(ideDir, { recursive: true });
return ideDir;
}
interface WritePortAndWorkspaceArgs { interface WritePortAndWorkspaceArgs {
context: vscode.ExtensionContext; context: vscode.ExtensionContext;
port: number; port: number;
portFile: string; lockFile: string;
ppidPortFile: string;
authToken: string; authToken: string;
log: (message: string) => void; log: (message: string) => void;
} }
@@ -51,8 +64,7 @@ interface WritePortAndWorkspaceArgs {
async function writePortAndWorkspace({ async function writePortAndWorkspace({
context, context,
port, port,
portFile, lockFile,
ppidPortFile,
authToken, authToken,
log, log,
}: WritePortAndWorkspaceArgs): Promise<void> { }: WritePortAndWorkspaceArgs): Promise<void> {
@@ -71,26 +83,24 @@ async function writePortAndWorkspace({
workspacePath, workspacePath,
); );
const ideInfo = detectIdeFromEnv();
const content = JSON.stringify({ const content = JSON.stringify({
port, port,
workspacePath, workspacePath,
ppid: process.ppid, ppid: process.ppid,
authToken, authToken,
ideName: ideInfo.displayName,
}); });
log(`Writing port file to: ${portFile}`); log(`Writing IDE lock file to: ${lockFile}`);
log(`Writing ppid port file to: ${ppidPortFile}`);
try { try {
await Promise.all([ await fs.mkdir(path.dirname(lockFile), { recursive: true });
fs.writeFile(portFile, content).then(() => fs.chmod(portFile, 0o600)), await fs.writeFile(lockFile, content);
fs await fs.chmod(lockFile, 0o600);
.writeFile(ppidPortFile, content)
.then(() => fs.chmod(ppidPortFile, 0o600)),
]);
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
log(`Failed to write port to file: ${message}`); log(`Failed to write IDE lock file: ${message}`);
} }
} }
@@ -121,8 +131,7 @@ export class IDEServer {
private server: HTTPServer | undefined; private server: HTTPServer | undefined;
private context: vscode.ExtensionContext | undefined; private context: vscode.ExtensionContext | undefined;
private log: (message: string) => void; private log: (message: string) => void;
private portFile: string | undefined; private lockFile: string | undefined;
private ppidPortFile: string | undefined;
private port: number | undefined; private port: number | undefined;
private authToken: string | undefined; private authToken: string | undefined;
private transports: { [sessionId: string]: StreamableHTTPServerTransport } = private transports: { [sessionId: string]: StreamableHTTPServerTransport } =
@@ -174,19 +183,24 @@ export class IDEServer {
app.use((req, res, next) => { app.use((req, res, next) => {
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
if (authHeader) { if (!authHeader) {
const parts = authHeader.split(' '); this.log('Missing Authorization header. Rejecting request.');
if (parts.length !== 2 || parts[0] !== 'Bearer') { res.status(401).send('Unauthorized');
this.log('Malformed Authorization header. Rejecting request.'); return;
res.status(401).send('Unauthorized'); }
return;
} const parts = authHeader.split(' ');
const token = parts[1]; if (parts.length !== 2 || parts[0] !== 'Bearer') {
if (token !== this.authToken) { this.log('Malformed Authorization header. Rejecting request.');
this.log('Invalid auth token provided. Rejecting request.'); res.status(401).send('Unauthorized');
res.status(401).send('Unauthorized'); return;
return; }
}
const token = parts[1];
if (token !== this.authToken) {
this.log('Invalid auth token provided. Rejecting request.');
res.status(401).send('Unauthorized');
return;
} }
next(); next();
}); });
@@ -327,22 +341,21 @@ export class IDEServer {
const address = (this.server as HTTPServer).address(); const address = (this.server as HTTPServer).address();
if (address && typeof address !== 'string') { if (address && typeof address !== 'string') {
this.port = address.port; this.port = address.port;
this.portFile = path.join( try {
os.tmpdir(), const ideDir = await getGlobalIdeDir();
`qwen-code-ide-server-${this.port}.json`, // Name the lock file by port to support multiple server instances.
); this.lockFile = path.join(ideDir, `${this.port}.lock`);
this.ppidPortFile = path.join( } catch (err) {
os.tmpdir(), const message = err instanceof Error ? err.message : String(err);
`qwen-code-ide-server-${process.ppid}.json`, this.log(`Failed to determine IDE lock directory: ${message}`);
); }
this.log(`IDE server listening on http://127.0.0.1:${this.port}`); this.log(`IDE server listening on http://127.0.0.1:${this.port}`);
if (this.authToken) { if (this.authToken && this.lockFile) {
await writePortAndWorkspace({ await writePortAndWorkspace({
context, context,
port: this.port, port: this.port,
portFile: this.portFile, lockFile: this.lockFile,
ppidPortFile: this.ppidPortFile,
authToken: this.authToken, authToken: this.authToken,
log: this.log, log: this.log,
}); });
@@ -371,15 +384,13 @@ export class IDEServer {
this.context && this.context &&
this.server && this.server &&
this.port && this.port &&
this.portFile && this.lockFile &&
this.ppidPortFile &&
this.authToken this.authToken
) { ) {
await writePortAndWorkspace({ await writePortAndWorkspace({
context: this.context, context: this.context,
port: this.port, port: this.port,
portFile: this.portFile, lockFile: this.lockFile,
ppidPortFile: this.ppidPortFile,
authToken: this.authToken, authToken: this.authToken,
log: this.log, log: this.log,
}); });
@@ -405,16 +416,9 @@ export class IDEServer {
if (this.context) { if (this.context) {
this.context.environmentVariableCollection.clear(); this.context.environmentVariableCollection.clear();
} }
if (this.portFile) { if (this.lockFile) {
try { try {
await fs.unlink(this.portFile); await fs.unlink(this.lockFile);
} catch (_err) {
// Ignore errors if the file doesn't exist.
}
}
if (this.ppidPortFile) {
try {
await fs.unlink(this.ppidPortFile);
} catch (_err) { } catch (_err) {
// Ignore errors if the file doesn't exist. // Ignore errors if the file doesn't exist.
} }

View File

@@ -48,5 +48,5 @@
} }
.assistant-message-container.assistant-message-loading::after { .assistant-message-container.assistant-message-loading::after {
display: none display: none;
} }

View File

@@ -172,7 +172,8 @@
/* Loading animation for toolcall header */ /* Loading animation for toolcall header */
@keyframes toolcallHeaderPulse { @keyframes toolcallHeaderPulse {
0%, 100% { 0%,
100% {
opacity: 1; opacity: 1;
} }
50% { 50% {

View File

@@ -51,7 +51,8 @@
.composer-form:focus-within { .composer-form:focus-within {
/* match existing highlight behavior */ /* match existing highlight behavior */
border-color: var(--app-input-highlight); border-color: var(--app-input-highlight);
box-shadow: 0 1px 2px color-mix(in srgb, var(--app-input-highlight), transparent 80%); box-shadow: 0 1px 2px
color-mix(in srgb, var(--app-input-highlight), transparent 80%);
} }
/* Composer: input editable area */ /* Composer: input editable area */
@@ -66,7 +67,7 @@
The data attribute is needed because some browsers insert a <br> in The data attribute is needed because some browsers insert a <br> in
contentEditable, which breaks :empty matching. */ contentEditable, which breaks :empty matching. */
.composer-input:empty:before, .composer-input:empty:before,
.composer-input[data-empty="true"]::before { .composer-input[data-empty='true']::before {
content: attr(data-placeholder); content: attr(data-placeholder);
color: var(--app-input-placeholder-foreground); color: var(--app-input-placeholder-foreground);
pointer-events: none; pointer-events: none;
@@ -80,7 +81,7 @@
outline: none; outline: none;
} }
.composer-input:disabled, .composer-input:disabled,
.composer-input[contenteditable="false"] { .composer-input[contenteditable='false'] {
color: #999; color: #999;
cursor: not-allowed; cursor: not-allowed;
} }