diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000..b7c25c1 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,19 @@ +{ + "name": "qwen-code", + "interface": { + "displayName": "Qwen Code" + }, + "plugins": [ + { + "name": "qwen", + "source": { + "source": "local", + "path": "./plugins/qwen-codex" + }, + "policy": { + "installation": "AVAILABLE" + }, + "category": "Developer Tools" + } + ] +} diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index b20d890..b946785 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -4,14 +4,14 @@ "name": "doudouOUC" }, "metadata": { - "description": "Qwen Code plugins for Claude Code.", - "version": "0.4.0" + "description": "Qwen Code plugins for Claude Code and Codex CLI.", + "version": "0.5.0" }, "plugins": [ { "name": "qwen", "description": "Run Qwen Code review from Claude Code.", - "version": "0.4.0", + "version": "0.5.0", "author": { "name": "doudouOUC" }, diff --git a/README.md b/README.md index 18b03bb..6a647d0 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@ -# Qwen Code Plugin for Claude Code +# Qwen Code Review Plugin -Use Qwen Code review from inside Claude Code. +Use Qwen Code review from Claude Code and Codex CLI. -This plugin adds: +This repository contains two plugins for the same Qwen Code review capability: -- `/qwen:setup` to check whether the local `qwen` CLI is available. -- `/qwen:review` to run Qwen Code's built-in `/review` skill against the current repository. -- `/qwen:status`, `/qwen:result`, and `/qwen:cancel` to manage long-running review jobs. +- **Claude Code plugin** (`plugins/qwen/`) — adds `/qwen:review` and job management commands. +- **Codex CLI plugin** (`plugins/qwen-codex/`) — adds the `$qwen-review` skill. + +Both plugins call the local `qwen` CLI to run Qwen Code's built-in `/review` skill. ## Requirements -- Claude Code with plugin support. - Node.js 18.18 or later. - Qwen Code installed and available on `PATH`. @@ -20,33 +20,23 @@ Install Qwen Code if needed: npm install -g @qwen-code/qwen-code ``` -## Install - -Add this repository as a Claude Code plugin marketplace: +## Install — Claude Code ```bash /plugin marketplace add doudouOUC/qwen-code-plugin-cc -``` - -Install the plugin: - -```bash /plugin install qwen@qwen-code -``` - -Reload plugins: - -```bash /reload-plugins +/qwen:setup ``` -Check setup: +## Install — Codex CLI ```bash -/qwen:setup +codex plugin marketplace add doudouOUC/qwen-code-plugin-cc +codex plugin add qwen@qwen-code ``` -## Usage +## Claude Code Usage Review local changes: @@ -71,13 +61,6 @@ Run with a specific Qwen Code model: /qwen:review -m qwen3-coder-plus 123 --comment ``` -Recommended model style for larger reviews: - -```bash -/qwen:review --model qwen3.7-max -/qwen:review --model -``` - Review a pull request: ```bash @@ -96,60 +79,38 @@ Post Qwen Code inline comments on a PR: /qwen:review 123 --comment ``` -Check a long-running review while it is still running: +Manage jobs: ```bash /qwen:status /qwen:status qwen-review-1234abcd -``` - -Read a finished review again: - -```bash /qwen:result qwen-review-1234abcd -``` - -Cancel a running review: - -```bash /qwen:cancel qwen-review-1234abcd ``` -## Notes - -`/qwen:review` is review-only from Claude Code's side. It forwards your review target arguments to Qwen Code's `/review` skill and appends a run-scoped review-only system prompt. In the default background mode Claude Code owns the background command, so it can report completion and surface the final Qwen Code output automatically. Use `/qwen:status` to inspect an active run, `/qwen:cancel` to stop it, and `/qwen:result ` if you want to read a stored result again. With `--wait`, it prints Qwen Code output unchanged in the current turn. +## Codex CLI / App Usage -`--wait`, `--background`, `--model `, `--model=`, and `-m ` are handled by this plugin. Other arguments are passed to Qwen Code's `/review` prompt unchanged. +Once installed, the `$qwen-review` skill is available in Codex CLI and Codex App. Ask Codex to review your code: -## How Qwen Code review runs +``` +Use $qwen-review to review the current changes +``` -This plugin does not implement its own reviewer. It starts the local Qwen Code -CLI and asks it to run its built-in `/review` skill. Qwen Code decides the -review target from the forwarded arguments: +Codex will run the `qwen` CLI with review-only flags and present the findings. -- no extra arguments: local uncommitted changes -- PR number or PR URL: that pull request's diff and PR context -- file path: that file's diff, or the current file content when there is no diff +## Notes -Qwen Code review is usually more expensive than a single prompt over `git diff`. -It can collect PR context, load project review rules, run deterministic checks, -inspect changed files, and synthesize verified findings. For large PRs this can -send repeated overlapping repository and review context to the model. +`/qwen:review` (Claude Code) and `$qwen-review` (Codex) are both review-only. They forward your review target arguments to Qwen Code's `/review` skill and append a review-only system prompt. -For that reason, prefer strong models with good prompt-cache behavior for larger -reviews. In environments where they are available, `qwen3.7-max` and DeepSeek -family models are good candidates because high cache hit rates can reduce repeat -review latency and cost. The plugin only forwards the model name to Qwen Code; -it does not validate model availability, so use the exact model identifier -configured in your Qwen Code environment. +`--wait`, `--background`, `--model `, `--model=`, and `-m ` are handled by the Claude Code plugin. Other arguments are passed to Qwen Code's `/review` prompt unchanged. -The companion runs Qwen Code with `--approval-mode yolo` so headless review can execute the analysis commands required by `/review`. Sandboxing is enabled by default. If your environment cannot run Qwen Code sandboxing, explicitly disable it with: +The Claude Code companion runs Qwen Code with `--approval-mode yolo` so headless review can execute the analysis commands required by `/review`. Sandboxing is enabled by default. If your environment cannot run Qwen Code sandboxing, explicitly disable it with: ```bash export QWEN_PLUGIN_NO_SANDBOX=1 ``` -Background job state is stored outside the project under: +Claude Code background job state is stored under: ```text ~/.qwen-code-plugin-cc/workspaces/ diff --git a/package.json b/package.json index 42f9143..55ac461 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,9 @@ { "name": "qwen-code-plugin-cc", - "version": "0.4.0", + "version": "0.5.0", "private": true, "type": "module", - "description": "Claude Code plugin for running Qwen Code review.", + "description": "Qwen Code review plugin for Claude Code and Codex CLI.", "license": "Apache-2.0", "scripts": { "test": "node --test tests/*.test.mjs" diff --git a/plugins/qwen-codex/.codex-plugin/plugin.json b/plugins/qwen-codex/.codex-plugin/plugin.json new file mode 100644 index 0000000..c6802d4 --- /dev/null +++ b/plugins/qwen-codex/.codex-plugin/plugin.json @@ -0,0 +1,26 @@ +{ + "name": "qwen", + "version": "0.5.0", + "description": "Run Qwen Code review from Codex CLI.", + "author": { + "name": "doudouOUC" + }, + "homepage": "https://github.com/doudouOUC/qwen-code-plugin-cc", + "repository": "https://github.com/doudouOUC/qwen-code-plugin-cc", + "license": "Apache-2.0", + "keywords": ["qwen", "review", "code-review"], + "skills": "./skills/", + "interface": { + "displayName": "Qwen Code Review", + "shortDescription": "Run Qwen Code review from Codex.", + "longDescription": "Use Qwen Code's /review skill to review local changes, pull requests, or specific files from within Codex CLI or Codex App.", + "developerName": "doudouOUC", + "category": "Developer Tools", + "capabilities": ["Read"], + "defaultPrompt": [ + "Use $qwen-review to review the current changes", + "Use $qwen-review to review PR #", + "Use $qwen-review to review src/" + ] + } +} diff --git a/plugins/qwen-codex/lib/argv.mjs b/plugins/qwen-codex/lib/argv.mjs new file mode 100644 index 0000000..132fffd --- /dev/null +++ b/plugins/qwen-codex/lib/argv.mjs @@ -0,0 +1,122 @@ +export function scanRawArgumentString(raw) { + const source = String(raw ?? ''); + const tokens = []; + let current = ''; + let start = null; + let quote = null; + let escaped = false; + let quoted = false; + let escapedToken = false; + + function startToken(index) { + if (start === null) { + start = index; + } + } + + function finishToken(end) { + if (start === null) { + return; + } + tokens.push({ + value: current, + start, + end, + quoted, + escaped: escapedToken, + }); + current = ''; + start = null; + quoted = false; + escapedToken = false; + } + + for (let index = 0; index < source.length; index += 1) { + const char = source[index]; + if (escaped) { + current += char; + escaped = false; + continue; + } + if (char === '\\') { + startToken(index); + escaped = true; + escapedToken = true; + continue; + } + if (quote) { + if (char === quote) { + quote = null; + } else { + current += char; + } + continue; + } + if (char === '"' || char === "'") { + startToken(index); + quote = char; + quoted = true; + continue; + } + if (/\s/.test(char)) { + finishToken(index); + continue; + } + startToken(index); + current += char; + } + + if (escaped) { + current += '\\'; + } + finishToken(source.length); + return tokens; +} + +export function splitRawArgumentString(raw) { + return scanRawArgumentString(raw).map((token) => token.value); +} + +export function normalizeArgv(args) { + return args.length === 1 ? splitRawArgumentString(args[0]) : args; +} + +export function isModeFlag(token, flag) { + return token.value === flag && !token.quoted && !token.escaped; +} + +export function isModelEqualsFlag(token) { + return ( + !token.quoted && + !token.escaped && + token.value.startsWith('--model=') && + token.value.length > '--model='.length + ); +} + +export function validateModelValue(flag, value) { + if (!value || value.startsWith('-')) { + throw new Error(`Missing value for ${flag}.`); + } + return value; +} + +export function removeRawTokenSpans(raw, spans) { + const source = String(raw ?? ''); + let result = String(raw ?? ''); + const expandedSpans = spans.map((span) => { + let end = span.end; + while (end < source.length && /\s/.test(source[end])) { + end += 1; + } + return { + ...span, + end, + }; + }); + + for (const span of expandedSpans.sort((left, right) => right.start - left.start)) { + result = `${result.slice(0, span.start)}${result.slice(span.end)}`; + } + return result.trim(); +} diff --git a/plugins/qwen-codex/lib/fs-utils.mjs b/plugins/qwen-codex/lib/fs-utils.mjs new file mode 100644 index 0000000..b73166a --- /dev/null +++ b/plugins/qwen-codex/lib/fs-utils.mjs @@ -0,0 +1,100 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { spawnSync } from 'node:child_process'; + +export function commandStatus(command, args = [], options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + + if (result.error) { + return { + available: false, + detail: result.error.message, + stdout: '', + stderr: '', + }; + } + + return { + available: result.status === 0, + detail: + result.status === 0 + ? (result.stdout || result.stderr).trim() + : (result.stderr || result.stdout).trim(), + stdout: result.stdout, + stderr: result.stderr, + }; +} + +export function ensureDir(dir) { + fs.mkdirSync(dir, { recursive: true }); +} + +export function readJsonFile(file) { + return JSON.parse(fs.readFileSync(file, 'utf8')); +} + +export function writeJsonFile(file, value) { + ensureDir(path.dirname(file)); + const tempFile = `${file}.${process.pid}.tmp`; + fs.writeFileSync(tempFile, `${JSON.stringify(value, null, 2)}\n`); + fs.renameSync(tempFile, file); +} + +export function appendFile(file, value) { + ensureDir(path.dirname(file)); + fs.appendFileSync(file, value); +} + +export function nowIso() { + return new Date().toISOString(); +} + +export function hashWorkspace(workspaceRoot) { + return crypto.createHash('sha256').update(workspaceRoot).digest('hex').slice(0, 16); +} + +export function resolveWorkspaceRoot(cwd) { + const gitRoot = commandStatus('git', ['rev-parse', '--show-toplevel'], { + cwd, + }); + return gitRoot.available ? gitRoot.stdout.trim() : cwd; +} + +export function isProcessAlive(pid) { + if (!pid) { + return false; + } + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error?.code === 'EPERM'; + } +} + +export function killPid(pid) { + if (!pid) { + return false; + } + try { + if (process.platform !== 'win32') { + process.kill(-pid, 'SIGTERM'); + } else { + process.kill(pid, 'SIGTERM'); + } + return true; + } catch { + try { + process.kill(pid, 'SIGTERM'); + return true; + } catch { + return false; + } + } +} diff --git a/plugins/qwen-codex/lib/jobs.mjs b/plugins/qwen-codex/lib/jobs.mjs new file mode 100644 index 0000000..7ad8ccc --- /dev/null +++ b/plugins/qwen-codex/lib/jobs.mjs @@ -0,0 +1,157 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { + ensureDir, + hashWorkspace, + isProcessAlive, + nowIso, + readJsonFile, + resolveWorkspaceRoot, + writeJsonFile, +} from './fs-utils.mjs'; + +export function getWorkspaceState(cwd, stateRoot) { + const workspaceRoot = resolveWorkspaceRoot(cwd); + const workspaceId = hashWorkspace(workspaceRoot); + const root = path.join(stateRoot, 'workspaces', workspaceId); + + return { + workspaceRoot, + workspaceId, + root, + jobsDir: path.join(root, 'jobs'), + logsDir: path.join(root, 'logs'), + }; +} + +function jobFileFor(cwd, jobId, stateRoot) { + return path.join(getWorkspaceState(cwd, stateRoot).jobsDir, `${jobId}.json`); +} + +export function readJob(cwd, jobId, stateRoot) { + const jobFile = jobFileFor(cwd, jobId, stateRoot); + if (!fs.existsSync(jobFile)) { + throw new Error(`Job not found: ${jobId}`); + } + return readJsonFile(jobFile); +} + +export function writeJob(job) { + writeJsonFile(job.jobFile, { + ...job, + updatedAt: nowIso(), + }); +} + +export function updateJob(cwd, jobId, patch, stateRoot) { + const job = readJob(cwd, jobId, stateRoot); + const nextJob = { + ...job, + ...patch, + updatedAt: nowIso(), + }; + writeJsonFile(nextJob.jobFile, nextJob); + return nextJob; +} + +export function listJobs(cwd, stateRoot) { + const state = getWorkspaceState(cwd, stateRoot); + if (!fs.existsSync(state.jobsDir)) { + return []; + } + + return fs + .readdirSync(state.jobsDir) + .filter((name) => name.endsWith('.json')) + .map((name) => { + try { + return readJsonFile(path.join(state.jobsDir, name)); + } catch { + return null; + } + }) + .filter(Boolean) + .sort((left, right) => String(right.startedAt).localeCompare(String(left.startedAt))); +} + +export function createJob(cwd, request, stateRoot, idPrefix) { + const state = getWorkspaceState(cwd, stateRoot); + ensureDir(state.jobsDir); + ensureDir(state.logsDir); + + const id = `${idPrefix}-${crypto.randomBytes(4).toString('hex')}`; + const { prompt, rawArguments, model, ...extra } = request; + const job = { + id, + kind: 'review', + ...extra, + status: 'queued', + cwd, + workspaceRoot: state.workspaceRoot, + workspaceId: state.workspaceId, + prompt, + rawArguments, + model, + workerPid: null, + childPid: null, + exitCode: null, + signal: null, + sessionId: null, + result: null, + summary: null, + error: null, + startedAt: nowIso(), + updatedAt: nowIso(), + endedAt: null, + jobFile: path.join(state.jobsDir, `${id}.json`), + stdoutFile: path.join(state.logsDir, `${id}.stdout.log`), + stderrFile: path.join(state.logsDir, `${id}.stderr.log`), + jsonlFile: path.join(state.logsDir, `${id}.jsonl`), + }; + + writeJob(job); + return job; +} + +export function refreshJobStatus(cwd, job, stateRoot) { + if ( + (job.status === 'queued' || job.status === 'running') && + job.workerPid && + !isProcessAlive(job.workerPid) + ) { + return updateJob(cwd, job.id, { + status: 'crashed', + endedAt: nowIso(), + error: 'Worker process exited without recording a final status.', + }, stateRoot); + } + return job; +} + +export function resolveJob(cwd, jobId, stateRoot, label) { + if (jobId) { + return refreshJobStatus(cwd, readJob(cwd, jobId, stateRoot), stateRoot); + } + + const job = listJobs(cwd, stateRoot)[0]; + if (!job) { + throw new Error(`No ${label} jobs found for this workspace.`); + } + return refreshJobStatus(cwd, job, stateRoot); +} + +export function renderJobLine(job) { + const bits = [job.id, job.status]; + if (job.model) { + bits.push(`model=${job.model}`); + } + if (job.sessionId) { + bits.push(`session=${job.sessionId}`); + } + if (job.summary) { + bits.push(job.summary); + } + return bits.join(' | '); +} diff --git a/plugins/qwen-codex/lib/tracked-review.mjs b/plugins/qwen-codex/lib/tracked-review.mjs new file mode 100644 index 0000000..f1966eb --- /dev/null +++ b/plugins/qwen-codex/lib/tracked-review.mjs @@ -0,0 +1,73 @@ +import process from 'node:process'; + +import { killPid, nowIso } from './fs-utils.mjs'; +import { readJob, updateJob } from './jobs.mjs'; + +export async function runTrackedReviewJob(cwd, jobId, stateRoot, runReviewFn) { + if (readJob(cwd, jobId, stateRoot).status === 'canceled') { + return { + finalStatus: 'canceled', + result: { + status: 'canceled', + exitCode: null, + signal: null, + sessionId: null, + result: '', + stderr: '', + error: 'Canceled by user.', + }, + }; + } + const job = updateJob(cwd, jobId, { + status: 'running', + workerPid: process.pid, + }, stateRoot); + + const result = await runReviewFn(cwd, job.prompt, { + model: job.model, + stdoutFile: job.stdoutFile, + stderrFile: job.stderrFile, + jsonlFile: job.jsonlFile, + onChildPid(pid) { + if (readJob(cwd, jobId, stateRoot).status === 'canceled') { + killPid(pid); + return; + } + updateJob(cwd, jobId, { + childPid: pid, + status: 'running', + }, stateRoot); + }, + }); + + const latestJob = readJob(cwd, jobId, stateRoot); + const finalStatus = latestJob.status === 'canceled' ? 'canceled' : result.status; + updateJob(cwd, jobId, { + status: finalStatus, + childPid: null, + exitCode: result.exitCode, + signal: result.signal, + sessionId: result.sessionId, + result: result.result, + summary: result.result ? result.result.split(/\r?\n/).find(Boolean) ?? null : null, + error: + finalStatus === 'canceled' + ? latestJob.error + : finalStatus === 'succeeded' + ? null + : result.error, + endedAt: nowIso(), + }, stateRoot); + + return { finalStatus, result }; +} + +export function writeReviewOutput(finalStatus, result) { + if (result.result) { + process.stdout.write(`${result.result.trimEnd()}\n`); + } + if (result.stderr) { + process.stderr.write(result.stderr); + } + process.exitCode = finalStatus === 'succeeded' ? 0 : 1; +} diff --git a/plugins/qwen-codex/scripts/qwen-companion.mjs b/plugins/qwen-codex/scripts/qwen-companion.mjs new file mode 100755 index 0000000..17a2c0c --- /dev/null +++ b/plugins/qwen-codex/scripts/qwen-companion.mjs @@ -0,0 +1,447 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process'; +import os from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; + +import { + isModeFlag, + isModelEqualsFlag, + normalizeArgv, + removeRawTokenSpans, + scanRawArgumentString, + validateModelValue, +} from '../lib/argv.mjs'; +import { appendFile, commandStatus, killPid } from '../lib/fs-utils.mjs'; +import { + createJob, + listJobs, + refreshJobStatus, + renderJobLine, + resolveJob, + updateJob, +} from '../lib/jobs.mjs'; +import { runTrackedReviewJob, writeReviewOutput } from '../lib/tracked-review.mjs'; + +const INSTALL_COMMAND = 'npm install -g @qwen-code/qwen-code'; +const REVIEW_ONLY_SYSTEM_PROMPT = [ + 'You are running from the Claude Code Qwen plugin in review-only mode.', + 'When executing /review, report findings only.', + 'Do not apply autofixes, edit files, stage files, commit, push, or mutate the working tree.', +].join(' '); + +function getStateRoot() { + return process.env.QWEN_PLUGIN_STATE_DIR + ? path.resolve(process.env.QWEN_PLUGIN_STATE_DIR) + : path.join(os.homedir(), '.qwen-code-plugin-cc'); +} + +function parseReviewInput(args) { + if (args.length === 1) { + const raw = String(args[0] ?? ''); + const removeSpans = []; + let model = null; + const tokens = scanRawArgumentString(raw); + + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (isModeFlag(token, '--wait')) { + removeSpans.push(token); + } else if (isModeFlag(token, '--background')) { + removeSpans.push(token); + } else if (isModelEqualsFlag(token)) { + model = validateModelValue('--model', token.value.slice('--model='.length)); + removeSpans.push(token); + } else if (!token.quoted && !token.escaped && token.value === '--model=') { + throw new Error('Missing value for --model.'); + } else if (isModeFlag(token, '--model') || isModeFlag(token, '-m')) { + const valueToken = tokens[index + 1]; + model = validateModelValue(token.value, valueToken?.value); + removeSpans.push(token, valueToken); + index += 1; + } + } + + return { + model, + rawArguments: removeRawTokenSpans(raw, removeSpans), + }; + } + + const tokens = normalizeArgv(args); + let model = null; + const reviewTokens = []; + + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (token === '--wait') { + continue; + } else if (token === '--background') { + continue; + } else if (token.startsWith('--model=') && token.length > '--model='.length) { + model = validateModelValue('--model', token.slice('--model='.length)); + } else if (token === '--model=') { + throw new Error('Missing value for --model.'); + } else if (token === '--model' || token === '-m') { + const value = tokens[index + 1]; + model = validateModelValue(token, value); + index += 1; + } else { + reviewTokens.push(token); + } + } + + return { + model, + rawArguments: reviewTokens.join(' '), + }; +} + +function buildQwenReviewPrompt(rawArguments) { + const trimmed = rawArguments.trim(); + return trimmed ? `/review ${trimmed}` : '/review'; +} + +function buildQwenArgs(prompt, options = {}) { + const args = [ + '--approval-mode', + 'yolo', + '--append-system-prompt', + REVIEW_ONLY_SYSTEM_PROMPT, + ]; + + if (process.env.QWEN_PLUGIN_NO_SANDBOX !== '1') { + args.push('--sandbox'); + } + + if (options.model) { + args.push('--model', options.model); + } + + if (options.streamJson) { + args.push('--output-format', 'stream-json', '--include-partial-messages'); + } + + args.push('--prompt', prompt); + return args; +} + +function extractEventText(event) { + if (typeof event?.result === 'string') { + return event.result; + } + + const content = event?.message?.content; + if (!Array.isArray(content)) { + return ''; + } + + return content + .map((item) => (item?.type === 'text' && typeof item.text === 'string' ? item.text : '')) + .join(''); +} + +function processJsonLine(line, state) { + let event; + try { + event = JSON.parse(line); + } catch { + return; + } + + if (event.session_id || event.sessionId) { + state.sessionId = event.session_id ?? event.sessionId; + } + + const text = extractEventText(event); + if (text) { + state.lastText = text; + } +} + +function runQwenReview(cwd, prompt, options = {}) { + return new Promise((resolve) => { + const child = spawn( + 'qwen', + buildQwenArgs(prompt, { + model: options.model, + streamJson: true, + }), + { + cwd, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + + const parsedState = { + sessionId: null, + lastText: '', + }; + let stdoutBuffer = ''; + let stderr = ''; + + if (options.onChildPid) { + options.onChildPid(child.pid ?? null); + } + + child.stdout.on('data', (chunk) => { + const text = chunk.toString('utf8'); + if (options.stdoutFile) { + appendFile(options.stdoutFile, text); + } + stdoutBuffer += text; + const lines = stdoutBuffer.split(/\r?\n/); + stdoutBuffer = lines.pop() ?? ''; + for (const line of lines) { + if (!line.trim()) { + continue; + } + if (options.jsonlFile) { + appendFile(options.jsonlFile, `${line}\n`); + } + processJsonLine(line, parsedState); + } + }); + + child.stderr.on('data', (chunk) => { + const text = chunk.toString('utf8'); + stderr += text; + if (options.stderrFile) { + appendFile(options.stderrFile, text); + } + }); + + child.on('error', (error) => { + resolve({ + status: 'failed', + exitCode: null, + signal: null, + sessionId: parsedState.sessionId, + result: parsedState.lastText, + stderr, + error: error.message, + }); + }); + + child.on('close', (code, signal) => { + if (stdoutBuffer.trim()) { + if (options.jsonlFile) { + appendFile(options.jsonlFile, `${stdoutBuffer}\n`); + } + processJsonLine(stdoutBuffer, parsedState); + } + resolve({ + status: code === 0 ? 'succeeded' : 'failed', + exitCode: code, + signal, + sessionId: parsedState.sessionId, + result: parsedState.lastText, + stderr, + error: code === 0 ? null : stderr.trim() || `qwen exited with code ${code ?? signal}`, + }); + }); + }); +} + +function printUsage() { + console.log( + [ + 'Usage:', + ' node scripts/qwen-companion.mjs setup', + ' node scripts/qwen-companion.mjs review [--wait|--background] [--model model] [review arguments]', + ' node scripts/qwen-companion.mjs status [job-id]', + ' node scripts/qwen-companion.mjs result [job-id]', + ' node scripts/qwen-companion.mjs cancel [job-id]', + ].join('\n'), + ); +} + +function getQwenAvailability(cwd) { + return commandStatus('qwen', ['--version'], { cwd }); +} + +function ensureQwenAvailable(cwd) { + const qwenStatus = getQwenAvailability(cwd); + if (!qwenStatus.available) { + throw new Error( + `Qwen Code CLI is not installed or is not on PATH.\nInstall it with: ${INSTALL_COMMAND}`, + ); + } + return qwenStatus; +} + +function handleSetup() { + const cwd = process.cwd(); + const nodeStatus = commandStatus('node', ['--version'], { cwd }); + const npmStatus = commandStatus('npm', ['--version'], { cwd }); + const qwenStatus = getQwenAvailability(cwd); + + console.log('Qwen Code plugin for Claude Code'); + console.log(''); + console.log(`Node.js: ${nodeStatus.available ? nodeStatus.detail : 'not found'}`); + console.log(`npm: ${npmStatus.available ? npmStatus.detail : 'not found'}`); + console.log(`qwen: ${qwenStatus.available ? qwenStatus.detail : 'not found'}`); + console.log(''); + + if (!qwenStatus.available) { + console.log('Qwen Code CLI is not installed or is not on PATH.'); + console.log(`Install it with: ${INSTALL_COMMAND}`); + process.exitCode = 1; + return; + } + + console.log('Ready. Try: /qwen:review'); +} + +async function handleReview(args) { + const cwd = process.cwd(); + ensureQwenAvailable(cwd); + + const request = parseReviewInput(args); + const prompt = buildQwenReviewPrompt(request.rawArguments); + const stateRoot = getStateRoot(); + const job = createJob(cwd, { + model: request.model, + prompt, + rawArguments: request.rawArguments, + }, stateRoot, 'qwen-review'); + + const { finalStatus, result } = await runTrackedReviewJob(cwd, job.id, stateRoot, runQwenReview); + writeReviewOutput(finalStatus, result); +} + +async function handleWorker(args) { + const [jobId] = args; + if (!jobId) { + throw new Error('Missing job id.'); + } + + await runTrackedReviewJob(process.cwd(), jobId, getStateRoot(), runQwenReview); +} + +function handleStatus(args) { + const cwd = process.cwd(); + const stateRoot = getStateRoot(); + const [jobId] = normalizeArgv(args); + + if (jobId) { + const job = resolveJob(cwd, jobId, stateRoot, 'Qwen'); + console.log(renderJobLine(job)); + if (job.model) { + console.log(`Model: ${job.model}`); + } + console.log(`Started: ${job.startedAt}`); + console.log(`Updated: ${job.updatedAt}`); + if (job.endedAt) { + console.log(`Ended: ${job.endedAt}`); + } + console.log(`Log: ${job.stdoutFile}`); + return; + } + + const jobs = listJobs(cwd, stateRoot).slice(0, 10).map((job) => refreshJobStatus(cwd, job, stateRoot)); + if (jobs.length === 0) { + console.log('No Qwen jobs found for this workspace.'); + return; + } + for (const job of jobs) { + console.log(renderJobLine(job)); + } +} + +function handleResult(args) { + const cwd = process.cwd(); + const stateRoot = getStateRoot(); + const [jobId] = normalizeArgv(args); + const job = resolveJob(cwd, jobId, stateRoot, 'Qwen'); + + console.log(`Job: ${job.id}`); + console.log(`Status: ${job.status}`); + if (job.model) { + console.log(`Model: ${job.model}`); + } + if (job.sessionId) { + console.log(`Session: ${job.sessionId}`); + } + console.log(''); + + if (job.result) { + console.log(job.result.trimEnd()); + return; + } + + if (job.status === 'queued' || job.status === 'running') { + console.log('Qwen review is still running.'); + console.log(`Use /qwen:status ${job.id} to check progress.`); + return; + } + + if (job.error) { + console.log(job.error); + return; + } + + console.log('No result was recorded.'); +} + +function handleCancel(args) { + const cwd = process.cwd(); + const stateRoot = getStateRoot(); + const [jobId] = normalizeArgv(args); + const job = resolveJob(cwd, jobId, stateRoot, 'Qwen'); + + if (!['queued', 'running'].includes(job.status)) { + console.log(`Qwen job ${job.id} is already ${job.status}.`); + return; + } + + updateJob(cwd, job.id, { + status: 'canceled', + endedAt: new Date().toISOString(), + error: 'Canceled by user.', + }, stateRoot); + killPid(job.childPid); + killPid(job.workerPid); + console.log(`Qwen review cancel requested: ${job.id}`); +} + +async function main() { + const [command, ...args] = process.argv.slice(2); + + try { + switch (command) { + case 'setup': + handleSetup(); + break; + case 'review': + await handleReview(args); + break; + case 'worker': + await handleWorker(args); + break; + case 'status': + handleStatus(args); + break; + case 'result': + handleResult(args); + break; + case 'cancel': + handleCancel(args); + break; + case undefined: + case '--help': + case '-h': + printUsage(); + break; + default: + throw new Error(`Unknown command: ${command}`); + } + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} + +await main(); diff --git a/plugins/qwen-codex/skills/qwen-review/SKILL.md b/plugins/qwen-codex/skills/qwen-review/SKILL.md new file mode 100644 index 0000000..d707912 --- /dev/null +++ b/plugins/qwen-codex/skills/qwen-review/SKILL.md @@ -0,0 +1,80 @@ +--- +name: qwen-review +description: Run Qwen Code review on local changes, pull requests, or specific files using the Qwen Code CLI. +--- + +# Qwen Code Review + +Run Qwen Code reviews through the companion script bundled with this plugin. + +## CRITICAL: Use ONLY the companion script + +**Do NOT run `qwen` CLI commands directly.** Do not use `qwen review`, `qwen review fetch-pr`, or any other qwen subcommands. The companion script handles all qwen invocation, argument construction, system prompts, and output parsing automatically. + +## Finding the companion script + +The companion script is at `scripts/qwen-companion.mjs` relative to this plugin's install root. Find it with: + +```bash +QWEN_COMPANION=$(find ~/.codex/plugins/cache -path '*/qwen/*/scripts/qwen-companion.mjs' 2>/dev/null | head -1) +``` + +If not found, tell the user to reinstall the plugin. + +## Prerequisites + +The `qwen` CLI must be installed. The companion script checks this automatically. If missing, it will tell you to run: + +```bash +npm install -g @qwen-code/qwen-code +``` + +## Running a review + +Reviews can take several minutes. **Always run in the background** to avoid blocking or context truncation. + +### Step 1: Start the review + +```bash +QWEN_COMPANION=$(find ~/.codex/plugins/cache -path '*/qwen/*/scripts/qwen-companion.mjs' 2>/dev/null | head -1) +QWEN_REVIEW_OUT=$(mktemp /tmp/qwen-review-XXXXXX.txt) +node "$QWEN_COMPANION" review "--wait $REVIEW_ARGS" > "$QWEN_REVIEW_OUT" 2>&1 & +QWEN_PID=$! +echo "Qwen review started (PID $QWEN_PID), output: $QWEN_REVIEW_OUT" +``` + +Replace `$REVIEW_ARGS` with the user's review target: + +- **Local changes:** (empty — just `--wait`) +- **PR number:** `--wait 6138` +- **PR with inline comments:** `--wait 6138 --comment` +- **Specific file:** `--wait src/auth.ts` +- **With model selection:** `--wait --model qwen3-coder-plus 6138` + +### Step 2: Wait and report + +Tell the user the review is running and may take a few minutes. + +```bash +wait $QWEN_PID 2>/dev/null +echo "exit: $?" +``` + +### Step 3: Read and present results + +```bash +cat "$QWEN_REVIEW_OUT" +``` + +Present the review findings to the user. Do not attempt to fix any issues unless the user explicitly asks. + +## Checking setup + +```bash +node "$QWEN_COMPANION" setup +``` + +## Constraints + +- **Review only.** Never fix, patch, stage, commit, or push based on review findings. +- **Never run qwen directly.** Always use the companion script. diff --git a/plugins/qwen-codex/skills/qwen-review/agents/openai.yaml b/plugins/qwen-codex/skills/qwen-review/agents/openai.yaml new file mode 100644 index 0000000..8d2cb2b --- /dev/null +++ b/plugins/qwen-codex/skills/qwen-review/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Qwen Code Review" + short_description: "Run Qwen Code review on changes, PRs, or files" + default_prompt: "Use $qwen-review to review the current changes" diff --git a/plugins/qwen/.claude-plugin/plugin.json b/plugins/qwen/.claude-plugin/plugin.json index 59b9ecf..6d7dd63 100644 --- a/plugins/qwen/.claude-plugin/plugin.json +++ b/plugins/qwen/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "qwen", - "version": "0.4.0", + "version": "0.5.0", "description": "Run Qwen Code review from Claude Code.", "author": { "name": "doudouOUC" diff --git a/plugins/qwen/lib/argv.mjs b/plugins/qwen/lib/argv.mjs new file mode 100644 index 0000000..132fffd --- /dev/null +++ b/plugins/qwen/lib/argv.mjs @@ -0,0 +1,122 @@ +export function scanRawArgumentString(raw) { + const source = String(raw ?? ''); + const tokens = []; + let current = ''; + let start = null; + let quote = null; + let escaped = false; + let quoted = false; + let escapedToken = false; + + function startToken(index) { + if (start === null) { + start = index; + } + } + + function finishToken(end) { + if (start === null) { + return; + } + tokens.push({ + value: current, + start, + end, + quoted, + escaped: escapedToken, + }); + current = ''; + start = null; + quoted = false; + escapedToken = false; + } + + for (let index = 0; index < source.length; index += 1) { + const char = source[index]; + if (escaped) { + current += char; + escaped = false; + continue; + } + if (char === '\\') { + startToken(index); + escaped = true; + escapedToken = true; + continue; + } + if (quote) { + if (char === quote) { + quote = null; + } else { + current += char; + } + continue; + } + if (char === '"' || char === "'") { + startToken(index); + quote = char; + quoted = true; + continue; + } + if (/\s/.test(char)) { + finishToken(index); + continue; + } + startToken(index); + current += char; + } + + if (escaped) { + current += '\\'; + } + finishToken(source.length); + return tokens; +} + +export function splitRawArgumentString(raw) { + return scanRawArgumentString(raw).map((token) => token.value); +} + +export function normalizeArgv(args) { + return args.length === 1 ? splitRawArgumentString(args[0]) : args; +} + +export function isModeFlag(token, flag) { + return token.value === flag && !token.quoted && !token.escaped; +} + +export function isModelEqualsFlag(token) { + return ( + !token.quoted && + !token.escaped && + token.value.startsWith('--model=') && + token.value.length > '--model='.length + ); +} + +export function validateModelValue(flag, value) { + if (!value || value.startsWith('-')) { + throw new Error(`Missing value for ${flag}.`); + } + return value; +} + +export function removeRawTokenSpans(raw, spans) { + const source = String(raw ?? ''); + let result = String(raw ?? ''); + const expandedSpans = spans.map((span) => { + let end = span.end; + while (end < source.length && /\s/.test(source[end])) { + end += 1; + } + return { + ...span, + end, + }; + }); + + for (const span of expandedSpans.sort((left, right) => right.start - left.start)) { + result = `${result.slice(0, span.start)}${result.slice(span.end)}`; + } + return result.trim(); +} diff --git a/plugins/qwen/lib/fs-utils.mjs b/plugins/qwen/lib/fs-utils.mjs new file mode 100644 index 0000000..b73166a --- /dev/null +++ b/plugins/qwen/lib/fs-utils.mjs @@ -0,0 +1,100 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { spawnSync } from 'node:child_process'; + +export function commandStatus(command, args = [], options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + + if (result.error) { + return { + available: false, + detail: result.error.message, + stdout: '', + stderr: '', + }; + } + + return { + available: result.status === 0, + detail: + result.status === 0 + ? (result.stdout || result.stderr).trim() + : (result.stderr || result.stdout).trim(), + stdout: result.stdout, + stderr: result.stderr, + }; +} + +export function ensureDir(dir) { + fs.mkdirSync(dir, { recursive: true }); +} + +export function readJsonFile(file) { + return JSON.parse(fs.readFileSync(file, 'utf8')); +} + +export function writeJsonFile(file, value) { + ensureDir(path.dirname(file)); + const tempFile = `${file}.${process.pid}.tmp`; + fs.writeFileSync(tempFile, `${JSON.stringify(value, null, 2)}\n`); + fs.renameSync(tempFile, file); +} + +export function appendFile(file, value) { + ensureDir(path.dirname(file)); + fs.appendFileSync(file, value); +} + +export function nowIso() { + return new Date().toISOString(); +} + +export function hashWorkspace(workspaceRoot) { + return crypto.createHash('sha256').update(workspaceRoot).digest('hex').slice(0, 16); +} + +export function resolveWorkspaceRoot(cwd) { + const gitRoot = commandStatus('git', ['rev-parse', '--show-toplevel'], { + cwd, + }); + return gitRoot.available ? gitRoot.stdout.trim() : cwd; +} + +export function isProcessAlive(pid) { + if (!pid) { + return false; + } + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error?.code === 'EPERM'; + } +} + +export function killPid(pid) { + if (!pid) { + return false; + } + try { + if (process.platform !== 'win32') { + process.kill(-pid, 'SIGTERM'); + } else { + process.kill(pid, 'SIGTERM'); + } + return true; + } catch { + try { + process.kill(pid, 'SIGTERM'); + return true; + } catch { + return false; + } + } +} diff --git a/plugins/qwen/lib/jobs.mjs b/plugins/qwen/lib/jobs.mjs new file mode 100644 index 0000000..7ad8ccc --- /dev/null +++ b/plugins/qwen/lib/jobs.mjs @@ -0,0 +1,157 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { + ensureDir, + hashWorkspace, + isProcessAlive, + nowIso, + readJsonFile, + resolveWorkspaceRoot, + writeJsonFile, +} from './fs-utils.mjs'; + +export function getWorkspaceState(cwd, stateRoot) { + const workspaceRoot = resolveWorkspaceRoot(cwd); + const workspaceId = hashWorkspace(workspaceRoot); + const root = path.join(stateRoot, 'workspaces', workspaceId); + + return { + workspaceRoot, + workspaceId, + root, + jobsDir: path.join(root, 'jobs'), + logsDir: path.join(root, 'logs'), + }; +} + +function jobFileFor(cwd, jobId, stateRoot) { + return path.join(getWorkspaceState(cwd, stateRoot).jobsDir, `${jobId}.json`); +} + +export function readJob(cwd, jobId, stateRoot) { + const jobFile = jobFileFor(cwd, jobId, stateRoot); + if (!fs.existsSync(jobFile)) { + throw new Error(`Job not found: ${jobId}`); + } + return readJsonFile(jobFile); +} + +export function writeJob(job) { + writeJsonFile(job.jobFile, { + ...job, + updatedAt: nowIso(), + }); +} + +export function updateJob(cwd, jobId, patch, stateRoot) { + const job = readJob(cwd, jobId, stateRoot); + const nextJob = { + ...job, + ...patch, + updatedAt: nowIso(), + }; + writeJsonFile(nextJob.jobFile, nextJob); + return nextJob; +} + +export function listJobs(cwd, stateRoot) { + const state = getWorkspaceState(cwd, stateRoot); + if (!fs.existsSync(state.jobsDir)) { + return []; + } + + return fs + .readdirSync(state.jobsDir) + .filter((name) => name.endsWith('.json')) + .map((name) => { + try { + return readJsonFile(path.join(state.jobsDir, name)); + } catch { + return null; + } + }) + .filter(Boolean) + .sort((left, right) => String(right.startedAt).localeCompare(String(left.startedAt))); +} + +export function createJob(cwd, request, stateRoot, idPrefix) { + const state = getWorkspaceState(cwd, stateRoot); + ensureDir(state.jobsDir); + ensureDir(state.logsDir); + + const id = `${idPrefix}-${crypto.randomBytes(4).toString('hex')}`; + const { prompt, rawArguments, model, ...extra } = request; + const job = { + id, + kind: 'review', + ...extra, + status: 'queued', + cwd, + workspaceRoot: state.workspaceRoot, + workspaceId: state.workspaceId, + prompt, + rawArguments, + model, + workerPid: null, + childPid: null, + exitCode: null, + signal: null, + sessionId: null, + result: null, + summary: null, + error: null, + startedAt: nowIso(), + updatedAt: nowIso(), + endedAt: null, + jobFile: path.join(state.jobsDir, `${id}.json`), + stdoutFile: path.join(state.logsDir, `${id}.stdout.log`), + stderrFile: path.join(state.logsDir, `${id}.stderr.log`), + jsonlFile: path.join(state.logsDir, `${id}.jsonl`), + }; + + writeJob(job); + return job; +} + +export function refreshJobStatus(cwd, job, stateRoot) { + if ( + (job.status === 'queued' || job.status === 'running') && + job.workerPid && + !isProcessAlive(job.workerPid) + ) { + return updateJob(cwd, job.id, { + status: 'crashed', + endedAt: nowIso(), + error: 'Worker process exited without recording a final status.', + }, stateRoot); + } + return job; +} + +export function resolveJob(cwd, jobId, stateRoot, label) { + if (jobId) { + return refreshJobStatus(cwd, readJob(cwd, jobId, stateRoot), stateRoot); + } + + const job = listJobs(cwd, stateRoot)[0]; + if (!job) { + throw new Error(`No ${label} jobs found for this workspace.`); + } + return refreshJobStatus(cwd, job, stateRoot); +} + +export function renderJobLine(job) { + const bits = [job.id, job.status]; + if (job.model) { + bits.push(`model=${job.model}`); + } + if (job.sessionId) { + bits.push(`session=${job.sessionId}`); + } + if (job.summary) { + bits.push(job.summary); + } + return bits.join(' | '); +} diff --git a/plugins/qwen/lib/tracked-review.mjs b/plugins/qwen/lib/tracked-review.mjs new file mode 100644 index 0000000..f1966eb --- /dev/null +++ b/plugins/qwen/lib/tracked-review.mjs @@ -0,0 +1,73 @@ +import process from 'node:process'; + +import { killPid, nowIso } from './fs-utils.mjs'; +import { readJob, updateJob } from './jobs.mjs'; + +export async function runTrackedReviewJob(cwd, jobId, stateRoot, runReviewFn) { + if (readJob(cwd, jobId, stateRoot).status === 'canceled') { + return { + finalStatus: 'canceled', + result: { + status: 'canceled', + exitCode: null, + signal: null, + sessionId: null, + result: '', + stderr: '', + error: 'Canceled by user.', + }, + }; + } + const job = updateJob(cwd, jobId, { + status: 'running', + workerPid: process.pid, + }, stateRoot); + + const result = await runReviewFn(cwd, job.prompt, { + model: job.model, + stdoutFile: job.stdoutFile, + stderrFile: job.stderrFile, + jsonlFile: job.jsonlFile, + onChildPid(pid) { + if (readJob(cwd, jobId, stateRoot).status === 'canceled') { + killPid(pid); + return; + } + updateJob(cwd, jobId, { + childPid: pid, + status: 'running', + }, stateRoot); + }, + }); + + const latestJob = readJob(cwd, jobId, stateRoot); + const finalStatus = latestJob.status === 'canceled' ? 'canceled' : result.status; + updateJob(cwd, jobId, { + status: finalStatus, + childPid: null, + exitCode: result.exitCode, + signal: result.signal, + sessionId: result.sessionId, + result: result.result, + summary: result.result ? result.result.split(/\r?\n/).find(Boolean) ?? null : null, + error: + finalStatus === 'canceled' + ? latestJob.error + : finalStatus === 'succeeded' + ? null + : result.error, + endedAt: nowIso(), + }, stateRoot); + + return { finalStatus, result }; +} + +export function writeReviewOutput(finalStatus, result) { + if (result.result) { + process.stdout.write(`${result.result.trimEnd()}\n`); + } + if (result.stderr) { + process.stderr.write(result.stderr); + } + process.exitCode = finalStatus === 'succeeded' ? 0 : 1; +} diff --git a/plugins/qwen/scripts/qwen-companion.mjs b/plugins/qwen/scripts/qwen-companion.mjs index c391bae..17a2c0c 100755 --- a/plugins/qwen/scripts/qwen-companion.mjs +++ b/plugins/qwen/scripts/qwen-companion.mjs @@ -1,12 +1,29 @@ #!/usr/bin/env node -import { spawn, spawnSync } from 'node:child_process'; -import crypto from 'node:crypto'; -import fs from 'node:fs'; +import { spawn } from 'node:child_process'; import os from 'node:os'; import path from 'node:path'; import process from 'node:process'; +import { + isModeFlag, + isModelEqualsFlag, + normalizeArgv, + removeRawTokenSpans, + scanRawArgumentString, + validateModelValue, +} from '../lib/argv.mjs'; +import { appendFile, commandStatus, killPid } from '../lib/fs-utils.mjs'; +import { + createJob, + listJobs, + refreshJobStatus, + renderJobLine, + resolveJob, + updateJob, +} from '../lib/jobs.mjs'; +import { runTrackedReviewJob, writeReviewOutput } from '../lib/tracked-review.mjs'; + const INSTALL_COMMAND = 'npm install -g @qwen-code/qwen-code'; const REVIEW_ONLY_SYSTEM_PROMPT = [ 'You are running from the Claude Code Qwen plugin in review-only mode.', @@ -14,127 +31,10 @@ const REVIEW_ONLY_SYSTEM_PROMPT = [ 'Do not apply autofixes, edit files, stage files, commit, push, or mutate the working tree.', ].join(' '); -function scanRawArgumentString(raw) { - const source = String(raw ?? ''); - const tokens = []; - let current = ''; - let start = null; - let quote = null; - let escaped = false; - let quoted = false; - let escapedToken = false; - - function startToken(index) { - if (start === null) { - start = index; - } - } - - function finishToken(end) { - if (start === null) { - return; - } - tokens.push({ - value: current, - start, - end, - quoted, - escaped: escapedToken, - }); - current = ''; - start = null; - quoted = false; - escapedToken = false; - } - - for (let index = 0; index < source.length; index += 1) { - const char = source[index]; - if (escaped) { - current += char; - escaped = false; - continue; - } - if (char === '\\') { - startToken(index); - escaped = true; - escapedToken = true; - continue; - } - if (quote) { - if (char === quote) { - quote = null; - } else { - current += char; - } - continue; - } - if (char === '"' || char === "'") { - startToken(index); - quote = char; - quoted = true; - continue; - } - if (/\s/.test(char)) { - finishToken(index); - continue; - } - startToken(index); - current += char; - } - - if (escaped) { - current += '\\'; - } - finishToken(source.length); - return tokens; -} - -function splitRawArgumentString(raw) { - return scanRawArgumentString(raw).map((token) => token.value); -} - -function normalizeArgv(args) { - return args.length === 1 ? splitRawArgumentString(args[0]) : args; -} - -function isModeFlag(token, flag) { - return token.value === flag && !token.quoted && !token.escaped; -} - -function isModelEqualsFlag(token) { - return ( - !token.quoted && - !token.escaped && - token.value.startsWith('--model=') && - token.value.length > '--model='.length - ); -} - -function validateModelValue(flag, value) { - if (!value || value.startsWith('-')) { - throw new Error(`Missing value for ${flag}.`); - } - return value; -} - -function removeRawTokenSpans(raw, spans) { - const source = String(raw ?? ''); - let result = String(raw ?? ''); - const expandedSpans = spans.map((span) => { - let end = span.end; - while (end < source.length && /\s/.test(source[end])) { - end += 1; - } - return { - ...span, - end, - }; - }); - - for (const span of expandedSpans.sort((left, right) => right.start - left.start)) { - result = `${result.slice(0, span.start)}${result.slice(span.end)}`; - } - return result.trim(); +function getStateRoot() { + return process.env.QWEN_PLUGIN_STATE_DIR + ? path.resolve(process.env.QWEN_PLUGIN_STATE_DIR) + : path.join(os.homedir(), '.qwen-code-plugin-cc'); } function parseReviewInput(args) { @@ -198,202 +98,6 @@ function parseReviewInput(args) { }; } -function commandStatus(command, args = [], options = {}) { - const result = spawnSync(command, args, { - cwd: options.cwd, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - }); - - if (result.error) { - return { - available: false, - detail: result.error.message, - stdout: '', - stderr: '', - }; - } - - return { - available: result.status === 0, - detail: - result.status === 0 - ? (result.stdout || result.stderr).trim() - : (result.stderr || result.stdout).trim(), - stdout: result.stdout, - stderr: result.stderr, - }; -} - -function ensureDir(dir) { - fs.mkdirSync(dir, { recursive: true }); -} - -function readJsonFile(file) { - return JSON.parse(fs.readFileSync(file, 'utf8')); -} - -function writeJsonFile(file, value) { - ensureDir(path.dirname(file)); - const tempFile = `${file}.${process.pid}.tmp`; - fs.writeFileSync(tempFile, `${JSON.stringify(value, null, 2)}\n`); - fs.renameSync(tempFile, file); -} - -function appendFile(file, value) { - ensureDir(path.dirname(file)); - fs.appendFileSync(file, value); -} - -function nowIso() { - return new Date().toISOString(); -} - -function hashWorkspace(workspaceRoot) { - return crypto.createHash('sha256').update(workspaceRoot).digest('hex').slice(0, 16); -} - -function resolveWorkspaceRoot(cwd) { - const gitRoot = commandStatus('git', ['rev-parse', '--show-toplevel'], { - cwd, - }); - return gitRoot.available ? gitRoot.stdout.trim() : cwd; -} - -function getStateRoot() { - return process.env.QWEN_PLUGIN_STATE_DIR - ? path.resolve(process.env.QWEN_PLUGIN_STATE_DIR) - : path.join(os.homedir(), '.qwen-code-plugin-cc'); -} - -function getWorkspaceState(cwd) { - const workspaceRoot = resolveWorkspaceRoot(cwd); - const workspaceId = hashWorkspace(workspaceRoot); - const root = path.join(getStateRoot(), 'workspaces', workspaceId); - - return { - workspaceRoot, - workspaceId, - root, - jobsDir: path.join(root, 'jobs'), - logsDir: path.join(root, 'logs'), - }; -} - -function jobFileFor(cwd, jobId) { - return path.join(getWorkspaceState(cwd).jobsDir, `${jobId}.json`); -} - -function readJob(cwd, jobId) { - const jobFile = jobFileFor(cwd, jobId); - if (!fs.existsSync(jobFile)) { - throw new Error(`Qwen job not found: ${jobId}`); - } - return readJsonFile(jobFile); -} - -function writeJob(job) { - writeJsonFile(job.jobFile, { - ...job, - updatedAt: nowIso(), - }); -} - -function updateJob(cwd, jobId, patch) { - const job = readJob(cwd, jobId); - const nextJob = { - ...job, - ...patch, - updatedAt: nowIso(), - }; - writeJsonFile(nextJob.jobFile, nextJob); - return nextJob; -} - -function listJobs(cwd) { - const state = getWorkspaceState(cwd); - if (!fs.existsSync(state.jobsDir)) { - return []; - } - - return fs - .readdirSync(state.jobsDir) - .filter((name) => name.endsWith('.json')) - .map((name) => { - try { - return readJsonFile(path.join(state.jobsDir, name)); - } catch { - return null; - } - }) - .filter(Boolean) - .sort((left, right) => String(right.startedAt).localeCompare(String(left.startedAt))); -} - -function createJob(cwd, request) { - const state = getWorkspaceState(cwd); - ensureDir(state.jobsDir); - ensureDir(state.logsDir); - - const id = `qwen-review-${crypto.randomBytes(4).toString('hex')}`; - const job = { - id, - kind: 'review', - status: 'queued', - cwd, - workspaceRoot: state.workspaceRoot, - workspaceId: state.workspaceId, - prompt: request.prompt, - rawArguments: request.rawArguments, - model: request.model, - workerPid: null, - qwenPid: null, - exitCode: null, - signal: null, - sessionId: null, - result: null, - summary: null, - error: null, - startedAt: nowIso(), - updatedAt: nowIso(), - endedAt: null, - jobFile: path.join(state.jobsDir, `${id}.json`), - stdoutFile: path.join(state.logsDir, `${id}.stdout.log`), - stderrFile: path.join(state.logsDir, `${id}.stderr.log`), - jsonlFile: path.join(state.logsDir, `${id}.jsonl`), - }; - - writeJob(job); - return job; -} - -function isProcessAlive(pid) { - if (!pid) { - return false; - } - try { - process.kill(pid, 0); - return true; - } catch (error) { - return error?.code === 'EPERM'; - } -} - -function refreshJobStatus(cwd, job) { - if ( - (job.status === 'queued' || job.status === 'running') && - job.workerPid && - !isProcessAlive(job.workerPid) - ) { - return updateJob(cwd, job.id, { - status: 'crashed', - endedAt: nowIso(), - error: 'Worker process exited without recording a final status.', - }); - } - return job; -} - function buildQwenReviewPrompt(rawArguments) { const trimmed = rawArguments.trim(); return trimmed ? `/review ${trimmed}` : '/review'; @@ -478,8 +182,8 @@ function runQwenReview(cwd, prompt, options = {}) { let stdoutBuffer = ''; let stderr = ''; - if (options.onQwenPid) { - options.onQwenPid(child.pid ?? null); + if (options.onChildPid) { + options.onChildPid(child.pid ?? null); } child.stdout.on('data', (chunk) => { @@ -597,13 +301,14 @@ async function handleReview(args) { const request = parseReviewInput(args); const prompt = buildQwenReviewPrompt(request.rawArguments); + const stateRoot = getStateRoot(); const job = createJob(cwd, { model: request.model, prompt, rawArguments: request.rawArguments, - }); + }, stateRoot, 'qwen-review'); - const { finalStatus, result } = await runTrackedReviewJob(cwd, job.id); + const { finalStatus, result } = await runTrackedReviewJob(cwd, job.id, stateRoot, runQwenReview); writeReviewOutput(finalStatus, result); } @@ -613,110 +318,16 @@ async function handleWorker(args) { throw new Error('Missing job id.'); } - await runTrackedReviewJob(process.cwd(), jobId); -} - -async function runTrackedReviewJob(cwd, jobId) { - if (readJob(cwd, jobId).status === 'canceled') { - return { - finalStatus: 'canceled', - result: { - status: 'canceled', - exitCode: null, - signal: null, - sessionId: null, - result: '', - stderr: '', - error: 'Canceled by user.', - }, - }; - } - const job = updateJob(cwd, jobId, { - status: 'running', - workerPid: process.pid, - }); - - const result = await runQwenReview(cwd, job.prompt, { - model: job.model, - stdoutFile: job.stdoutFile, - stderrFile: job.stderrFile, - jsonlFile: job.jsonlFile, - onQwenPid(pid) { - if (readJob(cwd, jobId).status === 'canceled') { - killPid(pid); - return; - } - updateJob(cwd, jobId, { - qwenPid: pid, - status: 'running', - }); - }, - }); - - const latestJob = readJob(cwd, jobId); - const finalStatus = latestJob.status === 'canceled' ? 'canceled' : result.status; - updateJob(cwd, jobId, { - status: finalStatus, - qwenPid: null, - exitCode: result.exitCode, - signal: result.signal, - sessionId: result.sessionId, - result: result.result, - summary: result.result ? result.result.split(/\r?\n/).find(Boolean) ?? null : null, - error: - finalStatus === 'canceled' - ? latestJob.error - : finalStatus === 'succeeded' - ? null - : result.error, - endedAt: nowIso(), - }); - - return { finalStatus, result }; -} - -function writeReviewOutput(finalStatus, result) { - if (result.result) { - process.stdout.write(`${result.result.trimEnd()}\n`); - } - if (result.stderr) { - process.stderr.write(result.stderr); - } - process.exitCode = finalStatus === 'succeeded' ? 0 : 1; -} - -function resolveJob(cwd, jobId) { - if (jobId) { - return refreshJobStatus(cwd, readJob(cwd, jobId)); - } - - const job = listJobs(cwd)[0]; - if (!job) { - throw new Error('No Qwen jobs found for this workspace.'); - } - return refreshJobStatus(cwd, job); -} - -function renderJobLine(job) { - const bits = [job.id, job.status]; - if (job.model) { - bits.push(`model=${job.model}`); - } - if (job.sessionId) { - bits.push(`session=${job.sessionId}`); - } - if (job.summary) { - bits.push(job.summary); - } - return bits.join(' | '); + await runTrackedReviewJob(process.cwd(), jobId, getStateRoot(), runQwenReview); } function handleStatus(args) { const cwd = process.cwd(); + const stateRoot = getStateRoot(); const [jobId] = normalizeArgv(args); if (jobId) { - const job = resolveJob(cwd, jobId); + const job = resolveJob(cwd, jobId, stateRoot, 'Qwen'); console.log(renderJobLine(job)); if (job.model) { console.log(`Model: ${job.model}`); @@ -730,7 +341,7 @@ function handleStatus(args) { return; } - const jobs = listJobs(cwd).slice(0, 10).map((job) => refreshJobStatus(cwd, job)); + const jobs = listJobs(cwd, stateRoot).slice(0, 10).map((job) => refreshJobStatus(cwd, job, stateRoot)); if (jobs.length === 0) { console.log('No Qwen jobs found for this workspace.'); return; @@ -742,8 +353,9 @@ function handleStatus(args) { function handleResult(args) { const cwd = process.cwd(); + const stateRoot = getStateRoot(); const [jobId] = normalizeArgv(args); - const job = resolveJob(cwd, jobId); + const job = resolveJob(cwd, jobId, stateRoot, 'Qwen'); console.log(`Job: ${job.id}`); console.log(`Status: ${job.status}`); @@ -774,31 +386,11 @@ function handleResult(args) { console.log('No result was recorded.'); } -function killPid(pid) { - if (!pid) { - return false; - } - try { - if (process.platform !== 'win32') { - process.kill(-pid, 'SIGTERM'); - } else { - process.kill(pid, 'SIGTERM'); - } - return true; - } catch { - try { - process.kill(pid, 'SIGTERM'); - return true; - } catch { - return false; - } - } -} - function handleCancel(args) { const cwd = process.cwd(); + const stateRoot = getStateRoot(); const [jobId] = normalizeArgv(args); - const job = resolveJob(cwd, jobId); + const job = resolveJob(cwd, jobId, stateRoot, 'Qwen'); if (!['queued', 'running'].includes(job.status)) { console.log(`Qwen job ${job.id} is already ${job.status}.`); @@ -807,10 +399,10 @@ function handleCancel(args) { updateJob(cwd, job.id, { status: 'canceled', - endedAt: nowIso(), + endedAt: new Date().toISOString(), error: 'Canceled by user.', - }); - killPid(job.qwenPid); + }, stateRoot); + killPid(job.childPid); killPid(job.workerPid); console.log(`Qwen review cancel requested: ${job.id}`); } diff --git a/tests/plugin.test.mjs b/tests/plugin.test.mjs index 8d179c2..83e1a51 100644 --- a/tests/plugin.test.mjs +++ b/tests/plugin.test.mjs @@ -7,15 +7,15 @@ import test from 'node:test'; import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const PLUGIN_ROOT = path.join(ROOT, 'plugins', 'qwen'); -const COMPANION = path.join(PLUGIN_ROOT, 'scripts', 'qwen-companion.mjs'); +const QWEN_PLUGIN_ROOT = path.join(ROOT, 'plugins', 'qwen'); +const COMPANION = path.join(QWEN_PLUGIN_ROOT, 'scripts', 'qwen-companion.mjs'); function readJson(relativePath) { return JSON.parse(fs.readFileSync(path.join(ROOT, relativePath), 'utf8')); } -function readPlugin(relativePath) { - return fs.readFileSync(path.join(PLUGIN_ROOT, relativePath), 'utf8'); +function readQwenPlugin(relativePath) { + return fs.readFileSync(path.join(QWEN_PLUGIN_ROOT, relativePath), 'utf8'); } function createFakeQwen(options = {}) { @@ -126,26 +126,57 @@ function waitForCommand(args, env, predicate) { assert.fail(`Timed out waiting for ${args.join(' ')}. Last output: ${last?.stdout}${last?.stderr}`); } -test('marketplace exposes the qwen plugin', () => { +test('claude code marketplace exposes the qwen plugin', () => { const marketplace = readJson('.claude-plugin/marketplace.json'); assert.equal(marketplace.name, 'qwen-code'); - assert.equal(marketplace.metadata.version, '0.4.0'); + assert.equal(marketplace.metadata.version, '0.5.0'); assert.equal(marketplace.plugins.length, 1); assert.equal(marketplace.plugins[0].name, 'qwen'); - assert.equal(marketplace.plugins[0].version, '0.4.0'); + assert.equal(marketplace.plugins[0].version, '0.5.0'); assert.equal(marketplace.plugins[0].source, './plugins/qwen'); }); -test('plugin manifest uses the expected Claude Code plugin name', () => { +test('codex marketplace exposes the qwen plugin', () => { + const marketplace = readJson('.agents/plugins/marketplace.json'); + + assert.equal(marketplace.name, 'qwen-code'); + assert.ok(marketplace.plugins.length >= 1); + + const qwen = marketplace.plugins.find((p) => p.name === 'qwen'); + assert.ok(qwen); + assert.equal(qwen.source.source, 'local'); + assert.match(qwen.source.path, /qwen-codex/); +}); + +test('qwen plugin manifest uses the expected Claude Code plugin name', () => { const plugin = readJson('plugins/qwen/.claude-plugin/plugin.json'); assert.equal(plugin.name, 'qwen'); - assert.equal(plugin.version, '0.4.0'); + assert.equal(plugin.version, '0.5.0'); +}); + +test('qwen-codex plugin manifest uses the expected Codex plugin name', () => { + const plugin = readJson('plugins/qwen-codex/.codex-plugin/plugin.json'); + + assert.equal(plugin.name, 'qwen'); + assert.ok(plugin.skills); + assert.ok(plugin.interface); +}); + +test('qwen-codex skill file exists', () => { + const skill = fs.readFileSync( + path.join(ROOT, 'plugins', 'qwen-codex', 'skills', 'qwen-review', 'SKILL.md'), + 'utf8', + ); + + assert.match(skill, /name:\s*qwen-review/); + assert.match(skill, /qwen/i); + assert.match(skill, /review/i); }); test('review command is a deterministic review-only forwarder', () => { - const source = readPlugin('commands/review.md'); + const source = readQwenPlugin('commands/review.md'); assert.match(source, /disable-model-invocation:\s*true/); assert.match(source, /\bBash\(/); @@ -168,13 +199,13 @@ test('review command is a deterministic review-only forwarder', () => { }); test('status, result, and cancel commands point to companion entrypoints', () => { - assert.match(readPlugin('commands/status.md'), /qwen-companion\.mjs" status "\$ARGUMENTS"/); - assert.match(readPlugin('commands/result.md'), /qwen-companion\.mjs" result "\$ARGUMENTS"/); - assert.match(readPlugin('commands/cancel.md'), /qwen-companion\.mjs" cancel "\$ARGUMENTS"/); + assert.match(readQwenPlugin('commands/status.md'), /qwen-companion\.mjs" status "\$ARGUMENTS"/); + assert.match(readQwenPlugin('commands/result.md'), /qwen-companion\.mjs" result "\$ARGUMENTS"/); + assert.match(readQwenPlugin('commands/cancel.md'), /qwen-companion\.mjs" cancel "\$ARGUMENTS"/); }); test('setup command points users to the companion script', () => { - const source = readPlugin('commands/setup.md'); + const source = readQwenPlugin('commands/setup.md'); assert.match(source, /disable-model-invocation:\s*true/); assert.match(source, /qwen-companion\.mjs" setup "\$ARGUMENTS"/);