mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-01-13 20:39:14 +00:00
Compare commits
1 Commits
fix/1454-s
...
fix/nonint
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cb4371373 |
3
.github/CODEOWNERS
vendored
3
.github/CODEOWNERS
vendored
@@ -1,3 +0,0 @@
|
||||
* @tanzhenxin @DennisYu07 @gwinthis @LaZzyMan @pomelo-nwu @Mingholy
|
||||
# SDK TypeScript package changes require review from Mingholy
|
||||
packages/sdk-typescript/** @Mingholy
|
||||
17
.github/workflows/release-sdk.yml
vendored
17
.github/workflows/release-sdk.yml
vendored
@@ -241,7 +241,7 @@ jobs:
|
||||
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
|
||||
id: 'pr'
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.CI_BOT_PAT }}'
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
|
||||
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
|
||||
run: |-
|
||||
@@ -258,15 +258,26 @@ jobs:
|
||||
|
||||
echo "PR_URL=${pr_url}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: 'Wait for CI checks to complete'
|
||||
if: |-
|
||||
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
PR_URL: '${{ steps.pr.outputs.PR_URL }}'
|
||||
run: |-
|
||||
set -euo pipefail
|
||||
echo "Waiting for CI checks to complete..."
|
||||
gh pr checks "${PR_URL}" --watch --interval 30
|
||||
|
||||
- name: 'Enable auto-merge for release PR'
|
||||
if: |-
|
||||
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.CI_BOT_PAT }}'
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
PR_URL: '${{ steps.pr.outputs.PR_URL }}'
|
||||
run: |-
|
||||
set -euo pipefail
|
||||
gh pr merge "${PR_URL}" --merge --auto --delete-branch
|
||||
gh pr merge "${PR_URL}" --merge --auto
|
||||
|
||||
- name: 'Create Issue on Failure'
|
||||
if: |-
|
||||
|
||||
@@ -25,7 +25,7 @@ Qwen Code is an open-source AI agent for the terminal, optimized for [Qwen3-Code
|
||||
- **OpenAI-compatible, OAuth free tier**: use an OpenAI-compatible API, or sign in with Qwen OAuth to get 2,000 free requests/day.
|
||||
- **Open-source, co-evolving**: both the framework and the Qwen3-Coder model are open-source—and they ship and evolve together.
|
||||
- **Agentic workflow, feature-rich**: rich built-in tools (Skills, SubAgents, Plan Mode) for a full agentic workflow and a Claude Code-like experience.
|
||||
- **Terminal-first, IDE-friendly**: built for developers who live in the command line, with optional integration for VS Code, Zed, and JetBrains IDEs.
|
||||
- **Terminal-first, IDE-friendly**: built for developers who live in the command line, with optional integration for VS Code and Zed.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -137,11 +137,10 @@ Use `-p` to run Qwen Code without the interactive UI—ideal for scripts, automa
|
||||
|
||||
#### IDE integration
|
||||
|
||||
Use Qwen Code inside your editor (VS Code, Zed, and JetBrains IDEs):
|
||||
Use Qwen Code inside your editor (VS Code and Zed):
|
||||
|
||||
- [Use in VS Code](https://qwenlm.github.io/qwen-code-docs/en/users/integration-vscode/)
|
||||
- [Use in Zed](https://qwenlm.github.io/qwen-code-docs/en/users/integration-zed/)
|
||||
- [Use in JetBrains IDEs](https://qwenlm.github.io/qwen-code-docs/en/users/integration-jetbrains/)
|
||||
|
||||
#### TypeScript SDK
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ export default {
|
||||
},
|
||||
'integration-vscode': 'Visual Studio Code',
|
||||
'integration-zed': 'Zed IDE',
|
||||
'integration-jetbrains': 'JetBrains IDEs',
|
||||
'integration-github-action': 'Github Actions',
|
||||
'Code with Qwen Code': {
|
||||
type: 'separator',
|
||||
|
||||
@@ -59,7 +59,6 @@ Commands for managing AI tools and models.
|
||||
| ---------------- | --------------------------------------------- | --------------------------------------------- |
|
||||
| `/mcp` | List configured MCP servers and tools | `/mcp`, `/mcp desc` |
|
||||
| `/tools` | Display currently available tool list | `/tools`, `/tools desc` |
|
||||
| `/skills` | List and run available skills (experimental) | `/skills`, `/skills <name>` |
|
||||
| `/approval-mode` | Change approval mode for tool usage | `/approval-mode <mode (auto-edit)> --project` |
|
||||
| →`plan` | Analysis only, no execution | Secure review |
|
||||
| →`default` | Require approval for edits | Daily use |
|
||||
|
||||
@@ -49,8 +49,6 @@ Cross-platform sandboxing with complete process isolation.
|
||||
|
||||
By default, Qwen Code uses a published sandbox image (configured in the CLI package) and will pull it as needed.
|
||||
|
||||
The container sandbox mounts your workspace and your `~/.qwen` directory into the container so auth and settings persist between runs.
|
||||
|
||||
**Best for**: Strong isolation on any OS, consistent tooling inside a known image.
|
||||
|
||||
### Choosing a method
|
||||
@@ -159,7 +157,7 @@ For a working allowlist-style proxy example, see: [Example Proxy Script](/develo
|
||||
|
||||
## Linux UID/GID handling
|
||||
|
||||
On Linux, Qwen Code defaults to enabling UID/GID mapping so the sandbox runs as your user (and reuses the mounted `~/.qwen`). Override with:
|
||||
The sandbox automatically handles user permissions on Linux. Override these permissions with:
|
||||
|
||||
```bash
|
||||
export SANDBOX_SET_UID_GID=true # Force host UID/GID
|
||||
|
||||
@@ -27,14 +27,6 @@ Agent Skills package expertise into discoverable capabilities. Each Skill consis
|
||||
|
||||
Skills are **model-invoked** — the model autonomously decides when to use them based on your request and the Skill’s description. This is different from slash commands, which are **user-invoked** (you explicitly type `/command`).
|
||||
|
||||
If you want to invoke a Skill explicitly, use the `/skills` slash command:
|
||||
|
||||
```bash
|
||||
/skills <skill-name>
|
||||
```
|
||||
|
||||
The `/skills` command is only available when you run with `--experimental-skills`. Use autocomplete to browse available Skills and descriptions.
|
||||
|
||||
### Benefits
|
||||
|
||||
- Extend Qwen Code for your workflows
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 36 KiB |
@@ -1,57 +0,0 @@
|
||||
# JetBrains IDEs
|
||||
|
||||
> JetBrains IDEs provide native support for AI coding assistants through the Agent Control Protocol (ACP). This integration allows you to use Qwen Code directly within your JetBrains IDE with real-time code suggestions.
|
||||
|
||||
### Features
|
||||
|
||||
- **Native agent experience**: Integrated AI assistant panel within your JetBrains IDE
|
||||
- **Agent Control Protocol**: Full support for ACP enabling advanced IDE interactions
|
||||
- **Symbol management**: #-mention files to add them to the conversation context
|
||||
- **Conversation history**: Access to past conversations within the IDE
|
||||
|
||||
### Requirements
|
||||
|
||||
- JetBrains IDE with ACP support (IntelliJ IDEA, WebStorm, PyCharm, etc.)
|
||||
- Qwen Code CLI installed
|
||||
|
||||
### Installation
|
||||
|
||||
1. Install Qwen Code CLI:
|
||||
|
||||
```bash
|
||||
npm install -g @qwen-code/qwen-code
|
||||
```
|
||||
|
||||
2. Open your JetBrains IDE and navigate to AI Chat tool window.
|
||||
|
||||
3. Click the 3-dot menu in the upper-right corner and select **Configure ACP Agent** and configure Qwen Code with the following settings:
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_servers": {
|
||||
"qwen": {
|
||||
"command": "/path/to/qwen",
|
||||
"args": ["--acp"],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. The Qwen Code agent should now be available in the AI Assistant panel
|
||||
|
||||

|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Agent not appearing
|
||||
|
||||
- Run `qwen --version` in terminal to verify installation
|
||||
- Ensure your JetBrains IDE version supports ACP
|
||||
- Restart your JetBrains IDE
|
||||
|
||||
### Qwen Code not responding
|
||||
|
||||
- Check your internet connection
|
||||
- Verify CLI works by running `qwen` in terminal
|
||||
- [File an issue on GitHub](https://github.com/qwenlm/qwen-code/issues) if the problem persists
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
### Requirements
|
||||
|
||||
- VS Code 1.85.0 or higher
|
||||
- VS Code 1.98.0 or higher
|
||||
|
||||
### Installation
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
### Extension not installing
|
||||
|
||||
- Ensure you have VS Code 1.85.0 or higher
|
||||
- Ensure you have VS Code 1.98.0 or higher
|
||||
- Check that VS Code has permission to install extensions
|
||||
- Try installing directly from the Marketplace website
|
||||
|
||||
|
||||
@@ -9,18 +9,11 @@ This guide provides solutions to common issues and debugging tips, including top
|
||||
|
||||
## Authentication or login errors
|
||||
|
||||
- **Error: `UNABLE_TO_GET_ISSUER_CERT_LOCALLY`, `UNABLE_TO_VERIFY_LEAF_SIGNATURE`, or `unable to get local issuer certificate`**
|
||||
- **Error: `UNABLE_TO_GET_ISSUER_CERT_LOCALLY` or `unable to get local issuer certificate`**
|
||||
- **Cause:** You may be on a corporate network with a firewall that intercepts and inspects SSL/TLS traffic. This often requires a custom root CA certificate to be trusted by Node.js.
|
||||
- **Solution:** Set the `NODE_EXTRA_CA_CERTS` environment variable to the absolute path of your corporate root CA certificate file.
|
||||
- Example: `export NODE_EXTRA_CA_CERTS=/path/to/your/corporate-ca.crt`
|
||||
|
||||
- **Error: `Device authorization flow failed: fetch failed`**
|
||||
- **Cause:** Node.js could not reach Qwen OAuth endpoints (often a proxy or SSL/TLS trust issue). When available, Qwen Code will also print the underlying error cause (for example: `UNABLE_TO_VERIFY_LEAF_SIGNATURE`).
|
||||
- **Solution:**
|
||||
- Confirm you can access `https://chat.qwen.ai` from the same machine/network.
|
||||
- If you are behind a proxy, set it via `qwen --proxy <url>` (or the `proxy` setting in `settings.json`).
|
||||
- If your network uses a corporate TLS inspection CA, set `NODE_EXTRA_CA_CERTS` as described above.
|
||||
|
||||
- **Issue: Unable to display UI after authentication failure**
|
||||
- **Cause:** If authentication fails after selecting an authentication type, the `security.auth.selectedType` setting may be persisted in `settings.json`. On restart, the CLI may get stuck trying to authenticate with the failed auth type and fail to display the UI.
|
||||
- **Solution:** Clear the `security.auth.selectedType` configuration item in your `settings.json` file:
|
||||
|
||||
9
package-lock.json
generated
9
package-lock.json
generated
@@ -6216,7 +6216,10 @@
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"readdirp": "^4.0.1"
|
||||
},
|
||||
@@ -13879,7 +13882,10 @@
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 14.18.0"
|
||||
},
|
||||
@@ -17968,7 +17974,6 @@
|
||||
"ajv-formats": "^3.0.0",
|
||||
"async-mutex": "^0.5.0",
|
||||
"chardet": "^2.1.0",
|
||||
"chokidar": "^4.0.3",
|
||||
"diff": "^7.0.0",
|
||||
"dotenv": "^17.1.0",
|
||||
"fast-levenshtein": "^2.0.6",
|
||||
@@ -18588,7 +18593,7 @@
|
||||
},
|
||||
"packages/sdk-typescript": {
|
||||
"name": "@qwen-code/sdk",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
|
||||
@@ -170,17 +170,7 @@ function normalizeOutputFormat(
|
||||
}
|
||||
|
||||
export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
let rawArgv = hideBin(process.argv);
|
||||
|
||||
// hack: if the first argument is the CLI entry point, remove it
|
||||
if (
|
||||
rawArgv.length > 0 &&
|
||||
(rawArgv[0].endsWith('/dist/qwen-cli/cli.js') ||
|
||||
rawArgv[0].endsWith('/dist/cli.js'))
|
||||
) {
|
||||
rawArgv = rawArgv.slice(1);
|
||||
}
|
||||
|
||||
const rawArgv = hideBin(process.argv);
|
||||
const yargsInstance = yargs(rawArgv)
|
||||
.locale('en')
|
||||
.scriptName('qwen')
|
||||
|
||||
@@ -55,7 +55,6 @@ import { disableExtension } from './extension.js';
|
||||
|
||||
// These imports will get the versions from the vi.mock('./settings.js', ...) factory.
|
||||
import {
|
||||
getSettingsWarnings,
|
||||
loadSettings,
|
||||
USER_SETTINGS_PATH, // This IS the mocked path.
|
||||
getSystemSettingsPath,
|
||||
@@ -419,86 +418,6 @@ describe('Settings Loading and Merging', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should warn about ignored legacy keys in a v2 settings file', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
);
|
||||
const userSettingsContent = {
|
||||
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||
usageStatisticsEnabled: false,
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(getSettingsWarnings(settings)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining(
|
||||
"Legacy setting 'usageStatisticsEnabled' will be ignored",
|
||||
),
|
||||
]),
|
||||
);
|
||||
expect(getSettingsWarnings(settings)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("'privacy.usageStatisticsEnabled'"),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should warn about unknown top-level keys in a v2 settings file', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
);
|
||||
const userSettingsContent = {
|
||||
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||
someUnknownKey: 'value',
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(getSettingsWarnings(settings)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining(
|
||||
"Unknown setting 'someUnknownKey' will be ignored",
|
||||
),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not warn for valid v2 container keys', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
);
|
||||
const userSettingsContent = {
|
||||
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||
model: { name: 'qwen-coder' },
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(getSettingsWarnings(settings)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should rewrite allowedTools to tools.allowed during migration', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
|
||||
@@ -344,97 +344,6 @@ const KNOWN_V2_CONTAINERS = new Set(
|
||||
Object.values(MIGRATION_MAP).map((path) => path.split('.')[0]),
|
||||
);
|
||||
|
||||
function getSettingsFileKeyWarnings(
|
||||
settings: Record<string, unknown>,
|
||||
settingsFilePath: string,
|
||||
): string[] {
|
||||
const version = settings[SETTINGS_VERSION_KEY];
|
||||
if (typeof version !== 'number' || version < SETTINGS_VERSION) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const warnings: string[] = [];
|
||||
const ignoredLegacyKeys = new Set<string>();
|
||||
|
||||
// Ignored legacy keys (V1 top-level keys that moved to a nested V2 path).
|
||||
for (const [oldKey, newPath] of Object.entries(MIGRATION_MAP)) {
|
||||
if (oldKey === newPath) {
|
||||
continue;
|
||||
}
|
||||
if (!(oldKey in settings)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const oldValue = settings[oldKey];
|
||||
|
||||
// If this key is a V2 container (like 'model') and it's already an object,
|
||||
// it's likely already in V2 format. Don't warn.
|
||||
if (
|
||||
KNOWN_V2_CONTAINERS.has(oldKey) &&
|
||||
typeof oldValue === 'object' &&
|
||||
oldValue !== null &&
|
||||
!Array.isArray(oldValue)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ignoredLegacyKeys.add(oldKey);
|
||||
warnings.push(
|
||||
`⚠️ Legacy setting '${oldKey}' will be ignored in ${settingsFilePath}. Please use '${newPath}' instead.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Unknown top-level keys.
|
||||
const schemaKeys = new Set(Object.keys(getSettingsSchema()));
|
||||
for (const key of Object.keys(settings)) {
|
||||
if (key === SETTINGS_VERSION_KEY) {
|
||||
continue;
|
||||
}
|
||||
if (ignoredLegacyKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
if (schemaKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
warnings.push(
|
||||
`⚠️ Unknown setting '${key}' will be ignored in ${settingsFilePath}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects warnings for ignored legacy and unknown settings keys.
|
||||
*
|
||||
* For `$version: 2` settings files, we do not apply implicit migrations.
|
||||
* Instead, we surface actionable, de-duplicated warnings in the terminal UI.
|
||||
*/
|
||||
export function getSettingsWarnings(loadedSettings: LoadedSettings): string[] {
|
||||
const warningSet = new Set<string>();
|
||||
|
||||
for (const scope of [SettingScope.User, SettingScope.Workspace]) {
|
||||
const settingsFile = loadedSettings.forScope(scope);
|
||||
if (settingsFile.rawJson === undefined) {
|
||||
continue; // File not present / not loaded.
|
||||
}
|
||||
const settingsObject = settingsFile.originalSettings as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
for (const warning of getSettingsFileKeyWarnings(
|
||||
settingsObject,
|
||||
settingsFile.path,
|
||||
)) {
|
||||
warningSet.add(warning);
|
||||
}
|
||||
}
|
||||
|
||||
return [...warningSet];
|
||||
}
|
||||
|
||||
export function migrateSettingsToV1(
|
||||
v2Settings: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
|
||||
@@ -17,11 +17,7 @@ import * as cliConfig from './config/config.js';
|
||||
import { loadCliConfig, parseArguments } from './config/config.js';
|
||||
import { ExtensionStorage, loadExtensions } from './config/extension.js';
|
||||
import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js';
|
||||
import {
|
||||
getSettingsWarnings,
|
||||
loadSettings,
|
||||
migrateDeprecatedSettings,
|
||||
} from './config/settings.js';
|
||||
import { loadSettings, migrateDeprecatedSettings } from './config/settings.js';
|
||||
import {
|
||||
initializeApp,
|
||||
type InitializationResult,
|
||||
@@ -346,7 +342,6 @@ export async function main() {
|
||||
extensionEnablementManager,
|
||||
argv,
|
||||
);
|
||||
registerCleanup(() => config.shutdown());
|
||||
|
||||
if (config.getListExtensions()) {
|
||||
console.log('Installed extensions:');
|
||||
@@ -405,15 +400,12 @@ export async function main() {
|
||||
|
||||
let input = config.getQuestion();
|
||||
const startupWarnings = [
|
||||
...new Set([
|
||||
...(await getStartupWarnings()),
|
||||
...(await getUserStartupWarnings({
|
||||
workspaceRoot: process.cwd(),
|
||||
useRipgrep: settings.merged.tools?.useRipgrep ?? true,
|
||||
useBuiltinRipgrep: settings.merged.tools?.useBuiltinRipgrep ?? true,
|
||||
})),
|
||||
...getSettingsWarnings(settings),
|
||||
]),
|
||||
...(await getStartupWarnings()),
|
||||
...(await getUserStartupWarnings({
|
||||
workspaceRoot: process.cwd(),
|
||||
useRipgrep: settings.merged.tools?.useRipgrep ?? true,
|
||||
useBuiltinRipgrep: settings.merged.tools?.useBuiltinRipgrep ?? true,
|
||||
})),
|
||||
];
|
||||
|
||||
// Render UI, passing necessary config values. Check that there is no command line question.
|
||||
|
||||
@@ -1826,7 +1826,7 @@ describe('runNonInteractive', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should print tool output to console in text mode (non-Task tools)', async () => {
|
||||
it('should print tool description and output to console in text mode (non-Task tools)', async () => {
|
||||
// Test that tool output is printed to stdout in text mode
|
||||
const toolCallEvent: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
@@ -1839,6 +1839,21 @@ describe('runNonInteractive', () => {
|
||||
},
|
||||
};
|
||||
|
||||
// Mock the tool registry to return a tool with displayName and build method
|
||||
const mockTool = {
|
||||
displayName: 'Shell',
|
||||
build: (args: Record<string, unknown>) => {
|
||||
// @ts-expect-error - accessing indexed property for test mock
|
||||
const command: string = args.command || '';
|
||||
return {
|
||||
getDescription: () => String(command),
|
||||
};
|
||||
},
|
||||
};
|
||||
vi.mocked(mockToolRegistry.getTool).mockReturnValue(
|
||||
mockTool as unknown as ReturnType<typeof mockToolRegistry.getTool>,
|
||||
);
|
||||
|
||||
// Mock tool execution with outputUpdateHandler being called
|
||||
mockCoreExecuteToolCall.mockImplementation(
|
||||
async (_config, _request, _signal, options) => {
|
||||
@@ -1901,8 +1916,15 @@ describe('runNonInteractive', () => {
|
||||
);
|
||||
|
||||
// Verify tool output was written to stdout
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('Package outdated\n');
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('npm@1.0.0 -> npm@2.0.0\n');
|
||||
// First call should be tool description
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('Shell: npm outdated');
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('\n');
|
||||
// Then the actual tool output
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('Package outdated');
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('npm@1.0.0 -> npm@2.0.0');
|
||||
// Final newline after tool execution
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('\n');
|
||||
// And the model's response
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('Dependencies checked');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -351,19 +351,51 @@ export async function runNonInteractive(
|
||||
const taskToolProgressHandler = taskToolProgress?.handler;
|
||||
|
||||
// Create output handler for non-Task tools in text mode (for console output)
|
||||
const toolOutputLines: string[] = [];
|
||||
const nonTaskOutputHandler =
|
||||
!isTaskTool && !adapter
|
||||
? (callId: string, outputChunk: ToolResultDisplay) => {
|
||||
const toolRegistry = config.getToolRegistry();
|
||||
const tool = toolRegistry.getTool(finalRequestInfo.name);
|
||||
if (tool) {
|
||||
try {
|
||||
const invocation = tool.build(finalRequestInfo.args);
|
||||
const description = invocation.getDescription();
|
||||
toolOutputLines.push(
|
||||
`${tool.displayName}: ${description}`,
|
||||
);
|
||||
toolOutputLines.push('\n');
|
||||
} catch {
|
||||
// If we can't build invocation, just show tool name
|
||||
toolOutputLines.push(`${tool.displayName}`);
|
||||
toolOutputLines.push('\n');
|
||||
}
|
||||
}
|
||||
// Print tool output to console in text mode
|
||||
if (typeof outputChunk === 'string') {
|
||||
process.stdout.write(outputChunk);
|
||||
// Indent output lines to show they're part of the tool execution
|
||||
const lines = outputChunk.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (i === lines.length - 1 && lines[i] === '') {
|
||||
// Skip trailing empty line
|
||||
continue;
|
||||
}
|
||||
toolOutputLines.push(lines[i]);
|
||||
}
|
||||
} else if (
|
||||
outputChunk &&
|
||||
typeof outputChunk === 'object' &&
|
||||
'ansiOutput' in outputChunk
|
||||
) {
|
||||
// Handle ANSI output - just print as string for now
|
||||
process.stdout.write(String(outputChunk.ansiOutput));
|
||||
// Handle ANSI output - indent it similarly
|
||||
const ansiStr = String(outputChunk.ansiOutput);
|
||||
const lines = ansiStr.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (i === lines.length - 1 && lines[i] === '') {
|
||||
continue;
|
||||
}
|
||||
toolOutputLines.push(lines[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
@@ -386,6 +418,11 @@ export async function runNonInteractive(
|
||||
: undefined,
|
||||
);
|
||||
|
||||
if (toolOutputLines.length > 0) {
|
||||
toolOutputLines.forEach((line) => process.stdout.write(line));
|
||||
process.stdout.write('\n');
|
||||
}
|
||||
|
||||
// Note: In JSON mode, subagent messages are automatically added to the main
|
||||
// adapter's messages array and will be output together on emitResult()
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ import { quitCommand } from '../ui/commands/quitCommand.js';
|
||||
import { restoreCommand } from '../ui/commands/restoreCommand.js';
|
||||
import { resumeCommand } from '../ui/commands/resumeCommand.js';
|
||||
import { settingsCommand } from '../ui/commands/settingsCommand.js';
|
||||
import { skillsCommand } from '../ui/commands/skillsCommand.js';
|
||||
import { statsCommand } from '../ui/commands/statsCommand.js';
|
||||
import { summaryCommand } from '../ui/commands/summaryCommand.js';
|
||||
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
|
||||
@@ -79,7 +78,6 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
quitCommand,
|
||||
restoreCommand(this.config),
|
||||
resumeCommand,
|
||||
...(this.config?.getExperimentalSkills?.() ? [skillsCommand] : []),
|
||||
statsCommand,
|
||||
summaryCommand,
|
||||
themeCommand,
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
CommandKind,
|
||||
type CommandCompletionItem,
|
||||
type CommandContext,
|
||||
type SlashCommand,
|
||||
} from './types.js';
|
||||
import { MessageType, type HistoryItemSkillsList } from '../types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { AsyncFzf } from 'fzf';
|
||||
import type { SkillConfig } from '@qwen-code/qwen-code-core';
|
||||
|
||||
export const skillsCommand: SlashCommand = {
|
||||
name: 'skills',
|
||||
get description() {
|
||||
return t('List available skills.');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context: CommandContext, args?: string) => {
|
||||
const rawArgs = args?.trim() ?? '';
|
||||
const [skillName = ''] = rawArgs.split(/\s+/);
|
||||
|
||||
const skillManager = context.services.config?.getSkillManager();
|
||||
if (!skillManager) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: t('Could not retrieve skill manager.'),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const skills = await skillManager.listSkills();
|
||||
if (skills.length === 0) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: t('No skills are currently available.'),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!skillName) {
|
||||
const sortedSkills = [...skills].sort((left, right) =>
|
||||
left.name.localeCompare(right.name),
|
||||
);
|
||||
const skillsListItem: HistoryItemSkillsList = {
|
||||
type: MessageType.SKILLS_LIST,
|
||||
skills: sortedSkills.map((skill) => ({ name: skill.name })),
|
||||
};
|
||||
context.ui.addItem(skillsListItem, Date.now());
|
||||
return;
|
||||
}
|
||||
const normalizedName = skillName.toLowerCase();
|
||||
const hasSkill = skills.some(
|
||||
(skill) => skill.name.toLowerCase() === normalizedName,
|
||||
);
|
||||
|
||||
if (!hasSkill) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: t('Unknown skill: {{name}}', { name: skillName }),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const rawInput = context.invocation?.raw ?? `/skills ${rawArgs}`;
|
||||
return {
|
||||
type: 'submit_prompt',
|
||||
content: [{ text: rawInput }],
|
||||
};
|
||||
},
|
||||
completion: async (
|
||||
context: CommandContext,
|
||||
partialArg: string,
|
||||
): Promise<CommandCompletionItem[]> => {
|
||||
const skillManager = context.services.config?.getSkillManager();
|
||||
if (!skillManager) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const skills = await skillManager.listSkills();
|
||||
const normalizedPartial = partialArg.trim();
|
||||
const matches = await getSkillMatches(skills, normalizedPartial);
|
||||
|
||||
return matches.map((skill) => ({
|
||||
value: skill.name,
|
||||
description: skill.description,
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
async function getSkillMatches(
|
||||
skills: SkillConfig[],
|
||||
query: string,
|
||||
): Promise<SkillConfig[]> {
|
||||
if (!query) {
|
||||
return skills;
|
||||
}
|
||||
|
||||
const names = skills.map((skill) => skill.name);
|
||||
const skillMap = new Map(skills.map((skill) => [skill.name, skill]));
|
||||
|
||||
try {
|
||||
const fzf = new AsyncFzf(names, {
|
||||
fuzzy: 'v2',
|
||||
casing: 'case-insensitive',
|
||||
});
|
||||
const results = (await fzf.find(query)) as Array<{ item: string }>;
|
||||
return results
|
||||
.map((result) => skillMap.get(result.item))
|
||||
.filter((skill): skill is SkillConfig => !!skill);
|
||||
} catch (error) {
|
||||
console.error('[skillsCommand] Fuzzy match failed:', error);
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return skills.filter((skill) =>
|
||||
skill.name.toLowerCase().startsWith(lowerQuery),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -209,12 +209,6 @@ export enum CommandKind {
|
||||
MCP_PROMPT = 'mcp-prompt',
|
||||
}
|
||||
|
||||
export interface CommandCompletionItem {
|
||||
value: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// The standardized contract for any command in the system.
|
||||
export interface SlashCommand {
|
||||
name: string;
|
||||
@@ -240,7 +234,7 @@ export interface SlashCommand {
|
||||
completion?: (
|
||||
context: CommandContext,
|
||||
partialArg: string,
|
||||
) => Promise<Array<string | CommandCompletionItem> | null>;
|
||||
) => Promise<string[]>;
|
||||
|
||||
subCommands?: SlashCommand[];
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ import { Help } from './Help.js';
|
||||
import type { SlashCommand } from '../commands/types.js';
|
||||
import { ExtensionsList } from './views/ExtensionsList.js';
|
||||
import { getMCPServerStatus } from '@qwen-code/qwen-code-core';
|
||||
import { SkillsList } from './views/SkillsList.js';
|
||||
import { ToolsList } from './views/ToolsList.js';
|
||||
import { McpStatus } from './views/McpStatus.js';
|
||||
|
||||
@@ -154,9 +153,6 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
||||
showDescriptions={itemForDisplay.showDescriptions}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'skills_list' && (
|
||||
<SkillsList skills={itemForDisplay.skills} />
|
||||
)}
|
||||
{itemForDisplay.type === 'mcp_status' && (
|
||||
<McpStatus {...itemForDisplay} serverStatus={getMCPServerStatus} />
|
||||
)}
|
||||
|
||||
@@ -106,7 +106,7 @@ export function SuggestionsDisplay({
|
||||
</Box>
|
||||
|
||||
{suggestion.description && (
|
||||
<Box flexGrow={1} paddingLeft={2}>
|
||||
<Box flexGrow={1} paddingLeft={3}>
|
||||
<Text color={textColor} wrap="truncate">
|
||||
{suggestion.description}
|
||||
</Text>
|
||||
|
||||
@@ -23,7 +23,7 @@ export const InfoMessage: React.FC<InfoMessageProps> = ({ text }) => {
|
||||
const prefixWidth = prefix.length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
<Box flexDirection="row" marginTop={1}>
|
||||
<Box width={prefixWidth}>
|
||||
<Text color={theme.status.warning}>{prefix}</Text>
|
||||
</Box>
|
||||
|
||||
@@ -18,7 +18,7 @@ export const WarningMessage: React.FC<WarningMessageProps> = ({ text }) => {
|
||||
const prefixWidth = 3;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
<Box flexDirection="row" marginTop={1}>
|
||||
<Box width={prefixWidth}>
|
||||
<Text color={Colors.AccentYellow}>{prefix}</Text>
|
||||
</Box>
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { type SkillDefinition } from '../../types.js';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
|
||||
interface SkillsListProps {
|
||||
skills: readonly SkillDefinition[];
|
||||
}
|
||||
|
||||
export const SkillsList: React.FC<SkillsListProps> = ({ skills }) => (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{t('Available skills:')}
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
{skills.length > 0 ? (
|
||||
skills.map((skill) => (
|
||||
<Box key={skill.name} flexDirection="row">
|
||||
<Text color={theme.text.primary}>{' '}- </Text>
|
||||
<Text bold color={theme.text.accent}>
|
||||
{skill.name}
|
||||
</Text>
|
||||
</Box>
|
||||
))
|
||||
) : (
|
||||
<Text color={theme.text.primary}> {t('No skills available')}</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
@@ -573,45 +573,6 @@ describe('useSlashCompletion', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should map completion items with descriptions for argument suggestions', async () => {
|
||||
const mockCompletionFn = vi.fn().mockResolvedValue([
|
||||
{ value: 'pdf', description: 'Create PDF documents' },
|
||||
{ value: 'xlsx', description: 'Work with spreadsheets' },
|
||||
]);
|
||||
|
||||
const slashCommands = [
|
||||
createTestCommand({
|
||||
name: 'skills',
|
||||
description: 'List available skills',
|
||||
completion: mockCompletionFn,
|
||||
}),
|
||||
];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTestHarnessForSlashCompletion(
|
||||
true,
|
||||
'/skills ',
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.suggestions).toEqual([
|
||||
{
|
||||
label: 'pdf',
|
||||
value: 'pdf',
|
||||
description: 'Create PDF documents',
|
||||
},
|
||||
{
|
||||
label: 'xlsx',
|
||||
value: 'xlsx',
|
||||
description: 'Work with spreadsheets',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should call command.completion with an empty string when args start with a space', async () => {
|
||||
const mockCompletionFn = vi
|
||||
.fn()
|
||||
|
||||
@@ -9,7 +9,6 @@ import { AsyncFzf } from 'fzf';
|
||||
import type { Suggestion } from '../components/SuggestionsDisplay.js';
|
||||
import {
|
||||
CommandKind,
|
||||
type CommandCompletionItem,
|
||||
type CommandContext,
|
||||
type SlashCommand,
|
||||
} from '../commands/types.js';
|
||||
@@ -216,9 +215,10 @@ function useCommandSuggestions(
|
||||
)) || [];
|
||||
|
||||
if (!signal.aborted) {
|
||||
const finalSuggestions = results
|
||||
.map((item) => toSuggestion(item))
|
||||
.filter((suggestion): suggestion is Suggestion => !!suggestion);
|
||||
const finalSuggestions = results.map((s) => ({
|
||||
label: s,
|
||||
value: s,
|
||||
}));
|
||||
setSuggestions(finalSuggestions);
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -310,20 +310,6 @@ function useCommandSuggestions(
|
||||
return { suggestions, isLoading };
|
||||
}
|
||||
|
||||
function toSuggestion(item: string | CommandCompletionItem): Suggestion | null {
|
||||
if (typeof item === 'string') {
|
||||
return { label: item, value: item };
|
||||
}
|
||||
if (!item.value) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
label: item.label ?? item.value,
|
||||
value: item.value,
|
||||
description: item.description,
|
||||
};
|
||||
}
|
||||
|
||||
function useCompletionPositions(
|
||||
query: string | null,
|
||||
parserResult: CommandParserResult,
|
||||
|
||||
@@ -201,21 +201,12 @@ export interface ToolDefinition {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface SkillDefinition {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type HistoryItemToolsList = HistoryItemBase & {
|
||||
type: 'tools_list';
|
||||
tools: ToolDefinition[];
|
||||
showDescriptions: boolean;
|
||||
};
|
||||
|
||||
export type HistoryItemSkillsList = HistoryItemBase & {
|
||||
type: 'skills_list';
|
||||
skills: SkillDefinition[];
|
||||
};
|
||||
|
||||
// JSON-friendly types for using as a simple data model showing info about an
|
||||
// MCP Server.
|
||||
export interface JsonMcpTool {
|
||||
@@ -277,7 +268,6 @@ export type HistoryItemWithoutId =
|
||||
| HistoryItemCompression
|
||||
| HistoryItemExtensionsList
|
||||
| HistoryItemToolsList
|
||||
| HistoryItemSkillsList
|
||||
| HistoryItemMcpStatus;
|
||||
|
||||
export type HistoryItem = HistoryItemWithoutId & { id: number };
|
||||
@@ -299,7 +289,6 @@ export enum MessageType {
|
||||
SUMMARY = 'summary',
|
||||
EXTENSIONS_LIST = 'extensions_list',
|
||||
TOOLS_LIST = 'tools_list',
|
||||
SKILLS_LIST = 'skills_list',
|
||||
MCP_STATUS = 'mcp_status',
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { exec, execSync, spawn, type ChildProcess } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { quote, parse } from 'shell-quote';
|
||||
import {
|
||||
@@ -49,16 +50,16 @@ const BUILTIN_SEATBELT_PROFILES = [
|
||||
|
||||
/**
|
||||
* Determines whether the sandbox container should be run with the current user's UID and GID.
|
||||
* This is often necessary on Linux systems when using rootful Docker without userns-remap
|
||||
* configured, to avoid permission issues with
|
||||
* This is often necessary on Linux systems (especially Debian/Ubuntu based) when using
|
||||
* rootful Docker without userns-remap configured, to avoid permission issues with
|
||||
* mounted volumes.
|
||||
*
|
||||
* The behavior is controlled by the `SANDBOX_SET_UID_GID` environment variable:
|
||||
* - If `SANDBOX_SET_UID_GID` is "1" or "true", this function returns `true`.
|
||||
* - If `SANDBOX_SET_UID_GID` is "0" or "false", this function returns `false`.
|
||||
* - If `SANDBOX_SET_UID_GID` is not set:
|
||||
* - On Linux, it defaults to `true`.
|
||||
* - On other OSes, it defaults to `false`.
|
||||
* - On Debian/Ubuntu Linux, it defaults to `true`.
|
||||
* - On other OSes, or if OS detection fails, it defaults to `false`.
|
||||
*
|
||||
* For more context on running Docker containers as non-root, see:
|
||||
* https://medium.com/redbubble/running-a-docker-container-as-a-non-root-user-7d2e00f8ee15
|
||||
@@ -75,20 +76,31 @@ async function shouldUseCurrentUserInSandbox(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If environment variable is not explicitly set, check for Debian/Ubuntu Linux
|
||||
if (os.platform() === 'linux') {
|
||||
const debugEnv = [process.env['DEBUG'], process.env['DEBUG_MODE']].some(
|
||||
(v) => v === 'true' || v === '1',
|
||||
);
|
||||
if (debugEnv) {
|
||||
// Use stderr so it doesn't clutter normal STDOUT output (e.g. in `--prompt` runs).
|
||||
console.error(
|
||||
'INFO: Using current user UID/GID in Linux sandbox. Set SANDBOX_SET_UID_GID=false to disable.',
|
||||
try {
|
||||
const osReleaseContent = await readFile('/etc/os-release', 'utf8');
|
||||
if (
|
||||
osReleaseContent.includes('ID=debian') ||
|
||||
osReleaseContent.includes('ID=ubuntu') ||
|
||||
osReleaseContent.match(/^ID_LIKE=.*debian.*/m) || // Covers derivatives
|
||||
osReleaseContent.match(/^ID_LIKE=.*ubuntu.*/m) // Covers derivatives
|
||||
) {
|
||||
// note here and below we use console.error for informational messages on stderr
|
||||
console.error(
|
||||
'INFO: Defaulting to use current user UID/GID for Debian/Ubuntu-based Linux.',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
} catch (_err) {
|
||||
// Silently ignore if /etc/os-release is not found or unreadable.
|
||||
// The default (false) will be applied in this case.
|
||||
console.warn(
|
||||
'Warning: Could not read /etc/os-release to auto-detect Debian/Ubuntu for UID/GID default.',
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return false; // Default to false if no other condition is met
|
||||
}
|
||||
|
||||
// docker does not allow container names to contain ':' or '/', so we
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"@google/genai": "1.30.0",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"async-mutex": "^0.5.0",
|
||||
"@opentelemetry/exporter-logs-otlp-grpc": "^0.203.0",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.203.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-grpc": "^0.203.0",
|
||||
@@ -39,9 +40,7 @@
|
||||
"@xterm/headless": "5.5.0",
|
||||
"ajv": "^8.17.1",
|
||||
"ajv-formats": "^3.0.0",
|
||||
"async-mutex": "^0.5.0",
|
||||
"chardet": "^2.1.0",
|
||||
"chokidar": "^4.0.3",
|
||||
"diff": "^7.0.0",
|
||||
"dotenv": "^17.1.0",
|
||||
"fast-levenshtein": "^2.0.6",
|
||||
|
||||
@@ -673,7 +673,6 @@ export class Config {
|
||||
this.promptRegistry = new PromptRegistry();
|
||||
this.subagentManager = new SubagentManager(this);
|
||||
this.skillManager = new SkillManager(this);
|
||||
await this.skillManager.startWatching();
|
||||
|
||||
// Load session subagents if they were provided before initialization
|
||||
if (this.sessionSubagents.length > 0) {
|
||||
@@ -774,13 +773,6 @@ export class Config {
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases resources owned by the config instance.
|
||||
*/
|
||||
async shutdown(): Promise<void> {
|
||||
this.skillManager?.stopWatching();
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a new session and resets session-scoped services.
|
||||
*/
|
||||
|
||||
@@ -16,8 +16,6 @@ import {
|
||||
isDeviceTokenPending,
|
||||
isDeviceTokenSuccess,
|
||||
isErrorResponse,
|
||||
qwenOAuth2Events,
|
||||
QwenOAuth2Event,
|
||||
QwenOAuth2Client,
|
||||
type DeviceAuthorizationResponse,
|
||||
type DeviceTokenResponse,
|
||||
@@ -847,58 +845,6 @@ describe('getQwenOAuthClient', () => {
|
||||
|
||||
SharedTokenManager.getInstance = originalGetInstance;
|
||||
});
|
||||
|
||||
it('should include troubleshooting hints when device auth fetch fails', async () => {
|
||||
// Make SharedTokenManager fail so we hit the fallback device-flow path
|
||||
const mockTokenManager = {
|
||||
getValidCredentials: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Token refresh failed')),
|
||||
};
|
||||
|
||||
const originalGetInstance = SharedTokenManager.getInstance;
|
||||
SharedTokenManager.getInstance = vi.fn().mockReturnValue(mockTokenManager);
|
||||
|
||||
const tlsCause = new Error('unable to verify the first certificate');
|
||||
(tlsCause as Error & { code?: string }).code =
|
||||
'UNABLE_TO_VERIFY_LEAF_SIGNATURE';
|
||||
|
||||
const fetchError = new TypeError('fetch failed') as TypeError & {
|
||||
cause?: unknown;
|
||||
};
|
||||
fetchError.cause = tlsCause;
|
||||
|
||||
vi.mocked(global.fetch).mockRejectedValue(fetchError);
|
||||
|
||||
const emitSpy = vi.spyOn(qwenOAuth2Events, 'emit');
|
||||
|
||||
let thrownError: unknown;
|
||||
try {
|
||||
const { getQwenOAuthClient } = await import('./qwenOAuth2.js');
|
||||
await getQwenOAuthClient(mockConfig);
|
||||
} catch (error: unknown) {
|
||||
thrownError = error;
|
||||
}
|
||||
|
||||
expect(thrownError).toBeInstanceOf(Error);
|
||||
expect((thrownError as Error).message).toContain(
|
||||
'Device authorization flow failed: fetch failed',
|
||||
);
|
||||
expect((thrownError as Error).message).toContain(
|
||||
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
||||
);
|
||||
expect((thrownError as Error).message).toContain('NODE_EXTRA_CA_CERTS');
|
||||
expect((thrownError as Error).message).toContain('--proxy');
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith(
|
||||
QwenOAuth2Event.AuthProgress,
|
||||
'error',
|
||||
expect.stringContaining('NODE_EXTRA_CA_CERTS'),
|
||||
);
|
||||
|
||||
emitSpy.mockRestore();
|
||||
SharedTokenManager.getInstance = originalGetInstance;
|
||||
});
|
||||
});
|
||||
|
||||
describe('CredentialsClearRequiredError', () => {
|
||||
|
||||
@@ -13,7 +13,6 @@ import open from 'open';
|
||||
import { EventEmitter } from 'events';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { formatFetchErrorForUser } from '../utils/fetch.js';
|
||||
import {
|
||||
SharedTokenManager,
|
||||
TokenManagerError,
|
||||
@@ -848,12 +847,8 @@ async function authWithQwenDeviceFlow(
|
||||
console.error('\n' + timeoutMessage);
|
||||
return { success: false, reason: 'timeout', message: timeoutMessage };
|
||||
} catch (error: unknown) {
|
||||
const fullErrorMessage = formatFetchErrorForUser(error, {
|
||||
url: QWEN_OAUTH_BASE_URL,
|
||||
});
|
||||
const message = `Device authorization flow failed: ${fullErrorMessage}`;
|
||||
|
||||
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const message = `Device authorization flow failed: ${errorMessage}`;
|
||||
console.error(message);
|
||||
return { success: false, reason: 'error', message };
|
||||
} finally {
|
||||
|
||||
@@ -818,7 +818,7 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
});
|
||||
|
||||
describe('Platform-Specific Behavior', () => {
|
||||
it('should use cmd.exe and hide window on Windows', async () => {
|
||||
it('should use cmd.exe on Windows', async () => {
|
||||
mockPlatform.mockReturnValue('win32');
|
||||
await simulateExecution('dir "foo bar"', (cp) =>
|
||||
cp.emit('exit', 0, null),
|
||||
@@ -829,8 +829,7 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
[],
|
||||
expect.objectContaining({
|
||||
shell: true,
|
||||
detached: false,
|
||||
windowsHide: true,
|
||||
detached: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -229,8 +229,7 @@ export class ShellExecutionService {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsVerbatimArguments: true,
|
||||
shell: isWindows ? true : 'bash',
|
||||
detached: !isWindows,
|
||||
windowsHide: isWindows,
|
||||
detached: true,
|
||||
env: {
|
||||
...process.env,
|
||||
QWEN_CODE: '1',
|
||||
|
||||
@@ -5,10 +5,8 @@
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import * as fsSync from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { watch as watchFs, type FSWatcher } from 'chokidar';
|
||||
import { parse as parseYaml } from '../utils/yaml-parser.js';
|
||||
import type {
|
||||
SkillConfig,
|
||||
@@ -31,9 +29,6 @@ export class SkillManager {
|
||||
private skillsCache: Map<SkillLevel, SkillConfig[]> | null = null;
|
||||
private readonly changeListeners: Set<() => void> = new Set();
|
||||
private parseErrors: Map<string, SkillError> = new Map();
|
||||
private readonly watchers: Map<string, FSWatcher> = new Map();
|
||||
private watchStarted = false;
|
||||
private refreshTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(private readonly config: Config) {}
|
||||
|
||||
@@ -226,36 +221,6 @@ export class SkillManager {
|
||||
this.notifyChangeListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts watching skill directories for changes.
|
||||
*/
|
||||
async startWatching(): Promise<void> {
|
||||
if (this.watchStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.watchStarted = true;
|
||||
await this.refreshCache();
|
||||
this.updateWatchersFromCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops watching skill directories for changes.
|
||||
*/
|
||||
stopWatching(): void {
|
||||
for (const watcher of this.watchers.values()) {
|
||||
void watcher.close().catch((error) => {
|
||||
console.warn('Failed to close skills watcher:', error);
|
||||
});
|
||||
}
|
||||
this.watchers.clear();
|
||||
this.watchStarted = false;
|
||||
if (this.refreshTimer) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a SKILL.md file and returns the configuration.
|
||||
*
|
||||
@@ -484,77 +449,4 @@ export class SkillManager {
|
||||
this.skillsCache.set(level, levelSkills);
|
||||
}
|
||||
}
|
||||
|
||||
private updateWatchersFromCache(): void {
|
||||
const desiredPaths = new Set<string>();
|
||||
|
||||
for (const level of ['project', 'user'] as const) {
|
||||
const baseDir = this.getSkillsBaseDir(level);
|
||||
const parentDir = path.dirname(baseDir);
|
||||
if (fsSync.existsSync(parentDir)) {
|
||||
desiredPaths.add(parentDir);
|
||||
}
|
||||
if (fsSync.existsSync(baseDir)) {
|
||||
desiredPaths.add(baseDir);
|
||||
}
|
||||
|
||||
const levelSkills = this.skillsCache?.get(level) || [];
|
||||
for (const skill of levelSkills) {
|
||||
const skillDir = path.dirname(skill.filePath);
|
||||
if (fsSync.existsSync(skillDir)) {
|
||||
desiredPaths.add(skillDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const existingPath of this.watchers.keys()) {
|
||||
if (!desiredPaths.has(existingPath)) {
|
||||
void this.watchers
|
||||
.get(existingPath)
|
||||
?.close()
|
||||
.catch((error) => {
|
||||
console.warn(
|
||||
`Failed to close skills watcher for ${existingPath}:`,
|
||||
error,
|
||||
);
|
||||
});
|
||||
this.watchers.delete(existingPath);
|
||||
}
|
||||
}
|
||||
|
||||
for (const watchPath of desiredPaths) {
|
||||
if (this.watchers.has(watchPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const watcher = watchFs(watchPath, {
|
||||
ignoreInitial: true,
|
||||
})
|
||||
.on('all', () => {
|
||||
this.scheduleRefresh();
|
||||
})
|
||||
.on('error', (error) => {
|
||||
console.warn(`Skills watcher error for ${watchPath}:`, error);
|
||||
});
|
||||
this.watchers.set(watchPath, watcher);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to watch skills directory at ${watchPath}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleRefresh(): void {
|
||||
if (this.refreshTimer) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
}
|
||||
|
||||
this.refreshTimer = setTimeout(() => {
|
||||
this.refreshTimer = null;
|
||||
void this.refreshCache().then(() => this.updateWatchersFromCache());
|
||||
}, 150);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,98 +1,67 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ShellTool > getDescription > should return the non-windows description when not on windows 1`] = `
|
||||
"Executes a given shell command (as \`bash -c <command>\`) in a persistent shell session with optional timeout, ensuring proper handling and security measures.
|
||||
"This tool executes a given shell command as \`bash -c <command>\`. Command can start background processes using \`&\`. Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`.
|
||||
|
||||
IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.
|
||||
**Background vs Foreground Execution:**
|
||||
You should decide whether commands should run in background or foreground based on their nature:
|
||||
|
||||
**Use background execution (is_background: true) for:**
|
||||
- Long-running development servers: \`npm run start\`, \`npm run dev\`, \`yarn dev\`, \`bun run start\`
|
||||
- Build watchers: \`npm run watch\`, \`webpack --watch\`
|
||||
- Database servers: \`mongod\`, \`mysql\`, \`redis-server\`
|
||||
- Web servers: \`python -m http.server\`, \`php -S localhost:8000\`
|
||||
- Any command expected to run indefinitely until manually stopped
|
||||
|
||||
**Use foreground execution (is_background: false) for:**
|
||||
- One-time commands: \`ls\`, \`cat\`, \`grep\`
|
||||
- Build commands: \`npm run build\`, \`make\`
|
||||
- Installation commands: \`npm install\`, \`pip install\`
|
||||
- Git operations: \`git commit\`, \`git push\`
|
||||
- Test runs: \`npm test\`, \`pytest\`
|
||||
|
||||
The following information is returned:
|
||||
|
||||
**Usage notes**:
|
||||
- The command argument is required.
|
||||
- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 120000ms (2 minutes).
|
||||
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
|
||||
|
||||
- Avoid using run_shell_command with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
|
||||
- File search: Use glob (NOT find or ls)
|
||||
- Content search: Use grep_search (NOT grep or rg)
|
||||
- Read files: Use read_file (NOT cat/head/tail)
|
||||
- Edit files: Use edit (NOT sed/awk)
|
||||
- Write files: Use write_file (NOT echo >/cat <<EOF)
|
||||
- Communication: Output text directly (NOT echo/printf)
|
||||
- When issuing multiple commands:
|
||||
- If the commands are independent and can run in parallel, make multiple run_shell_command tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two run_shell_command tool calls in parallel.
|
||||
- If the commands depend on each other and must run sequentially, use a single run_shell_command call with '&&' to chain them together (e.g., \`git add . && git commit -m "message" && git push\`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before run_shell_command for git operations, or git add before git commit), run these operations sequentially instead.
|
||||
- Use ';' only when you need to run commands sequentially but don't care if earlier commands fail
|
||||
- DO NOT use newlines to separate commands (newlines are ok in quoted strings)
|
||||
- Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of \`cd\`. You may use \`cd\` if the User explicitly requests it.
|
||||
<good-example>
|
||||
pytest /foo/bar/tests
|
||||
</good-example>
|
||||
<bad-example>
|
||||
cd /foo/bar && pytest tests
|
||||
</bad-example>
|
||||
|
||||
**Background vs Foreground Execution:**
|
||||
- You should decide whether commands should run in background or foreground based on their nature:
|
||||
- Use background execution (is_background: true) for:
|
||||
- Long-running development servers: \`npm run start\`, \`npm run dev\`, \`yarn dev\`, \`bun run start\`
|
||||
- Build watchers: \`npm run watch\`, \`webpack --watch\`
|
||||
- Database servers: \`mongod\`, \`mysql\`, \`redis-server\`
|
||||
- Web servers: \`python -m http.server\`, \`php -S localhost:8000\`
|
||||
- Any command expected to run indefinitely until manually stopped
|
||||
|
||||
- Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`.
|
||||
- Use foreground execution (is_background: false) for:
|
||||
- One-time commands: \`ls\`, \`cat\`, \`grep\`
|
||||
- Build commands: \`npm run build\`, \`make\`
|
||||
- Installation commands: \`npm install\`, \`pip install\`
|
||||
- Git operations: \`git commit\`, \`git push\`
|
||||
- Test runs: \`npm test\`, \`pytest\`
|
||||
"
|
||||
Command: Executed command.
|
||||
Directory: Directory where command was executed, or \`(root)\`.
|
||||
Stdout: Output on stdout stream. Can be \`(empty)\` or partial on error and for any unwaited background processes.
|
||||
Stderr: Output on stderr stream. Can be \`(empty)\` or partial on error and for any unwaited background processes.
|
||||
Error: Error or \`(none)\` if no error was reported for the subprocess.
|
||||
Exit Code: Exit code or \`(none)\` if terminated by signal.
|
||||
Signal: Signal number or \`(none)\` if no signal was received.
|
||||
Background PIDs: List of background processes started or \`(none)\`.
|
||||
Process Group PGID: Process group started or \`(none)\`"
|
||||
`;
|
||||
|
||||
exports[`ShellTool > getDescription > should return the windows description when on windows 1`] = `
|
||||
"Executes a given shell command (as \`cmd.exe /c <command>\`) in a persistent shell session with optional timeout, ensuring proper handling and security measures.
|
||||
"This tool executes a given shell command as \`cmd.exe /c <command>\`. Command can start background processes using \`start /b\`.
|
||||
|
||||
IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.
|
||||
**Background vs Foreground Execution:**
|
||||
You should decide whether commands should run in background or foreground based on their nature:
|
||||
|
||||
**Use background execution (is_background: true) for:**
|
||||
- Long-running development servers: \`npm run start\`, \`npm run dev\`, \`yarn dev\`, \`bun run start\`
|
||||
- Build watchers: \`npm run watch\`, \`webpack --watch\`
|
||||
- Database servers: \`mongod\`, \`mysql\`, \`redis-server\`
|
||||
- Web servers: \`python -m http.server\`, \`php -S localhost:8000\`
|
||||
- Any command expected to run indefinitely until manually stopped
|
||||
|
||||
**Use foreground execution (is_background: false) for:**
|
||||
- One-time commands: \`ls\`, \`cat\`, \`grep\`
|
||||
- Build commands: \`npm run build\`, \`make\`
|
||||
- Installation commands: \`npm install\`, \`pip install\`
|
||||
- Git operations: \`git commit\`, \`git push\`
|
||||
- Test runs: \`npm test\`, \`pytest\`
|
||||
|
||||
The following information is returned:
|
||||
|
||||
**Usage notes**:
|
||||
- The command argument is required.
|
||||
- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 120000ms (2 minutes).
|
||||
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
|
||||
|
||||
- Avoid using run_shell_command with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
|
||||
- File search: Use glob (NOT find or ls)
|
||||
- Content search: Use grep_search (NOT grep or rg)
|
||||
- Read files: Use read_file (NOT cat/head/tail)
|
||||
- Edit files: Use edit (NOT sed/awk)
|
||||
- Write files: Use write_file (NOT echo >/cat <<EOF)
|
||||
- Communication: Output text directly (NOT echo/printf)
|
||||
- When issuing multiple commands:
|
||||
- If the commands are independent and can run in parallel, make multiple run_shell_command tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two run_shell_command tool calls in parallel.
|
||||
- If the commands depend on each other and must run sequentially, use a single run_shell_command call with '&&' to chain them together (e.g., \`git add . && git commit -m "message" && git push\`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before run_shell_command for git operations, or git add before git commit), run these operations sequentially instead.
|
||||
- Use ';' only when you need to run commands sequentially but don't care if earlier commands fail
|
||||
- DO NOT use newlines to separate commands (newlines are ok in quoted strings)
|
||||
- Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of \`cd\`. You may use \`cd\` if the User explicitly requests it.
|
||||
<good-example>
|
||||
pytest /foo/bar/tests
|
||||
</good-example>
|
||||
<bad-example>
|
||||
cd /foo/bar && pytest tests
|
||||
</bad-example>
|
||||
|
||||
**Background vs Foreground Execution:**
|
||||
- You should decide whether commands should run in background or foreground based on their nature:
|
||||
- Use background execution (is_background: true) for:
|
||||
- Long-running development servers: \`npm run start\`, \`npm run dev\`, \`yarn dev\`, \`bun run start\`
|
||||
- Build watchers: \`npm run watch\`, \`webpack --watch\`
|
||||
- Database servers: \`mongod\`, \`mysql\`, \`redis-server\`
|
||||
- Web servers: \`python -m http.server\`, \`php -S localhost:8000\`
|
||||
- Any command expected to run indefinitely until manually stopped
|
||||
|
||||
- Use foreground execution (is_background: false) for:
|
||||
- One-time commands: \`ls\`, \`cat\`, \`grep\`
|
||||
- Build commands: \`npm run build\`, \`make\`
|
||||
- Installation commands: \`npm install\`, \`pip install\`
|
||||
- Git operations: \`git commit\`, \`git push\`
|
||||
- Test runs: \`npm test\`, \`pytest\`
|
||||
"
|
||||
Command: Executed command.
|
||||
Directory: Directory where command was executed, or \`(root)\`.
|
||||
Stdout: Output on stdout stream. Can be \`(empty)\` or partial on error and for any unwaited background processes.
|
||||
Stderr: Output on stderr stream. Can be \`(empty)\` or partial on error and for any unwaited background processes.
|
||||
Error: Error or \`(none)\` if no error was reported for the subprocess.
|
||||
Exit Code: Exit code or \`(none)\` if terminated by signal.
|
||||
Signal: Signal number or \`(none)\` if no signal was received.
|
||||
Background PIDs: List of background processes started or \`(none)\`.
|
||||
Process Group PGID: Process group started or \`(none)\`"
|
||||
`;
|
||||
|
||||
@@ -59,9 +59,6 @@ describe('ShellTool', () => {
|
||||
getWorkspaceContext: vi
|
||||
.fn()
|
||||
.mockReturnValue(createMockWorkspaceContext('/test/dir')),
|
||||
storage: {
|
||||
getUserSkillsDir: vi.fn().mockReturnValue('/test/dir/.qwen/skills'),
|
||||
},
|
||||
getGeminiClient: vi.fn(),
|
||||
getGitCoAuthor: vi.fn().mockReturnValue({
|
||||
enabled: true,
|
||||
@@ -145,42 +142,6 @@ describe('ShellTool', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error for a directory within the user skills directory', () => {
|
||||
expect(() =>
|
||||
shellTool.build({
|
||||
command: 'ls',
|
||||
directory: '/test/dir/.qwen/skills/my-skill',
|
||||
is_background: false,
|
||||
}),
|
||||
).toThrow(
|
||||
'Explicitly running shell commands from within the user skills directory is not allowed. Please use absolute paths for command parameter instead.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error for the user skills directory itself', () => {
|
||||
expect(() =>
|
||||
shellTool.build({
|
||||
command: 'ls',
|
||||
directory: '/test/dir/.qwen/skills',
|
||||
is_background: false,
|
||||
}),
|
||||
).toThrow(
|
||||
'Explicitly running shell commands from within the user skills directory is not allowed. Please use absolute paths for command parameter instead.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should resolve directory path before checking user skills directory', () => {
|
||||
expect(() =>
|
||||
shellTool.build({
|
||||
command: 'ls',
|
||||
directory: '/test/dir/.qwen/skills/../skills/my-skill',
|
||||
is_background: false,
|
||||
}),
|
||||
).toThrow(
|
||||
'Explicitly running shell commands from within the user skills directory is not allowed. Please use absolute paths for command parameter instead.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return an invocation for a valid absolute directory path', () => {
|
||||
(mockConfig.getWorkspaceContext as Mock).mockReturnValue(
|
||||
createMockWorkspaceContext('/test/dir', ['/another/workspace']),
|
||||
@@ -709,7 +670,7 @@ describe('ShellTool', () => {
|
||||
),
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal),
|
||||
mockAbortSignal,
|
||||
false,
|
||||
{},
|
||||
);
|
||||
@@ -900,7 +861,7 @@ describe('ShellTool', () => {
|
||||
),
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal),
|
||||
mockAbortSignal,
|
||||
false,
|
||||
{},
|
||||
);
|
||||
@@ -909,8 +870,8 @@ describe('ShellTool', () => {
|
||||
it('should add co-author to git commit with multi-line message', async () => {
|
||||
const command = `git commit -m "Fix bug
|
||||
|
||||
This is a detailed description
|
||||
spanning multiple lines"`;
|
||||
This is a detailed description
|
||||
spanning multiple lines"`;
|
||||
const invocation = shellTool.build({ command, is_background: false });
|
||||
const promise = invocation.execute(mockAbortSignal);
|
||||
|
||||
@@ -933,7 +894,7 @@ describe('ShellTool', () => {
|
||||
),
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal),
|
||||
mockAbortSignal,
|
||||
false,
|
||||
{},
|
||||
);
|
||||
@@ -1038,248 +999,4 @@ describe('ShellTool', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeout parameter', () => {
|
||||
it('should validate timeout parameter correctly', () => {
|
||||
// Valid timeout
|
||||
expect(() => {
|
||||
shellTool.build({
|
||||
command: 'echo test',
|
||||
is_background: false,
|
||||
timeout: 5000,
|
||||
});
|
||||
}).not.toThrow();
|
||||
|
||||
// Valid small timeout
|
||||
expect(() => {
|
||||
shellTool.build({
|
||||
command: 'echo test',
|
||||
is_background: false,
|
||||
timeout: 500,
|
||||
});
|
||||
}).not.toThrow();
|
||||
|
||||
// Zero timeout
|
||||
expect(() => {
|
||||
shellTool.build({
|
||||
command: 'echo test',
|
||||
is_background: false,
|
||||
timeout: 0,
|
||||
});
|
||||
}).toThrow('Timeout must be a positive number.');
|
||||
|
||||
// Negative timeout
|
||||
expect(() => {
|
||||
shellTool.build({
|
||||
command: 'echo test',
|
||||
is_background: false,
|
||||
timeout: -1000,
|
||||
});
|
||||
}).toThrow('Timeout must be a positive number.');
|
||||
|
||||
// Timeout too large
|
||||
expect(() => {
|
||||
shellTool.build({
|
||||
command: 'echo test',
|
||||
is_background: false,
|
||||
timeout: 700000,
|
||||
});
|
||||
}).toThrow('Timeout cannot exceed 600000ms (10 minutes).');
|
||||
|
||||
// Non-integer timeout
|
||||
expect(() => {
|
||||
shellTool.build({
|
||||
command: 'echo test',
|
||||
is_background: false,
|
||||
timeout: 5000.5,
|
||||
});
|
||||
}).toThrow('Timeout must be an integer number of milliseconds.');
|
||||
|
||||
// Non-number timeout (schema validation catches this first)
|
||||
expect(() => {
|
||||
shellTool.build({
|
||||
command: 'echo test',
|
||||
is_background: false,
|
||||
timeout: 'invalid' as unknown as number,
|
||||
});
|
||||
}).toThrow('params/timeout must be number');
|
||||
});
|
||||
|
||||
it('should include timeout in description for foreground commands', () => {
|
||||
const invocation = shellTool.build({
|
||||
command: 'npm test',
|
||||
is_background: false,
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
expect(invocation.getDescription()).toBe('npm test [timeout: 30000ms]');
|
||||
});
|
||||
|
||||
it('should not include timeout in description for background commands', () => {
|
||||
const invocation = shellTool.build({
|
||||
command: 'npm start',
|
||||
is_background: true,
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
expect(invocation.getDescription()).toBe('npm start [background]');
|
||||
});
|
||||
|
||||
it('should create combined signal with timeout for foreground execution', async () => {
|
||||
const mockAbortSignal = new AbortController().signal;
|
||||
const invocation = shellTool.build({
|
||||
command: 'sleep 1',
|
||||
is_background: false,
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
const promise = invocation.execute(mockAbortSignal);
|
||||
|
||||
resolveExecutionPromise({
|
||||
rawOutput: Buffer.from(''),
|
||||
output: '',
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
aborted: false,
|
||||
pid: 12345,
|
||||
executionMethod: 'child_process',
|
||||
});
|
||||
|
||||
await promise;
|
||||
|
||||
// Verify that ShellExecutionService was called with a combined signal
|
||||
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal),
|
||||
false,
|
||||
{},
|
||||
);
|
||||
|
||||
// The signal passed should be different from the original signal
|
||||
const calledSignal = mockShellExecutionService.mock.calls[0][3];
|
||||
expect(calledSignal).not.toBe(mockAbortSignal);
|
||||
});
|
||||
|
||||
it('should not create timeout signal for background execution', async () => {
|
||||
const mockAbortSignal = new AbortController().signal;
|
||||
const invocation = shellTool.build({
|
||||
command: 'npm start',
|
||||
is_background: true,
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
const promise = invocation.execute(mockAbortSignal);
|
||||
|
||||
resolveExecutionPromise({
|
||||
rawOutput: Buffer.from(''),
|
||||
output: 'Background command started. PID: 12345',
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
aborted: false,
|
||||
pid: 12345,
|
||||
executionMethod: 'child_process',
|
||||
});
|
||||
|
||||
await promise;
|
||||
|
||||
// For background execution, the original signal should be used
|
||||
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
mockAbortSignal,
|
||||
false,
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle timeout vs user cancellation correctly', async () => {
|
||||
const userAbortController = new AbortController();
|
||||
const invocation = shellTool.build({
|
||||
command: 'sleep 10',
|
||||
is_background: false,
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Mock AbortSignal.timeout and AbortSignal.any
|
||||
const mockTimeoutSignal = {
|
||||
aborted: false,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
} as unknown as AbortSignal;
|
||||
|
||||
const mockCombinedSignal = {
|
||||
aborted: true,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
} as unknown as AbortSignal;
|
||||
|
||||
const originalAbortSignal = globalThis.AbortSignal;
|
||||
vi.stubGlobal('AbortSignal', {
|
||||
...originalAbortSignal,
|
||||
timeout: vi.fn().mockReturnValue(mockTimeoutSignal),
|
||||
any: vi.fn().mockReturnValue(mockCombinedSignal),
|
||||
});
|
||||
|
||||
const promise = invocation.execute(userAbortController.signal);
|
||||
|
||||
resolveExecutionPromise({
|
||||
rawOutput: Buffer.from('partial output'),
|
||||
output: 'partial output',
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
error: null,
|
||||
aborted: true,
|
||||
pid: 12345,
|
||||
executionMethod: 'child_process',
|
||||
});
|
||||
|
||||
const result = await promise;
|
||||
|
||||
// Restore original AbortSignal
|
||||
vi.stubGlobal('AbortSignal', originalAbortSignal);
|
||||
|
||||
expect(result.llmContent).toContain('Command timed out after 5000ms');
|
||||
expect(result.llmContent).toContain(
|
||||
'Below is the output before it timed out',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use default timeout behavior when timeout is not specified', async () => {
|
||||
const mockAbortSignal = new AbortController().signal;
|
||||
const invocation = shellTool.build({
|
||||
command: 'echo test',
|
||||
is_background: false,
|
||||
});
|
||||
|
||||
const promise = invocation.execute(mockAbortSignal);
|
||||
|
||||
resolveExecutionPromise({
|
||||
rawOutput: Buffer.from('test'),
|
||||
output: 'test',
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
aborted: false,
|
||||
pid: 12345,
|
||||
executionMethod: 'child_process',
|
||||
});
|
||||
|
||||
await promise;
|
||||
|
||||
// Should create a combined signal with the default timeout when no timeout is specified
|
||||
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal),
|
||||
false,
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,7 +34,6 @@ import type {
|
||||
import { ShellExecutionService } from '../services/shellExecutionService.js';
|
||||
import { formatMemoryUsage } from '../utils/formatters.js';
|
||||
import type { AnsiOutput } from '../utils/terminalSerializer.js';
|
||||
import { isSubpath } from '../utils/paths.js';
|
||||
import {
|
||||
getCommandRoots,
|
||||
isCommandAllowed,
|
||||
@@ -43,12 +42,10 @@ import {
|
||||
} from '../utils/shell-utils.js';
|
||||
|
||||
export const OUTPUT_UPDATE_INTERVAL_MS = 1000;
|
||||
const DEFAULT_FOREGROUND_TIMEOUT_MS = 120000;
|
||||
|
||||
export interface ShellToolParams {
|
||||
command: string;
|
||||
is_background: boolean;
|
||||
timeout?: number;
|
||||
description?: string;
|
||||
directory?: string;
|
||||
}
|
||||
@@ -75,9 +72,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
// append background indicator
|
||||
if (this.params.is_background) {
|
||||
description += ` [background]`;
|
||||
} else if (this.params.timeout) {
|
||||
// append timeout for foreground commands
|
||||
description += ` [timeout: ${this.params.timeout}ms]`;
|
||||
}
|
||||
// append optional (description), replacing any line breaks with spaces
|
||||
if (this.params.description) {
|
||||
@@ -136,17 +130,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
};
|
||||
}
|
||||
|
||||
const effectiveTimeout = this.params.is_background
|
||||
? undefined
|
||||
: (this.params.timeout ?? DEFAULT_FOREGROUND_TIMEOUT_MS);
|
||||
|
||||
// Create combined signal with timeout for foreground execution
|
||||
let combinedSignal = signal;
|
||||
if (effectiveTimeout) {
|
||||
const timeoutSignal = AbortSignal.timeout(effectiveTimeout);
|
||||
combinedSignal = AbortSignal.any([signal, timeoutSignal]);
|
||||
}
|
||||
|
||||
const isWindows = os.platform() === 'win32';
|
||||
const tempFileName = `shell_pgrep_${crypto
|
||||
.randomBytes(6)
|
||||
@@ -236,7 +219,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
lastUpdateTime = Date.now();
|
||||
}
|
||||
},
|
||||
combinedSignal,
|
||||
signal,
|
||||
this.config.getShouldUseNodePtyShell(),
|
||||
shellExecutionConfig ?? {},
|
||||
);
|
||||
@@ -287,28 +270,11 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
|
||||
let llmContent = '';
|
||||
if (result.aborted) {
|
||||
// Check if it was a timeout or user cancellation
|
||||
const wasTimeout =
|
||||
!this.params.is_background &&
|
||||
effectiveTimeout &&
|
||||
combinedSignal.aborted &&
|
||||
!signal.aborted;
|
||||
|
||||
if (wasTimeout) {
|
||||
llmContent = `Command timed out after ${effectiveTimeout}ms before it could complete.`;
|
||||
if (result.output.trim()) {
|
||||
llmContent += ` Below is the output before it timed out:\n${result.output}`;
|
||||
} else {
|
||||
llmContent += ' There was no output before it timed out.';
|
||||
}
|
||||
llmContent = 'Command was cancelled by user before it could complete.';
|
||||
if (result.output.trim()) {
|
||||
llmContent += ` Below is the output before it was cancelled:\n${result.output}`;
|
||||
} else {
|
||||
llmContent =
|
||||
'Command was cancelled by user before it could complete.';
|
||||
if (result.output.trim()) {
|
||||
llmContent += ` Below is the output before it was cancelled:\n${result.output}`;
|
||||
} else {
|
||||
llmContent += ' There was no output before it was cancelled.';
|
||||
}
|
||||
llmContent += ' There was no output before it was cancelled.';
|
||||
}
|
||||
} else {
|
||||
// Create a formatted error string for display, replacing the wrapper command
|
||||
@@ -339,16 +305,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
returnDisplayMessage = result.output;
|
||||
} else {
|
||||
if (result.aborted) {
|
||||
// Check if it was a timeout or user cancellation
|
||||
const wasTimeout =
|
||||
!this.params.is_background &&
|
||||
effectiveTimeout &&
|
||||
combinedSignal.aborted &&
|
||||
!signal.aborted;
|
||||
|
||||
returnDisplayMessage = wasTimeout
|
||||
? `Command timed out after ${effectiveTimeout}ms.`
|
||||
: 'Command cancelled by user.';
|
||||
returnDisplayMessage = 'Command cancelled by user.';
|
||||
} else if (result.signal) {
|
||||
returnDisplayMessage = `Command terminated by signal: ${result.signal}`;
|
||||
} else if (result.error) {
|
||||
@@ -449,59 +406,42 @@ Co-authored-by: ${gitCoAuthorSettings.name} <${gitCoAuthorSettings.email}>`;
|
||||
}
|
||||
|
||||
function getShellToolDescription(): string {
|
||||
const isWindows = os.platform() === 'win32';
|
||||
const executionWrapper = isWindows
|
||||
? 'cmd.exe /c <command>'
|
||||
: 'bash -c <command>';
|
||||
const processGroupNote = isWindows
|
||||
? ''
|
||||
: '\n - Command is executed as a subprocess that leads its own process group. Command process group can be terminated as `kill -- -PGID` or signaled as `kill -s SIGNAL -- -PGID`.';
|
||||
const toolDescription = `
|
||||
|
||||
return `Executes a given shell command (as \`${executionWrapper}\`) in a persistent shell session with optional timeout, ensuring proper handling and security measures.
|
||||
**Background vs Foreground Execution:**
|
||||
You should decide whether commands should run in background or foreground based on their nature:
|
||||
|
||||
**Use background execution (is_background: true) for:**
|
||||
- Long-running development servers: \`npm run start\`, \`npm run dev\`, \`yarn dev\`, \`bun run start\`
|
||||
- Build watchers: \`npm run watch\`, \`webpack --watch\`
|
||||
- Database servers: \`mongod\`, \`mysql\`, \`redis-server\`
|
||||
- Web servers: \`python -m http.server\`, \`php -S localhost:8000\`
|
||||
- Any command expected to run indefinitely until manually stopped
|
||||
|
||||
**Use foreground execution (is_background: false) for:**
|
||||
- One-time commands: \`ls\`, \`cat\`, \`grep\`
|
||||
- Build commands: \`npm run build\`, \`make\`
|
||||
- Installation commands: \`npm install\`, \`pip install\`
|
||||
- Git operations: \`git commit\`, \`git push\`
|
||||
- Test runs: \`npm test\`, \`pytest\`
|
||||
|
||||
The following information is returned:
|
||||
|
||||
IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.
|
||||
Command: Executed command.
|
||||
Directory: Directory where command was executed, or \`(root)\`.
|
||||
Stdout: Output on stdout stream. Can be \`(empty)\` or partial on error and for any unwaited background processes.
|
||||
Stderr: Output on stderr stream. Can be \`(empty)\` or partial on error and for any unwaited background processes.
|
||||
Error: Error or \`(none)\` if no error was reported for the subprocess.
|
||||
Exit Code: Exit code or \`(none)\` if terminated by signal.
|
||||
Signal: Signal number or \`(none)\` if no signal was received.
|
||||
Background PIDs: List of background processes started or \`(none)\`.
|
||||
Process Group PGID: Process group started or \`(none)\``;
|
||||
|
||||
**Usage notes**:
|
||||
- The command argument is required.
|
||||
- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 120000ms (2 minutes).
|
||||
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
|
||||
|
||||
- Avoid using run_shell_command with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
|
||||
- File search: Use ${ToolNames.GLOB} (NOT find or ls)
|
||||
- Content search: Use ${ToolNames.GREP} (NOT grep or rg)
|
||||
- Read files: Use ${ToolNames.READ_FILE} (NOT cat/head/tail)
|
||||
- Edit files: Use ${ToolNames.EDIT} (NOT sed/awk)
|
||||
- Write files: Use ${ToolNames.WRITE_FILE} (NOT echo >/cat <<EOF)
|
||||
- Communication: Output text directly (NOT echo/printf)
|
||||
- When issuing multiple commands:
|
||||
- If the commands are independent and can run in parallel, make multiple run_shell_command tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two run_shell_command tool calls in parallel.
|
||||
- If the commands depend on each other and must run sequentially, use a single run_shell_command call with '&&' to chain them together (e.g., \`git add . && git commit -m "message" && git push\`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before run_shell_command for git operations, or git add before git commit), run these operations sequentially instead.
|
||||
- Use ';' only when you need to run commands sequentially but don't care if earlier commands fail
|
||||
- DO NOT use newlines to separate commands (newlines are ok in quoted strings)
|
||||
- Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of \`cd\`. You may use \`cd\` if the User explicitly requests it.
|
||||
<good-example>
|
||||
pytest /foo/bar/tests
|
||||
</good-example>
|
||||
<bad-example>
|
||||
cd /foo/bar && pytest tests
|
||||
</bad-example>
|
||||
|
||||
**Background vs Foreground Execution:**
|
||||
- You should decide whether commands should run in background or foreground based on their nature:
|
||||
- Use background execution (is_background: true) for:
|
||||
- Long-running development servers: \`npm run start\`, \`npm run dev\`, \`yarn dev\`, \`bun run start\`
|
||||
- Build watchers: \`npm run watch\`, \`webpack --watch\`
|
||||
- Database servers: \`mongod\`, \`mysql\`, \`redis-server\`
|
||||
- Web servers: \`python -m http.server\`, \`php -S localhost:8000\`
|
||||
- Any command expected to run indefinitely until manually stopped
|
||||
${processGroupNote}
|
||||
- Use foreground execution (is_background: false) for:
|
||||
- One-time commands: \`ls\`, \`cat\`, \`grep\`
|
||||
- Build commands: \`npm run build\`, \`make\`
|
||||
- Installation commands: \`npm install\`, \`pip install\`
|
||||
- Git operations: \`git commit\`, \`git push\`
|
||||
- Test runs: \`npm test\`, \`pytest\`
|
||||
`;
|
||||
if (os.platform() === 'win32') {
|
||||
return `This tool executes a given shell command as \`cmd.exe /c <command>\`. Command can start background processes using \`start /b\`.${toolDescription}`;
|
||||
} else {
|
||||
return `This tool executes a given shell command as \`bash -c <command>\`. Command can start background processes using \`&\`. Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`.${toolDescription}`;
|
||||
}
|
||||
}
|
||||
|
||||
function getCommandDescription(): string {
|
||||
@@ -545,10 +485,6 @@ export class ShellTool extends BaseDeclarativeTool<
|
||||
description:
|
||||
'Whether to run the command in background. Default is false. Set to true for long-running processes like development servers, watchers, or daemons that should continue running without blocking further commands.',
|
||||
},
|
||||
timeout: {
|
||||
type: 'number',
|
||||
description: 'Optional timeout in milliseconds (max 600000)',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
description:
|
||||
@@ -586,35 +522,10 @@ export class ShellTool extends BaseDeclarativeTool<
|
||||
if (getCommandRoots(params.command).length === 0) {
|
||||
return 'Could not identify command root to obtain permission from user.';
|
||||
}
|
||||
if (params.timeout !== undefined) {
|
||||
if (
|
||||
typeof params.timeout !== 'number' ||
|
||||
!Number.isInteger(params.timeout)
|
||||
) {
|
||||
return 'Timeout must be an integer number of milliseconds.';
|
||||
}
|
||||
if (params.timeout <= 0) {
|
||||
return 'Timeout must be a positive number.';
|
||||
}
|
||||
if (params.timeout > 600000) {
|
||||
return 'Timeout cannot exceed 600000ms (10 minutes).';
|
||||
}
|
||||
}
|
||||
if (params.directory) {
|
||||
if (!path.isAbsolute(params.directory)) {
|
||||
return 'Directory must be an absolute path.';
|
||||
}
|
||||
|
||||
const userSkillsDir = this.config.storage.getUserSkillsDir();
|
||||
const resolvedDirectoryPath = path.resolve(params.directory);
|
||||
const isWithinUserSkills = isSubpath(
|
||||
userSkillsDir,
|
||||
resolvedDirectoryPath,
|
||||
);
|
||||
if (isWithinUserSkills) {
|
||||
return `Explicitly running shell commands from within the user skills directory is not allowed. Please use absolute paths for command parameter instead.`;
|
||||
}
|
||||
|
||||
const workspaceDirs = this.config.getWorkspaceContext().getDirectories();
|
||||
const isWithinWorkspace = workspaceDirs.some((wsDir) =>
|
||||
params.directory!.startsWith(wsDir),
|
||||
|
||||
@@ -324,9 +324,7 @@ describe('SkillTool', () => {
|
||||
'Review code for quality and best practices.',
|
||||
);
|
||||
|
||||
expect(result.returnDisplay).toBe(
|
||||
'Specialized skill for reviewing code quality',
|
||||
);
|
||||
expect(result.returnDisplay).toBe('Launching skill: code-review');
|
||||
});
|
||||
|
||||
it('should include allowedTools in result when present', async () => {
|
||||
@@ -351,7 +349,7 @@ describe('SkillTool', () => {
|
||||
// Base description is omitted from llmContent; ensure body is present.
|
||||
expect(llmText).toContain('Help write comprehensive tests.');
|
||||
|
||||
expect(result.returnDisplay).toBe('Skill for writing and running tests');
|
||||
expect(result.returnDisplay).toBe('Launching skill: testing');
|
||||
});
|
||||
|
||||
it('should handle skill not found error', async () => {
|
||||
@@ -418,7 +416,7 @@ describe('SkillTool', () => {
|
||||
).createInvocation(params);
|
||||
const description = invocation.getDescription();
|
||||
|
||||
expect(description).toBe('Use skill: "code-review"');
|
||||
expect(description).toBe('Launching skill: "code-review"');
|
||||
});
|
||||
|
||||
it('should handle skill without additional files', async () => {
|
||||
@@ -438,9 +436,7 @@ describe('SkillTool', () => {
|
||||
const llmText = partToString(result.llmContent);
|
||||
expect(llmText).not.toContain('## Additional Files');
|
||||
|
||||
expect(result.returnDisplay).toBe(
|
||||
'Specialized skill for reviewing code quality',
|
||||
);
|
||||
expect(result.returnDisplay).toBe('Launching skill: code-review');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,7 +49,7 @@ export class SkillTool extends BaseDeclarativeTool<SkillParams, ToolResult> {
|
||||
'Execute a skill within the main conversation. Loading available skills...', // Initial description
|
||||
Kind.Read,
|
||||
initialSchema,
|
||||
false, // isOutputMarkdown
|
||||
true, // isOutputMarkdown
|
||||
false, // canUpdateOutput
|
||||
);
|
||||
|
||||
@@ -128,10 +128,6 @@ Important:
|
||||
- Only use skills listed in <available_skills> below
|
||||
- Do not invoke a skill that is already running
|
||||
- Do not use this tool for built-in CLI commands (like /help, /clear, etc.)
|
||||
- When executing scripts or loading referenced files, ALWAYS resolve absolute paths from skill's base directory. Examples:
|
||||
- \`bash scripts/init.sh\` -> \`bash /path/to/skill/scripts/init.sh\`
|
||||
- \`python scripts/helper.py\` -> \`python /path/to/skill/scripts/helper.py\`
|
||||
- \`reference.md\` -> \`/path/to/skill/reference.md\`
|
||||
</skills_instructions>
|
||||
|
||||
<available_skills>
|
||||
@@ -187,7 +183,7 @@ class SkillToolInvocation extends BaseToolInvocation<SkillParams, ToolResult> {
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `Use skill: "${this.params.skill}"`;
|
||||
return `Launching skill: "${this.params.skill}"`;
|
||||
}
|
||||
|
||||
override async shouldConfirmExecute(): Promise<false> {
|
||||
@@ -242,16 +238,16 @@ class SkillToolInvocation extends BaseToolInvocation<SkillParams, ToolResult> {
|
||||
const baseDir = path.dirname(skill.filePath);
|
||||
|
||||
// Build markdown content for LLM (show base dir, then body)
|
||||
const llmContent = `Base directory for this skill: ${baseDir}\nImportant: ALWAYS resolve absolute paths from this base directory when working with skills.\n\n${skill.body}\n`;
|
||||
const llmContent = `Base directory for this skill: ${baseDir}\n\n${skill.body}\n`;
|
||||
|
||||
return {
|
||||
llmContent: [{ text: llmContent }],
|
||||
returnDisplay: skill.description,
|
||||
returnDisplay: `Launching skill: ${skill.name}`,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
console.error(`[SkillsTool] Error using skill: ${errorMessage}`);
|
||||
console.error(`[SkillsTool] Error launching skill: ${errorMessage}`);
|
||||
|
||||
// Log failed skill launch
|
||||
logSkillLaunch(
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { FetchError, formatFetchErrorForUser } from './fetch.js';
|
||||
|
||||
describe('formatFetchErrorForUser', () => {
|
||||
it('includes troubleshooting hints for TLS errors', () => {
|
||||
const tlsCause = new Error('unable to verify the first certificate');
|
||||
(tlsCause as Error & { code?: string }).code =
|
||||
'UNABLE_TO_VERIFY_LEAF_SIGNATURE';
|
||||
|
||||
const fetchError = new TypeError('fetch failed') as TypeError & {
|
||||
cause?: unknown;
|
||||
};
|
||||
fetchError.cause = tlsCause;
|
||||
|
||||
const message = formatFetchErrorForUser(fetchError, {
|
||||
url: 'https://chat.qwen.ai',
|
||||
});
|
||||
|
||||
expect(message).toContain('fetch failed');
|
||||
expect(message).toContain('UNABLE_TO_VERIFY_LEAF_SIGNATURE');
|
||||
expect(message).toContain('Troubleshooting:');
|
||||
expect(message).toContain('Confirm you can reach https://chat.qwen.ai');
|
||||
expect(message).toContain('--proxy');
|
||||
expect(message).toContain('NODE_EXTRA_CA_CERTS');
|
||||
});
|
||||
|
||||
it('includes troubleshooting hints for network codes', () => {
|
||||
const fetchError = new FetchError(
|
||||
'Request timed out after 100ms',
|
||||
'ETIMEDOUT',
|
||||
);
|
||||
const message = formatFetchErrorForUser(fetchError, {
|
||||
url: 'https://example.com',
|
||||
});
|
||||
|
||||
expect(message).toContain('Request timed out after 100ms');
|
||||
expect(message).toContain('Troubleshooting:');
|
||||
expect(message).toContain('Confirm you can reach https://example.com');
|
||||
expect(message).toContain('--proxy');
|
||||
expect(message).not.toContain('NODE_EXTRA_CA_CERTS');
|
||||
});
|
||||
|
||||
it('does not include troubleshooting for non-fetch errors', () => {
|
||||
expect(formatFetchErrorForUser(new Error('boom'))).toBe('boom');
|
||||
});
|
||||
});
|
||||
@@ -17,26 +17,6 @@ const PRIVATE_IP_RANGES = [
|
||||
/^fe80:/,
|
||||
];
|
||||
|
||||
const TLS_ERROR_CODES = new Set([
|
||||
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
|
||||
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
||||
'SELF_SIGNED_CERT_IN_CHAIN',
|
||||
'DEPTH_ZERO_SELF_SIGNED_CERT',
|
||||
'CERT_HAS_EXPIRED',
|
||||
'ERR_TLS_CERT_ALTNAME_INVALID',
|
||||
]);
|
||||
|
||||
const FETCH_TROUBLESHOOTING_ERROR_CODES = new Set([
|
||||
...TLS_ERROR_CODES,
|
||||
'ECONNRESET',
|
||||
'ETIMEDOUT',
|
||||
'ECONNREFUSED',
|
||||
'ENOTFOUND',
|
||||
'EAI_AGAIN',
|
||||
'EHOSTUNREACH',
|
||||
'ENETUNREACH',
|
||||
]);
|
||||
|
||||
export class FetchError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -75,118 +55,3 @@ export async function fetchWithTimeout(
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
function getErrorCode(error: unknown): string | undefined {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
'code' in error &&
|
||||
typeof (error as Record<string, unknown>)['code'] === 'string'
|
||||
) {
|
||||
return (error as Record<string, string>)['code'];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formatUnknownErrorMessage(error: unknown): string | undefined {
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof error === 'number' ||
|
||||
typeof error === 'boolean' ||
|
||||
typeof error === 'bigint'
|
||||
) {
|
||||
return String(error);
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
if (!error || typeof error !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const message = (error as Record<string, unknown>)['message'];
|
||||
if (typeof message === 'string') {
|
||||
return message;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formatErrorCause(error: unknown): string | undefined {
|
||||
if (!(error instanceof Error)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const cause = (error as Error & { cause?: unknown }).cause;
|
||||
if (!cause) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const causeCode = getErrorCode(cause);
|
||||
const causeMessage = formatUnknownErrorMessage(cause);
|
||||
|
||||
if (!causeCode && !causeMessage) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (causeCode && causeMessage && !causeMessage.includes(causeCode)) {
|
||||
return `${causeCode}: ${causeMessage}`;
|
||||
}
|
||||
|
||||
return causeMessage ?? causeCode;
|
||||
}
|
||||
|
||||
export function formatFetchErrorForUser(
|
||||
error: unknown,
|
||||
options: { url?: string } = {},
|
||||
): string {
|
||||
const errorMessage = getErrorMessage(error);
|
||||
|
||||
const code =
|
||||
error instanceof Error
|
||||
? (getErrorCode((error as Error & { cause?: unknown }).cause) ??
|
||||
getErrorCode(error))
|
||||
: getErrorCode(error);
|
||||
|
||||
const cause = formatErrorCause(error);
|
||||
const fullErrorMessage = [
|
||||
errorMessage,
|
||||
cause ? `(cause: ${cause})` : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const shouldShowFetchHints =
|
||||
errorMessage.toLowerCase().includes('fetch failed') ||
|
||||
(code != null && FETCH_TROUBLESHOOTING_ERROR_CODES.has(code));
|
||||
|
||||
const shouldShowTlsHint = code != null && TLS_ERROR_CODES.has(code);
|
||||
|
||||
if (!shouldShowFetchHints) {
|
||||
return fullErrorMessage;
|
||||
}
|
||||
|
||||
const hintLines = [
|
||||
'',
|
||||
'Troubleshooting:',
|
||||
...(options.url
|
||||
? [`- Confirm you can reach ${options.url} from this machine.`]
|
||||
: []),
|
||||
'- If you are behind a proxy, pass `--proxy <url>` (or set `proxy` in settings).',
|
||||
...(shouldShowTlsHint
|
||||
? [
|
||||
'- If your network uses a corporate TLS inspection CA, set `NODE_EXTRA_CA_CERTS` to your CA bundle.',
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return `${fullErrorMessage}${hintLines.join('\n')}`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/sdk",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.0",
|
||||
"description": "TypeScript SDK for programmatic access to qwen-code CLI",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.mjs",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import type { SDKUserMessage } from '../types/protocol.js';
|
||||
import { serializeJsonLine } from '../utils/jsonLines.js';
|
||||
import { ProcessTransport } from '../transport/ProcessTransport.js';
|
||||
import { prepareSpawnInfo, type SpawnInfo } from '../utils/cliPath.js';
|
||||
import { parseExecutableSpec } from '../utils/cliPath.js';
|
||||
import { Query } from './Query.js';
|
||||
import type { QueryOptions } from '../types/types.js';
|
||||
import { QueryOptionsSchema } from '../types/queryOptionsSchema.js';
|
||||
@@ -32,17 +32,17 @@ export function query({
|
||||
*/
|
||||
options?: QueryOptions;
|
||||
}): Query {
|
||||
const spawnInfo = validateOptions(options);
|
||||
const parsedExecutable = validateOptions(options);
|
||||
|
||||
const isSingleTurn = typeof prompt === 'string';
|
||||
|
||||
const pathToQwenExecutable = options.pathToQwenExecutable;
|
||||
const pathToQwenExecutable =
|
||||
options.pathToQwenExecutable ?? parsedExecutable.executablePath;
|
||||
|
||||
const abortController = options.abortController ?? new AbortController();
|
||||
|
||||
const transport = new ProcessTransport({
|
||||
pathToQwenExecutable,
|
||||
spawnInfo,
|
||||
cwd: options.cwd,
|
||||
model: options.model,
|
||||
permissionMode: options.permissionMode,
|
||||
@@ -97,7 +97,9 @@ export function query({
|
||||
return queryInstance;
|
||||
}
|
||||
|
||||
function validateOptions(options: QueryOptions): SpawnInfo | undefined {
|
||||
function validateOptions(
|
||||
options: QueryOptions,
|
||||
): ReturnType<typeof parseExecutableSpec> {
|
||||
const validationResult = QueryOptionsSchema.safeParse(options);
|
||||
if (!validationResult.success) {
|
||||
const errors = validationResult.error.errors
|
||||
@@ -106,10 +108,13 @@ function validateOptions(options: QueryOptions): SpawnInfo | undefined {
|
||||
throw new Error(`Invalid QueryOptions: ${errors}`);
|
||||
}
|
||||
|
||||
let parsedExecutable: ReturnType<typeof parseExecutableSpec>;
|
||||
try {
|
||||
return prepareSpawnInfo(options.pathToQwenExecutable);
|
||||
parsedExecutable = parseExecutableSpec(options.pathToQwenExecutable);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Invalid pathToQwenExecutable: ${errorMessage}`);
|
||||
}
|
||||
|
||||
return parsedExecutable;
|
||||
}
|
||||
|
||||
@@ -44,9 +44,7 @@ export class ProcessTransport implements Transport {
|
||||
const cwd = this.options.cwd ?? process.cwd();
|
||||
const env = { ...process.env, ...this.options.env };
|
||||
|
||||
const spawnInfo =
|
||||
this.options.spawnInfo ??
|
||||
prepareSpawnInfo(this.options.pathToQwenExecutable);
|
||||
const spawnInfo = prepareSpawnInfo(this.options.pathToQwenExecutable);
|
||||
|
||||
const stderrMode =
|
||||
this.options.debug || this.options.stderr ? 'pipe' : 'ignore';
|
||||
@@ -142,7 +140,6 @@ export class ProcessTransport implements Transport {
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--channel=SDK',
|
||||
'--experimental-skills',
|
||||
];
|
||||
|
||||
if (this.options.model) {
|
||||
|
||||
@@ -4,13 +4,11 @@ import type {
|
||||
SubagentConfig,
|
||||
SDKMcpServerConfig,
|
||||
} from './protocol.js';
|
||||
import type { SpawnInfo } from '../utils/cliPath.js';
|
||||
|
||||
export type { PermissionMode };
|
||||
|
||||
export type TransportOptions = {
|
||||
pathToQwenExecutable?: string;
|
||||
spawnInfo?: SpawnInfo;
|
||||
pathToQwenExecutable: string;
|
||||
cwd?: string;
|
||||
model?: string;
|
||||
permissionMode?: PermissionMode;
|
||||
@@ -179,25 +177,32 @@ export interface QueryOptions {
|
||||
model?: string;
|
||||
|
||||
/**
|
||||
* Path to the Qwen CLI executable.
|
||||
*
|
||||
* If not provided, the SDK automatically uses the bundled CLI included in the package.
|
||||
* Path to the Qwen CLI executable or runtime specification.
|
||||
*
|
||||
* Supports multiple formats:
|
||||
* - Command name (no path separators): `'qwen'` -> executes from PATH
|
||||
* - JavaScript file: `'/path/to/cli.js'` -> uses Node.js (or Bun if running under Bun)
|
||||
* - TypeScript file: `'/path/to/index.ts'` -> uses tsx if available (silent support for dev/debug)
|
||||
* - Native binary: `'/path/to/qwen'` -> executes directly
|
||||
* - 'qwen' -> native binary (auto-detected from PATH)
|
||||
* - '/path/to/qwen' -> native binary (explicit path)
|
||||
* - '/path/to/cli.js' -> Node.js bundle (default for .js files)
|
||||
* - '/path/to/index.ts' -> TypeScript source (requires tsx)
|
||||
* - 'bun:/path/to/cli.js' -> Force Bun runtime
|
||||
* - 'node:/path/to/cli.js' -> Force Node.js runtime
|
||||
* - 'tsx:/path/to/index.ts' -> Force tsx runtime
|
||||
* - 'deno:/path/to/cli.ts' -> Force Deno runtime
|
||||
*
|
||||
* Runtime detection:
|
||||
* - `.js/.mjs/.cjs` files: Node.js (or Bun if running under Bun)
|
||||
* - `.ts/.tsx` files: tsx if available, otherwise treated as native
|
||||
* - Command names: executed directly from PATH
|
||||
* - Other files: executed as native binaries
|
||||
* If not provided, the SDK will auto-detect the native binary in this order:
|
||||
* 1. QWEN_CODE_CLI_PATH environment variable
|
||||
* 2. ~/.volta/bin/qwen
|
||||
* 3. ~/.npm-global/bin/qwen
|
||||
* 4. /usr/local/bin/qwen
|
||||
* 5. ~/.local/bin/qwen
|
||||
* 6. ~/node_modules/.bin/qwen
|
||||
* 7. ~/.yarn/bin/qwen
|
||||
*
|
||||
* The .ts files are only supported for debugging purposes.
|
||||
*
|
||||
* @example '/path/to/cli.js'
|
||||
* @example 'qwen'
|
||||
* @example './packages/cli/index.ts'
|
||||
* @example '/usr/local/bin/qwen'
|
||||
* @example 'tsx:/path/to/packages/cli/src/index.ts'
|
||||
*/
|
||||
pathToQwenExecutable?: string;
|
||||
|
||||
|
||||
@@ -1,29 +1,28 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* CLI path resolution and subprocess spawning utilities
|
||||
* CLI path auto-detection and subprocess spawning utilities
|
||||
*
|
||||
* Supports multiple execution modes:
|
||||
* 1. Bundled CLI: Node.js bundle included in the SDK package (default)
|
||||
* 2. Node.js bundle: 'node /path/to/cli.js' (custom path)
|
||||
* 3. Bun bundle: 'bun /path/to/cli.js' (alternative runtime)
|
||||
* 4. TypeScript source: 'tsx /path/to/index.ts' (development)
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
/**
|
||||
* Executable types supported by the SDK
|
||||
*/
|
||||
export type ExecutableType = 'node' | 'bun' | 'tsx' | 'native';
|
||||
export type ExecutableType = 'native' | 'node' | 'bun' | 'tsx' | 'deno';
|
||||
|
||||
/**
|
||||
* Spawn information for CLI process
|
||||
*/
|
||||
export type SpawnInfo = {
|
||||
/** Command to execute (e.g., 'node', 'bun', 'tsx', or native binary path) */
|
||||
/** Command to execute (e.g., 'qwen', 'node', 'bun', 'tsx') */
|
||||
command: string;
|
||||
/** Arguments to pass to command */
|
||||
args: string[];
|
||||
@@ -33,243 +32,49 @@ export type SpawnInfo = {
|
||||
originalInput: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the directory containing the current module (ESM or CJS)
|
||||
*/
|
||||
function getCurrentModuleDir(): string {
|
||||
let moduleDir: string | null = null;
|
||||
|
||||
try {
|
||||
if (typeof import.meta !== 'undefined' && import.meta.url) {
|
||||
moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
}
|
||||
} catch {
|
||||
// Fall through to CJS
|
||||
}
|
||||
|
||||
if (!moduleDir) {
|
||||
try {
|
||||
if (typeof __dirname !== 'undefined') {
|
||||
moduleDir = __dirname;
|
||||
}
|
||||
} catch {
|
||||
// Fall through
|
||||
}
|
||||
}
|
||||
|
||||
if (moduleDir) {
|
||||
return path.normalize(moduleDir);
|
||||
}
|
||||
throw new Error('Cannot find module directory.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the SDK package root directory
|
||||
*/
|
||||
function findSdkPackageRoot(): string | null {
|
||||
try {
|
||||
const require = createRequire(import.meta.url);
|
||||
const packageJsonPath = require.resolve('@qwen-code/sdk/package.json');
|
||||
const packageRoot = path.dirname(packageJsonPath);
|
||||
const cliPath = path.join(packageRoot, 'dist', 'cli', 'cli.js');
|
||||
if (fs.existsSync(cliPath)) {
|
||||
return packageRoot;
|
||||
}
|
||||
} catch {
|
||||
// Continue to fallback strategy
|
||||
}
|
||||
|
||||
const currentDir = getCurrentModuleDir();
|
||||
let dir = currentDir;
|
||||
const root = path.parse(dir).root;
|
||||
let bestMatch: string | null = null;
|
||||
|
||||
while (dir !== root) {
|
||||
const packageJsonPath = path.join(dir, 'package.json');
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
const cliPath = path.join(dir, 'dist', 'cli', 'cli.js');
|
||||
if (fs.existsSync(cliPath)) {
|
||||
try {
|
||||
const packageJson = JSON.parse(
|
||||
fs.readFileSync(packageJsonPath, 'utf-8'),
|
||||
);
|
||||
if (packageJson.name === '@qwen-code/sdk') {
|
||||
return dir;
|
||||
}
|
||||
if (!bestMatch) {
|
||||
bestMatch = dir;
|
||||
}
|
||||
} catch {
|
||||
if (!bestMatch) {
|
||||
bestMatch = dir;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize path separators for regex matching
|
||||
*/
|
||||
function normalizeForRegex(dirPath: string): string {
|
||||
return dirPath.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve bundled CLI using import.meta.url relative path
|
||||
*/
|
||||
function tryResolveCliFromImportMeta(): string | null {
|
||||
try {
|
||||
if (typeof import.meta !== 'undefined' && import.meta.url) {
|
||||
const cliUrl = new URL('./cli/cli.js', import.meta.url);
|
||||
const cliPath = fileURLToPath(cliUrl);
|
||||
if (fs.existsSync(cliPath)) {
|
||||
return cliPath;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all candidate paths for the bundled CLI
|
||||
*/
|
||||
function getBundledCliCandidatePaths(): string[] {
|
||||
const candidates: string[] = [];
|
||||
|
||||
const importMetaResolved = tryResolveCliFromImportMeta();
|
||||
if (importMetaResolved) {
|
||||
candidates.push(importMetaResolved);
|
||||
}
|
||||
|
||||
try {
|
||||
const currentDir = getCurrentModuleDir();
|
||||
const normalizedDir = normalizeForRegex(currentDir);
|
||||
|
||||
candidates.push(path.join(currentDir, 'cli', 'cli.js'));
|
||||
|
||||
if (/\/src\/utils$/.test(normalizedDir)) {
|
||||
const packageRoot = path.dirname(path.dirname(currentDir));
|
||||
candidates.push(path.join(packageRoot, 'dist', 'cli', 'cli.js'));
|
||||
}
|
||||
|
||||
const packageRoot = findSdkPackageRoot();
|
||||
if (packageRoot) {
|
||||
candidates.push(path.join(packageRoot, 'dist', 'cli', 'cli.js'));
|
||||
}
|
||||
|
||||
const monorepoMatch = normalizedDir.match(
|
||||
/^(.+?)\/packages\/sdk-typescript/,
|
||||
);
|
||||
if (monorepoMatch && monorepoMatch[1]) {
|
||||
const monorepoRoot =
|
||||
process.platform === 'win32'
|
||||
? monorepoMatch[1].replace(/\//g, '\\')
|
||||
: monorepoMatch[1];
|
||||
candidates.push(path.join(monorepoRoot, 'dist', 'cli.js'));
|
||||
}
|
||||
} catch {
|
||||
const packageRoot = findSdkPackageRoot();
|
||||
if (packageRoot) {
|
||||
candidates.push(path.join(packageRoot, 'dist', 'cli', 'cli.js'));
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the bundled CLI path
|
||||
*/
|
||||
function getBundledCliPath(): string | null {
|
||||
const candidates = getBundledCliCandidatePaths();
|
||||
try {
|
||||
const currentFile =
|
||||
typeof __filename !== 'undefined'
|
||||
? __filename
|
||||
: fileURLToPath(import.meta.url);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
const currentDir = path.dirname(currentFile);
|
||||
|
||||
const bundledCliPath = path.join(currentDir, 'cli', 'cli.js');
|
||||
|
||||
if (fs.existsSync(bundledCliPath)) {
|
||||
return bundledCliPath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the bundled CLI path or throw error
|
||||
*/
|
||||
export function findBundledCliPath(): string {
|
||||
export function findNativeCliPath(): string {
|
||||
const bundledCli = getBundledCliPath();
|
||||
if (bundledCli) {
|
||||
return bundledCli;
|
||||
}
|
||||
|
||||
const candidates = getBundledCliCandidatePaths();
|
||||
throw new Error(
|
||||
'Bundled qwen CLI not found. The CLI should be included in the SDK package.\n' +
|
||||
'Searched locations:\n' +
|
||||
candidates.map((c) => ` - ${c}`).join('\n') +
|
||||
'\n\nIf you need to use a custom CLI, provide explicit path:\n' +
|
||||
' • query({ pathToQwenExecutable: "/path/to/cli.js" })',
|
||||
'If you need to use a custom CLI, provide explicit executable:\n' +
|
||||
' • query({ pathToQwenExecutable: "/path/to/cli.js" })\n' +
|
||||
' • TypeScript source: query({ pathToQwenExecutable: "/path/to/index.ts" })\n' +
|
||||
' • Force specific runtime: query({ pathToQwenExecutable: "bun:/path/to/cli.js" })',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate file exists and is a file
|
||||
*/
|
||||
function validateFilePath(filePath: string): void {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(
|
||||
`Executable file not found at '${filePath}'. ` +
|
||||
'Please check the file path and ensure the file exists.',
|
||||
);
|
||||
}
|
||||
|
||||
const stats = fs.statSync(filePath);
|
||||
if (!stats.isFile()) {
|
||||
throw new Error(
|
||||
`Path '${filePath}' exists but is not a file. ` +
|
||||
'Please provide a path to an executable file.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if path contains separators (file path vs command name)
|
||||
*/
|
||||
function isFilePath(spec: string): boolean {
|
||||
return spec.includes('/') || spec.includes('\\');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file is JavaScript
|
||||
*/
|
||||
function isJavaScriptFile(filePath: string): boolean {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
return ['.js', '.mjs', '.cjs'].includes(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file is TypeScript
|
||||
*/
|
||||
function isTypeScriptFile(filePath: string): boolean {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
return ['.ts', '.tsx'].includes(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if command is available in PATH
|
||||
*/
|
||||
function isCommandAvailable(command: string): boolean {
|
||||
try {
|
||||
// Use 'which' on Unix-like systems, 'where' on Windows
|
||||
const whichCommand = process.platform === 'win32' ? 'where' : 'which';
|
||||
execSync(`${whichCommand} ${command}`, {
|
||||
stdio: 'ignore',
|
||||
timeout: 1000,
|
||||
timeout: 5000, // 5 second timeout
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
@@ -277,87 +82,245 @@ function isCommandAvailable(command: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tsx is available
|
||||
*/
|
||||
function isTsxAvailable(): boolean {
|
||||
return isCommandAvailable('tsx');
|
||||
function validateRuntimeAvailability(runtime: string): boolean {
|
||||
// Node.js is always available since we're running in Node.js
|
||||
if (runtime === 'node') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if the runtime command is available in PATH
|
||||
return isCommandAvailable(runtime);
|
||||
}
|
||||
|
||||
function validateFileExtensionForRuntime(
|
||||
filePath: string,
|
||||
runtime: string,
|
||||
): boolean {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
|
||||
switch (runtime) {
|
||||
case 'node':
|
||||
case 'bun':
|
||||
return ['.js', '.mjs', '.cjs'].includes(ext);
|
||||
case 'tsx':
|
||||
return ['.ts', '.tsx'].includes(ext);
|
||||
case 'deno':
|
||||
return ['.ts', '.tsx', '.js', '.mjs'].includes(ext);
|
||||
default:
|
||||
return true; // Unknown runtime, let it pass
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get JavaScript runtime type (bun if running under bun, otherwise node)
|
||||
* Parse executable specification into components with comprehensive validation
|
||||
*
|
||||
* Supports multiple formats:
|
||||
* - 'qwen' -> native binary (auto-detected)
|
||||
* - '/path/to/qwen' -> native binary (explicit path)
|
||||
* - '/path/to/cli.js' -> Node.js bundle (default for .js files)
|
||||
* - '/path/to/index.ts' -> TypeScript source (requires tsx)
|
||||
*
|
||||
* Advanced runtime specification (for overriding defaults):
|
||||
* - 'bun:/path/to/cli.js' -> Force Bun runtime
|
||||
* - 'node:/path/to/cli.js' -> Force Node.js runtime
|
||||
* - 'tsx:/path/to/index.ts' -> Force tsx runtime
|
||||
* - 'deno:/path/to/cli.ts' -> Force Deno runtime
|
||||
*
|
||||
* @param executableSpec - Executable specification
|
||||
* @returns Parsed executable information
|
||||
* @throws Error if specification is invalid or files don't exist
|
||||
*/
|
||||
function getJsRuntimeType(): 'bun' | 'node' {
|
||||
export function parseExecutableSpec(executableSpec?: string): {
|
||||
runtime?: string;
|
||||
executablePath: string;
|
||||
isExplicitRuntime: boolean;
|
||||
} {
|
||||
if (
|
||||
typeof process !== 'undefined' &&
|
||||
'versions' in process &&
|
||||
'bun' in process.versions
|
||||
executableSpec === '' ||
|
||||
(executableSpec && executableSpec.trim() === '')
|
||||
) {
|
||||
return 'bun';
|
||||
}
|
||||
return 'node';
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare spawn information for CLI process
|
||||
*/
|
||||
export function prepareSpawnInfo(executableSpec?: string): SpawnInfo {
|
||||
if (executableSpec !== undefined && executableSpec.trim() === '') {
|
||||
throw new Error('Executable path cannot be empty');
|
||||
throw new Error('Command name cannot be empty');
|
||||
}
|
||||
|
||||
if (executableSpec === undefined) {
|
||||
const bundledCliPath = findBundledCliPath();
|
||||
if (!executableSpec) {
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: [bundledCliPath],
|
||||
type: getJsRuntimeType(),
|
||||
originalInput: '',
|
||||
executablePath: findNativeCliPath(),
|
||||
isExplicitRuntime: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!isFilePath(executableSpec)) {
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(executableSpec)) {
|
||||
throw new Error(
|
||||
`Invalid command name '${executableSpec}'. ` +
|
||||
'Command names should only contain letters, numbers, dots, hyphens, and underscores.',
|
||||
);
|
||||
}
|
||||
return {
|
||||
command: executableSpec,
|
||||
args: [],
|
||||
type: 'native',
|
||||
originalInput: executableSpec,
|
||||
};
|
||||
}
|
||||
// Check for runtime prefix (e.g., 'bun:/path/to/cli.js')
|
||||
// Use whitelist mechanism: only treat as runtime spec if prefix matches supported runtimes
|
||||
const supportedRuntimes = ['node', 'bun', 'tsx', 'deno'];
|
||||
const runtimeMatch = executableSpec.match(/^([^:]+):(.+)$/);
|
||||
|
||||
const resolvedPath = path.resolve(executableSpec);
|
||||
validateFilePath(resolvedPath);
|
||||
if (runtimeMatch) {
|
||||
const [, runtime, filePath] = runtimeMatch;
|
||||
|
||||
if (isJavaScriptFile(resolvedPath)) {
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: [resolvedPath],
|
||||
type: getJsRuntimeType(),
|
||||
originalInput: executableSpec,
|
||||
};
|
||||
}
|
||||
// Only process as runtime specification if it matches a supported runtime
|
||||
if (runtime && supportedRuntimes.includes(runtime)) {
|
||||
if (!filePath) {
|
||||
throw new Error(`Invalid runtime specification: '${executableSpec}'`);
|
||||
}
|
||||
|
||||
if (!validateRuntimeAvailability(runtime)) {
|
||||
throw new Error(
|
||||
`Runtime '${runtime}' is not available on this system. Please install it first.`,
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
throw new Error(
|
||||
`Executable file not found at '${resolvedPath}' for runtime '${runtime}'. ` +
|
||||
'Please check the file path and ensure the file exists.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!validateFileExtensionForRuntime(resolvedPath, runtime)) {
|
||||
const ext = path.extname(resolvedPath);
|
||||
throw new Error(
|
||||
`File extension '${ext}' is not compatible with runtime '${runtime}'. ` +
|
||||
`Expected extensions for ${runtime}: ${getExpectedExtensions(runtime).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (isTypeScriptFile(resolvedPath)) {
|
||||
if (isTsxAvailable()) {
|
||||
return {
|
||||
command: 'tsx',
|
||||
args: [resolvedPath],
|
||||
type: 'tsx',
|
||||
originalInput: executableSpec,
|
||||
runtime,
|
||||
executablePath: resolvedPath,
|
||||
isExplicitRuntime: true,
|
||||
};
|
||||
}
|
||||
// If not a supported runtime, fall through to treat as file path (e.g., Windows paths like 'D:\path\to\cli.js')
|
||||
}
|
||||
|
||||
// Check if it's a command name (no path separators) or a file path
|
||||
const isCommandName =
|
||||
!executableSpec.includes('/') && !executableSpec.includes('\\');
|
||||
|
||||
if (isCommandName) {
|
||||
// It's a command name like 'qwen' - validate it's a reasonable command name
|
||||
if (!executableSpec || executableSpec.trim() === '') {
|
||||
throw new Error('Command name cannot be empty');
|
||||
}
|
||||
|
||||
// Basic validation for command names
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(executableSpec)) {
|
||||
throw new Error(
|
||||
`Invalid command name '${executableSpec}'. Command names should only contain letters, numbers, dots, hyphens, and underscores.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
executablePath: executableSpec,
|
||||
isExplicitRuntime: false,
|
||||
};
|
||||
}
|
||||
|
||||
// It's a file path - validate and resolve
|
||||
const resolvedPath = path.resolve(executableSpec);
|
||||
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
throw new Error(
|
||||
`Executable file not found at '${resolvedPath}'. ` +
|
||||
'Please check the file path and ensure the file exists. ' +
|
||||
'You can also:\n' +
|
||||
' • Set QWEN_CODE_CLI_PATH environment variable\n' +
|
||||
' • Install qwen globally: npm install -g qwen\n' +
|
||||
' • For TypeScript files, ensure tsx is installed: npm install -g tsx\n' +
|
||||
' • Force specific runtime: bun:/path/to/cli.js or tsx:/path/to/index.ts',
|
||||
);
|
||||
}
|
||||
|
||||
// Additional validation for file paths
|
||||
const stats = fs.statSync(resolvedPath);
|
||||
if (!stats.isFile()) {
|
||||
throw new Error(
|
||||
`Path '${resolvedPath}' exists but is not a file. Please provide a path to an executable file.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
command: resolvedPath,
|
||||
args: [],
|
||||
type: 'native',
|
||||
originalInput: executableSpec,
|
||||
executablePath: resolvedPath,
|
||||
isExplicitRuntime: false,
|
||||
};
|
||||
}
|
||||
|
||||
function getExpectedExtensions(runtime: string): string[] {
|
||||
switch (runtime) {
|
||||
case 'node':
|
||||
case 'bun':
|
||||
return ['.js', '.mjs', '.cjs'];
|
||||
case 'tsx':
|
||||
return ['.ts', '.tsx'];
|
||||
case 'deno':
|
||||
return ['.ts', '.tsx', '.js', '.mjs'];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function detectRuntimeFromExtension(filePath: string): string | undefined {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
|
||||
if (['.js', '.mjs', '.cjs'].includes(ext)) {
|
||||
// Default to Node.js for JavaScript files
|
||||
return 'node';
|
||||
}
|
||||
|
||||
if (['.ts', '.tsx'].includes(ext)) {
|
||||
// Check if tsx is available for TypeScript files
|
||||
if (isCommandAvailable('tsx')) {
|
||||
return 'tsx';
|
||||
}
|
||||
// If tsx is not available, suggest it in error message
|
||||
throw new Error(
|
||||
`TypeScript file '${filePath}' requires 'tsx' runtime, but it's not available. ` +
|
||||
'Please install tsx: npm install -g tsx, or use explicit runtime: tsx:/path/to/file.ts',
|
||||
);
|
||||
}
|
||||
|
||||
// Native executable or unknown extension
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function prepareSpawnInfo(executableSpec?: string): SpawnInfo {
|
||||
const parsed = parseExecutableSpec(executableSpec);
|
||||
const { runtime, executablePath, isExplicitRuntime } = parsed;
|
||||
|
||||
// If runtime is explicitly specified, use it
|
||||
if (isExplicitRuntime && runtime) {
|
||||
const runtimeCommand = runtime === 'node' ? process.execPath : runtime;
|
||||
|
||||
return {
|
||||
command: runtimeCommand,
|
||||
args: [executablePath],
|
||||
type: runtime as ExecutableType,
|
||||
originalInput: executableSpec || '',
|
||||
};
|
||||
}
|
||||
|
||||
// If no explicit runtime, try to detect from file extension
|
||||
const detectedRuntime = detectRuntimeFromExtension(executablePath);
|
||||
|
||||
if (detectedRuntime) {
|
||||
const runtimeCommand =
|
||||
detectedRuntime === 'node' ? process.execPath : detectedRuntime;
|
||||
|
||||
return {
|
||||
command: runtimeCommand,
|
||||
args: [executablePath],
|
||||
type: detectedRuntime as ExecutableType,
|
||||
originalInput: executableSpec || '',
|
||||
};
|
||||
}
|
||||
|
||||
// Native executable or command name - use it directly
|
||||
return {
|
||||
command: executablePath,
|
||||
args: [],
|
||||
type: 'native',
|
||||
originalInput: executableSpec || '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Unit tests for CLI path utilities
|
||||
* Tests executable detection, parsing, and spawn info preparation
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
import {
|
||||
parseExecutableSpec,
|
||||
prepareSpawnInfo,
|
||||
findBundledCliPath,
|
||||
findNativeCliPath,
|
||||
} from '../../src/utils/cliPath.js';
|
||||
|
||||
// Mock fs module
|
||||
@@ -26,43 +21,36 @@ const mockFs = vi.mocked(fs);
|
||||
vi.mock('node:child_process');
|
||||
const mockExecSync = vi.mocked(execSync);
|
||||
|
||||
// Mock process.versions for bun detection
|
||||
const originalVersions = process.versions;
|
||||
|
||||
describe('CLI Path Utilities', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset process.versions
|
||||
Object.defineProperty(process, 'versions', {
|
||||
value: { ...originalVersions },
|
||||
writable: true,
|
||||
});
|
||||
// Default: tsx is available (can be overridden in specific tests)
|
||||
mockExecSync.mockReturnValue(Buffer.from(''));
|
||||
// Default: mock statSync to return a proper file stat object
|
||||
mockFs.statSync.mockReturnValue({
|
||||
isFile: () => true,
|
||||
} as ReturnType<typeof import('fs').statSync>);
|
||||
// Default: return true for existsSync (can be overridden in specific tests)
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
// Default: tsx is available (can be overridden in specific tests)
|
||||
mockExecSync.mockReturnValue(Buffer.from(''));
|
||||
});
|
||||
|
||||
describe('findBundledCliPath', () => {
|
||||
it('should find bundled CLI when it exists', () => {
|
||||
// Mock existsSync to return true for bundled CLI
|
||||
mockFs.existsSync.mockImplementation((p) => {
|
||||
const pathStr = p.toString();
|
||||
return (
|
||||
pathStr.includes('cli/cli.js') || pathStr.includes('cli\\cli.js')
|
||||
);
|
||||
});
|
||||
|
||||
const result = findBundledCliPath();
|
||||
|
||||
expect(result).toContain('cli.js');
|
||||
});
|
||||
|
||||
it('should throw descriptive error when bundled CLI not found', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
expect(() => findBundledCliPath()).toThrow('Bundled qwen CLI not found');
|
||||
expect(() => findBundledCliPath()).toThrow('Searched locations:');
|
||||
afterEach(() => {
|
||||
// Restore original process.versions
|
||||
Object.defineProperty(process, 'versions', {
|
||||
value: originalVersions,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareSpawnInfo', () => {
|
||||
describe('parseExecutableSpec', () => {
|
||||
describe('auto-detection (no spec provided)', () => {
|
||||
it('should auto-detect bundled CLI when no spec provided', () => {
|
||||
// Mock existsSync to return true for bundled CLI
|
||||
@@ -73,23 +61,176 @@ describe('CLI Path Utilities', () => {
|
||||
);
|
||||
});
|
||||
|
||||
const result = prepareSpawnInfo();
|
||||
const result = parseExecutableSpec();
|
||||
|
||||
expect(result.command).toBe(process.execPath);
|
||||
expect(result.args[0]).toContain('cli.js');
|
||||
expect(result.type).toBe('node');
|
||||
expect(result.originalInput).toBe('');
|
||||
expect(result.executablePath).toContain('cli.js');
|
||||
expect(result.isExplicitRuntime).toBe(false);
|
||||
});
|
||||
|
||||
it('should throw when bundled CLI not found', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
expect(() => prepareSpawnInfo()).toThrow('Bundled qwen CLI not found');
|
||||
expect(() => parseExecutableSpec()).toThrow(
|
||||
'Bundled qwen CLI not found',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runtime prefix parsing', () => {
|
||||
it('should parse node runtime prefix', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
const result = parseExecutableSpec('node:/path/to/cli.js');
|
||||
|
||||
expect(result).toEqual({
|
||||
runtime: 'node',
|
||||
executablePath: path.resolve('/path/to/cli.js'),
|
||||
isExplicitRuntime: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse bun runtime prefix', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
const result = parseExecutableSpec('bun:/path/to/cli.js');
|
||||
|
||||
expect(result).toEqual({
|
||||
runtime: 'bun',
|
||||
executablePath: path.resolve('/path/to/cli.js'),
|
||||
isExplicitRuntime: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse tsx runtime prefix', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
const result = parseExecutableSpec('tsx:/path/to/index.ts');
|
||||
|
||||
expect(result).toEqual({
|
||||
runtime: 'tsx',
|
||||
executablePath: path.resolve('/path/to/index.ts'),
|
||||
isExplicitRuntime: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse deno runtime prefix', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
const result = parseExecutableSpec('deno:/path/to/cli.ts');
|
||||
|
||||
expect(result).toEqual({
|
||||
runtime: 'deno',
|
||||
executablePath: path.resolve('/path/to/cli.ts'),
|
||||
isExplicitRuntime: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should treat non-whitelisted runtime prefixes as command names', () => {
|
||||
// With whitelist approach, 'invalid:format' is not recognized as a runtime spec
|
||||
// so it's treated as a command name, which fails validation due to the colon
|
||||
expect(() => parseExecutableSpec('invalid:format')).toThrow(
|
||||
'Invalid command name',
|
||||
);
|
||||
});
|
||||
|
||||
it('should treat Windows drive letters as file paths, not runtime specs', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
// Test various Windows drive letters
|
||||
const windowsPaths = [
|
||||
'C:\\path\\to\\cli.js',
|
||||
'D:\\path\\to\\cli.js',
|
||||
'E:\\Users\\dev\\qwen\\cli.js',
|
||||
];
|
||||
|
||||
for (const winPath of windowsPaths) {
|
||||
const result = parseExecutableSpec(winPath);
|
||||
|
||||
expect(result.isExplicitRuntime).toBe(false);
|
||||
expect(result.runtime).toBeUndefined();
|
||||
expect(result.executablePath).toBe(path.resolve(winPath));
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle Windows paths with forward slashes', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
const result = parseExecutableSpec('C:/path/to/cli.js');
|
||||
|
||||
expect(result.isExplicitRuntime).toBe(false);
|
||||
expect(result.runtime).toBeUndefined();
|
||||
expect(result.executablePath).toBe(path.resolve('C:/path/to/cli.js'));
|
||||
});
|
||||
|
||||
it('should throw when runtime-prefixed file does not exist', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
expect(() => parseExecutableSpec('node:/nonexistent/cli.js')).toThrow(
|
||||
'Executable file not found at',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('command name detection', () => {
|
||||
it('should detect command names without path separators', () => {
|
||||
const result = parseExecutableSpec('qwen');
|
||||
|
||||
expect(result).toEqual({
|
||||
executablePath: 'qwen',
|
||||
isExplicitRuntime: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should detect command names on Windows', () => {
|
||||
const result = parseExecutableSpec('qwen.exe');
|
||||
|
||||
expect(result).toEqual({
|
||||
executablePath: 'qwen.exe',
|
||||
isExplicitRuntime: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('file path resolution', () => {
|
||||
it('should resolve absolute file paths', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
const result = parseExecutableSpec('/absolute/path/to/qwen');
|
||||
|
||||
expect(result).toEqual({
|
||||
executablePath: path.resolve('/absolute/path/to/qwen'),
|
||||
isExplicitRuntime: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should resolve relative file paths', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
const result = parseExecutableSpec('./relative/path/to/qwen');
|
||||
|
||||
expect(result).toEqual({
|
||||
executablePath: path.resolve('./relative/path/to/qwen'),
|
||||
isExplicitRuntime: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw when file path does not exist', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
expect(() => parseExecutableSpec('/nonexistent/path')).toThrow(
|
||||
'Executable file not found at',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareSpawnInfo', () => {
|
||||
beforeEach(() => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe('native executables', () => {
|
||||
it('should prepare spawn info for native binary command', () => {
|
||||
const result = prepareSpawnInfo('qwen');
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -100,38 +241,37 @@ describe('CLI Path Utilities', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should detect command names on Windows', () => {
|
||||
const result = prepareSpawnInfo('qwen.exe');
|
||||
it('should prepare spawn info for native binary path', () => {
|
||||
const result = prepareSpawnInfo('/usr/local/bin/qwen');
|
||||
|
||||
expect(result).toEqual({
|
||||
command: 'qwen.exe',
|
||||
command: path.resolve('/usr/local/bin/qwen'),
|
||||
args: [],
|
||||
type: 'native',
|
||||
originalInput: 'qwen.exe',
|
||||
originalInput: '/usr/local/bin/qwen',
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject invalid command name characters', () => {
|
||||
expect(() => prepareSpawnInfo('qwen@invalid')).toThrow(
|
||||
"Invalid command name 'qwen@invalid'. Command names should only contain letters, numbers, dots, hyphens, and underscores.",
|
||||
);
|
||||
});
|
||||
|
||||
it('should accept valid command names', () => {
|
||||
expect(() => prepareSpawnInfo('qwen')).not.toThrow();
|
||||
expect(() => prepareSpawnInfo('qwen-code')).not.toThrow();
|
||||
expect(() => prepareSpawnInfo('qwen_code')).not.toThrow();
|
||||
expect(() => prepareSpawnInfo('qwen.exe')).not.toThrow();
|
||||
expect(() => prepareSpawnInfo('qwen123')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('JavaScript files', () => {
|
||||
beforeEach(() => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
it('should use node for .js files', () => {
|
||||
const result = prepareSpawnInfo('/path/to/cli.js');
|
||||
|
||||
expect(result).toEqual({
|
||||
command: process.execPath,
|
||||
args: [path.resolve('/path/to/cli.js')],
|
||||
type: 'node',
|
||||
originalInput: '/path/to/cli.js',
|
||||
});
|
||||
});
|
||||
|
||||
it('should use node for .js files', () => {
|
||||
it('should default to node for .js files (not auto-detect bun)', () => {
|
||||
// Even when running under bun, default to node for .js files
|
||||
Object.defineProperty(process, 'versions', {
|
||||
value: { ...originalVersions, bun: '1.0.0' },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const result = prepareSpawnInfo('/path/to/cli.js');
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -166,10 +306,6 @@ describe('CLI Path Utilities', () => {
|
||||
});
|
||||
|
||||
describe('TypeScript files', () => {
|
||||
beforeEach(() => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('should use tsx for .ts files when tsx is available', () => {
|
||||
// tsx is available by default in beforeEach
|
||||
const result = prepareSpawnInfo('/path/to/index.ts');
|
||||
@@ -193,178 +329,107 @@ describe('CLI Path Utilities', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should fallback to native when tsx is not available', () => {
|
||||
it('should throw helpful error when tsx is not available', () => {
|
||||
// Mock tsx not being available
|
||||
mockExecSync.mockImplementation(() => {
|
||||
throw new Error('Command not found');
|
||||
});
|
||||
|
||||
const result = prepareSpawnInfo('/path/to/index.ts');
|
||||
const resolvedPath = path.resolve('/path/to/index.ts');
|
||||
expect(() => prepareSpawnInfo('/path/to/index.ts')).toThrow(
|
||||
`TypeScript file '${resolvedPath}' requires 'tsx' runtime, but it's not available`,
|
||||
);
|
||||
expect(() => prepareSpawnInfo('/path/to/index.ts')).toThrow(
|
||||
'Please install tsx: npm install -g tsx',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('explicit runtime specifications', () => {
|
||||
it('should use explicit node runtime', () => {
|
||||
const result = prepareSpawnInfo('node:/path/to/cli.js');
|
||||
|
||||
expect(result).toEqual({
|
||||
command: path.resolve('/path/to/index.ts'),
|
||||
args: [],
|
||||
type: 'native',
|
||||
originalInput: '/path/to/index.ts',
|
||||
command: process.execPath,
|
||||
args: [path.resolve('/path/to/cli.js')],
|
||||
type: 'node',
|
||||
originalInput: 'node:/path/to/cli.js',
|
||||
});
|
||||
});
|
||||
|
||||
it('should use explicit bun runtime', () => {
|
||||
const result = prepareSpawnInfo('bun:/path/to/cli.js');
|
||||
|
||||
expect(result).toEqual({
|
||||
command: 'bun',
|
||||
args: [path.resolve('/path/to/cli.js')],
|
||||
type: 'bun',
|
||||
originalInput: 'bun:/path/to/cli.js',
|
||||
});
|
||||
});
|
||||
|
||||
it('should use explicit tsx runtime', () => {
|
||||
const result = prepareSpawnInfo('tsx:/path/to/index.ts');
|
||||
|
||||
expect(result).toEqual({
|
||||
command: 'tsx',
|
||||
args: [path.resolve('/path/to/index.ts')],
|
||||
type: 'tsx',
|
||||
originalInput: 'tsx:/path/to/index.ts',
|
||||
});
|
||||
});
|
||||
|
||||
it('should use explicit deno runtime', () => {
|
||||
const result = prepareSpawnInfo('deno:/path/to/cli.ts');
|
||||
|
||||
expect(result).toEqual({
|
||||
command: 'deno',
|
||||
args: [path.resolve('/path/to/cli.ts')],
|
||||
type: 'deno',
|
||||
originalInput: 'deno:/path/to/cli.ts',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('native executables', () => {
|
||||
beforeEach(() => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('should prepare spawn info for native binary path', () => {
|
||||
const result = prepareSpawnInfo('/usr/local/bin/qwen');
|
||||
|
||||
expect(result).toEqual({
|
||||
command: path.resolve('/usr/local/bin/qwen'),
|
||||
args: [],
|
||||
type: 'native',
|
||||
originalInput: '/usr/local/bin/qwen',
|
||||
describe('auto-detection fallback', () => {
|
||||
it('should auto-detect bundled CLI when no spec provided', () => {
|
||||
// Mock existsSync to return true for bundled CLI
|
||||
mockFs.existsSync.mockImplementation((p) => {
|
||||
const pathStr = p.toString();
|
||||
return (
|
||||
pathStr.includes('cli/cli.js') || pathStr.includes('cli\\cli.js')
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('file path resolution', () => {
|
||||
it('should resolve absolute file paths', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
const result = prepareSpawnInfo('/absolute/path/to/qwen');
|
||||
|
||||
expect(result.command).toBe(path.resolve('/absolute/path/to/qwen'));
|
||||
expect(result.type).toBe('native');
|
||||
});
|
||||
|
||||
it('should resolve relative file paths', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
const result = prepareSpawnInfo('./relative/path/to/cli.js');
|
||||
const result = prepareSpawnInfo();
|
||||
|
||||
expect(result.command).toBe(process.execPath);
|
||||
expect(result.args[0]).toBe(path.resolve('./relative/path/to/cli.js'));
|
||||
expect(result.args[0]).toContain('cli.js');
|
||||
expect(result.type).toBe('node');
|
||||
});
|
||||
|
||||
it('should throw when file path does not exist', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
expect(() => prepareSpawnInfo('/nonexistent/path')).toThrow(
|
||||
'Executable file not found at',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when path is a directory', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
mockFs.statSync.mockReturnValue({
|
||||
isFile: () => false,
|
||||
} as ReturnType<typeof import('fs').statSync>);
|
||||
|
||||
expect(() => prepareSpawnInfo('/path/to/directory')).toThrow(
|
||||
'exists but is not a file',
|
||||
);
|
||||
expect(result.originalInput).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Windows path handling', () => {
|
||||
beforeEach(() => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('should handle Windows paths with drive letters', () => {
|
||||
const windowsPath = 'D:\\path\\to\\cli.js';
|
||||
const result = prepareSpawnInfo(windowsPath);
|
||||
|
||||
expect(result).toEqual({
|
||||
command: process.execPath,
|
||||
args: [path.resolve(windowsPath)],
|
||||
type: 'node',
|
||||
originalInput: windowsPath,
|
||||
describe('findNativeCliPath', () => {
|
||||
it('should find bundled CLI', () => {
|
||||
// Mock existsSync to return true for bundled CLI
|
||||
mockFs.existsSync.mockImplementation((p) => {
|
||||
const pathStr = p.toString();
|
||||
return (
|
||||
pathStr.includes('cli/cli.js') || pathStr.includes('cli\\cli.js')
|
||||
);
|
||||
});
|
||||
|
||||
const result = findNativeCliPath();
|
||||
|
||||
expect(result).toContain('cli.js');
|
||||
});
|
||||
|
||||
it('should handle Windows paths with forward slashes', () => {
|
||||
const windowsPath = 'C:/path/to/cli.js';
|
||||
const result = prepareSpawnInfo(windowsPath);
|
||||
|
||||
expect(result.command).toBe(process.execPath);
|
||||
expect(result.args[0]).toBe(path.resolve(windowsPath));
|
||||
expect(result.type).toBe('node');
|
||||
});
|
||||
|
||||
it('should not confuse Windows drive letters with invalid syntax', () => {
|
||||
const windowsPath = 'D:\\workspace\\project\\cli.js';
|
||||
const result = prepareSpawnInfo(windowsPath);
|
||||
|
||||
// Should use node runtime based on .js extension
|
||||
expect(result.type).toBe('node');
|
||||
expect(result.command).toBe(process.execPath);
|
||||
});
|
||||
|
||||
it('should handle Windows paths when file is missing', () => {
|
||||
it('should throw descriptive error when bundled CLI not found', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
expect(() => prepareSpawnInfo('D:\\missing\\cli.js')).toThrow(
|
||||
'Executable file not found at',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle mixed path separators', () => {
|
||||
// Users might paste paths with mixed separators
|
||||
const mixedPath = 'C:\\Users/project\\cli.js';
|
||||
const result = prepareSpawnInfo(mixedPath);
|
||||
|
||||
expect(result.command).toBe(process.execPath);
|
||||
expect(result.type).toBe('node');
|
||||
// path.resolve normalizes the separators
|
||||
expect(result.args[0]).toBe(path.resolve(mixedPath));
|
||||
});
|
||||
|
||||
it('should handle UNC paths', () => {
|
||||
// Windows network paths: \\server\share\path
|
||||
const uncPath = '\\\\server\\share\\path\\cli.js';
|
||||
const result = prepareSpawnInfo(uncPath);
|
||||
|
||||
expect(result.command).toBe(process.execPath);
|
||||
expect(result.type).toBe('node');
|
||||
expect(result.args[0]).toBe(path.resolve(uncPath));
|
||||
});
|
||||
|
||||
it('should handle Windows native executables', () => {
|
||||
const windowsPath = 'C:\\Program Files\\qwen\\qwen.exe';
|
||||
const result = prepareSpawnInfo(windowsPath);
|
||||
|
||||
// .exe files without .js extension should be treated as native
|
||||
expect(result.type).toBe('native');
|
||||
expect(result.command).toBe(path.resolve(windowsPath));
|
||||
expect(result.args).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error cases', () => {
|
||||
it('should throw for empty string', () => {
|
||||
expect(() => prepareSpawnInfo('')).toThrow(
|
||||
'Executable path cannot be empty',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw for whitespace-only string', () => {
|
||||
expect(() => prepareSpawnInfo(' ')).toThrow(
|
||||
'Executable path cannot be empty',
|
||||
);
|
||||
});
|
||||
|
||||
it('should provide helpful error for missing file', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
expect(() => prepareSpawnInfo('/missing/file')).toThrow(
|
||||
'Executable file not found at',
|
||||
);
|
||||
expect(() => prepareSpawnInfo('/missing/file')).toThrow(
|
||||
'Please check the file path and ensure the file exists',
|
||||
);
|
||||
expect(() => findNativeCliPath()).toThrow('Bundled qwen CLI not found');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -373,6 +438,18 @@ describe('CLI Path Utilities', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('should handle development with TypeScript source', () => {
|
||||
const devPath = '/Users/dev/qwen-code/packages/cli/index.ts';
|
||||
const result = prepareSpawnInfo(devPath);
|
||||
|
||||
expect(result).toEqual({
|
||||
command: 'tsx',
|
||||
args: [path.resolve(devPath)],
|
||||
type: 'tsx',
|
||||
originalInput: devPath,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle production bundle validation', () => {
|
||||
const bundlePath = '/path/to/bundled/cli.js';
|
||||
const result = prepareSpawnInfo(bundlePath);
|
||||
@@ -396,27 +473,235 @@ describe('CLI Path Utilities', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle ESM bundle', () => {
|
||||
const bundlePath = '/path/to/cli.mjs';
|
||||
const result = prepareSpawnInfo(bundlePath);
|
||||
it('should handle bun runtime with bundle', () => {
|
||||
const bundlePath = '/path/to/cli.js';
|
||||
const result = prepareSpawnInfo(`bun:${bundlePath}`);
|
||||
|
||||
expect(result).toEqual({
|
||||
command: process.execPath,
|
||||
command: 'bun',
|
||||
args: [path.resolve(bundlePath)],
|
||||
type: 'node',
|
||||
originalInput: bundlePath,
|
||||
type: 'bun',
|
||||
originalInput: `bun:${bundlePath}`,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle CJS bundle', () => {
|
||||
const bundlePath = '/path/to/cli.cjs';
|
||||
const result = prepareSpawnInfo(bundlePath);
|
||||
it('should handle Windows paths with drive letters', () => {
|
||||
const windowsPath = 'D:\\path\\to\\cli.js';
|
||||
const result = prepareSpawnInfo(windowsPath);
|
||||
|
||||
expect(result).toEqual({
|
||||
command: process.execPath,
|
||||
args: [path.resolve(bundlePath)],
|
||||
args: [path.resolve(windowsPath)],
|
||||
type: 'node',
|
||||
originalInput: bundlePath,
|
||||
originalInput: windowsPath,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle Windows paths with TypeScript files', () => {
|
||||
const windowsPath = 'C:\\Users\\dev\\qwen\\index.ts';
|
||||
const result = prepareSpawnInfo(windowsPath);
|
||||
|
||||
expect(result).toEqual({
|
||||
command: 'tsx',
|
||||
args: [path.resolve(windowsPath)],
|
||||
type: 'tsx',
|
||||
originalInput: windowsPath,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not confuse Windows drive letters with runtime prefixes', () => {
|
||||
// Ensure 'D:' is not treated as a runtime specification
|
||||
const windowsPath = 'D:\\workspace\\project\\cli.js';
|
||||
const result = prepareSpawnInfo(windowsPath);
|
||||
|
||||
// Should use node runtime based on .js extension, not treat 'D' as runtime
|
||||
expect(result.type).toBe('node');
|
||||
expect(result.command).toBe(process.execPath);
|
||||
expect(result.args).toEqual([path.resolve(windowsPath)]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error cases', () => {
|
||||
it('should provide helpful error for missing TypeScript file', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
expect(() => prepareSpawnInfo('/missing/index.ts')).toThrow(
|
||||
'Executable file not found at',
|
||||
);
|
||||
});
|
||||
|
||||
it('should provide helpful error for missing JavaScript file', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
expect(() => prepareSpawnInfo('/missing/cli.js')).toThrow(
|
||||
'Executable file not found at',
|
||||
);
|
||||
});
|
||||
|
||||
it('should treat non-whitelisted runtime prefixes as command names', () => {
|
||||
// With whitelist approach, 'invalid:spec' is not recognized as a runtime spec
|
||||
// so it's treated as a command name, which fails validation due to the colon
|
||||
expect(() => prepareSpawnInfo('invalid:spec')).toThrow(
|
||||
'Invalid command name',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle Windows paths correctly even when file is missing', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
expect(() => prepareSpawnInfo('D:\\missing\\cli.js')).toThrow(
|
||||
'Executable file not found at',
|
||||
);
|
||||
// Should not throw 'Invalid command name' error (which would happen if 'D:' was treated as invalid command)
|
||||
expect(() => prepareSpawnInfo('D:\\missing\\cli.js')).not.toThrow(
|
||||
'Invalid command name',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('comprehensive validation', () => {
|
||||
describe('runtime validation', () => {
|
||||
it('should treat unsupported runtime prefixes as file paths', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
// With whitelist approach, 'unsupported:' is not recognized as a runtime spec
|
||||
// so 'unsupported:/path/to/file.js' is treated as a file path
|
||||
const result = parseExecutableSpec('unsupported:/path/to/file.js');
|
||||
|
||||
// Should be treated as a file path, not a runtime specification
|
||||
expect(result.isExplicitRuntime).toBe(false);
|
||||
expect(result.runtime).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should validate runtime availability for explicit runtime specs', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
// Mock bun not being available
|
||||
mockExecSync.mockImplementation((command) => {
|
||||
if (command.includes('bun')) {
|
||||
throw new Error('Command not found');
|
||||
}
|
||||
return Buffer.from('');
|
||||
});
|
||||
|
||||
expect(() => parseExecutableSpec('bun:/path/to/cli.js')).toThrow(
|
||||
"Runtime 'bun' is not available on this system. Please install it first.",
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow node runtime (always available)', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
expect(() => parseExecutableSpec('node:/path/to/cli.js')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should validate file extension matches runtime', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
expect(() => parseExecutableSpec('tsx:/path/to/file.js')).toThrow(
|
||||
"File extension '.js' is not compatible with runtime 'tsx'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate node runtime with JavaScript files', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
expect(() => parseExecutableSpec('node:/path/to/file.ts')).toThrow(
|
||||
"File extension '.ts' is not compatible with runtime 'node'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should accept valid runtime-file combinations', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
expect(() => parseExecutableSpec('tsx:/path/to/file.ts')).not.toThrow();
|
||||
expect(() =>
|
||||
parseExecutableSpec('node:/path/to/file.js'),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
parseExecutableSpec('bun:/path/to/file.mjs'),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('command name validation', () => {
|
||||
it('should reject empty command names', () => {
|
||||
expect(() => parseExecutableSpec('')).toThrow(
|
||||
'Command name cannot be empty',
|
||||
);
|
||||
expect(() => parseExecutableSpec(' ')).toThrow(
|
||||
'Command name cannot be empty',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject invalid command name characters', () => {
|
||||
expect(() => parseExecutableSpec('qwen@invalid')).toThrow(
|
||||
"Invalid command name 'qwen@invalid'. Command names should only contain letters, numbers, dots, hyphens, and underscores.",
|
||||
);
|
||||
|
||||
expect(() => parseExecutableSpec('qwen/invalid')).not.toThrow(); // This is treated as a path
|
||||
});
|
||||
|
||||
it('should accept valid command names', () => {
|
||||
expect(() => parseExecutableSpec('qwen')).not.toThrow();
|
||||
expect(() => parseExecutableSpec('qwen-code')).not.toThrow();
|
||||
expect(() => parseExecutableSpec('qwen_code')).not.toThrow();
|
||||
expect(() => parseExecutableSpec('qwen.exe')).not.toThrow();
|
||||
expect(() => parseExecutableSpec('qwen123')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('file path validation', () => {
|
||||
it('should validate file exists', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
expect(() => parseExecutableSpec('/nonexistent/path')).toThrow(
|
||||
'Executable file not found at',
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate path points to a file, not directory', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
mockFs.statSync.mockReturnValue({
|
||||
isFile: () => false,
|
||||
} as ReturnType<typeof import('fs').statSync>);
|
||||
|
||||
expect(() => parseExecutableSpec('/path/to/directory')).toThrow(
|
||||
'exists but is not a file',
|
||||
);
|
||||
});
|
||||
|
||||
it('should accept valid file paths', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
mockFs.statSync.mockReturnValue({
|
||||
isFile: () => true,
|
||||
} as ReturnType<typeof import('fs').statSync>);
|
||||
|
||||
expect(() => parseExecutableSpec('/path/to/qwen')).not.toThrow();
|
||||
expect(() => parseExecutableSpec('./relative/path')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error message quality', () => {
|
||||
it('should provide helpful error for missing runtime-prefixed file', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
expect(() => parseExecutableSpec('tsx:/missing/file.ts')).toThrow(
|
||||
'Executable file not found at',
|
||||
);
|
||||
expect(() => parseExecutableSpec('tsx:/missing/file.ts')).toThrow(
|
||||
'Please check the file path and ensure the file exists',
|
||||
);
|
||||
});
|
||||
|
||||
it('should provide helpful error for missing regular file', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
expect(() => parseExecutableSpec('/missing/file')).toThrow(
|
||||
'Executable file not found at',
|
||||
);
|
||||
expect(() => parseExecutableSpec('/missing/file')).toThrow(
|
||||
'Please check the file path and ensure the file exists',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,36 +1,25 @@
|
||||
# Qwen Code Companion
|
||||
|
||||
Seamlessly integrate [Qwen Code](https://github.com/QwenLM/qwen-code) into Visual Studio Code with native IDE features and an intuitive interface. This extension bundles everything you need to get started immediately.
|
||||
The Qwen Code Companion extension seamlessly integrates [Qwen Code](https://github.com/QwenLM/qwen-code). This extension is compatible with both VS Code and VS Code forks.
|
||||
|
||||
## Demo
|
||||
# Features
|
||||
|
||||
<video src="https://cloud.video.taobao.com/vod/IKKwfM-kqNI3OJjM_U8uMCSMAoeEcJhs6VNCQmZxUfk.mp4" controls width="800">
|
||||
Your browser does not support the video tag. You can open the video directly:
|
||||
https://cloud.video.taobao.com/vod/IKKwfM-kqNI3OJjM_U8uMCSMAoeEcJhs6VNCQmZxUfk.mp4
|
||||
</video>
|
||||
- Open Editor File Context: Qwen Code gains awareness of the files you have open in your editor, providing it with a richer understanding of your project's structure and content.
|
||||
|
||||
## Features
|
||||
- Selection Context: Qwen Code can easily access your cursor's position and selected text within the editor, giving it valuable context directly from your current work.
|
||||
|
||||
- **Native IDE experience**: Dedicated Qwen Code sidebar panel accessed via the Qwen icon
|
||||
- **Native diffing**: Review, edit, and accept changes in VS Code's diff view
|
||||
- **Auto-accept edits mode**: Automatically apply Qwen's changes as they're made
|
||||
- **File management**: @-mention files or attach files and images using the system file picker
|
||||
- **Conversation history & multiple sessions**: Access past conversations and run multiple sessions simultaneously
|
||||
- **Open file & selection context**: Share active files, cursor position, and selections for more precise help
|
||||
- Native Diffing: Seamlessly view, modify, and accept code changes suggested by Qwen Code directly within the editor.
|
||||
|
||||
## Requirements
|
||||
- Launch Qwen Code: Quickly start a new Qwen Code session from the Command Palette (Cmd+Shift+P or Ctrl+Shift+P) by running the "Qwen Code: Run" command.
|
||||
|
||||
- Visual Studio Code 1.85.0 or newer
|
||||
# Requirements
|
||||
|
||||
## Installation
|
||||
To use this extension, you'll need:
|
||||
|
||||
1. Install from the VS Code Marketplace: https://marketplace.visualstudio.com/items?itemName=qwenlm.qwen-code-vscode-ide-companion
|
||||
- VS Code version 1.101.0 or newer
|
||||
- Qwen Code (installed separately) running within the VS Code integrated terminal
|
||||
|
||||
2. Two ways to use
|
||||
- Chat panel: Click the Qwen icon in the Activity Bar, or run `Qwen Code: Open` from the Command Palette (`Cmd+Shift+P` / `Ctrl+Shift+P`).
|
||||
- Terminal session (classic): Run `Qwen Code: Run` to launch a session in the integrated terminal (bundled CLI).
|
||||
|
||||
## Development and Debugging
|
||||
# Development and Debugging
|
||||
|
||||
To debug and develop this extension locally:
|
||||
|
||||
@@ -87,6 +76,6 @@ npx vsce package
|
||||
pnpm vsce package
|
||||
```
|
||||
|
||||
## Terms of Service and Privacy Notice
|
||||
# Terms of Service and Privacy Notice
|
||||
|
||||
By installing this extension, you agree to the [Terms of Service](https://github.com/QwenLM/qwen-code/blob/main/docs/tos-privacy.md).
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
import { WebViewProvider } from './webview/WebViewProvider.js';
|
||||
import { registerNewCommands } from './commands/index.js';
|
||||
import { ReadonlyFileSystemProvider } from './services/readonlyFileSystemProvider.js';
|
||||
import { isWindows } from './utils/platform.js';
|
||||
|
||||
const CLI_IDE_COMPANION_IDENTIFIER = 'qwenlm.qwen-code-vscode-ide-companion';
|
||||
const INFO_MESSAGE_SHOWN_KEY = 'qwenCodeInfoMessageShown';
|
||||
@@ -313,38 +312,13 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
'qwen-cli',
|
||||
'cli.js',
|
||||
).fsPath;
|
||||
const execPath = process.execPath;
|
||||
const lowerExecPath = execPath.toLowerCase();
|
||||
const needsElectronRunAsNode =
|
||||
lowerExecPath.includes('code') ||
|
||||
lowerExecPath.includes('electron');
|
||||
|
||||
let qwenCmd: string;
|
||||
const terminalOptions: vscode.TerminalOptions = {
|
||||
const quote = (s: string) => `"${s.replaceAll('"', '\\"')}"`;
|
||||
const qwenCmd = `${quote(process.execPath)} ${quote(cliEntry)}`;
|
||||
const terminal = vscode.window.createTerminal({
|
||||
name: `Qwen Code (${selectedFolder.name})`,
|
||||
cwd: selectedFolder.uri.fsPath,
|
||||
location,
|
||||
};
|
||||
|
||||
if (isWindows) {
|
||||
// Use system Node via cmd.exe; avoid PowerShell parsing issues
|
||||
const quoteCmd = (s: string) => `"${s.replace(/"/g, '""')}"`;
|
||||
const cliQuoted = quoteCmd(cliEntry);
|
||||
// TODO: @yiliang114, temporarily run through node, and later hope to decouple from the local node
|
||||
qwenCmd = `node ${cliQuoted}`;
|
||||
terminalOptions.shellPath = process.env.ComSpec;
|
||||
} else {
|
||||
const quotePosix = (s: string) => `"${s.replace(/"/g, '\\"')}"`;
|
||||
const baseCmd = `${quotePosix(execPath)} ${quotePosix(cliEntry)}`;
|
||||
if (needsElectronRunAsNode) {
|
||||
// macOS Electron helper needs ELECTRON_RUN_AS_NODE=1;
|
||||
qwenCmd = `ELECTRON_RUN_AS_NODE=1 ${baseCmd}`;
|
||||
} else {
|
||||
qwenCmd = baseCmd;
|
||||
}
|
||||
}
|
||||
|
||||
const terminal = vscode.window.createTerminal(terminalOptions);
|
||||
});
|
||||
terminal.show();
|
||||
terminal.sendText(qwenCmd);
|
||||
}
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as vscode from 'vscode';
|
||||
import { OpenFilesManager } from './open-files-manager.js';
|
||||
import { MAX_FILES } from './services/open-files-manager/constants.js';
|
||||
import { OpenFilesManager, MAX_FILES } from './open-files-manager.js';
|
||||
|
||||
vi.mock('vscode', () => ({
|
||||
EventEmitter: vi.fn(() => {
|
||||
|
||||
@@ -9,23 +9,9 @@ import type {
|
||||
File,
|
||||
IdeContext,
|
||||
} from '@qwen-code/qwen-code-core/src/ide/types.js';
|
||||
import {
|
||||
isFileUri,
|
||||
isNotebookFileUri,
|
||||
isNotebookCellUri,
|
||||
removeFile,
|
||||
renameFile,
|
||||
getNotebookUriFromCellUri,
|
||||
} from './services/open-files-manager/utils.js';
|
||||
import {
|
||||
addOrMoveToFront,
|
||||
updateActiveContext,
|
||||
} from './services/open-files-manager/text-handler.js';
|
||||
import {
|
||||
addOrMoveToFrontNotebook,
|
||||
updateNotebookActiveContext,
|
||||
updateNotebookCellSelection,
|
||||
} from './services/open-files-manager/notebook-handler.js';
|
||||
|
||||
export const MAX_FILES = 10;
|
||||
const MAX_SELECTED_TEXT_LENGTH = 16384; // 16 KiB limit
|
||||
|
||||
/**
|
||||
* Keeps track of the workspace state, including open files, cursor position, and selected text.
|
||||
@@ -39,102 +25,33 @@ export class OpenFilesManager {
|
||||
constructor(private readonly context: vscode.ExtensionContext) {
|
||||
const editorWatcher = vscode.window.onDidChangeActiveTextEditor(
|
||||
(editor) => {
|
||||
if (editor && isFileUri(editor.document.uri)) {
|
||||
addOrMoveToFront(this.openFiles, editor);
|
||||
if (editor && this.isFileUri(editor.document.uri)) {
|
||||
this.addOrMoveToFront(editor);
|
||||
this.fireWithDebounce();
|
||||
} else if (editor && isNotebookCellUri(editor.document.uri)) {
|
||||
// Handle when a notebook cell becomes active (which indicates the notebook is active)
|
||||
const notebookUri = getNotebookUriFromCellUri(editor.document.uri);
|
||||
if (notebookUri && isNotebookFileUri(notebookUri)) {
|
||||
// Find the notebook editor for this cell
|
||||
const notebookEditor = vscode.window.visibleNotebookEditors.find(
|
||||
(nbEditor) =>
|
||||
nbEditor.notebook.uri.toString() === notebookUri.toString(),
|
||||
);
|
||||
if (notebookEditor) {
|
||||
addOrMoveToFrontNotebook(this.openFiles, notebookEditor);
|
||||
this.fireWithDebounce();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Watch for when notebook editors gain focus by monitoring focus changes
|
||||
// Since VS Code doesn't have a direct onDidChangeActiveNotebookEditor event,
|
||||
// we monitor when visible notebook editors change and assume the last one shown is active
|
||||
let notebookFocusWatcher: vscode.Disposable | undefined;
|
||||
if (vscode.window.onDidChangeVisibleNotebookEditors) {
|
||||
notebookFocusWatcher = vscode.window.onDidChangeVisibleNotebookEditors(
|
||||
() => {
|
||||
// When visible notebook editors change, the currently focused one is likely the active one
|
||||
const activeNotebookEditor = vscode.window.activeNotebookEditor;
|
||||
if (
|
||||
activeNotebookEditor &&
|
||||
isNotebookFileUri(activeNotebookEditor.notebook.uri)
|
||||
) {
|
||||
addOrMoveToFrontNotebook(this.openFiles, activeNotebookEditor);
|
||||
this.fireWithDebounce();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const selectionWatcher = vscode.window.onDidChangeTextEditorSelection(
|
||||
(event) => {
|
||||
if (isFileUri(event.textEditor.document.uri)) {
|
||||
updateActiveContext(this.openFiles, event.textEditor);
|
||||
this.fireWithDebounce();
|
||||
} else if (isNotebookCellUri(event.textEditor.document.uri)) {
|
||||
// Handle text selections within notebook cells
|
||||
updateNotebookCellSelection(
|
||||
this.openFiles,
|
||||
event.textEditor,
|
||||
event.selections,
|
||||
);
|
||||
if (this.isFileUri(event.textEditor.document.uri)) {
|
||||
this.updateActiveContext(event.textEditor);
|
||||
this.fireWithDebounce();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Add notebook cell selection watcher for .ipynb files if the API is available
|
||||
let notebookCellSelectionWatcher: vscode.Disposable | undefined;
|
||||
if (vscode.window.onDidChangeNotebookEditorSelection) {
|
||||
notebookCellSelectionWatcher =
|
||||
vscode.window.onDidChangeNotebookEditorSelection((event) => {
|
||||
if (isNotebookFileUri(event.notebookEditor.notebook.uri)) {
|
||||
// Ensure the notebook is added to the active list if selected
|
||||
addOrMoveToFrontNotebook(this.openFiles, event.notebookEditor);
|
||||
updateNotebookActiveContext(this.openFiles, event.notebookEditor);
|
||||
this.fireWithDebounce();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const closeWatcher = vscode.workspace.onDidCloseTextDocument((document) => {
|
||||
if (isFileUri(document.uri)) {
|
||||
removeFile(this.openFiles, document.uri);
|
||||
if (this.isFileUri(document.uri)) {
|
||||
this.remove(document.uri);
|
||||
this.fireWithDebounce();
|
||||
}
|
||||
});
|
||||
|
||||
// Add notebook close watcher if the API is available
|
||||
let notebookCloseWatcher: vscode.Disposable | undefined;
|
||||
if (vscode.workspace.onDidCloseNotebookDocument) {
|
||||
notebookCloseWatcher = vscode.workspace.onDidCloseNotebookDocument(
|
||||
(document) => {
|
||||
if (isNotebookFileUri(document.uri)) {
|
||||
removeFile(this.openFiles, document.uri);
|
||||
this.fireWithDebounce();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const deleteWatcher = vscode.workspace.onDidDeleteFiles((event) => {
|
||||
for (const uri of event.files) {
|
||||
if (isFileUri(uri) || isNotebookFileUri(uri)) {
|
||||
removeFile(this.openFiles, uri);
|
||||
if (this.isFileUri(uri)) {
|
||||
this.remove(uri);
|
||||
}
|
||||
}
|
||||
this.fireWithDebounce();
|
||||
@@ -142,12 +59,12 @@ export class OpenFilesManager {
|
||||
|
||||
const renameWatcher = vscode.workspace.onDidRenameFiles((event) => {
|
||||
for (const { oldUri, newUri } of event.files) {
|
||||
if (isFileUri(oldUri) || isNotebookFileUri(oldUri)) {
|
||||
if (isFileUri(newUri) || isNotebookFileUri(newUri)) {
|
||||
renameFile(this.openFiles, oldUri, newUri);
|
||||
if (this.isFileUri(oldUri)) {
|
||||
if (this.isFileUri(newUri)) {
|
||||
this.rename(oldUri, newUri);
|
||||
} else {
|
||||
// The file was renamed to a non-file URI, so we should remove it.
|
||||
removeFile(this.openFiles, oldUri);
|
||||
this.remove(oldUri);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,37 +79,87 @@ export class OpenFilesManager {
|
||||
renameWatcher,
|
||||
);
|
||||
|
||||
// Conditionally add notebook-specific watchers if they were created
|
||||
if (notebookCellSelectionWatcher) {
|
||||
context.subscriptions.push(notebookCellSelectionWatcher);
|
||||
}
|
||||
|
||||
if (notebookCloseWatcher) {
|
||||
context.subscriptions.push(notebookCloseWatcher);
|
||||
}
|
||||
|
||||
if (notebookFocusWatcher) {
|
||||
context.subscriptions.push(notebookFocusWatcher);
|
||||
}
|
||||
|
||||
// Just add current active file on start-up.
|
||||
if (
|
||||
vscode.window.activeTextEditor &&
|
||||
isFileUri(vscode.window.activeTextEditor.document.uri)
|
||||
this.isFileUri(vscode.window.activeTextEditor.document.uri)
|
||||
) {
|
||||
addOrMoveToFront(this.openFiles, vscode.window.activeTextEditor);
|
||||
this.addOrMoveToFront(vscode.window.activeTextEditor);
|
||||
}
|
||||
}
|
||||
|
||||
private isFileUri(uri: vscode.Uri): boolean {
|
||||
return uri.scheme === 'file';
|
||||
}
|
||||
|
||||
private addOrMoveToFront(editor: vscode.TextEditor) {
|
||||
// Deactivate previous active file
|
||||
const currentActive = this.openFiles.find((f) => f.isActive);
|
||||
if (currentActive) {
|
||||
currentActive.isActive = false;
|
||||
currentActive.cursor = undefined;
|
||||
currentActive.selectedText = undefined;
|
||||
}
|
||||
|
||||
// Also add current active notebook if applicable and the API is available
|
||||
if (
|
||||
vscode.window.activeNotebookEditor &&
|
||||
isNotebookFileUri(vscode.window.activeNotebookEditor.notebook.uri)
|
||||
) {
|
||||
addOrMoveToFrontNotebook(
|
||||
this.openFiles,
|
||||
vscode.window.activeNotebookEditor,
|
||||
);
|
||||
// Remove if it exists
|
||||
const index = this.openFiles.findIndex(
|
||||
(f) => f.path === editor.document.uri.fsPath,
|
||||
);
|
||||
if (index !== -1) {
|
||||
this.openFiles.splice(index, 1);
|
||||
}
|
||||
|
||||
// Add to the front as active
|
||||
this.openFiles.unshift({
|
||||
path: editor.document.uri.fsPath,
|
||||
timestamp: Date.now(),
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
// Enforce max length
|
||||
if (this.openFiles.length > MAX_FILES) {
|
||||
this.openFiles.pop();
|
||||
}
|
||||
|
||||
this.updateActiveContext(editor);
|
||||
}
|
||||
|
||||
private remove(uri: vscode.Uri) {
|
||||
const index = this.openFiles.findIndex((f) => f.path === uri.fsPath);
|
||||
if (index !== -1) {
|
||||
this.openFiles.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
private rename(oldUri: vscode.Uri, newUri: vscode.Uri) {
|
||||
const index = this.openFiles.findIndex((f) => f.path === oldUri.fsPath);
|
||||
if (index !== -1) {
|
||||
this.openFiles[index].path = newUri.fsPath;
|
||||
}
|
||||
}
|
||||
|
||||
private updateActiveContext(editor: vscode.TextEditor) {
|
||||
const file = this.openFiles.find(
|
||||
(f) => f.path === editor.document.uri.fsPath,
|
||||
);
|
||||
if (!file || !file.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
file.cursor = editor.selection.active
|
||||
? {
|
||||
line: editor.selection.active.line + 1,
|
||||
character: editor.selection.active.character,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
let selectedText: string | undefined =
|
||||
editor.document.getText(editor.selection) || undefined;
|
||||
if (selectedText && selectedText.length > MAX_SELECTED_TEXT_LENGTH) {
|
||||
selectedText =
|
||||
selectedText.substring(0, MAX_SELECTED_TEXT_LENGTH) + '... [TRUNCATED]';
|
||||
}
|
||||
file.selectedText = selectedText;
|
||||
}
|
||||
|
||||
private fireWithDebounce() {
|
||||
|
||||
@@ -26,7 +26,6 @@ import type {
|
||||
} from '../types/connectionTypes.js';
|
||||
import { AcpFileHandler } from '../services/acpFileHandler.js';
|
||||
import type { ChildProcess } from 'child_process';
|
||||
import { isWindows } from '../utils/platform.js';
|
||||
|
||||
/**
|
||||
* ACP Message Handler Class
|
||||
@@ -48,7 +47,7 @@ export class AcpMessageHandler {
|
||||
sendResponseMessage(child: ChildProcess | null, response: AcpResponse): void {
|
||||
if (child?.stdin) {
|
||||
const jsonString = JSON.stringify(response);
|
||||
const lineEnding = isWindows ? '\r\n' : '\n';
|
||||
const lineEnding = process.platform === 'win32' ? '\r\n' : '\n';
|
||||
child.stdin.write(jsonString + lineEnding);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
|
||||
import { AGENT_METHODS } from '../constants/acpSchema.js';
|
||||
import type { PendingRequest } from '../types/connectionTypes.js';
|
||||
import type { ChildProcess } from 'child_process';
|
||||
import { isWindows } from '../utils/platform.js';
|
||||
|
||||
/**
|
||||
* ACP Session Manager Class
|
||||
@@ -103,7 +102,7 @@ export class AcpSessionManager {
|
||||
): void {
|
||||
if (child?.stdin) {
|
||||
const jsonString = JSON.stringify(message);
|
||||
const lineEnding = isWindows ? '\r\n' : '\n';
|
||||
const lineEnding = process.platform === 'win32' ? '\r\n' : '\n';
|
||||
child.stdin.write(jsonString + lineEnding);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export const MAX_FILES = 10;
|
||||
export const MAX_SELECTED_TEXT_LENGTH = 16384; // 16 KiB limit
|
||||
@@ -1,119 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import type { File } from '@qwen-code/qwen-code-core/src/ide/types.js';
|
||||
import { MAX_FILES, MAX_SELECTED_TEXT_LENGTH } from './constants.js';
|
||||
import {
|
||||
deactivateCurrentActiveFile,
|
||||
enforceMaxFiles,
|
||||
truncateSelectedText,
|
||||
getNotebookUriFromCellUri,
|
||||
} from './utils.js';
|
||||
|
||||
export function addOrMoveToFrontNotebook(
|
||||
openFiles: File[],
|
||||
notebookEditor: vscode.NotebookEditor,
|
||||
) {
|
||||
// Deactivate previous active file
|
||||
deactivateCurrentActiveFile(openFiles);
|
||||
|
||||
// Remove if it exists
|
||||
const index = openFiles.findIndex(
|
||||
(f) => f.path === notebookEditor.notebook.uri.fsPath,
|
||||
);
|
||||
if (index !== -1) {
|
||||
openFiles.splice(index, 1);
|
||||
}
|
||||
|
||||
// Add to the front as active
|
||||
openFiles.unshift({
|
||||
path: notebookEditor.notebook.uri.fsPath,
|
||||
timestamp: Date.now(),
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
// Enforce max length
|
||||
enforceMaxFiles(openFiles, MAX_FILES);
|
||||
|
||||
updateNotebookActiveContext(openFiles, notebookEditor);
|
||||
}
|
||||
|
||||
export function updateNotebookActiveContext(
|
||||
openFiles: File[],
|
||||
notebookEditor: vscode.NotebookEditor,
|
||||
) {
|
||||
const file = openFiles.find(
|
||||
(f) => f.path === notebookEditor.notebook.uri.fsPath,
|
||||
);
|
||||
if (!file || !file.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
// For notebook editors, selections may span multiple cells
|
||||
// We'll gather selected text from all selected cells
|
||||
const selections = notebookEditor.selections;
|
||||
let combinedSelectedText = '';
|
||||
|
||||
for (const selection of selections) {
|
||||
// Process each selected cell range
|
||||
for (let i = selection.start; i < selection.end; i++) {
|
||||
const cell = notebookEditor.notebook.cellAt(i);
|
||||
if (cell && cell.kind === vscode.NotebookCellKind.Code) {
|
||||
// For now, we'll get the full cell content if it's in a selection
|
||||
// TODO: Implement per-cell cursor position and finer-grained selection if needed
|
||||
combinedSelectedText += cell.document.getText() + '\n';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (combinedSelectedText) {
|
||||
combinedSelectedText = combinedSelectedText.trim();
|
||||
file.selectedText = truncateSelectedText(
|
||||
combinedSelectedText,
|
||||
MAX_SELECTED_TEXT_LENGTH,
|
||||
);
|
||||
} else {
|
||||
file.selectedText = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function updateNotebookCellSelection(
|
||||
openFiles: File[],
|
||||
cellEditor: vscode.TextEditor,
|
||||
selections: readonly vscode.Selection[],
|
||||
) {
|
||||
// Find the parent notebook by traversing the URI
|
||||
const notebookUri = getNotebookUriFromCellUri(cellEditor.document.uri);
|
||||
if (!notebookUri) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the corresponding file entry for this notebook
|
||||
const file = openFiles.find((f) => f.path === notebookUri.fsPath);
|
||||
if (!file || !file.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract the selected text from the cell editor
|
||||
let selectedText = '';
|
||||
for (const selection of selections) {
|
||||
const text = cellEditor.document.getText(selection);
|
||||
if (text) {
|
||||
selectedText += text + '\n';
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedText) {
|
||||
selectedText = selectedText.trim();
|
||||
file.selectedText = truncateSelectedText(
|
||||
selectedText,
|
||||
MAX_SELECTED_TEXT_LENGTH,
|
||||
);
|
||||
} else {
|
||||
file.selectedText = undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type * as vscode from 'vscode';
|
||||
import type { File } from '@qwen-code/qwen-code-core/src/ide/types.js';
|
||||
import { MAX_FILES, MAX_SELECTED_TEXT_LENGTH } from './constants.js';
|
||||
import {
|
||||
deactivateCurrentActiveFile,
|
||||
enforceMaxFiles,
|
||||
truncateSelectedText,
|
||||
} from './utils.js';
|
||||
|
||||
export function addOrMoveToFront(openFiles: File[], editor: vscode.TextEditor) {
|
||||
// Deactivate previous active file
|
||||
deactivateCurrentActiveFile(openFiles);
|
||||
|
||||
// Remove if it exists
|
||||
const index = openFiles.findIndex(
|
||||
(f) => f.path === editor.document.uri.fsPath,
|
||||
);
|
||||
if (index !== -1) {
|
||||
openFiles.splice(index, 1);
|
||||
}
|
||||
|
||||
// Add to the front as active
|
||||
openFiles.unshift({
|
||||
path: editor.document.uri.fsPath,
|
||||
timestamp: Date.now(),
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
// Enforce max length
|
||||
enforceMaxFiles(openFiles, MAX_FILES);
|
||||
|
||||
updateActiveContext(openFiles, editor);
|
||||
}
|
||||
|
||||
export function updateActiveContext(
|
||||
openFiles: File[],
|
||||
editor: vscode.TextEditor,
|
||||
) {
|
||||
const file = openFiles.find((f) => f.path === editor.document.uri.fsPath);
|
||||
if (!file || !file.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
file.cursor = editor.selection.active
|
||||
? {
|
||||
line: editor.selection.active.line + 1,
|
||||
character: editor.selection.active.character,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
let selectedText: string | undefined =
|
||||
editor.document.getText(editor.selection) || undefined;
|
||||
selectedText = truncateSelectedText(selectedText, MAX_SELECTED_TEXT_LENGTH);
|
||||
file.selectedText = selectedText;
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import type { File } from '@qwen-code/qwen-code-core/src/ide/types.js';
|
||||
|
||||
export function isFileUri(uri: vscode.Uri): boolean {
|
||||
return uri.scheme === 'file';
|
||||
}
|
||||
|
||||
export function isNotebookFileUri(uri: vscode.Uri): boolean {
|
||||
return uri.scheme === 'file' && uri.path.toLowerCase().endsWith('.ipynb');
|
||||
}
|
||||
|
||||
export function isNotebookCellUri(uri: vscode.Uri): boolean {
|
||||
// Notebook cell URIs have the scheme 'vscode-notebook-cell'
|
||||
return uri.scheme === 'vscode-notebook-cell';
|
||||
}
|
||||
|
||||
export function removeFile(openFiles: File[], uri: vscode.Uri): void {
|
||||
const index = openFiles.findIndex((f) => f.path === uri.fsPath);
|
||||
if (index !== -1) {
|
||||
openFiles.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
export function renameFile(
|
||||
openFiles: File[],
|
||||
oldUri: vscode.Uri,
|
||||
newUri: vscode.Uri,
|
||||
): void {
|
||||
const index = openFiles.findIndex((f) => f.path === oldUri.fsPath);
|
||||
if (index !== -1) {
|
||||
openFiles[index].path = newUri.fsPath;
|
||||
}
|
||||
}
|
||||
|
||||
export function deactivateCurrentActiveFile(openFiles: File[]): void {
|
||||
const currentActive = openFiles.find((f) => f.isActive);
|
||||
if (currentActive) {
|
||||
currentActive.isActive = false;
|
||||
currentActive.cursor = undefined;
|
||||
currentActive.selectedText = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function enforceMaxFiles(openFiles: File[], maxFiles: number): void {
|
||||
if (openFiles.length > maxFiles) {
|
||||
openFiles.pop();
|
||||
}
|
||||
}
|
||||
|
||||
export function truncateSelectedText(
|
||||
selectedText: string | undefined,
|
||||
maxLength: number,
|
||||
): string | undefined {
|
||||
if (!selectedText) {
|
||||
return undefined;
|
||||
}
|
||||
if (selectedText.length > maxLength) {
|
||||
return selectedText.substring(0, maxLength) + '... [TRUNCATED]';
|
||||
}
|
||||
return selectedText;
|
||||
}
|
||||
|
||||
export function getNotebookUriFromCellUri(
|
||||
cellUri: vscode.Uri,
|
||||
): vscode.Uri | null {
|
||||
// Most efficient approach: Check if the currently active notebook editor contains this cell
|
||||
const activeNotebookEditor = vscode.window.activeNotebookEditor;
|
||||
if (
|
||||
activeNotebookEditor &&
|
||||
isNotebookFileUri(activeNotebookEditor.notebook.uri)
|
||||
) {
|
||||
for (let i = 0; i < activeNotebookEditor.notebook.cellCount; i++) {
|
||||
const cell = activeNotebookEditor.notebook.cellAt(i);
|
||||
if (cell.document.uri.toString() === cellUri.toString()) {
|
||||
return activeNotebookEditor.notebook.uri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If not in the active editor, check all visible notebook editors
|
||||
for (const editor of vscode.window.visibleNotebookEditors) {
|
||||
if (
|
||||
editor !== activeNotebookEditor &&
|
||||
isNotebookFileUri(editor.notebook.uri)
|
||||
) {
|
||||
for (let i = 0; i < editor.notebook.cellCount; i++) {
|
||||
const cell = editor.notebook.cellAt(i);
|
||||
if (cell.document.uri.toString() === cellUri.toString()) {
|
||||
return editor.notebook.uri;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/** Whether the current platform is Windows */
|
||||
export const isWindows = process.platform === 'win32';
|
||||
Reference in New Issue
Block a user