diff --git a/eslint.config.js b/eslint.config.js index 8a35ef6f..e477d95f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -170,7 +170,7 @@ export default tseslint.config( }, // extra settings for scripts that we run directly with node { - files: ['./scripts/**/*.js', 'esbuild.config.js'], + files: ['./scripts/**/*.js', 'esbuild.config.js', 'packages/*/scripts/**/*.js'], languageOptions: { globals: { ...globals.node, diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 8210d5d5..3aa3f957 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -449,7 +449,8 @@ export async function main() { } const nonInteractiveConfig = await validateNonInteractiveAuth( - settings.merged.security?.auth?.selectedType, + settings.merged.security?.auth?.selectedType || + (argv.authType as AuthType), settings.merged.security?.auth?.useExternal, config, settings, diff --git a/packages/cli/src/validateNonInterActiveAuth.ts b/packages/cli/src/validateNonInterActiveAuth.ts index 78ccc993..1590c074 100644 --- a/packages/cli/src/validateNonInterActiveAuth.ts +++ b/packages/cli/src/validateNonInterActiveAuth.ts @@ -41,7 +41,7 @@ export async function validateNonInteractiveAuth( } const effectiveAuthType = - enforcedType || getAuthTypeFromEnv() || configuredAuthType; + enforcedType || configuredAuthType || getAuthTypeFromEnv(); if (!effectiveAuthType) { const message = `Please set an Auth method in your ${USER_SETTINGS_PATH} or specify one of the following environment variables before running: QWEN_OAUTH, OPENAI_API_KEY`; diff --git a/packages/sdk-typescript/package.json b/packages/sdk-typescript/package.json index 067d1d22..63fed227 100644 --- a/packages/sdk-typescript/package.json +++ b/packages/sdk-typescript/package.json @@ -2,14 +2,15 @@ "name": "@qwen-code/sdk-typescript", "version": "0.1.0", "description": "TypeScript SDK for programmatic access to qwen-code CLI", - "main": "dist/index.js", - "types": "dist/index.d.ts", + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", "type": "module", "exports": { ".": { "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "require": "./dist/index.js" + "import": "./dist/index.mjs", + "require": "./dist/index.cjs" }, "./package.json": "./package.json" }, @@ -19,14 +20,16 @@ "LICENSE" ], "scripts": { - "build": "tsc", + "build": "node scripts/build.js", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "lint": "eslint src test", "lint:fix": "eslint src test --fix", + "typecheck": "tsc --noEmit", "clean": "rm -rf dist", - "prepublishOnly": "npm run clean && npm run build" + "prepublishOnly": "npm run clean && npm run build", + "prepack": "npm run build" }, "keywords": [ "qwen", @@ -49,20 +52,23 @@ "@typescript-eslint/eslint-plugin": "^7.13.0", "@typescript-eslint/parser": "^7.13.0", "@vitest/coverage-v8": "^1.6.0", + "dts-bundle-generator": "^9.5.1", + "esbuild": "^0.25.12", "eslint": "^8.57.0", "typescript": "^5.4.5", - "vitest": "^1.6.0" + "vitest": "^1.6.0", + "zod": "^3.23.8" }, "peerDependencies": { "typescript": ">=5.0.0" }, "repository": { "type": "git", - "url": "https://github.com/qwen-ai/qwen-code.git", - "directory": "packages/sdk/typescript" + "url": "https://github.com/QwenLM/qwen-code.git", + "directory": "packages/sdk-typescript" }, "bugs": { - "url": "https://github.com/qwen-ai/qwen-code/issues" + "url": "https://github.com/QwenLM/qwen-code/issues" }, - "homepage": "https://github.com/qwen-ai/qwen-code#readme" + "homepage": "https://qwenlm.github.io/qwen-code-docs/" } diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js new file mode 100755 index 00000000..055584a5 --- /dev/null +++ b/packages/sdk-typescript/scripts/build.js @@ -0,0 +1,95 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execSync } from 'node:child_process'; +import { rmSync, mkdirSync, existsSync, cpSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import esbuild from 'esbuild'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const rootDir = join(__dirname, '..'); + +rmSync(join(rootDir, 'dist'), { recursive: true, force: true }); +mkdirSync(join(rootDir, 'dist'), { recursive: true }); + +execSync('tsc --project tsconfig.build.json', { + stdio: 'inherit', + cwd: rootDir, +}); + +try { + execSync( + 'npx dts-bundle-generator -o dist/index.d.ts src/index.ts --no-check', + { + stdio: 'inherit', + cwd: rootDir, + }, + ); + + const dirsToRemove = ['mcp', 'query', 'transport', 'types', 'utils']; + for (const dir of dirsToRemove) { + const dirPath = join(rootDir, 'dist', dir); + if (existsSync(dirPath)) { + rmSync(dirPath, { recursive: true, force: true }); + } + } +} catch (error) { + console.warn( + 'Could not bundle type definitions, keeping separate .d.ts files', + error.message, + ); +} + +await esbuild.build({ + entryPoints: [join(rootDir, 'src', 'index.ts')], + bundle: true, + format: 'esm', + platform: 'node', + target: 'node18', + outfile: join(rootDir, 'dist', 'index.mjs'), + external: ['@modelcontextprotocol/sdk'], + sourcemap: false, + minify: true, + minifyWhitespace: true, + minifyIdentifiers: true, + minifySyntax: true, + legalComments: 'none', + keepNames: false, + treeShaking: true, +}); + +await esbuild.build({ + entryPoints: [join(rootDir, 'src', 'index.ts')], + bundle: true, + format: 'cjs', + platform: 'node', + target: 'node18', + outfile: join(rootDir, 'dist', 'index.cjs'), + external: ['@modelcontextprotocol/sdk'], + sourcemap: false, + minify: true, + minifyWhitespace: true, + minifyIdentifiers: true, + minifySyntax: true, + legalComments: 'none', + keepNames: false, + treeShaking: true, +}); + +const filesToCopy = ['README.md', 'LICENSE']; +for (const file of filesToCopy) { + const sourcePath = join(rootDir, '..', '..', file); + const targetPath = join(rootDir, 'dist', file); + if (existsSync(sourcePath)) { + try { + cpSync(sourcePath, targetPath); + } catch (error) { + console.warn(`Could not copy ${file}:`, error.message); + } + } +} diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index 23ba3f93..5992c6c5 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -1,10 +1,10 @@ export { query } from './query/createQuery.js'; export { AbortError, isAbortError } from './types/errors.js'; export { Query } from './query/Query.js'; - -export type { ExternalMcpServerConfig } from './types/queryOptionsSchema.js'; +export { SdkLogger } from './utils/logger.js'; export type { QueryOptions } from './query/createQuery.js'; +export type { LogLevel, LoggerConfig, ScopedLogger } from './utils/logger.js'; export type { ContentBlock, @@ -29,8 +29,9 @@ export { } from './types/protocol.js'; export type { - JSONSchema, PermissionMode, CanUseTool, PermissionResult, + ExternalMcpServerConfig, + SdkMcpServerConfig, } from './types/types.js'; diff --git a/packages/sdk-typescript/src/mcp/SdkControlServerTransport.ts b/packages/sdk-typescript/src/mcp/SdkControlServerTransport.ts index c160a9af..06392a4f 100644 --- a/packages/sdk-typescript/src/mcp/SdkControlServerTransport.ts +++ b/packages/sdk-typescript/src/mcp/SdkControlServerTransport.ts @@ -9,6 +9,7 @@ */ import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; +import { SdkLogger } from '../utils/logger.js'; export type SendToQueryCallback = (message: JSONRPCMessage) => Promise; @@ -21,6 +22,7 @@ export class SdkControlServerTransport { sendToQuery: SendToQueryCallback; private serverName: string; private started = false; + private logger; onmessage?: (message: JSONRPCMessage) => void; onerror?: (error: Error) => void; @@ -29,10 +31,14 @@ export class SdkControlServerTransport { constructor(options: SdkControlServerTransportOptions) { this.sendToQuery = options.sendToQuery; this.serverName = options.serverName; + this.logger = SdkLogger.createLogger( + `SdkControlServerTransport:${options.serverName}`, + ); } async start(): Promise { this.started = true; + this.logger.debug('Transport started'); } async send(message: JSONRPCMessage): Promise { @@ -43,10 +49,10 @@ export class SdkControlServerTransport { } try { - // Send via Query's control plane + this.logger.debug('Sending message to Query', message); await this.sendToQuery(message); } catch (error) { - // Invoke error callback if set + this.logger.error('Error sending message:', error); if (this.onerror) { this.onerror(error instanceof Error ? error : new Error(String(error))); } @@ -60,6 +66,7 @@ export class SdkControlServerTransport { } this.started = false; + this.logger.debug('Transport closed'); // Notify MCP Server if (this.onclose) { @@ -69,29 +76,22 @@ export class SdkControlServerTransport { handleMessage(message: JSONRPCMessage): void { if (!this.started) { - console.warn( - `[SdkControlServerTransport] Received message for closed transport (${this.serverName})`, - ); + this.logger.warn('Received message for closed transport'); return; } + this.logger.debug('Handling message from CLI', message); if (this.onmessage) { this.onmessage(message); } else { - console.warn( - `[SdkControlServerTransport] No onmessage handler set for ${this.serverName}`, - ); + this.logger.warn('No onmessage handler set'); } } handleError(error: Error): void { + this.logger.error('Transport error:', error); if (this.onerror) { this.onerror(error); - } else { - console.error( - `[SdkControlServerTransport] Error for ${this.serverName}:`, - error, - ); } } diff --git a/packages/sdk-typescript/src/query/Query.ts b/packages/sdk-typescript/src/query/Query.ts index c8039d4c..de4c4852 100644 --- a/packages/sdk-typescript/src/query/Query.ts +++ b/packages/sdk-typescript/src/query/Query.ts @@ -11,6 +11,7 @@ const CONTROL_REQUEST_TIMEOUT = 30000; const STREAM_CLOSE_TIMEOUT = 10000; import { randomUUID } from 'node:crypto'; +import { SdkLogger } from '../utils/logger.js'; import type { CLIMessage, CLIUserMessage, @@ -30,7 +31,7 @@ import { isControlCancel, } from '../types/protocol.js'; import type { Transport } from '../transport/Transport.js'; -import { type QueryOptions } from '../types/queryOptionsSchema.js'; +import type { QueryOptions } from '../types/types.js'; import { Stream } from '../utils/Stream.js'; import { serializeJsonLine } from '../utils/jsonLines.js'; import { AbortError } from '../types/errors.js'; @@ -49,6 +50,8 @@ interface TransportWithEndInput extends Transport { endInput(): void; } +const logger = SdkLogger.createLogger('Query'); + export class Query implements AsyncIterable { private transport: Transport; private options: QueryOptions; @@ -101,13 +104,13 @@ export class Query implements AsyncIterable { if (this.abortController.signal.aborted) { this.inputStream.error(new AbortError('Query aborted by user')); this.close().catch((err) => { - console.error('[Query] Error during abort cleanup:', err); + logger.error('Error during abort cleanup:', err); }); } else { this.abortController.signal.addEventListener('abort', () => { this.inputStream.error(new AbortError('Query aborted by user')); this.close().catch((err) => { - console.error('[Query] Error during abort cleanup:', err); + logger.error('Error during abort cleanup:', err); }); }); } @@ -120,7 +123,7 @@ export class Query implements AsyncIterable { private async initialize(): Promise { try { - await this.setupSdkMcpServers(); + logger.debug('Initializing Query'); const sdkMcpServerNames = Array.from(this.sdkMcpTransports.keys()); @@ -131,52 +134,13 @@ export class Query implements AsyncIterable { mcpServers: this.options.mcpServers, agents: this.options.agents, }); + logger.info('Query initialized successfully'); } catch (error) { - console.error('[Query] Initialization error:', error); + logger.error('Initialization error:', error); throw error; } } - private async setupSdkMcpServers(): Promise { - if (!this.options.sdkMcpServers) { - return; - } - - const externalNames = Object.keys(this.options.mcpServers ?? {}); - const sdkNames = Object.keys(this.options.sdkMcpServers); - - const conflicts = sdkNames.filter((name) => externalNames.includes(name)); - if (conflicts.length > 0) { - throw new Error( - `MCP server name conflicts between mcpServers and sdkMcpServers: ${conflicts.join(', ')}`, - ); - } - - /** - * Import SdkControlServerTransport dynamically to avoid circular dependencies. - * Create transport for each server that sends MCP messages via control plane. - */ - const { SdkControlServerTransport } = await import( - '../mcp/SdkControlServerTransport.js' - ); - - for (const [name, server] of Object.entries(this.options.sdkMcpServers)) { - const transport = new SdkControlServerTransport({ - serverName: name, - sendToQuery: async (message: JSONRPCMessage) => { - await this.sendControlRequest(ControlRequestType.MCP_MESSAGE, { - server_name: name, - message, - }); - }, - }); - - await transport.start(); - await server.connect(transport); - this.sdkMcpTransports.set(name, transport); - } - } - private startMessageRouter(): void { if (this.messageRouterStarted) { return; @@ -256,9 +220,7 @@ export class Query implements AsyncIterable { return; } - if (process.env['DEBUG']) { - console.warn('[Query] Unknown message type:', message); - } + logger.warn('Unknown message type:', message); this.inputStream.enqueue(message as CLIMessage); } @@ -267,6 +229,7 @@ export class Query implements AsyncIterable { ): Promise { const { request_id, request: payload } = request; + logger.debug(`Handling control request: ${payload.subtype}`); const requestAbortController = new AbortController(); try { @@ -299,6 +262,7 @@ export class Query implements AsyncIterable { } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); + logger.error(`Control request error (${payload.subtype}):`, errorMessage); await this.sendControlResponse(request_id, false, errorMessage); } } @@ -369,8 +333,8 @@ export class Query implements AsyncIterable { */ const errorMessage = error instanceof Error ? error.message : String(error); - console.warn( - '[Query] Permission callback error (denying by default):', + logger.warn( + 'Permission callback error (denying by default):', errorMessage, ); return { @@ -448,9 +412,10 @@ export class Query implements AsyncIterable { const pending = this.pendingControlRequests.get(request_id); if (!pending) { - console.warn( - '[Query] Received response for unknown request:', + logger.warn( + 'Received response for unknown request:', request_id, + JSON.stringify(payload), ); return; } @@ -459,6 +424,9 @@ export class Query implements AsyncIterable { this.pendingControlRequests.delete(request_id); if (payload.subtype === 'success') { + logger.debug( + `Control response success for request: ${request_id}: ${JSON.stringify(payload.response)}`, + ); pending.resolve(payload.response as Record | null); } else { /** @@ -469,6 +437,10 @@ export class Query implements AsyncIterable { typeof payload.error === 'string' ? payload.error : (payload.error?.message ?? 'Unknown error'); + logger.error( + `Control response error for request ${request_id}:`, + errorMessage, + ); pending.reject(new Error(errorMessage)); } } @@ -477,12 +449,13 @@ export class Query implements AsyncIterable { const { request_id } = request; if (!request_id) { - console.warn('[Query] Received cancel request without request_id'); + logger.warn('Received cancel request without request_id'); return; } const pending = this.pendingControlRequests.get(request_id); if (pending) { + logger.debug(`Cancelling control request: ${request_id}`); pending.abortController.abort(); clearTimeout(pending.timeout); this.pendingControlRequests.delete(request_id); @@ -580,10 +553,11 @@ export class Query implements AsyncIterable { try { await transport.close(); } catch (error) { - console.error('[Query] Error closing MCP transport:', error); + logger.error('Error closing MCP transport:', error); } } this.sdkMcpTransports.clear(); + logger.info('Query closed'); } private async *readSdkMessages(): AsyncGenerator { @@ -652,7 +626,7 @@ export class Query implements AsyncIterable { this.endInput(); } catch (error) { if (this.abortController.signal.aborted) { - console.log('[Query] Aborted during input streaming'); + logger.info('Aborted during input streaming'); this.inputStream.error( new AbortError('Query aborted during input streaming'), ); diff --git a/packages/sdk-typescript/src/query/createQuery.ts b/packages/sdk-typescript/src/query/createQuery.ts index 7549b2b3..71fd6e9b 100644 --- a/packages/sdk-typescript/src/query/createQuery.ts +++ b/packages/sdk-typescript/src/query/createQuery.ts @@ -7,18 +7,29 @@ import { serializeJsonLine } from '../utils/jsonLines.js'; import { ProcessTransport } from '../transport/ProcessTransport.js'; import { parseExecutableSpec } from '../utils/cliPath.js'; import { Query } from './Query.js'; -import { - QueryOptionsSchema, - type QueryOptions, -} from '../types/queryOptionsSchema.js'; +import type { QueryOptions } from '../types/types.js'; +import { QueryOptionsSchema } from '../types/queryOptionsSchema.js'; +import { SdkLogger } from '../utils/logger.js'; export type { QueryOptions }; +const logger = SdkLogger.createLogger('createQuery'); + export function query({ prompt, options = {}, }: { + /** + * The prompt to send to the Qwen Code CLI process. + * - `string` for single-turn query, + * - `AsyncIterable` for multi-turn query. + * + * The transport will remain open until the prompt is done. + */ prompt: string | AsyncIterable; + /** + * Configuration options for the query. + */ options?: QueryOptions; }): Query { const parsedExecutable = validateOptions(options); @@ -39,6 +50,7 @@ export function query({ abortController, debug: options.debug, stderr: options.stderr, + logLevel: options.logLevel, maxSessionTurns: options.maxSessionTurns, coreTools: options.coreTools, excludeTools: options.excludeTools, @@ -70,14 +82,14 @@ export function query({ await queryInstance.initialized; transport.write(serializeJsonLine(message)); } catch (err) { - console.error('[query] Error sending single-turn prompt:', err); + logger.error('Error sending single-turn prompt:', err); } })(); } else { queryInstance .streamInput(prompt as AsyncIterable) .catch((err) => { - console.error('[query] Error streaming input:', err); + logger.error('Error streaming input:', err); }); } @@ -103,17 +115,5 @@ function validateOptions( throw new Error(`Invalid pathToQwenExecutable: ${errorMessage}`); } - if (options.mcpServers && options.sdkMcpServers) { - const externalNames = Object.keys(options.mcpServers); - const sdkNames = Object.keys(options.sdkMcpServers); - - const conflicts = externalNames.filter((name) => sdkNames.includes(name)); - if (conflicts.length > 0) { - throw new Error( - `MCP server name conflicts between mcpServers and sdkMcpServers: ${conflicts.join(', ')}`, - ); - } - } - return parsedExecutable; } diff --git a/packages/sdk-typescript/src/transport/ProcessTransport.ts b/packages/sdk-typescript/src/transport/ProcessTransport.ts index ba13f044..d473160c 100644 --- a/packages/sdk-typescript/src/transport/ProcessTransport.ts +++ b/packages/sdk-typescript/src/transport/ProcessTransport.ts @@ -6,6 +6,9 @@ import type { Transport } from './Transport.js'; import { parseJsonLinesStream } from '../utils/jsonLines.js'; import { prepareSpawnInfo } from '../utils/cliPath.js'; import { AbortError } from '../types/errors.js'; +import { SdkLogger } from '../utils/logger.js'; + +const logger = SdkLogger.createLogger('ProcessTransport'); export class ProcessTransport implements Transport { private childProcess: ChildProcess | null = null; @@ -23,6 +26,11 @@ export class ProcessTransport implements Transport { this.options = options; this.abortController = this.options.abortController ?? new AbortController(); + SdkLogger.configure({ + debug: options.debug, + stderr: options.stderr, + logLevel: options.logLevel, + }); this.initialize(); } @@ -41,7 +49,7 @@ export class ProcessTransport implements Transport { const stderrMode = this.options.debug || this.options.stderr ? 'pipe' : 'ignore'; - this.logForDebugging( + logger.debug( `Spawning CLI (${spawnInfo.type}): ${spawnInfo.command} ${[...spawnInfo.args, ...cliArgs].join(' ')}`, ); @@ -61,7 +69,7 @@ export class ProcessTransport implements Transport { if (this.options.debug || this.options.stderr) { this.childProcess.stderr?.on('data', (data) => { - this.logForDebugging(data.toString()); + logger.debug(data.toString()); }); } @@ -79,8 +87,10 @@ export class ProcessTransport implements Transport { this.setupEventHandlers(); this.ready = true; + logger.info('CLI process started successfully'); } catch (error) { this.ready = false; + logger.error('Failed to initialize CLI process:', error); throw error; } } @@ -94,7 +104,7 @@ export class ProcessTransport implements Transport { this._exitError = new AbortError('CLI process aborted by user'); } else { this._exitError = new Error(`CLI process error: ${error.message}`); - this.logForDebugging(this._exitError.message); + logger.error(this._exitError.message); } }); @@ -106,7 +116,7 @@ export class ProcessTransport implements Transport { const error = this.getProcessExitError(code, signal); if (error) { this._exitError = error; - this.logForDebugging(error.message); + logger.error(error.message); } } }); @@ -269,28 +279,24 @@ export class ProcessTransport implements Transport { ); } - if (process.env['DEBUG']) { - this.logForDebugging( - `[ProcessTransport] Writing to stdin (${message.length} bytes): ${message.substring(0, 100)}`, - ); - } + logger.debug( + `Writing to stdin (${message.length} bytes): ${message.trim()}`, + ); try { const written = this.childStdin.write(message); if (!written) { - this.logForDebugging( - `[ProcessTransport] Write buffer full (${message.length} bytes), data queued. Waiting for drain event...`, - ); - } else if (process.env['DEBUG']) { - this.logForDebugging( - `[ProcessTransport] Write successful (${message.length} bytes)`, + logger.warn( + `Write buffer full (${message.length} bytes), data queued. Waiting for drain event...`, ); + } else { + logger.debug(`Write successful (${message.length} bytes)`); } } catch (error) { this.ready = false; - throw new Error( - `Failed to write to stdin: ${error instanceof Error ? error.message : String(error)}`, - ); + const errorMsg = `Failed to write to stdin: ${error instanceof Error ? error.message : String(error)}`; + logger.error(errorMsg); + throw new Error(errorMsg); } } @@ -340,13 +346,4 @@ export class ProcessTransport implements Transport { getOutputStream(): Readable | undefined { return this.childStdout || undefined; } - - private logForDebugging(message: string): void { - if (this.options.debug || process.env['DEBUG']) { - process.stderr.write(`[ProcessTransport] ${message}\n`); - } - if (this.options.stderr) { - this.options.stderr(message); - } - } } diff --git a/packages/sdk-typescript/src/types/queryOptionsSchema.ts b/packages/sdk-typescript/src/types/queryOptionsSchema.ts index c347bfdd..c4629357 100644 --- a/packages/sdk-typescript/src/types/queryOptionsSchema.ts +++ b/packages/sdk-typescript/src/types/queryOptionsSchema.ts @@ -50,7 +50,6 @@ export const QueryOptionsSchema = z }) .optional(), mcpServers: z.record(z.string(), ExternalMcpServerConfigSchema).optional(), - sdkMcpServers: z.record(z.string(), SdkMcpServerConfigSchema).optional(), abortController: z.instanceof(AbortController).optional(), debug: z.boolean().optional(), stderr: z @@ -58,6 +57,7 @@ export const QueryOptionsSchema = z (message: string) => void >((val) => typeof val === 'function', { message: 'stderr must be a function' }) .optional(), + logLevel: z.enum(['debug', 'info', 'warn', 'error']).optional(), maxSessionTurns: z.number().optional(), coreTools: z.array(z.string()).optional(), excludeTools: z.array(z.string()).optional(), @@ -79,8 +79,3 @@ export const QueryOptionsSchema = z includePartialMessages: z.boolean().optional(), }) .strict(); - -export type ExternalMcpServerConfig = z.infer< - typeof ExternalMcpServerConfigSchema ->; -export type QueryOptions = z.infer; diff --git a/packages/sdk-typescript/src/types/types.ts b/packages/sdk-typescript/src/types/types.ts index e4cbbb5b..0c23581b 100644 --- a/packages/sdk-typescript/src/types/types.ts +++ b/packages/sdk-typescript/src/types/types.ts @@ -1,8 +1,12 @@ -import type { PermissionMode, PermissionSuggestion } from './protocol.js'; +import type { + PermissionMode, + PermissionSuggestion, + SubagentConfig, +} from './protocol.js'; export type { PermissionMode }; -export type JSONSchema = { +type JSONSchema = { type: string; properties?: Record; required?: string[]; @@ -26,6 +30,7 @@ export type TransportOptions = { abortController?: AbortController; debug?: boolean; stderr?: (message: string) => void; + logLevel?: 'debug' | 'info' | 'warn' | 'error'; maxSessionTurns?: number; coreTools?: string[]; excludeTools?: string[]; @@ -54,3 +59,172 @@ export type PermissionResult = message: string; interrupt?: boolean; }; + +export interface ExternalMcpServerConfig { + command: string; + args?: string[]; + env?: Record; +} + +export interface SdkMcpServerConfig { + connect: (transport: unknown) => Promise; +} + +/** + * Configuration options for creating a query session with the Qwen CLI. + */ +export interface QueryOptions { + /** + * The working directory for the query session. + * This determines the context in which file operations and commands are executed. + * @default process.cwd() + */ + cwd?: string; + + /** + * The AI model to use for the query session. + * This takes precedence over the environment variables `OPENAI_MODEL` and `QWEN_MODEL` + * @example 'qwen-max', 'qwen-plus', 'qwen-turbo' + */ + model?: string; + + /** + * Path to the Qwen CLI executable or runtime specification. + * + * Supports multiple formats: + * - '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 + * + * 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 'qwen' + * @example '/usr/local/bin/qwen' + * @example 'tsx:/path/to/packages/cli/src/index.ts' + */ + pathToQwenExecutable?: string; + + /** + * Environment variables to pass to the Qwen CLI process. + * These variables will be merged with the current process environment. + */ + env?: Record; + + /** + * Alias for `approval-mode` command line argument. + * Behaves slightly differently from the command line argument. + * Permission mode controlling how the CLI handles tool usage and file operations **in non-interactive mode**. + * - 'default': Automatically deny all write-like tools(edit, write_file, etc.) and dangers commands. + * - 'plan': Shows a plan before executing operations + * - 'auto-edit': Automatically applies edits without confirmation + * - 'yolo': Executes all operations without prompting + * @default 'default' + */ + permissionMode?: 'default' | 'plan' | 'auto-edit' | 'yolo'; + + /** + * Custom permission handler for tool usage. + * This function is called when the SDK needs to determine if a tool should be allowed. + * Use this with `permissionMode` to gain more control over the tool usage. + * TODO: For now we don't support modifying the input. + */ + canUseTool?: CanUseTool; + + /** + * External MCP (Model Context Protocol) servers to connect to. + * Each server is identified by a unique name and configured with command, args, and environment. + * @example { 'my-server': { command: 'node', args: ['server.js'], env: { PORT: '3000' } } } + */ + mcpServers?: Record; + + /** + * AbortController to cancel the query session. + * Call abortController.abort() to terminate the session and cleanup resources. + * Remember to handle the AbortError when the session is aborted. + */ + abortController?: AbortController; + + /** + * Enable debug mode for verbose logging. + * When true, additional diagnostic information will be output. + * Use this with `logLevel` to control the verbosity of the logs. + * @default false + */ + debug?: boolean; + + /** + * Custom handler for stderr output from the Qwen CLI process. + * Use this to capture and process error messages or diagnostic output. + */ + stderr?: (message: string) => void; + + /** + * Logging level for the SDK. + * Controls the verbosity of log messages output by the SDK. + * @default 'info' + */ + logLevel?: 'debug' | 'info' | 'warn' | 'error'; + + /** + * Maximum number of conversation turns before the session automatically terminates. + * A turn consists of a user message and an assistant response. + * @default -1 (unlimited) + */ + maxSessionTurns?: number; + + /** + * Equivalent to `tool.core` in settings.json. + * List of core tools to enable for the session. + * If specified, only these tools will be available to the AI. + * @example ['read_file', 'write_file', 'run_terminal_cmd'] + */ + coreTools?: string[]; + + /** + * Equivalent to `tool.exclude` in settings.json. + * List of tools to exclude from the session. + * These tools will not be available to the AI, even if they are core tools. + * @example ['run_terminal_cmd', 'delete_file'] + */ + excludeTools?: string[]; + + /** + * Authentication type for the AI service. + * - 'openai': Use OpenAI-compatible authentication + * - 'qwen-oauth': Use Qwen OAuth authentication + * + * Though we support 'qwen-oauth', it's not recommended to use it in the SDK. + * Because the credentials are stored in `~/.qwen` and may need to refresh periodically. + */ + authType?: 'openai' | 'qwen-oauth'; + + /** + * Configuration for subagents that can be invoked during the session. + * Subagents are specialized AI agents that can handle specific tasks or domains. + * The invocation is marked as a `task` tool use with the name of agent and a tool_use_id. + * The tool use of these agent is marked with the parent_tool_use_id of the `task` tool use. + */ + agents?: SubagentConfig[]; + + /** + * Include partial messages in the response stream. + * When true, the SDK will emit incomplete messages as they are being generated, + * allowing for real-time streaming of the AI's response. + * @default false + */ + includePartialMessages?: boolean; +} diff --git a/packages/sdk-typescript/src/utils/jsonLines.ts b/packages/sdk-typescript/src/utils/jsonLines.ts index 6d1bd090..8af8ec6a 100644 --- a/packages/sdk-typescript/src/utils/jsonLines.ts +++ b/packages/sdk-typescript/src/utils/jsonLines.ts @@ -1,3 +1,5 @@ +import { SdkLogger } from './logger.js'; + export function serializeJsonLine(message: unknown): string { try { return JSON.stringify(message) + '\n'; @@ -12,11 +14,12 @@ export function parseJsonLineSafe( line: string, context = 'JsonLines', ): unknown | null { + const logger = SdkLogger.createLogger(context); try { return JSON.parse(line); } catch (error) { - console.warn( - `[${context}] Failed to parse JSON line, skipping:`, + logger.warn( + 'Failed to parse JSON line, skipping:', line.substring(0, 100), error instanceof Error ? error.message : String(error), ); @@ -37,6 +40,7 @@ export async function* parseJsonLinesStream( lines: AsyncIterable, context = 'JsonLines', ): AsyncGenerator { + const logger = SdkLogger.createLogger(context); for await (const line of lines) { if (line.trim().length === 0) { continue; @@ -49,8 +53,8 @@ export async function* parseJsonLinesStream( } if (!isValidMessage(message)) { - console.warn( - `[${context}] Invalid message structure (missing 'type' field), skipping:`, + logger.warn( + "Invalid message structure (missing 'type' field), skipping:", line.substring(0, 100), ); continue; diff --git a/packages/sdk-typescript/src/utils/logger.ts b/packages/sdk-typescript/src/utils/logger.ts new file mode 100644 index 00000000..afb7a495 --- /dev/null +++ b/packages/sdk-typescript/src/utils/logger.ts @@ -0,0 +1,147 @@ +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +export interface LoggerConfig { + debug?: boolean; + stderr?: (message: string) => void; + logLevel?: LogLevel; +} + +export interface ScopedLogger { + debug(message: string, ...args: unknown[]): void; + info(message: string, ...args: unknown[]): void; + warn(message: string, ...args: unknown[]): void; + error(message: string, ...args: unknown[]): void; +} + +const LOG_LEVEL_PRIORITY: Record = { + debug: 0, + info: 1, + warn: 2, + error: 3, +}; + +export class SdkLogger { + private static config: LoggerConfig = {}; + private static effectiveLevel: LogLevel = 'info'; + + static configure(config: LoggerConfig): void { + this.config = config; + this.effectiveLevel = this.determineLogLevel(); + } + + private static determineLogLevel(): LogLevel { + if (this.config.logLevel) { + return this.config.logLevel; + } + + if (this.config.debug) { + return 'debug'; + } + + const envLevel = process.env['DEBUG_QWEN_CODE_SDK_LEVEL']; + if (envLevel && this.isValidLogLevel(envLevel)) { + return envLevel as LogLevel; + } + + if (process.env['DEBUG_QWEN_CODE_SDK']) { + return 'debug'; + } + + return 'info'; + } + + private static isValidLogLevel(level: string): boolean { + return ['debug', 'info', 'warn', 'error'].includes(level); + } + + private static shouldLog(level: LogLevel): boolean { + return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[this.effectiveLevel]; + } + + private static formatTimestamp(): string { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + const hours = String(now.getHours()).padStart(2, '0'); + const minutes = String(now.getMinutes()).padStart(2, '0'); + const seconds = String(now.getSeconds()).padStart(2, '0'); + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; + } + + private static formatMessage( + level: LogLevel, + scope: string, + message: string, + args: unknown[], + ): string { + const timestamp = this.formatTimestamp(); + const levelStr = `[${level.toUpperCase()}]`.padEnd(7); + let fullMessage = `${timestamp} ${levelStr} [${scope}] ${message}`; + + if (args.length > 0) { + const argsStr = args + .map((arg) => { + if (typeof arg === 'string') { + return arg; + } + if (arg instanceof Error) { + return arg.message; + } + try { + return JSON.stringify(arg); + } catch { + return String(arg); + } + }) + .join(' '); + fullMessage += ` ${argsStr}`; + } + + return fullMessage; + } + + private static log( + level: LogLevel, + scope: string, + message: string, + args: unknown[], + ): void { + if (!this.shouldLog(level)) { + return; + } + + const formattedMessage = this.formatMessage(level, scope, message, args); + + if (this.config.stderr) { + this.config.stderr(formattedMessage); + } else { + if (level === 'warn' || level === 'error') { + process.stderr.write(formattedMessage + '\n'); + } else { + process.stdout.write(formattedMessage + '\n'); + } + } + } + + static createLogger(scope: string): ScopedLogger { + return { + debug: (message: string, ...args: unknown[]) => { + this.log('debug', scope, message, args); + }, + info: (message: string, ...args: unknown[]) => { + this.log('info', scope, message, args); + }, + warn: (message: string, ...args: unknown[]) => { + this.log('warn', scope, message, args); + }, + error: (message: string, ...args: unknown[]) => { + this.log('error', scope, message, args); + }, + }; + } + + static getEffectiveLevel(): LogLevel { + return this.effectiveLevel; + } +} diff --git a/packages/sdk-typescript/test/e2e/single-turn.test.ts b/packages/sdk-typescript/test/e2e/single-turn.test.ts index 93c1ecc8..2052b6b2 100644 --- a/packages/sdk-typescript/test/e2e/single-turn.test.ts +++ b/packages/sdk-typescript/test/e2e/single-turn.test.ts @@ -39,7 +39,8 @@ describe('Single-Turn Query (E2E)', () => { prompt: 'What is 2 + 2? Just give me the number.', options: { ...SHARED_TEST_OPTIONS, - debug: false, + debug: true, + logLevel: 'debug', }, }); diff --git a/packages/sdk-typescript/tsconfig.build.json b/packages/sdk-typescript/tsconfig.build.json new file mode 100644 index 00000000..53e1cea0 --- /dev/null +++ b/packages/sdk-typescript/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": false, + "sourceMap": false, + "emitDeclarationOnly": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "test", "**/*.test.ts", "**/*.spec.ts"] +}