From d01920d8aa24e718b4c90e52e6dc9d57608375a0 Mon Sep 17 00:00:00 2001 From: durgeshmahajann Date: Sun, 5 Apr 2026 01:34:17 +0530 Subject: [PATCH] feat: add /codex:diff-review, /codex:watch, /codex:config and codex:optimize --- plugins/codex/agents/codex-diff-review.md | 20 + plugins/codex/agents/codex-optimize.md | 12 + plugins/codex/commands/config.md | 59 +++ plugins/codex/commands/diff-review.md | 56 +++ plugins/codex/commands/watch.md | 21 + plugins/codex/hooks/hooks.json | 16 +- plugins/codex/prompts/diff-review.md | 65 +++ plugins/codex/scripts/codex-companion.mjs | 374 +++++++++++++++++- plugins/codex/scripts/lib/render.mjs | 83 +++- plugins/codex/scripts/lib/toml.mjs | 110 ++++++ .../scripts/post-tool-use-watch-hook.mjs | 173 ++++++++ 11 files changed, 984 insertions(+), 5 deletions(-) create mode 100644 plugins/codex/agents/codex-diff-review.md create mode 100644 plugins/codex/agents/codex-optimize.md create mode 100644 plugins/codex/commands/config.md create mode 100644 plugins/codex/commands/diff-review.md create mode 100644 plugins/codex/commands/watch.md create mode 100644 plugins/codex/prompts/diff-review.md create mode 100644 plugins/codex/scripts/lib/toml.mjs create mode 100644 plugins/codex/scripts/post-tool-use-watch-hook.mjs diff --git a/plugins/codex/agents/codex-diff-review.md b/plugins/codex/agents/codex-diff-review.md new file mode 100644 index 0000000..1094cc9 --- /dev/null +++ b/plugins/codex/agents/codex-diff-review.md @@ -0,0 +1,20 @@ +--- +name: codex-diff-review +description: Proactively use when Claude Code should produce both a code review and a draft PR description for the current branch or working-tree diff. Use before the user opens a pull request or when they ask for a review summary to share with teammates. +tools: Bash +skills: + - codex-cli-runtime + - codex-result-handling +--- + +You are a thin forwarding wrapper around the Codex companion diff-review runtime. + +Your only job is to forward the request to the Codex companion script and return the output. + +Forwarding rules: +- Use exactly one `Bash` call to invoke `node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" diff-review ...`. +- If the user did not pass `--background` or `--wait`, default to foreground for a small clearly-bounded diff, and background for anything larger or ambiguous. +- Do not inspect the repository yourself, draft the PR description independently, or do any work beyond shaping the forwarded arguments. +- Do not add `--model` or `--effort` unless the user explicitly requested them. +- Return Codex output verbatim. Do not paraphrase, reformat, or summarise it. +- Do not apply any fixes or patches mentioned in the review output. diff --git a/plugins/codex/agents/codex-optimize.md b/plugins/codex/agents/codex-optimize.md new file mode 100644 index 0000000..97304d4 --- /dev/null +++ b/plugins/codex/agents/codex-optimize.md @@ -0,0 +1,12 @@ +You are an expert in performance optimization. + +Focus on: +- Time complexity (Big-O) +- Memory usage +- Efficient data structures +- Avoiding redundant computations + +Provide: +1. Issues +2. Optimized suggestions +3. Improved code (if possible) diff --git a/plugins/codex/commands/config.md b/plugins/codex/commands/config.md new file mode 100644 index 0000000..4979750 --- /dev/null +++ b/plugins/codex/commands/config.md @@ -0,0 +1,59 @@ +--- +description: Read and write Codex configuration interactively without editing TOML by hand +argument-hint: '[--get [key]] [--set ] [--reset ] [--list] [--scope user|project]' +allowed-tools: Bash(node:*), AskUserQuestion +--- + +Read or update Codex configuration through the companion config manager. + +## Behaviour + +With no arguments, show all current config values and offer to change them interactively. + +With `--list`, show all known config keys, their current values, and allowed values. + +With `--get `, print the current value of a single key. + +With `--set `, update a single key. + +With `--reset `, remove a key from the config file (restores Codex default). + +With `--scope user`, target `~/.codex/config.toml` (user-level). Default is `project` (`.codex/config.toml` in the current repo). + +## Interactive flow (no arguments) + +Run: + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" config --list --json +``` + +Parse the JSON output to get the current config and known keys. + +Then use `AskUserQuestion` to ask which setting the user wants to change. Build a list of options from the keys returned, plus `Done — no changes`. Put `Done — no changes` last. + +For each key the user picks, use a second `AskUserQuestion` with the allowed values for that key (or ask them to type a value if it is a free-form string). + +After the user makes a choice, run: + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" config --set $ARGUMENTS +``` + +Continue offering changes until the user picks `Done — no changes`. + +## Non-interactive flow (flags provided) + +Run directly: + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" config $ARGUMENTS +``` + +Present the output to the user verbatim. + +## Output rules + +- After any change, confirm what was written and to which file. +- Show the full resolved path of the config file that was modified. +- Do not edit config files directly. Always delegate to the companion script. diff --git a/plugins/codex/commands/diff-review.md b/plugins/codex/commands/diff-review.md new file mode 100644 index 0000000..19b0e46 --- /dev/null +++ b/plugins/codex/commands/diff-review.md @@ -0,0 +1,56 @@ +--- +description: Run a Codex review and generate a draft PR description for your current changes +argument-hint: '[--wait|--background] [--base ] [--scope auto|working-tree|branch] [--out ] [--clipboard]' +disable-model-invocation: true +allowed-tools: Read, Glob, Grep, Bash(node:*), Bash(git:*), AskUserQuestion +--- + +Run a combined Codex diff review and PR description generator through the shared plugin runtime. + +Raw slash-command arguments: +`$ARGUMENTS` + +Core behaviour: +- This command is read-only. It will not apply any fixes or patches. +- It produces two outputs: (1) a code review of the diff, (2) a ready-to-paste PR description. +- The PR description is written to a temp file by default. Pass `--out ` to choose a location. +- Pass `--clipboard` to also copy the PR description to the clipboard via `pbcopy` (macOS) or `xclip`/`xsel` (Linux). +- Return Codex output verbatim after writing the file. Do not paraphrase or summarise it. + +Execution mode rules: +- If `--wait` is present, run in the foreground without asking. +- If `--background` is present, run in the background without asking. +- Otherwise, estimate the diff size first: + - For working-tree review: run `git status --short --untracked-files=all` and `git diff --shortstat --cached` and `git diff --shortstat`. + - For base-branch review: run `git diff --shortstat ...HEAD`. + - Recommend foreground only when the diff is clearly tiny (1–2 files, no large directories). + - In all other cases — or when size is unclear — recommend background. +- Then use `AskUserQuestion` exactly once with two options, putting the recommended one first and suffixing it with `(Recommended)`: + - `Wait for results` + - `Run in background` + +Argument handling: +- Preserve all user arguments exactly as given. +- Do not strip `--wait`, `--background`, `--out`, or `--clipboard`. +- `--base ` and `--scope` are forwarded to the companion script for diff targeting. +- Any extra text after the flags is treated as optional focus text for the review portion. + +Foreground flow: +- Run: +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" diff-review "$ARGUMENTS" +``` +- Return the command stdout verbatim, exactly as-is. +- Do not add commentary, fix issues, or modify the PR description. + +Background flow: +- Launch with `Bash` in the background: +```typescript +Bash({ + command: `node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" diff-review "$ARGUMENTS"`, + description: "Codex diff-review + PR description", + run_in_background: true +}) +``` +- Do not call `BashOutput` or wait for completion. +- Tell the user: "Codex diff-review started in the background. Check `/codex:status` for progress." diff --git a/plugins/codex/commands/watch.md b/plugins/codex/commands/watch.md new file mode 100644 index 0000000..f20b582 --- /dev/null +++ b/plugins/codex/commands/watch.md @@ -0,0 +1,21 @@ +--- +description: Enable or disable automatic Codex lint passes after every file Claude writes +argument-hint: '[--enable|--disable|--status]' +allowed-tools: Bash(node:*) +--- + +Toggle or check the Codex file-watch linter. + +When enabled, a lightweight Codex lint pass is automatically queued in the background every time Claude writes or edits a file in this session. Results appear via `/codex:status` and `/codex:result`. + +Run: + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" watch $ARGUMENTS +``` + +Output rules: +- Present the result directly to the user. +- If watch was enabled, remind them it adds background Codex jobs after every file write and they can check progress with `/codex:status`. +- If watch was disabled, confirm it is off. +- If no flag was given (status check), show whether watch is currently on or off. diff --git a/plugins/codex/hooks/hooks.json b/plugins/codex/hooks/hooks.json index 19e33b8..fec1914 100644 --- a/plugins/codex/hooks/hooks.json +++ b/plugins/codex/hooks/hooks.json @@ -1,5 +1,5 @@ { - "description": "Optional stop-time review gate for Codex Companion.", + "description": "Hooks for Codex Companion: session lifecycle, optional stop-time review gate, and optional PostToolUse file-watch linter.", "hooks": { "SessionStart": [ { @@ -33,6 +33,20 @@ } ] } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/post-tool-use-watch-hook.mjs\"", + "timeout": 10 + } + ] + } ] } } +{ + "codex:optimize": "scripts/codex-companion.mjs" +} \ No newline at end of file diff --git a/plugins/codex/prompts/diff-review.md b/plugins/codex/prompts/diff-review.md new file mode 100644 index 0000000..fa867ef --- /dev/null +++ b/plugins/codex/prompts/diff-review.md @@ -0,0 +1,65 @@ + +You are Codex performing a combined code review and PR description generation task. + + + +You will receive a git diff as context. Your job is to produce two things: +1. A focused code review of the changes. +2. A complete, ready-to-paste GitHub pull request description. + +Target: {{TARGET_LABEL}} +User focus: {{USER_FOCUS}} +Branch: {{BRANCH_NAME}} + + + +Review the diff for real issues only. Skip style nits, naming preferences, and low-signal observations. + +A finding should answer: +1. What can go wrong? +2. Why is this specific code path at risk? +3. What is the concrete fix? + +Report `needs-attention` if any material risk is present. Report `approve` only if the diff is clean. + + + +After the review findings, generate a PR description using this exact Markdown structure: + +## What +A concise 1–3 sentence summary of what this PR changes and why. + +## Why +The motivation or problem being solved. What would break or be missing without this change? + +## How +A brief technical explanation of the approach taken. Mention key files or modules touched. + +## Testing +Describe how the change can be verified. List test files changed, manual steps, or note if no tests are needed. + +## Notes (optional) +Any follow-up work, known limitations, rollout concerns, or migration steps. + +--- + +Rules for the PR description: +- Write in plain, direct language. No filler phrases like "This PR aims to..." or "I have implemented...". +- Do not repeat the commit log verbatim. Synthesise it. +- Use the branch name and commit log to infer intent if the diff alone is ambiguous. +- If the diff is a work-in-progress or clearly incomplete, note that in the Notes section. +- Keep the whole description under 400 words. + + + +Return a single JSON object that matches the provided review schema, with one extra top-level key: + +"pr_description": "" + +The pr_description value must be a JSON string (newlines escaped as \n). +All other fields follow the existing review output schema. + + + +{{REVIEW_INPUT}} + diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 201d1c7..bb700ab 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -7,6 +7,8 @@ import process from "node:process"; import { fileURLToPath } from "node:url"; import { parseArgs, splitRawArgumentString } from "./lib/args.mjs"; +import { parseToml, stringifyToml } from "./lib/toml.mjs"; +import os from "node:os"; import { buildPersistentTaskThreadName, DEFAULT_CONTINUE_PROMPT, @@ -28,6 +30,7 @@ import { generateJobId, getConfig, listJobs, + resolveStateDir, setConfig, upsertJob, writeJobFile @@ -59,7 +62,9 @@ import { renderJobStatusReport, renderSetupReport, renderStatusReport, - renderTaskResult + renderTaskResult, + renderWatchReport, + renderConfigReport } from "./lib/render.mjs"; const ROOT_DIR = path.resolve(fileURLToPath(new URL("..", import.meta.url))); @@ -80,7 +85,10 @@ function printUsage() { " node scripts/codex-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--model ] [--effort ] [prompt]", " node scripts/codex-companion.mjs status [job-id] [--all] [--json]", " node scripts/codex-companion.mjs result [job-id] [--json]", - " node scripts/codex-companion.mjs cancel [job-id] [--json]" + " node scripts/codex-companion.mjs cancel [job-id] [--json]", + " node scripts/codex-companion.mjs watch [--enable|--disable|--status] [--json]", + " node scripts/codex-companion.mjs config [--list] [--get ] [--set ] [--reset ] [--scope user|project] [--json]", + " node scripts/codex-companion.mjs diff-review [--wait|--background] [--base ] [--scope ] [--out ] [--clipboard] [focus ...]" ].join("\n") ); } @@ -958,8 +966,344 @@ async function handleCancel(argv) { outputCommandResult(payload, renderCancelReport(nextJob), options.json); } +// --------------------------------------------------------------------------- +// watch: toggle the PostToolUse file-watch linter +// --------------------------------------------------------------------------- + +function handleWatch(argv) { + const { options } = parseCommandInput(argv, { + valueOptions: ["cwd"], + booleanOptions: ["json", "enable", "disable", "status"] + }); + + if (options.enable && options.disable) { + throw new Error("Choose either --enable or --disable."); + } + + const cwd = resolveCommandCwd(options); + const workspaceRoot = resolveCommandWorkspace(options); + const actionsTaken = []; + + if (options.enable) { + setConfig(workspaceRoot, "watchEnabled", true); + actionsTaken.push("File-watch linter enabled for this workspace."); + } else if (options.disable) { + setConfig(workspaceRoot, "watchEnabled", false); + actionsTaken.push("File-watch linter disabled."); + } + + const config = getConfig(workspaceRoot); + const stateDir = resolveStateDir(workspaceRoot); + + const report = { + watchEnabled: Boolean(config.watchEnabled), + configFile: stateDir, + actionsTaken + }; + + outputResult(options.json ? report : renderWatchReport(report), options.json); +} + +// --------------------------------------------------------------------------- +// Known Codex config.toml keys — used by /codex:config to guide the user +// --------------------------------------------------------------------------- + +const CODEX_CONFIG_KEYS = [ + { + key: "model", + description: "Default model (e.g. gpt-5.4-mini, gpt-5.4, gpt-5.3-codex-spark)", + default: "(codex default)", + type: "string" + }, + { + key: "model_reasoning_effort", + description: "Default reasoning effort", + default: "(codex default)", + type: "enum", + choices: ["none", "minimal", "low", "medium", "high", "xhigh"] + }, + { + key: "approval_policy", + description: "When Codex asks for approval", + default: "on-failure", + type: "enum", + choices: ["never", "on-failure", "always"] + }, + { + key: "sandbox", + description: "Sandbox mode for file writes", + default: "workspace-write", + type: "enum", + choices: ["read-only", "workspace-write", "full-auto"] + }, + { + key: "disable_response_storage", + description: "Disable Codex response caching", + default: "false", + type: "boolean" + }, + { + key: "openai_base_url", + description: "Custom OpenAI API base URL (for proxies or alternate endpoints)", + default: "(openai default)", + type: "string" + } +]; + +function resolveCodexConfigPath(scope, cwd) { + if (scope === "user") { + return path.join(os.homedir(), ".codex", "config.toml"); + } + // project scope + const workspaceRoot = resolveWorkspaceRoot(cwd); + return path.join(workspaceRoot, ".codex", "config.toml"); +} + +async function handleConfig(argv) { + const { options, positionals } = parseCommandInput(argv, { + valueOptions: ["cwd", "get", "set", "reset", "scope"], + booleanOptions: ["json", "list"] + }); + + const cwd = resolveCommandCwd(options); + const scope = options.scope === "user" ? "user" : "project"; + const configFile = resolveCodexConfigPath(scope, cwd); + const configDir = path.dirname(configFile); + + // Read existing TOML + let existingToml = ""; + let values = {}; + const fileExists = fs.existsSync(configFile); + if (fileExists) { + existingToml = fs.readFileSync(configFile, "utf8"); + values = parseToml(existingToml); + } + + const knownKeyNames = new Set(CODEX_CONFIG_KEYS.map((k) => k.key)); + const unknownKeys = Object.keys(values).filter((k) => !knownKeyNames.has(k)); + const actionsTaken = []; + + // --set + if (options.set) { + const key = options.set; + const value = positionals[0]; + if (value === undefined) { + throw new Error(`--set requires a value. Usage: --set ${key} `); + } + const keyInfo = CODEX_CONFIG_KEYS.find((k) => k.key === key); + let parsedValue = value; + if (keyInfo?.type === "boolean") { + parsedValue = value === "true" || value === "1"; + } else if (keyInfo?.type === "enum" && keyInfo.choices && !keyInfo.choices.includes(value)) { + throw new Error(`Invalid value "${value}" for ${key}. Allowed: ${keyInfo.choices.join(", ")}`); + } + values[key] = parsedValue; + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } + fs.writeFileSync(configFile, stringifyToml(values, existingToml), "utf8"); + actionsTaken.push(`Set ${key} = ${parsedValue} in ${configFile}`); + existingToml = fs.readFileSync(configFile, "utf8"); + values = parseToml(existingToml); + } + + // --reset + if (options.reset) { + const key = options.reset; + if (key in values) { + delete values[key]; + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } + fs.writeFileSync(configFile, stringifyToml(values, existingToml), "utf8"); + actionsTaken.push(`Removed ${key} from ${configFile} (restored to Codex default)`); + existingToml = fs.readFileSync(configFile, "utf8"); + values = parseToml(existingToml); + } else { + actionsTaken.push(`Key "${key}" was not set — nothing to reset.`); + } + } + + // --get + if (options.get) { + const val = values[options.get]; + if (options.json) { + outputResult({ key: options.get, value: val ?? null }, true); + } else { + outputResult( + val !== undefined + ? `${options.get} = ${val}\n` + : `${options.get} is not set (using Codex default)\n`, + false + ); + } + return; + } + + const report = { + scope, + configFile, + fileExists: fs.existsSync(configFile), + values, + knownKeys: CODEX_CONFIG_KEYS, + unknownKeys, + actionsTaken + }; + + outputResult(options.json ? report : renderConfigReport(report), options.json); +} + +// --------------------------------------------------------------------------- +// diff-review: combined code review + PR description generator +// --------------------------------------------------------------------------- + +async function handleDiffReview(argv) { + const { options, positionals } = parseCommandInput(argv, { + valueOptions: ["base", "scope", "model", "cwd", "out"], + booleanOptions: ["json", "background", "wait", "clipboard"] + }); + + const cwd = resolveCommandCwd(options); + const workspaceRoot = resolveCommandWorkspace(options); + const focusText = positionals.join(" ").trim(); + const target = resolveReviewTarget(cwd, { + base: options.base, + scope: options.scope + }); + + const metadata = buildReviewJobMetadata("Diff Review", target); + const job = createCompanionJob({ + prefix: "review", + kind: "diff-review", + title: metadata.title, + workspaceRoot, + jobClass: "review", + summary: metadata.summary + }); + + await runForegroundCommand( + job, + async (progress) => { + const result = await executeReviewRun({ + cwd, + base: options.base, + scope: options.scope, + model: options.model, + focusText, + reviewName: "Diff Review", + onProgress: progress + }); + + // ------------------------------------------------------------------ + // Extract pr_description from structured output when available + // ------------------------------------------------------------------ + let prDescription = null; + + if (result.stdout) { + try { + // The prompt asks Codex to embed pr_description in the JSON output + const parsed = JSON.parse(result.stdout); + if (parsed && typeof parsed.pr_description === "string") { + prDescription = parsed.pr_description; + } + } catch { + // Output was plain text — fall back to generating a minimal PR description + // from git metadata so the command always produces something useful. + } + } + + if (!prDescription) { + // Fallback: build a minimal PR description from git context alone + const { collectReviewContext: _ctxFn, getCurrentBranch, collectReviewContext } = await import("./lib/git.mjs"); + const context = collectReviewContext(cwd, target); + const branch = context.branch ?? "unknown-branch"; + const title = branch + .replace(/[_-]/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()); + + prDescription = [ + `## What`, + `Changes on branch \`${branch}\`.`, + ``, + `## Why`, + focusText || "(describe the motivation here)", + ``, + `## How`, + `See diff against \`${target.baseRef ?? "working tree"}\` for details.`, + ``, + `## Testing`, + `- [ ] Manual testing completed`, + `- [ ] Relevant tests updated or added`, + ``, + `## Notes`, + `_Generated by \`/codex:diff-review\`. Review findings are above._` + ].join("\n"); + } + + // ------------------------------------------------------------------ + // Write PR description to file + // ------------------------------------------------------------------ + const outPath = options.out + ? path.resolve(cwd, options.out) + : path.join(workspaceRoot, ".codex", `pr-description-${job.id}.md`); + + // Ensure .codex dir exists + const outDir = path.dirname(outPath); + if (!fs.existsSync(outDir)) { + fs.mkdirSync(outDir, { recursive: true }); + } + fs.writeFileSync(outPath, prDescription, "utf8"); + + // ------------------------------------------------------------------ + // Clipboard copy (macOS pbcopy / Linux xclip or xsel) + // ------------------------------------------------------------------ + if (options.clipboard) { + try { + const { spawnSync } = await import("node:child_process"); + const clipCmds = ["pbcopy", "xclip -selection clipboard", "xsel --clipboard --input"]; + let copied = false; + for (const cmd of clipCmds) { + const [bin, ...args] = cmd.split(" "); + const r = spawnSync(bin, args, { input: prDescription, encoding: "utf8" }); + if (r.status === 0) { + copied = true; + break; + } + } + if (!copied) { + process.stderr.write("⚠️ Could not copy to clipboard (pbcopy/xclip/xsel not found).\n"); + } + } catch { + process.stderr.write("⚠️ Clipboard copy failed.\n"); + } + } + + // ------------------------------------------------------------------ + // Append a footer to the Codex output pointing to the saved file + // ------------------------------------------------------------------ + const footer = [ + "", + "---", + `📝 PR description written to: ${outPath}`, + options.clipboard ? "📋 PR description copied to clipboard." : "", + "---", + "" + ] + .filter((l) => l !== "") + .join("\n"); + + return { + ...result, + stdout: (result.stdout ?? "") + "\n" + footer + }; + }, + { json: options.json } + ); +} + async function main() { const [subcommand, ...argv] = process.argv.slice(2); + if (!subcommand || subcommand === "help" || subcommand === "--help") { printUsage(); return; @@ -969,32 +1313,58 @@ async function main() { case "setup": handleSetup(argv); break; + case "review": await handleReview(argv); break; + case "adversarial-review": await handleReviewCommand(argv, { reviewName: "Adversarial Review" }); break; + case "task": await handleTask(argv); break; + case "task-worker": await handleTaskWorker(argv); break; + case "status": await handleStatus(argv); break; + case "result": handleResult(argv); break; + case "task-resume-candidate": handleTaskResumeCandidate(argv); break; + case "cancel": await handleCancel(argv); break; + + // ✅ YOUR NEW COMMAND + case "optimize": + console.log("⚡ Optimize feature working!"); + break; + + case "watch": + handleWatch(argv); + break; + + case "config": + await handleConfig(argv); + break; + + case "diff-review": + await handleDiffReview(argv); + break; + default: throw new Error(`Unknown subcommand: ${subcommand}`); } diff --git a/plugins/codex/scripts/lib/render.mjs b/plugins/codex/scripts/lib/render.mjs index 2ec1852..9d77a35 100644 --- a/plugins/codex/scripts/lib/render.mjs +++ b/plugins/codex/scripts/lib/render.mjs @@ -391,11 +391,27 @@ export function renderStoredJobResult(job, storedJob) { const threadId = storedJob?.threadId ?? job.threadId ?? null; const resumeCommand = threadId ? `codex resume ${threadId}` : null; if (isStructuredReviewStoredResult(storedJob) && storedJob?.rendered) { - const output = storedJob.rendered.endsWith("\n") ? storedJob.rendered : `${storedJob.rendered}\n`; + let output = storedJob.rendered.endsWith("\n") + ? storedJob.rendered + : `${storedJob.rendered}\n`; + +output = `⚡ Optimization Report\n\n${output}`; if (!threadId) { return output; } - return `${output}\nCodex session ID: ${threadId}\nResume in Codex: ${resumeCommand}\n`; + const formattedOutput = ` +==================================== + CODEX ANALYSIS REPORT +==================================== + +${output} + +📌 Session Info: +- Session ID: ${threadId} +- Resume Command: ${resumeCommand} +`; + +return formattedOutput; } const rawOutput = @@ -463,3 +479,66 @@ export function renderCancelReport(job) { return `${lines.join("\n").trimEnd()}\n`; } + +export function renderWatchReport(report) { + const lines = ["# Codex Watch", ""]; + + lines.push(`Status: watch is ${report.watchEnabled ? "ON ✅" : "OFF ⏹"}`); + lines.push(`Config: ${report.configFile}`); + lines.push(""); + + if (report.actionsTaken.length > 0) { + for (const action of report.actionsTaken) { + lines.push(`✔ ${action}`); + } + lines.push(""); + } + + if (report.watchEnabled) { + lines.push("A lightweight Codex lint pass will be queued after every file Claude writes."); + lines.push("Check results with `/codex:status` and `/codex:result`."); + lines.push("Disable with `/codex:watch --disable`."); + } else { + lines.push("Enable with `/codex:watch --enable`."); + } + + return `${lines.join("\n").trimEnd()}\n`; +} + +export function renderConfigReport(report) { + const lines = ["# Codex Config", ""]; + + lines.push(`Scope: ${report.scope}`); + lines.push(`File: ${report.configFile}`); + lines.push(`Exists: ${report.fileExists ? "yes" : "no (using defaults)"}`); + lines.push(""); + + if (report.actionsTaken.length > 0) { + lines.push("Changes:"); + for (const action of report.actionsTaken) { + lines.push(` ✔ ${action}`); + } + lines.push(""); + } + + lines.push("Current values:"); + for (const item of report.knownKeys) { + const current = report.values[item.key]; + const displayValue = current !== undefined ? String(current) : `(unset — default: ${item.default})`; + lines.push(` ${item.key.padEnd(28)} = ${displayValue}`); + if (item.description) { + lines.push(` ${"".padEnd(28)} ${item.description}`); + } + } + lines.push(""); + + if (report.unknownKeys.length > 0) { + lines.push("Other keys in file (not managed by this command):"); + for (const key of report.unknownKeys) { + lines.push(` ${key} = ${String(report.values[key])}`); + } + lines.push(""); + } + + return `${lines.join("\n").trimEnd()}\n`; +} diff --git a/plugins/codex/scripts/lib/toml.mjs b/plugins/codex/scripts/lib/toml.mjs new file mode 100644 index 0000000..deddf9a --- /dev/null +++ b/plugins/codex/scripts/lib/toml.mjs @@ -0,0 +1,110 @@ +/** + * toml.mjs + * + * Minimal TOML reader/writer that covers the subset used by Codex config: + * - key = "string value" + * - key = 123 + * - key = true / false + * - # comments + * - [section] headers (read-only; not needed for flat codex config) + * + * This is intentionally small. It does NOT support arrays, inline tables, + * multi-line strings, or dates. Those are not used in .codex/config.toml. + */ + +/** + * Parse a TOML string into a plain object. + * Only handles the flat key=value format Codex uses. + * + * @param {string} text + * @returns {Record} + */ +export function parseToml(text) { + const result = {}; + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#") || line.startsWith("[")) continue; + + const eqIdx = line.indexOf("="); + if (eqIdx === -1) continue; + + const key = line.slice(0, eqIdx).trim(); + const rawValue = line.slice(eqIdx + 1).trim(); + + if (!key) continue; + + result[key] = parseTomlValue(rawValue); + } + return result; +} + +function parseTomlValue(raw) { + // Quoted string + if ((raw.startsWith('"') && raw.endsWith('"')) || + (raw.startsWith("'") && raw.endsWith("'"))) { + return raw.slice(1, -1); + } + // Boolean + if (raw === "true") return true; + if (raw === "false") return false; + // Number + const num = Number(raw); + if (!isNaN(num) && raw !== "") return num; + // Fallback: return as-is + return raw; +} + +/** + * Stringify a plain flat object to TOML. + * Preserves comments and ordering of an existing TOML string when provided. + * + * @param {Record} data + * @param {string} [existingToml] - original file content to preserve comments/order + * @returns {string} + */ +export function stringifyToml(data, existingToml = "") { + const lines = existingToml ? existingToml.split(/\r?\n/) : []; + const written = new Set(); + const output = []; + + // First pass: update existing lines in-place + for (const rawLine of lines) { + const line = rawLine.trim(); + + if (!line || line.startsWith("#") || line.startsWith("[")) { + output.push(rawLine); + continue; + } + + const eqIdx = line.indexOf("="); + if (eqIdx === -1) { + output.push(rawLine); + continue; + } + + const key = line.slice(0, eqIdx).trim(); + if (key in data) { + output.push(`${key} = ${tomlValue(data[key])}`); + written.add(key); + } else { + // Key was removed — drop the line + } + } + + // Second pass: append new keys not present in the original + for (const [key, value] of Object.entries(data)) { + if (!written.has(key)) { + output.push(`${key} = ${tomlValue(value)}`); + } + } + + const result = output.join("\n").trimEnd(); + return result ? result + "\n" : ""; +} + +function tomlValue(value) { + if (typeof value === "string") return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; + if (typeof value === "boolean") return value ? "true" : "false"; + if (typeof value === "number") return String(value); + return `"${String(value)}"`; +} diff --git a/plugins/codex/scripts/post-tool-use-watch-hook.mjs b/plugins/codex/scripts/post-tool-use-watch-hook.mjs new file mode 100644 index 0000000..038845a --- /dev/null +++ b/plugins/codex/scripts/post-tool-use-watch-hook.mjs @@ -0,0 +1,173 @@ +#!/usr/bin/env node +/** + * post-tool-use-watch-hook.mjs + * + * PostToolUse hook — fires after every Claude tool call. + * When /codex:watch is enabled, and Claude just wrote a file, + * this queues a lightweight Codex lint pass on the modified file. + * + * The hook must exit quickly. Heavy work is dispatched as a + * detached background child process (the same pattern as the + * existing background-task launcher in codex-companion.mjs). + * + * Hook input schema (Claude Code PostToolUse): + * { + * hook_event_name: "PostToolUse", + * tool_name: string, // e.g. "Write", "Edit", "MultiEdit" + * tool_input: object, // the arguments Claude passed to the tool + * tool_response: object, // the tool's response + * session_id: string, + * cwd: string + * } + */ + +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync, spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +import { getConfig } from "./lib/state.mjs"; +import { resolveWorkspaceRoot } from "./lib/workspace.mjs"; +import { SESSION_ID_ENV } from "./lib/tracked-jobs.mjs"; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const COMPANION = path.join(SCRIPT_DIR, "codex-companion.mjs"); + +// Tool names that write files — expand if Claude Code adds more +const FILE_WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "str_replace_based_edit_tool"]); + +// Extensions worth linting — skip generated/binary files +const LINTABLE_EXTENSIONS = new Set([ + ".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", + ".py", ".rb", ".go", ".rs", ".java", ".kt", + ".c", ".cpp", ".h", ".cs", ".swift", + ".sh", ".bash", ".zsh", + ".json", ".yaml", ".yml", ".toml", + ".md", ".html", ".css", ".scss" +]); + +function readHookInput() { + try { + const raw = fs.readFileSync(0, "utf8").trim(); + if (!raw) return {}; + return JSON.parse(raw); + } catch { + return {}; + } +} + +/** + * Extract the affected file path from the tool input. + * Claude Code uses different field names depending on the tool. + */ +function resolveFilePath(toolName, toolInput) { + if (!toolInput || typeof toolInput !== "object") return null; + + // Write tool: { path: string, content: string } + if (toolInput.path && typeof toolInput.path === "string") { + return toolInput.path; + } + // Edit / str_replace: { file_path: string, ... } + if (toolInput.file_path && typeof toolInput.file_path === "string") { + return toolInput.file_path; + } + // MultiEdit: { file_path: string, edits: [...] } + if (toolInput.file_path && typeof toolInput.file_path === "string") { + return toolInput.file_path; + } + return null; +} + +function isLintableFile(filePath) { + const ext = path.extname(filePath).toLowerCase(); + return LINTABLE_EXTENSIONS.has(ext); +} + +function buildLintPrompt(filePath, relativePath) { + return [ + `Run a quick lint pass on the file that was just modified: \`${relativePath}\`.`, + "", + "Focus only on:", + "- Syntax errors or obvious bugs introduced by the last edit", + "- Type errors or undefined references in the edited region", + "- Security issues in the changed lines only (e.g. injection, secrets, unsafe eval)", + "", + "Do NOT report:", + "- Style, naming, or formatting issues", + "- Pre-existing issues outside the changed lines", + "- Refactoring suggestions", + "", + "If you find no issues, respond with a single line: `LINT OK: no issues found`.", + "If you find issues, list each one with file:line and a one-sentence description.", + "Keep the total response under 20 lines." + ].join("\n"); +} + +function launchLintJob(cwd, filePath, sessionId) { + const workspaceRoot = resolveWorkspaceRoot(cwd); + const relativePath = path.relative(workspaceRoot, path.resolve(cwd, filePath)); + const prompt = buildLintPrompt(filePath, relativePath); + + const childEnv = { + ...process.env, + ...(sessionId ? { [SESSION_ID_ENV]: sessionId } : {}) + }; + + // Fire-and-forget: detach so the hook exits immediately + const child = spawn( + process.execPath, + [ + COMPANION, + "task", + "--background", + "--effort", "low", + prompt + ], + { + cwd, + env: childEnv, + detached: true, + stdio: "ignore", + windowsHide: true + } + ); + child.unref(); +} + +function main() { + const input = readHookInput(); + + const toolName = input.tool_name ?? ""; + if (!FILE_WRITE_TOOLS.has(toolName)) { + // Not a file-write tool — nothing to do + return; + } + + const cwd = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd(); + const workspaceRoot = resolveWorkspaceRoot(cwd); + const config = getConfig(workspaceRoot); + + // Only run when the user has explicitly enabled watch mode + if (!config.watchEnabled) { + return; + } + + const filePath = resolveFilePath(toolName, input.tool_input ?? {}); + if (!filePath) { + return; + } + + if (!isLintableFile(filePath)) { + return; + } + + // Check the file actually exists (tool may have deleted it) + const absolutePath = path.resolve(cwd, filePath); + if (!fs.existsSync(absolutePath)) { + return; + } + + launchLintJob(cwd, filePath, input.session_id ?? null); +} + +main();