Merge branch 'feat/bundle-cli-in-vscode' of https://github.com/QwenLM/qwen-code into feat/vscode-ide-companion-borading

This commit is contained in:
yiliang114
2025-12-13 21:21:53 +08:00
16 changed files with 385 additions and 704 deletions

2
.vscode/launch.json vendored
View File

@@ -27,7 +27,7 @@
"outFiles": [ "outFiles": [
"${workspaceFolder}/packages/vscode-ide-companion/dist/**/*.js" "${workspaceFolder}/packages/vscode-ide-companion/dist/**/*.js"
], ],
"preLaunchTask": "npm: build: vscode-ide-companion" "preLaunchTask": "launch: vscode-ide-companion (copy+build)"
}, },
{ {
"name": "Attach", "name": "Attach",

16
.vscode/tasks.json vendored
View File

@@ -20,6 +20,22 @@
"problemMatcher": [], "problemMatcher": [],
"label": "npm: build: vscode-ide-companion", "label": "npm: build: vscode-ide-companion",
"detail": "npm run build -w packages/vscode-ide-companion" "detail": "npm run build -w packages/vscode-ide-companion"
},
{
"label": "copy: bundled-cli (dev)",
"type": "shell",
"command": "node",
"args": ["packages/vscode-ide-companion/scripts/copy-bundled-cli.js"],
"problemMatcher": []
},
{
"label": "launch: vscode-ide-companion (copy+build)",
"dependsOrder": "sequence",
"dependsOn": [
"copy: bundled-cli (dev)",
"npm: build: vscode-ide-companion"
],
"problemMatcher": []
} }
] ]
} }

View File

@@ -1461,7 +1461,7 @@ describe('SettingsDialog', () => {
context: { context: {
fileFiltering: { fileFiltering: {
respectGitIgnore: false, respectGitIgnore: false,
respectQwemIgnore: true, respectQwenIgnore: true,
enableRecursiveFileSearch: false, enableRecursiveFileSearch: false,
disableFuzzySearch: true, disableFuzzySearch: true,
}, },
@@ -1535,7 +1535,7 @@ describe('SettingsDialog', () => {
loadMemoryFromIncludeDirectories: false, loadMemoryFromIncludeDirectories: false,
fileFiltering: { fileFiltering: {
respectGitIgnore: false, respectGitIgnore: false,
respectQwemIgnore: false, respectQwenIgnore: false,
enableRecursiveFileSearch: false, enableRecursiveFileSearch: false,
disableFuzzySearch: false, disableFuzzySearch: false,
}, },

View File

@@ -14,14 +14,14 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly
│ │ │ │
│ Language Auto (detect from system) │ │ Language Auto (detect from system) │
│ │ │ │
│ Terminal Bell true │
│ │
│ Output Format Text │ │ Output Format Text │
│ │ │ │
│ Hide Window Title false │ │ Hide Window Title false │
│ │ │ │
│ Show Status in Title false │ │ Show Status in Title false │
│ │ │ │
│ Hide Tips false │
│ │
│ ▼ │ │ ▼ │
│ │ │ │
│ │ │ │
@@ -48,14 +48,14 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select
│ │ │ │
│ Language Auto (detect from system) │ │ Language Auto (detect from system) │
│ │ │ │
│ Terminal Bell true │
│ │
│ Output Format Text │ │ Output Format Text │
│ │ │ │
│ Hide Window Title false │ │ Hide Window Title false │
│ │ │ │
│ Show Status in Title false │ │ Show Status in Title false │
│ │ │ │
│ Hide Tips false │
│ │
│ ▼ │ │ ▼ │
│ │ │ │
│ │ │ │
@@ -82,14 +82,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett
│ │ │ │
│ Language Auto (detect from system) │ │ Language Auto (detect from system) │
│ │ │ │
│ Terminal Bell true │
│ │
│ Output Format Text │ │ Output Format Text │
│ │ │ │
│ Hide Window Title false │ │ Hide Window Title false │
│ │ │ │
│ Show Status in Title false │ │ Show Status in Title false │
│ │ │ │
│ Hide Tips false │
│ │
│ ▼ │ │ ▼ │
│ │ │ │
│ │ │ │
@@ -116,14 +116,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin
│ │ │ │
│ Language Auto (detect from system) │ │ Language Auto (detect from system) │
│ │ │ │
│ Terminal Bell true │
│ │
│ Output Format Text │ │ Output Format Text │
│ │ │ │
│ Hide Window Title false* │ │ Hide Window Title false* │
│ │ │ │
│ Show Status in Title false │ │ Show Status in Title false │
│ │ │ │
│ Hide Tips false* │
│ │
│ ▼ │ │ ▼ │
│ │ │ │
│ │ │ │
@@ -150,14 +150,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
│ │ │ │
│ Language Auto (detect from system) │ │ Language Auto (detect from system) │
│ │ │ │
│ Terminal Bell true │
│ │
│ Output Format Text │ │ Output Format Text │
│ │ │ │
│ Hide Window Title false │ │ Hide Window Title false │
│ │ │ │
│ Show Status in Title false │ │ Show Status in Title false │
│ │ │ │
│ Hide Tips false │
│ │
│ ▼ │ │ ▼ │
│ │ │ │
│ │ │ │
@@ -184,14 +184,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
│ │ │ │
│ Language Auto (detect from system) │ │ Language Auto (detect from system) │
│ │ │ │
│ Terminal Bell true │
│ │
│ Output Format Text │ │ Output Format Text │
│ │ │ │
│ Hide Window Title false │ │ Hide Window Title false │
│ │ │ │
│ Show Status in Title false │ │ Show Status in Title false │
│ │ │ │
│ Hide Tips false │
│ │
│ ▼ │ │ ▼ │
│ │ │ │
│ │ │ │
@@ -218,14 +218,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set
│ │ │ │
│ Language Auto (detect from system) │ │ Language Auto (detect from system) │
│ │ │ │
│ Terminal Bell true │
│ │
│ Output Format Text │ │ Output Format Text │
│ │ │ │
│ Hide Window Title false │ │ Hide Window Title false │
│ │ │ │
│ Show Status in Title false │ │ Show Status in Title false │
│ │ │ │
│ Hide Tips false │
│ │
│ ▼ │ │ ▼ │
│ │ │ │
│ │ │ │
@@ -252,14 +252,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and
│ │ │ │
│ Language Auto (detect from system) │ │ Language Auto (detect from system) │
│ │ │ │
│ Terminal Bell true │
│ │
│ Output Format Text │ │ Output Format Text │
│ │ │ │
│ Hide Window Title false* │ │ Hide Window Title false* │
│ │ │ │
│ Show Status in Title false │ │ Show Status in Title false │
│ │ │ │
│ Hide Tips false │
│ │
│ ▼ │ │ ▼ │
│ │ │ │
│ │ │ │
@@ -286,14 +286,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security
│ │ │ │
│ Language Auto (detect from system) │ │ Language Auto (detect from system) │
│ │ │ │
│ Terminal Bell true │
│ │
│ Output Format Text │ │ Output Format Text │
│ │ │ │
│ Hide Window Title false │ │ Hide Window Title false │
│ │ │ │
│ Show Status in Title false │ │ Show Status in Title false │
│ │ │ │
│ Hide Tips false │
│ │
│ ▼ │ │ ▼ │
│ │ │ │
│ │ │ │
@@ -320,14 +320,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se
│ │ │ │
│ Language Auto (detect from system) │ │ Language Auto (detect from system) │
│ │ │ │
│ Terminal Bell true │
│ │
│ Output Format Text │ │ Output Format Text │
│ │ │ │
│ Hide Window Title true* │ │ Hide Window Title true* │
│ │ │ │
│ Show Status in Title false │ │ Show Status in Title false │
│ │ │ │
│ Hide Tips true* │
│ │
│ ▼ │ │ ▼ │
│ │ │ │
│ │ │ │

View File

@@ -1,5 +1,6 @@
** **
!dist/ !dist/
!dist/**
../ ../
../../ ../../
!LICENSE !LICENSE

View File

@@ -113,7 +113,7 @@
"main": "./dist/extension.cjs", "main": "./dist/extension.cjs",
"type": "module", "type": "module",
"scripts": { "scripts": {
"prepackage": "npm run generate:notices && npm run check-types && npm run lint && npm run build:prod", "prepackage": "node ./scripts/prepackage.js",
"build": "npm run build:dev", "build": "npm run build:dev",
"build:dev": "npm run check-types && npm run lint && node esbuild.js", "build:dev": "npm run check-types && npm run lint && node esbuild.js",
"build:prod": "node esbuild.js --production", "build:prod": "node esbuild.js --production",

View File

@@ -0,0 +1,67 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Copy the already-built root dist/ folder into the extension dist/qwen-cli/.
*
* Assumes repoRoot/dist already exists (e.g. produced by `npm run bundle` and
* optionally `npm run prepare:package`).
*/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import fs from 'node:fs/promises';
import { existsSync } from 'node:fs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const extensionRoot = path.resolve(__dirname, '..');
const repoRoot = path.resolve(extensionRoot, '..', '..');
const rootDistDir = path.join(repoRoot, 'dist');
const extensionDistDir = path.join(extensionRoot, 'dist');
const bundledCliDir = path.join(extensionDistDir, 'qwen-cli');
async function main() {
const cliJs = path.join(rootDistDir, 'cli.js');
const vendorDir = path.join(rootDistDir, 'vendor');
if (!existsSync(cliJs) || !existsSync(vendorDir)) {
throw new Error(
`[copy-bundled-cli] Missing root dist artifacts. Expected:\n- ${cliJs}\n- ${vendorDir}\n\nRun root "npm run bundle" first.`,
);
}
await fs.mkdir(extensionDistDir, { recursive: true });
const existingNodeModules = path.join(bundledCliDir, 'node_modules');
const tmpNodeModules = path.join(
extensionDistDir,
'qwen-cli.node_modules.tmp',
);
const keepNodeModules = existsSync(existingNodeModules);
// Preserve destination node_modules if it exists (e.g. after packaging install).
if (keepNodeModules) {
await fs.rm(tmpNodeModules, { recursive: true, force: true });
await fs.rename(existingNodeModules, tmpNodeModules);
}
await fs.rm(bundledCliDir, { recursive: true, force: true });
await fs.mkdir(bundledCliDir, { recursive: true });
await fs.cp(rootDistDir, bundledCliDir, { recursive: true });
if (keepNodeModules) {
await fs.rename(tmpNodeModules, existingNodeModules);
}
console.log(`[copy-bundled-cli] Copied ${rootDistDir} -> ${bundledCliDir}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,98 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* VS Code extension packaging orchestration.
*
* We bundle the CLI into the extension so users don't need a global install.
* To match the published CLI layout, we need to:
* - build root bundle (dist/cli.js + vendor/ + sandbox profiles)
* - run root prepare:package (dist/package.json + locales + README/LICENSE)
* - install production deps into root dist/ (dist/node_modules) so runtime deps
* like optional node-pty are present inside the VSIX payload.
*
* Then we generate notices and build the extension.
*/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const extensionRoot = path.resolve(__dirname, '..');
const repoRoot = path.resolve(extensionRoot, '..', '..');
const bundledCliDir = path.join(extensionRoot, 'dist', 'qwen-cli');
function npmBin() {
return process.platform === 'win32' ? 'npm.cmd' : 'npm';
}
function run(cmd, args, opts = {}) {
const res = spawnSync(cmd, args, {
stdio: 'inherit',
shell: false,
...opts,
});
if (res.error) {
throw res.error;
}
if (typeof res.status === 'number' && res.status !== 0) {
throw new Error(
`Command failed (${res.status}): ${cmd} ${args.map((a) => JSON.stringify(a)).join(' ')}`,
);
}
}
function main() {
const npm = npmBin();
console.log('[prepackage] Bundling root CLI...');
run(npm, ['--prefix', repoRoot, 'run', 'bundle'], { cwd: repoRoot });
console.log('[prepackage] Preparing root dist/ package metadata...');
run(npm, ['--prefix', repoRoot, 'run', 'prepare:package'], { cwd: repoRoot });
console.log('[prepackage] Generating notices...');
run(npm, ['run', 'generate:notices'], { cwd: extensionRoot });
console.log('[prepackage] Typechecking...');
run(npm, ['run', 'check-types'], { cwd: extensionRoot });
console.log('[prepackage] Linting...');
run(npm, ['run', 'lint'], { cwd: extensionRoot });
console.log('[prepackage] Building extension (production)...');
run(npm, ['run', 'build:prod'], { cwd: extensionRoot });
console.log('[prepackage] Copying bundled CLI dist/ into extension...');
run(
process.execPath,
[path.join(extensionRoot, 'scripts', 'copy-bundled-cli.js')],
{
cwd: extensionRoot,
},
);
console.log(
'[prepackage] Installing production deps into extension dist/qwen-cli...',
);
run(
npm,
[
'--prefix',
bundledCliDir,
'install',
'--omit=dev',
'--no-audit',
'--no-fund',
],
{ cwd: bundledCliDir },
);
}
main();

View File

@@ -1,58 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { CliFeatureFlags, CliVersionInfo } from './cliManager.js';
export class CliContextManager {
private static instance: CliContextManager;
private currentVersionInfo: CliVersionInfo | null = null;
private constructor() {}
/**
* Get singleton instance
*/
static getInstance(): CliContextManager {
if (!CliContextManager.instance) {
CliContextManager.instance = new CliContextManager();
}
return CliContextManager.instance;
}
/**
* Set current CLI version information
*
* @param versionInfo - CLI version information
*/
setCurrentVersionInfo(versionInfo: CliVersionInfo): void {
this.currentVersionInfo = versionInfo;
}
/**
* Get current CLI feature flags
*
* @returns Current CLI feature flags or default flags if not set
*/
getCurrentFeatures(): CliFeatureFlags {
if (this.currentVersionInfo) {
return this.currentVersionInfo.features;
}
// Return default feature flags (all disabled)
return {
supportsSessionList: false,
supportsSessionLoad: false,
};
}
supportsSessionList(): boolean {
return this.getCurrentFeatures().supportsSessionList;
}
supportsSessionLoad(): boolean {
return this.getCurrentFeatures().supportsSessionLoad;
}
}

View File

@@ -1,225 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as vscode from 'vscode';
import { CliManager } from './cliManager.js';
/**
* CLI Detection and Installation Handler
* Responsible for detecting, installing, and prompting for Qwen CLI
*/
export class CliInstaller {
/**
* Check CLI installation status and send results to WebView
* @param sendToWebView Callback function to send messages to WebView
*/
static async checkInstallation(
sendToWebView: (message: unknown) => void,
): Promise<void> {
try {
const result = await CliManager.detectQwenCli();
sendToWebView({
type: 'cliDetectionResult',
data: {
isInstalled: result.isInstalled,
cliPath: result.cliPath,
version: result.version,
error: result.error,
installInstructions: result.isInstalled
? undefined
: CliManager.getInstallationInstructions(),
},
});
if (!result.isInstalled) {
console.log('[CliInstaller] Qwen CLI not detected:', result.error);
} else {
console.log(
'[CliInstaller] Qwen CLI detected:',
result.cliPath,
result.version,
);
}
} catch (error) {
console.error('[CliInstaller] CLI detection error:', error);
}
}
/**
* Prompt user to install CLI
* Display warning message with installation options
*/
static async promptInstallation(): Promise<void> {
const selection = await vscode.window.showWarningMessage(
'Qwen Code CLI is not installed. You can browse conversation history, but cannot send new messages.',
'Install Now',
'View Documentation',
'Remind Me Later',
);
if (selection === 'Install Now') {
await this.install();
} else if (selection === 'View Documentation') {
vscode.env.openExternal(
vscode.Uri.parse('https://github.com/QwenLM/qwen-code#installation'),
);
}
}
/**
* Install Qwen CLI
* Install global CLI package via npm
*/
static async install(): Promise<void> {
try {
// Show progress notification
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: 'Installing Qwen Code CLI',
cancellable: false,
},
async (progress) => {
progress.report({
message: 'Running: npm install -g @qwen-code/qwen-code@latest',
});
const { exec } = await import('child_process');
const { promisify } = await import('util');
const execAsync = promisify(exec);
try {
// Use NVM environment to ensure we get the same Node.js version
// as when they run 'node -v' in terminal
// Fallback chain: default alias -> node alias -> current version
const installCommand =
process.platform === 'win32'
? 'npm install -g @qwen-code/qwen-code@latest'
: 'source ~/.nvm/nvm.sh 2>/dev/null && (nvm use default 2>/dev/null || nvm use node 2>/dev/null || nvm use 2>/dev/null); npm install -g @qwen-code/qwen-code@latest';
console.log(
'[CliInstaller] Installing with command:',
installCommand,
);
console.log(
'[CliInstaller] Current process PATH:',
process.env['PATH'],
);
// Also log Node.js version being used by VS Code
console.log(
'[CliInstaller] VS Code Node.js version:',
process.version,
);
console.log(
'[CliInstaller] VS Code Node.js execPath:',
process.execPath,
);
const { stdout, stderr } = await execAsync(
installCommand,
{
timeout: 120000,
shell: process.platform === 'win32' ? undefined : '/bin/bash',
}, // 2 minutes timeout
);
console.log('[CliInstaller] Installation output:', stdout);
if (stderr) {
console.warn('[CliInstaller] Installation stderr:', stderr);
}
// Clear cache and recheck
CliManager.clearCache();
const detection = await CliManager.detectQwenCli();
if (detection.isInstalled) {
vscode.window
.showInformationMessage(
`✅ Qwen Code CLI installed successfully! Version: ${detection.version}`,
'Reload Window',
)
.then((selection) => {
if (selection === 'Reload Window') {
vscode.commands.executeCommand(
'workbench.action.reloadWindow',
);
}
});
} else {
throw new Error(
'Installation completed but CLI still not detected',
);
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error('[CliInstaller] Installation failed:', errorMessage);
console.error('[CliInstaller] Error stack:', error);
// Provide specific guidance for permission errors
let userFriendlyMessage = `Failed to install Qwen Code CLI: ${errorMessage}`;
if (
errorMessage.includes('EACCES') ||
errorMessage.includes('Permission denied')
) {
userFriendlyMessage += `\n\nThis is likely due to permission issues. Possible solutions:
\n1. Reinstall without sudo: npm install -g @qwen-code/qwen-code@latest
\n2. Fix npm permissions: sudo chown -R $(whoami) $(npm config get prefix)/{lib/node_modules,bin,share}
\n3. Use nvm for Node.js version management to avoid permission issues
\n4. Configure npm to use a different directory: npm config set prefix ~/.npm-global`;
}
vscode.window
.showErrorMessage(
userFriendlyMessage,
'Try Manual Installation',
'View Documentation',
)
.then((selection) => {
if (selection === 'Try Manual Installation') {
const terminal = vscode.window.createTerminal(
'Qwen Code Installation',
);
terminal.show();
// Provide different installation commands based on error type
if (
errorMessage.includes('EACCES') ||
errorMessage.includes('Permission denied')
) {
terminal.sendText('# Try installing without sudo:');
terminal.sendText(
'npm install -g @qwen-code/qwen-code@latest',
);
terminal.sendText('');
terminal.sendText('# Or fix npm permissions:');
terminal.sendText(
'sudo chown -R $(whoami) $(npm config get prefix)/{lib/node_modules,bin,share}',
);
} else {
terminal.sendText(
'npm install -g @qwen-code/qwen-code@latest',
);
}
} else if (selection === 'View Documentation') {
vscode.env.openExternal(
vscode.Uri.parse(
'https://github.com/QwenLM/qwen-code#installation',
),
);
}
});
}
},
);
} catch (error) {
console.error('[CliInstaller] Install CLI error:', error);
}
}
}

View File

@@ -1,128 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { statSync } from 'fs';
export interface CliPathDetectionResult {
path: string | null;
error?: string;
}
/**
* Determine the correct Node.js executable path for a given CLI installation
* Handles various Node.js version managers (nvm, n, manual installations)
*
* @param cliPath - Path to the CLI executable
* @returns Path to the Node.js executable, or null if not found
*/
export function determineNodePathForCli(
cliPath: string,
): CliPathDetectionResult {
// Common patterns for Node.js installations
const nodePathPatterns = [
// NVM pattern: /Users/user/.nvm/versions/node/vXX.XX.X/bin/qwen -> /Users/user/.nvm/versions/node/vXX.XX.X/bin/node
cliPath.replace(/\/bin\/qwen$/, '/bin/node'),
// N pattern: /Users/user/n/bin/qwen -> /Users/user/n/bin/node
cliPath.replace(/\/bin\/qwen$/, '/bin/node'),
// Manual installation pattern: /usr/local/bin/qwen -> /usr/local/bin/node
cliPath.replace(/\/qwen$/, '/node'),
// Alternative pattern: /opt/nodejs/bin/qwen -> /opt/nodejs/bin/node
cliPath.replace(/\/bin\/qwen$/, '/bin/node'),
];
// Check each pattern
for (const nodePath of nodePathPatterns) {
try {
const stats = statSync(nodePath);
if (stats.isFile()) {
// Verify it's executable
if (stats.mode & 0o111) {
console.log(`[CLI] Found Node.js executable for CLI at: ${nodePath}`);
return { path: nodePath };
} else {
console.log(`[CLI] Node.js found at ${nodePath} but not executable`);
return {
path: null,
error: `Node.js found at ${nodePath} but not executable. You may need to fix file permissions or reinstall the CLI.`,
};
}
}
} catch (error) {
// Differentiate between error types
if (error instanceof Error) {
if ('code' in error && error.code === 'EACCES') {
console.log(`[CLI] Permission denied accessing ${nodePath}`);
return {
path: null,
error: `Permission denied accessing ${nodePath}. The CLI may have been installed with sudo privileges. Try reinstalling without sudo or adjusting file permissions.`,
};
} else if ('code' in error && error.code === 'ENOENT') {
// File not found, continue to next pattern
continue;
} else {
console.log(`[CLI] Error accessing ${nodePath}: ${error.message}`);
return {
path: null,
error: `Error accessing Node.js at ${nodePath}: ${error.message}`,
};
}
}
}
}
// Try to find node in the same directory as the CLI
const cliDir = cliPath.substring(0, cliPath.lastIndexOf('/'));
const potentialNodePaths = [`${cliDir}/node`, `${cliDir}/bin/node`];
for (const nodePath of potentialNodePaths) {
try {
const stats = statSync(nodePath);
if (stats.isFile()) {
if (stats.mode & 0o111) {
console.log(
`[CLI] Found Node.js executable in CLI directory at: ${nodePath}`,
);
return { path: nodePath };
} else {
console.log(`[CLI] Node.js found at ${nodePath} but not executable`);
return {
path: null,
error: `Node.js found at ${nodePath} but not executable. You may need to fix file permissions or reinstall the CLI.`,
};
}
}
} catch (error) {
// Differentiate between error types
if (error instanceof Error) {
if ('code' in error && error.code === 'EACCES') {
console.log(`[CLI] Permission denied accessing ${nodePath}`);
return {
path: null,
error: `Permission denied accessing ${nodePath}. The CLI may have been installed with sudo privileges. Try reinstalling without sudo or adjusting file permissions.`,
};
} else if ('code' in error && error.code === 'ENOENT') {
// File not found, continue
continue;
} else {
console.log(`[CLI] Error accessing ${nodePath}: ${error.message}`);
return {
path: null,
error: `Error accessing Node.js at ${nodePath}: ${error.message}`,
};
}
}
}
}
console.log(`[CLI] Could not determine Node.js path for CLI: ${cliPath}`);
return {
path: null,
error: `Could not find Node.js executable for CLI at ${cliPath}. Please verify the CLI installation.`,
};
}

View File

@@ -292,7 +292,14 @@ export async function activate(context: vscode.ExtensionContext) {
} }
if (selectedFolder) { if (selectedFolder) {
const qwenCmd = 'qwen'; const cliEntry = vscode.Uri.joinPath(
context.extensionUri,
'dist',
'qwen-cli',
'cli.js',
).fsPath;
const quote = (s: string) => `"${s.replaceAll('"', '\\"')}"`;
const qwenCmd = `${quote(process.execPath)} ${quote(cliEntry)}`;
const terminal = vscode.window.createTerminal({ const terminal = vscode.window.createTerminal({
name: `Qwen Code (${selectedFolder.name})`, name: `Qwen Code (${selectedFolder.name})`,
cwd: selectedFolder.uri.fsPath, cwd: selectedFolder.uri.fsPath,

View File

@@ -21,7 +21,7 @@ import type {
} from '../types/connectionTypes.js'; } from '../types/connectionTypes.js';
import { AcpMessageHandler } from './acpMessageHandler.js'; import { AcpMessageHandler } from './acpMessageHandler.js';
import { AcpSessionManager } from './acpSessionManager.js'; import { AcpSessionManager } from './acpSessionManager.js';
import { determineNodePathForCli } from '../cli/cliPathDetector.js'; import * as fs from 'node:fs';
/** /**
* ACP Connection Handler for VSCode Extension * ACP Connection Handler for VSCode Extension
@@ -57,12 +57,12 @@ export class AcpConnection {
/** /**
* Connect to Qwen ACP * Connect to Qwen ACP
* *
* @param cliPath - CLI path * @param cliEntryPath - Path to the bundled CLI entrypoint (cli.js)
* @param workingDir - Working directory * @param workingDir - Working directory
* @param extraArgs - Extra command line arguments * @param extraArgs - Extra command line arguments
*/ */
async connect( async connect(
cliPath: string, cliEntryPath: string,
workingDir: string = process.cwd(), workingDir: string = process.cwd(),
extraArgs: string[] = [], extraArgs: string[] = [],
): Promise<void> { ): Promise<void> {
@@ -72,7 +72,6 @@ export class AcpConnection {
this.workingDir = workingDir; this.workingDir = workingDir;
const isWindows = process.platform === 'win32';
const env = { ...process.env }; const env = { ...process.env };
// If proxy is configured in extraArgs, also set it as environment variable // If proxy is configured in extraArgs, also set it as environment variable
@@ -91,48 +90,20 @@ export class AcpConnection {
env['https_proxy'] = proxyUrl; env['https_proxy'] = proxyUrl;
} }
let spawnCommand: string; // Always run the bundled CLI using the VS Code extension host's Node runtime.
let spawnArgs: string[]; // This avoids PATH/NVM/global install problems and ensures deterministic behavior.
const spawnCommand: string = process.execPath;
const spawnArgs: string[] = [
cliEntryPath,
'--experimental-acp',
'--channel=VSCode',
...extraArgs,
];
if (cliPath.startsWith('npx ')) { if (!fs.existsSync(cliEntryPath)) {
const parts = cliPath.split(' '); throw new Error(
spawnCommand = isWindows ? 'npx.cmd' : 'npx'; `Bundled Qwen CLI entry not found at ${cliEntryPath}. The extension may not have been packaged correctly.`,
spawnArgs = [ );
...parts.slice(1),
'--experimental-acp',
'--channel=VSCode',
...extraArgs,
];
} else {
// For qwen CLI, ensure we use the correct Node.js version
// Handle various Node.js version managers (nvm, n, manual installations)
if (cliPath.includes('/qwen') && !isWindows) {
// Try to determine the correct node executable for this qwen installation
const nodePathResult = determineNodePathForCli(cliPath);
if (nodePathResult.path) {
spawnCommand = nodePathResult.path;
spawnArgs = [
cliPath,
'--experimental-acp',
'--channel=VSCode',
...extraArgs,
];
} else {
// Fallback to direct execution
spawnCommand = cliPath;
spawnArgs = ['--experimental-acp', '--channel=VSCode', ...extraArgs];
// Log any error for debugging
if (nodePathResult.error) {
console.warn(
`[ACP] Node.js path detection warning: ${nodePathResult.error}`,
);
}
}
} else {
spawnCommand = cliPath;
spawnArgs = ['--experimental-acp', '--channel=VSCode', ...extraArgs];
}
} }
console.log('[ACP] Spawning command:', spawnCommand, spawnArgs.join(' ')); console.log('[ACP] Spawning command:', spawnCommand, spawnArgs.join(' '));
@@ -141,7 +112,8 @@ export class AcpConnection {
cwd: workingDir, cwd: workingDir,
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
env, env,
shell: isWindows, // We spawn node directly; no shell needed (and shell quoting can break paths).
shell: false,
}; };
this.child = spawn(spawnCommand, spawnArgs, options); this.child = spawn(spawnCommand, spawnArgs, options);

View File

@@ -23,9 +23,7 @@ import {
type QwenConnectionResult, type QwenConnectionResult,
} from '../services/qwenConnectionHandler.js'; } from '../services/qwenConnectionHandler.js';
import { QwenSessionUpdateHandler } from './qwenSessionUpdateHandler.js'; import { QwenSessionUpdateHandler } from './qwenSessionUpdateHandler.js';
import { CliContextManager } from '../cli/cliContextManager.js';
import { authMethod } from '../types/acpTypes.js'; import { authMethod } from '../types/acpTypes.js';
import { MIN_CLI_VERSION_FOR_SESSION_METHODS } from '../cli/cliManager.js';
import { isAuthenticationRequiredError } from '../utils/authErrors.js'; import { isAuthenticationRequiredError } from '../utils/authErrors.js';
import { handleAuthenticateUpdate } from '../utils/authNotificationHandler.js'; import { handleAuthenticateUpdate } from '../utils/authNotificationHandler.js';
@@ -189,11 +187,11 @@ export class QwenAgentManager {
* Connect to Qwen service * Connect to Qwen service
* *
* @param workingDir - Working directory * @param workingDir - Working directory
* @param cliPath - CLI path (optional, if provided will override the path in configuration) * @param cliEntryPath - Path to bundled CLI entrypoint (cli.js)
*/ */
async connect( async connect(
workingDir: string, workingDir: string,
_cliPath?: string, cliEntryPath: string,
options?: AgentConnectOptions, options?: AgentConnectOptions,
): Promise<QwenConnectionResult> { ): Promise<QwenConnectionResult> {
this.currentWorkingDir = workingDir; this.currentWorkingDir = workingDir;
@@ -201,7 +199,7 @@ export class QwenAgentManager {
this.connection, this.connection,
this.sessionReader, this.sessionReader,
workingDir, workingDir,
_cliPath, cliEntryPath,
options, options,
); );
} }
@@ -284,71 +282,51 @@ export class QwenAgentManager {
'[QwenAgentManager] Getting session list with version-aware strategy', '[QwenAgentManager] Getting session list with version-aware strategy',
); );
// Check if CLI supports session/list method // Prefer ACP method first; fall back to file system if it fails for any reason.
const cliContextManager = CliContextManager.getInstance(); try {
const supportsSessionList = cliContextManager.supportsSessionList(); console.log('[QwenAgentManager] Attempting to get session list via ACP');
const response = await this.connection.listSessions();
console.log('[QwenAgentManager] ACP session list response:', response);
console.log( // sendRequest resolves with the JSON-RPC "result" directly
'[QwenAgentManager] CLI supports session/list:', // Newer CLI returns an object: { items: [...], nextCursor?, hasMore }
supportsSessionList, // Older prototypes might return an array. Support both.
); const res: unknown = response;
let items: Array<Record<string, unknown>> = [];
// Try ACP method first if supported if (Array.isArray(res)) {
if (supportsSessionList) { items = res as Array<Record<string, unknown>>;
try { } else if (res && typeof res === 'object' && 'items' in res) {
console.log( const itemsValue = (res as { items?: unknown }).items;
'[QwenAgentManager] Attempting to get session list via ACP method', items = Array.isArray(itemsValue)
); ? (itemsValue as Array<Record<string, unknown>>)
const response = await this.connection.listSessions(); : [];
console.log('[QwenAgentManager] ACP session list response:', response);
// sendRequest resolves with the JSON-RPC "result" directly
// Newer CLI returns an object: { items: [...], nextCursor?, hasMore }
// Older prototypes might return an array. Support both.
const res: unknown = response;
let items: Array<Record<string, unknown>> = [];
// Note: AcpSessionManager resolves `sendRequest` with the JSON-RPC
// "result" directly (not the full AcpResponse). Treat it as unknown
// and carefully narrow before accessing `items` to satisfy strict TS.
if (res && typeof res === 'object' && 'items' in res) {
const itemsValue = (res as { items?: unknown }).items;
items = Array.isArray(itemsValue)
? (itemsValue as Array<Record<string, unknown>>)
: [];
}
console.log(
'[QwenAgentManager] Sessions retrieved via ACP:',
res,
items.length,
);
if (items.length > 0) {
const sessions = items.map((item) => ({
id: item.sessionId || item.id,
sessionId: item.sessionId || item.id,
title: item.title || item.name || item.prompt || 'Untitled Session',
name: item.title || item.name || item.prompt || 'Untitled Session',
startTime: item.startTime,
lastUpdated: item.mtime || item.lastUpdated,
messageCount: item.messageCount || 0,
projectHash: item.projectHash,
filePath: item.filePath,
cwd: item.cwd,
}));
console.log(
'[QwenAgentManager] Sessions retrieved via ACP:',
sessions.length,
);
return sessions;
}
} catch (error) {
console.warn(
'[QwenAgentManager] ACP session list failed, falling back to file system method:',
error,
);
} }
console.log('[QwenAgentManager] Sessions retrieved via ACP:', {
count: items.length,
});
if (items.length > 0) {
const sessions = items.map((item) => ({
id: item.sessionId || item.id,
sessionId: item.sessionId || item.id,
title: item.title || item.name || item.prompt || 'Untitled Session',
name: item.title || item.name || item.prompt || 'Untitled Session',
startTime: item.startTime,
lastUpdated: item.mtime || item.lastUpdated,
messageCount: item.messageCount || 0,
projectHash: item.projectHash,
filePath: item.filePath,
cwd: item.cwd,
}));
return sessions;
}
} catch (error) {
console.warn(
'[QwenAgentManager] ACP session list failed, falling back to file system method:',
error,
);
} }
// Always fall back to file system method // Always fall back to file system method
@@ -404,63 +382,52 @@ export class QwenAgentManager {
}> { }> {
const size = params?.size ?? 20; const size = params?.size ?? 20;
const cursor = params?.cursor; const cursor = params?.cursor;
try {
const response = await this.connection.listSessions({
size,
...(cursor !== undefined ? { cursor } : {}),
});
// sendRequest resolves with the JSON-RPC "result" directly
const res: unknown = response;
let items: Array<Record<string, unknown>> = [];
const cliContextManager = CliContextManager.getInstance(); if (Array.isArray(res)) {
const supportsSessionList = cliContextManager.supportsSessionList(); items = res;
} else if (typeof res === 'object' && res !== null && 'items' in res) {
if (supportsSessionList) { const responseObject = res as {
try { items?: Array<Record<string, unknown>>;
const response = await this.connection.listSessions({ };
size, items = Array.isArray(responseObject.items) ? responseObject.items : [];
...(cursor !== undefined ? { cursor } : {}),
});
// sendRequest resolves with the JSON-RPC "result" directly
const res: unknown = response;
let items: Array<Record<string, unknown>> = [];
if (Array.isArray(res)) {
items = res;
} else if (typeof res === 'object' && res !== null && 'items' in res) {
const responseObject = res as {
items?: Array<Record<string, unknown>>;
};
items = Array.isArray(responseObject.items)
? responseObject.items
: [];
}
const mapped = items.map((item) => ({
id: item.sessionId || item.id,
sessionId: item.sessionId || item.id,
title: item.title || item.name || item.prompt || 'Untitled Session',
name: item.title || item.name || item.prompt || 'Untitled Session',
startTime: item.startTime,
lastUpdated: item.mtime || item.lastUpdated,
messageCount: item.messageCount || 0,
projectHash: item.projectHash,
filePath: item.filePath,
cwd: item.cwd,
}));
const nextCursor: number | undefined =
typeof res === 'object' && res !== null && 'nextCursor' in res
? typeof res.nextCursor === 'number'
? res.nextCursor
: undefined
: undefined;
const hasMore: boolean =
typeof res === 'object' && res !== null && 'hasMore' in res
? Boolean(res.hasMore)
: false;
return { sessions: mapped, nextCursor, hasMore };
} catch (error) {
console.warn(
'[QwenAgentManager] Paged ACP session list failed:',
error,
);
// fall through to file system
} }
const mapped = items.map((item) => ({
id: item.sessionId || item.id,
sessionId: item.sessionId || item.id,
title: item.title || item.name || item.prompt || 'Untitled Session',
name: item.title || item.name || item.prompt || 'Untitled Session',
startTime: item.startTime,
lastUpdated: item.mtime || item.lastUpdated,
messageCount: item.messageCount || 0,
projectHash: item.projectHash,
filePath: item.filePath,
cwd: item.cwd,
}));
const nextCursor: number | undefined =
typeof res === 'object' && res !== null && 'nextCursor' in res
? typeof res.nextCursor === 'number'
? res.nextCursor
: undefined
: undefined;
const hasMore: boolean =
typeof res === 'object' && res !== null && 'hasMore' in res
? Boolean(res.hasMore)
: false;
return { sessions: mapped, nextCursor, hasMore };
} catch (error) {
console.warn('[QwenAgentManager] Paged ACP session list failed:', error);
// fall through to file system
} }
// Fallback: file system for current project only (to match ACP semantics) // Fallback: file system for current project only (to match ACP semantics)
@@ -510,31 +477,28 @@ export class QwenAgentManager {
async getSessionMessages(sessionId: string): Promise<ChatMessage[]> { async getSessionMessages(sessionId: string): Promise<ChatMessage[]> {
try { try {
// Prefer reading CLI's JSONL if we can find filePath from session/list // Prefer reading CLI's JSONL if we can find filePath from session/list
const cliContextManager = CliContextManager.getInstance(); try {
if (cliContextManager.supportsSessionList()) { const list = await this.getSessionList();
try { const item = list.find(
const list = await this.getSessionList(); (s) => s.sessionId === sessionId || s.id === sessionId,
const item = list.find( );
(s) => s.sessionId === sessionId || s.id === sessionId, console.log(
); '[QwenAgentManager] Session list item for filePath lookup:',
console.log( item,
'[QwenAgentManager] Session list item for filePath lookup:', );
item, if (
); typeof item === 'object' &&
if ( item !== null &&
typeof item === 'object' && 'filePath' in item &&
item !== null && typeof item.filePath === 'string'
'filePath' in item && ) {
typeof item.filePath === 'string' const messages = await this.readJsonlMessages(item.filePath);
) { // Even if messages array is empty, we should return it rather than falling back
const messages = await this.readJsonlMessages(item.filePath); // This ensures we don't accidentally show messages from a different session format
// Even if messages array is empty, we should return it rather than falling back return messages;
// This ensures we don't accidentally show messages from a different session format
return messages;
}
} catch (e) {
console.warn('[QwenAgentManager] JSONL read path lookup failed:', e);
} }
} catch (e) {
console.warn('[QwenAgentManager] JSONL read path lookup failed:', e);
} }
// Fallback: legacy JSON session files // Fallback: legacy JSON session files
@@ -938,16 +902,6 @@ export class QwenAgentManager {
sessionId: string, sessionId: string,
cwdOverride?: string, cwdOverride?: string,
): Promise<unknown> { ): Promise<unknown> {
// Check if CLI supports session/load method
const cliContextManager = CliContextManager.getInstance();
const supportsSessionLoad = cliContextManager.supportsSessionLoad();
if (!supportsSessionLoad) {
throw new Error(
`CLI version does not support session/load method. Please upgrade to version ${MIN_CLI_VERSION_FOR_SESSION_METHODS} or later.`,
);
}
try { try {
// Route upcoming session/update messages as discrete messages for replay // Route upcoming session/update messages as discrete messages for replay
this.rehydratingSessionId = sessionId; this.rehydratingSessionId = sessionId;
@@ -1021,32 +975,18 @@ export class QwenAgentManager {
sessionId, sessionId,
); );
// Check if CLI supports session/load method // Prefer ACP session/load first; fall back to file system on failure.
const cliContextManager = CliContextManager.getInstance(); try {
const supportsSessionLoad = cliContextManager.supportsSessionLoad(); console.log('[QwenAgentManager] Attempting to load session via ACP');
await this.loadSessionViaAcp(sessionId);
console.log( console.log('[QwenAgentManager] Session loaded successfully via ACP');
'[QwenAgentManager] CLI supports session/load:', // After loading via ACP, we still need to get messages from file system.
supportsSessionLoad, // In future, we might get them directly from the ACP response.
); } catch (error) {
console.warn(
// Try ACP method first if supported '[QwenAgentManager] ACP session load failed, falling back to file system method:',
if (supportsSessionLoad) { error,
try { );
console.log(
'[QwenAgentManager] Attempting to load session via ACP method',
);
await this.loadSessionViaAcp(sessionId);
console.log('[QwenAgentManager] Session loaded successfully via ACP');
// After loading via ACP, we still need to get messages from file system
// In future, we might get them directly from the ACP response
} catch (error) {
console.warn(
'[QwenAgentManager] ACP session load failed, falling back to file system method:',
error,
);
}
} }
// Always fall back to file system method // Always fall back to file system method

View File

@@ -12,7 +12,6 @@
import type { AcpConnection } from './acpConnection.js'; import type { AcpConnection } from './acpConnection.js';
import type { QwenSessionReader } from '../services/qwenSessionReader.js'; import type { QwenSessionReader } from '../services/qwenSessionReader.js';
import { CliManager } from '../cli/cliManager.js';
import { authMethod } from '../types/acpTypes.js'; import { authMethod } from '../types/acpTypes.js';
import { isAuthenticationRequiredError } from '../utils/authErrors.js'; import { isAuthenticationRequiredError } from '../utils/authErrors.js';
@@ -32,13 +31,13 @@ export class QwenConnectionHandler {
* @param connection - ACP connection instance * @param connection - ACP connection instance
* @param sessionReader - Session reader instance * @param sessionReader - Session reader instance
* @param workingDir - Working directory * @param workingDir - Working directory
* @param cliPath - CLI path (optional, if provided will override the path in configuration) * @param cliEntryPath - Path to bundled CLI entrypoint (cli.js)
*/ */
async connect( async connect(
connection: AcpConnection, connection: AcpConnection,
sessionReader: QwenSessionReader, sessionReader: QwenSessionReader,
workingDir: string, workingDir: string,
cliPath?: string, cliEntryPath: string,
options?: { options?: {
autoAuthenticate?: boolean; autoAuthenticate?: boolean;
}, },
@@ -49,19 +48,10 @@ export class QwenConnectionHandler {
let sessionCreated = false; let sessionCreated = false;
let requiresAuth = false; let requiresAuth = false;
// Check if CLI exists using standard detection (with cached results for better performance)
const detectionResult = await CliManager.detectQwenCli(
/* forceRefresh */ false, // Use cached results when available for better performance
);
if (!detectionResult.isInstalled) {
throw new Error(detectionResult.error || 'Qwen CLI not found');
}
console.log('[QwenAgentManager] CLI detected at:', detectionResult.cliPath);
// Build extra CLI arguments (only essential parameters) // Build extra CLI arguments (only essential parameters)
const extraArgs: string[] = []; const extraArgs: string[] = [];
await connection.connect(cliPath!, workingDir, extraArgs); await connection.connect(cliEntryPath, workingDir, extraArgs);
// Try to restore existing session or create new session // Try to restore existing session or create new session
// Note: Auto-restore on connect is disabled to avoid surprising loads // Note: Auto-restore on connect is disabled to avoid surprising loads

View File

@@ -8,11 +8,9 @@ import * as vscode from 'vscode';
import { QwenAgentManager } from '../services/qwenAgentManager.js'; import { QwenAgentManager } from '../services/qwenAgentManager.js';
import { ConversationStore } from '../services/conversationStore.js'; import { ConversationStore } from '../services/conversationStore.js';
import type { AcpPermissionRequest } from '../types/acpTypes.js'; import type { AcpPermissionRequest } from '../types/acpTypes.js';
import { CliManager } from '../cli/cliManager.js';
import { PanelManager } from '../webview/PanelManager.js'; import { PanelManager } from '../webview/PanelManager.js';
import { MessageHandler } from '../webview/MessageHandler.js'; import { MessageHandler } from '../webview/MessageHandler.js';
import { WebViewContent } from '../webview/WebViewContent.js'; import { WebViewContent } from '../webview/WebViewContent.js';
import { CliInstaller } from '../cli/cliInstaller.js';
import { getFileName } from './utils/webviewUtils.js'; import { getFileName } from './utils/webviewUtils.js';
import { type ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import { type ApprovalModeValue } from '../types/approvalModeValueTypes.js';
import { isAuthenticationRequiredError } from '../utils/authErrors.js'; import { isAuthenticationRequiredError } from '../utils/authErrors.js';
@@ -564,33 +562,36 @@ export class WebViewProvider {
`[WebViewProvider] Using CLI-managed authentication (autoAuth=${autoAuthenticate})`, `[WebViewProvider] Using CLI-managed authentication (autoAuth=${autoAuthenticate})`,
); );
// Check if CLI is installed before attempting to connect const bundledCliEntry = vscode.Uri.joinPath(
const cliDetection = await CliManager.detectQwenCli(); this.extensionUri,
'dist',
'qwen-cli',
'cli.js',
).fsPath;
if (!cliDetection.isInstalled) { try {
console.log( console.log('[WebViewProvider] Connecting to bundled agent...');
'[WebViewProvider] Qwen CLI not detected, skipping agent connection', console.log('[WebViewProvider] Bundled CLI entry:', bundledCliEntry);
await this.agentManager.connect(workingDir, bundledCliEntry);
console.log('[WebViewProvider] Agent connected successfully');
this.agentInitialized = true;
// Load messages from the current Qwen session
await this.loadCurrentSessionMessages();
// Notify webview that agent is connected
this.sendMessageToWebView({
type: 'agentConnected',
data: {},
});
} catch (_error) {
console.error('[WebViewProvider] Agent connection error:', _error);
vscode.window.showWarningMessage(
`Failed to start bundled Qwen Code CLI: ${_error}\nYou can still use the chat UI, but messages won't be sent to AI.`,
); );
console.log( // Fallback to empty conversation
'[WebViewProvider] CLI detection error:',
cliDetection.error,
);
// Show VSCode notification with installation option
await CliInstaller.promptInstallation();
// Initialize empty conversation (can still browse history)
await this.initializeEmptyConversation(); await this.initializeEmptyConversation();
} else {
console.log(
'[WebViewProvider] Qwen CLI detected, attempting connection...',
);
console.log('[WebViewProvider] CLI path:', cliDetection.cliPath);
console.log('[WebViewProvider] CLI version:', cliDetection.version);
// Perform version check with throttled notifications
const versionChecker = CliManager.getInstance(this.context);
await versionChecker.checkCliVersion(true); // Silent check to avoid popup spam
try { try {
console.log('[WebViewProvider] Connecting to agent...'); console.log('[WebViewProvider] Connecting to agent...');
@@ -598,7 +599,7 @@ export class WebViewProvider {
// Pass the detected CLI path to ensure we use the correct installation // Pass the detected CLI path to ensure we use the correct installation
const connectResult = await this.agentManager.connect( const connectResult = await this.agentManager.connect(
workingDir, workingDir,
cliDetection.cliPath, bundledCliEntry,
options, options,
); );
console.log('[WebViewProvider] Agent connected successfully'); console.log('[WebViewProvider] Agent connected successfully');