Merge pull request #1265 from QwenLM/mingholy/chore/bundle-cli-into-sdk

Bundle CLI into SDK package and separate CLI & SDK E2E tests
This commit is contained in:
Mingholy
2025-12-19 16:45:35 +08:00
committed by GitHub
12 changed files with 161 additions and 154 deletions

View File

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

View File

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

View File

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

View File

@@ -358,7 +358,7 @@ Arguments passed directly when running the CLI can override other configurations
### Command-Line Arguments Table ### Command-Line Arguments Table
| Argument | Alias | Description | Possible Values | Notes | | Argument | Alias | Description | Possible Values | Notes |
| ---------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--model` | `-m` | Specifies the Qwen model to use for this session. | Model name | Example: `npm start -- --model qwen3-coder-plus` | | `--model` | `-m` | Specifies the Qwen model to use for this session. | Model name | Example: `npm start -- --model qwen3-coder-plus` |
| `--prompt` | `-p` | Used to pass a prompt directly to the command. This invokes Qwen Code in a non-interactive mode. | Your prompt text | For scripting examples, use the `--output-format json` flag to get structured output. | | `--prompt` | `-p` | Used to pass a prompt directly to the command. This invokes Qwen Code in a non-interactive mode. | Your prompt text | For scripting examples, use the `--output-format json` flag to get structured output. |
| `--prompt-interactive` | `-i` | Starts an interactive session with the provided prompt as the initial input. | Your prompt text | The prompt is processed within the interactive session, not before it. Cannot be used when piping input from stdin. Example: `qwen -i "explain this code"` | | `--prompt-interactive` | `-i` | Starts an interactive session with the provided prompt as the initial input. | Your prompt text | The prompt is processed within the interactive session, not before it. Cannot be used when piping input from stdin. Example: `qwen -i "explain this code"` |

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "@qwen-code/sdk", "name": "@qwen-code/sdk",
"version": "0.5.1", "version": "0.1.1",
"description": "TypeScript SDK for programmatic access to qwen-code CLI", "description": "TypeScript SDK for programmatic access to qwen-code CLI",
"main": "./dist/index.cjs", "main": "./dist/index.cjs",
"module": "./dist/index.mjs", "module": "./dist/index.mjs",
@@ -45,7 +45,8 @@
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4" "@modelcontextprotocol/sdk": "^1.0.4",
"tiktoken": "^1.0.21"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.14.0", "@types/node": "^20.14.0",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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