From 4ffcb1d2f6b65f908394370ec9c8f5b51410316d Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 2 Jul 2026 00:21:24 +0800 Subject: [PATCH] feat: add Codex CLI/App plugin for Qwen Code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Codex CLI plugin (plugins/qwen-codex/) that brings Qwen Code review to Codex CLI and Codex App via the $qwen-review skill. The plugin bundles the same companion script used by the Claude Code plugin and instructs the Codex model to run reviews through it. Also refactors the Claude Code plugin (plugins/qwen/) to extract shared utilities into plugins/qwen/lib/ so each plugin is fully self-contained within its source directory. Both plugins in one repo, two marketplace files: - .claude-plugin/marketplace.json (Claude Code) - .agents/plugins/marketplace.json (Codex CLI) 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) Co-Authored-By: Claude Opus 4.6 (1M context) --- .agents/plugins/marketplace.json | 19 + .claude-plugin/marketplace.json | 6 +- README.md | 89 +--- package.json | 4 +- plugins/qwen-codex/.codex-plugin/plugin.json | 26 + plugins/qwen-codex/lib/argv.mjs | 122 +++++ plugins/qwen-codex/lib/fs-utils.mjs | 100 ++++ plugins/qwen-codex/lib/jobs.mjs | 157 ++++++ plugins/qwen-codex/lib/tracked-review.mjs | 73 +++ plugins/qwen-codex/scripts/qwen-companion.mjs | 447 ++++++++++++++++ .../qwen-codex/skills/qwen-review/SKILL.md | 80 +++ .../skills/qwen-review/agents/openai.yaml | 4 + plugins/qwen/.claude-plugin/plugin.json | 2 +- plugins/qwen/lib/argv.mjs | 122 +++++ plugins/qwen/lib/fs-utils.mjs | 100 ++++ plugins/qwen/lib/jobs.mjs | 157 ++++++ plugins/qwen/lib/tracked-review.mjs | 73 +++ plugins/qwen/scripts/qwen-companion.mjs | 488 ++---------------- tests/plugin.test.mjs | 59 ++- 19 files changed, 1596 insertions(+), 532 deletions(-) create mode 100644 .agents/plugins/marketplace.json create mode 100644 plugins/qwen-codex/.codex-plugin/plugin.json create mode 100644 plugins/qwen-codex/lib/argv.mjs create mode 100644 plugins/qwen-codex/lib/fs-utils.mjs create mode 100644 plugins/qwen-codex/lib/jobs.mjs create mode 100644 plugins/qwen-codex/lib/tracked-review.mjs create mode 100755 plugins/qwen-codex/scripts/qwen-companion.mjs create mode 100644 plugins/qwen-codex/skills/qwen-review/SKILL.md create mode 100644 plugins/qwen-codex/skills/qwen-review/agents/openai.yaml create mode 100644 plugins/qwen/lib/argv.mjs create mode 100644 plugins/qwen/lib/fs-utils.mjs create mode 100644 plugins/qwen/lib/jobs.mjs create mode 100644 plugins/qwen/lib/tracked-review.mjs 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"/);