diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e175639..83219d04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Added + +- Let `/codex:review`, `/codex:adversarial-review`, `/codex:rescue`, and `/codex:setup` run Grok as the guest via `--guest grok` or `CC_GUEST=grok`. The default guest remains Codex; Grok uses `--prompt-file` plus `--output-format json` and does not inherit Codex model aliases or the Codex app-server. + ## 1.2.0 — 2026-08-28 ### Merged from upstream pull requests diff --git a/README.md b/README.md index bdc6ed26..87028883 100644 --- a/README.md +++ b/README.md @@ -90,12 +90,15 @@ Use it when you want: Use `--base ` for branch review. It also supports `--wait` and `--background`. It is not steerable and does not take custom focus text. Use [`/codex:adversarial-review`](#codexadversarial-review) when you want to challenge a specific decision or risk area. +`--guest` selects the runtime. The default is `codex`. Pass `--guest grok` (or set `CC_GUEST=grok`) to run Grok headless instead of Codex app-server. Grok does not inherit Codex model aliases such as `spark`; omit `--model` to use Grok's default, or pass a Grok model ID. + Examples: ```bash /codex:review /codex:review --base main /codex:review --background +/codex:review --guest grok ``` This command is read-only and will not perform any changes. When run in the background you can use [`/codex:status`](#codexstatus) to check on the progress and [`/codex:cancel`](#codexcancel) to cancel the ongoing task. diff --git a/plugins/codex/commands/adversarial-review.md b/plugins/codex/commands/adversarial-review.md index 0482349e..42a27a36 100644 --- a/plugins/codex/commands/adversarial-review.md +++ b/plugins/codex/commands/adversarial-review.md @@ -1,6 +1,6 @@ --- description: Run a Codex review that challenges the implementation approach and design choices -argument-hint: '[--wait|--background] [--base ] [--scope auto|working-tree|branch] [--model ] [--effort ] [--turn-timeout-ms ] [--config key=value] [focus ...]' +argument-hint: '[--wait|--background] [--guest ] [--base ] [--scope auto|working-tree|branch] [--model ] [--effort ] [--turn-timeout-ms ] [--config key=value] [focus ...]' disable-model-invocation: true allowed-tools: Read, Glob, Grep, Bash(node:*), Bash(git:*), AskUserQuestion --- diff --git a/plugins/codex/commands/rescue.md b/plugins/codex/commands/rescue.md index 176e22cd..1bd2aa60 100644 --- a/plugins/codex/commands/rescue.md +++ b/plugins/codex/commands/rescue.md @@ -1,6 +1,6 @@ --- description: Delegate investigation, an explicit fix request, or follow-up rescue work to the Codex rescue subagent -argument-hint: "[--background] [--resume|--fresh] [--model ] [--effort ] [--turn-timeout-ms ] [--config key=value]... [what Codex should investigate, solve, or continue]" +argument-hint: "[--background] [--guest ] [--resume|--fresh] [--model ] [--effort ] [--turn-timeout-ms ] [--config key=value]... [what Codex should investigate, solve, or continue]" allowed-tools: Bash(node:*), AskUserQuestion, Agent --- diff --git a/plugins/codex/commands/review.md b/plugins/codex/commands/review.md index 93f1af66..6750119d 100644 --- a/plugins/codex/commands/review.md +++ b/plugins/codex/commands/review.md @@ -1,6 +1,6 @@ --- description: Run a Codex code review against local git state -argument-hint: '[--wait|--background] [--base ] [--scope auto|working-tree|branch] [--model ] [--effort ] [--turn-timeout-ms ] [--config key=value]' +argument-hint: '[--wait|--background] [--guest ] [--base ] [--scope auto|working-tree|branch] [--model ] [--effort ] [--turn-timeout-ms ] [--config key=value]' disable-model-invocation: true allowed-tools: Read, Glob, Grep, Bash(node:*), Bash(git:*), AskUserQuestion --- @@ -32,7 +32,8 @@ Execution mode rules: - `Run in background` Argument handling: -- Preserve the user's arguments exactly. +- Preserve the user's arguments exactly, including `--guest ` (default guest is codex). +- Do not add `--guest grok` unless the user asked for Grok. - Do not strip `--wait` or `--background` yourself. - Do not add extra review instructions or rewrite the user's intent. - The companion script parses `--wait` and `--background`, but Claude Code's `Bash(..., run_in_background: true)` is what actually detaches the run. diff --git a/plugins/codex/commands/setup.md b/plugins/codex/commands/setup.md index 2ebbb0cb..228c15ae 100644 --- a/plugins/codex/commands/setup.md +++ b/plugins/codex/commands/setup.md @@ -1,6 +1,6 @@ --- description: Check whether the local Codex CLI is ready and optionally toggle the stop-time review gate -argument-hint: '[--enable-review-gate|--disable-review-gate]' +argument-hint: '[--guest ] [--enable-review-gate|--disable-review-gate]' allowed-tools: Bash(node:*), Bash(npm:*), AskUserQuestion --- diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index d3641f6d..e791ab1c 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -7,6 +7,16 @@ import process from "node:process"; import { fileURLToPath } from "node:url"; import { parseArgs, splitRawArgumentString } from "./lib/args.mjs"; +import { resolveGuest } from "./lib/guest.mjs"; +import { + GROK_READ_ONLY_TOOLS, + getGrokAvailability, + getGrokAuthStatus, + resolveGrokEffort, + resolveGrokModel, + runGrokReview, + runGrokTurn, +} from "./lib/grok-cli.mjs"; import { buildPersistentTaskThreadName, DEFAULT_CONTINUE_PROMPT, @@ -106,10 +116,10 @@ function printUsage() { console.log( [ "Usage:", - " node scripts/codex-companion.mjs setup [--enable-review-gate|--disable-review-gate] [--json]", - " node scripts/codex-companion.mjs review [--wait|--background] [--base ] [--scope ] [--model ] [--effort ] [--turn-timeout-ms ] [--config key=value]...", - " node scripts/codex-companion.mjs adversarial-review [--wait|--background] [--base ] [--scope ] [--model ] [--effort ] [--turn-timeout-ms ] [--config key=value]... [focus text]", - " node scripts/codex-companion.mjs task [--background|--await [--await-timeout-ms ]] [--prompt-stdin] [--write] [--resume-last|--resume|--fresh] [--model ] [--effort ] [--turn-timeout-ms ] [--config key=value]... [prompt]", + " node scripts/codex-companion.mjs setup [--guest ] [--enable-review-gate|--disable-review-gate] [--json]", + " node scripts/codex-companion.mjs review [--wait|--background] [--guest ] [--base ] [--scope ] [--model ] [--effort ] [--turn-timeout-ms ] [--config key=value]...", + " node scripts/codex-companion.mjs adversarial-review [--wait|--background] [--guest ] [--base ] [--scope ] [--model ] [--effort ] [--turn-timeout-ms ] [--config key=value]... [focus text]", + " node scripts/codex-companion.mjs task [--background|--await [--await-timeout-ms ]] [--prompt-stdin] [--write] [--resume-last|--resume|--fresh] [--guest ] [--model ] [--effort ] [--turn-timeout-ms ] [--config key=value]... [prompt]", " node scripts/codex-companion.mjs transfer [--source ] [--json]", " node scripts/codex-companion.mjs status [job-id] [--all] [--json]", " node scripts/codex-companion.mjs result [job-id] [--wait [--timeout-ms ]] [--json]", @@ -246,6 +256,18 @@ function maybePrintCommandHelp(options) { return true; } +function resolveCommandGuest(options = {}) { + return resolveGuest(options.guest); +} + +function guestActorLabel(guest) { + return guest === "grok" ? "Grok" : "Codex"; +} + +function grokExitStatus(result) { + return result?.status === "completed" ? 0 : 1; +} + function resolveCommandCwd(options = {}) { return options.cwd ? path.resolve(process.cwd(), options.cwd) : process.cwd(); } @@ -277,32 +299,51 @@ function firstMeaningfulLine(text, fallback) { return line ?? fallback; } -async function buildSetupReport(cwd, actionsTaken = []) { +async function buildSetupReport(cwd, actionsTaken = [], options = {}) { const workspaceRoot = resolveWorkspaceRoot(cwd); + const guest = options.guest ?? "codex"; const nodeStatus = binaryAvailable("node", ["--version"], { cwd }); const npmStatus = binaryAvailable("npm", ["--version"], { cwd }); const codexStatus = getCodexAvailability(cwd); const authStatus = await getCodexAuthStatus(cwd); + const grokStatus = getGrokAvailability(cwd); + const grokAuthStatus = getGrokAuthStatus(cwd); const config = getConfig(workspaceRoot); + const guestReady = + guest === "grok" + ? grokStatus.available && grokAuthStatus.loggedIn + : codexStatus.available && authStatus.loggedIn; const nextSteps = []; - if (!codexStatus.available) { - nextSteps.push("Install Codex with `npm install -g @openai/codex`."); - } - if (codexStatus.available && !authStatus.loggedIn && authStatus.requiresOpenaiAuth) { - nextSteps.push("Run `!codex login`."); - nextSteps.push("If browser login is blocked, retry with `!codex login --device-auth` or `!codex login --with-api-key`."); + if (guest === "grok") { + if (!grokStatus.available) { + nextSteps.push("Install Grok CLI."); + } + if (grokStatus.available && !grokAuthStatus.loggedIn) { + nextSteps.push("Run `grok login`."); + } + } else { + if (!codexStatus.available) { + nextSteps.push("Install Codex with `npm install -g @openai/codex`."); + } + if (codexStatus.available && !authStatus.loggedIn && authStatus.requiresOpenaiAuth) { + nextSteps.push("Run `!codex login`."); + nextSteps.push("If browser login is blocked, retry with `!codex login --device-auth` or `!codex login --with-api-key`."); + } } if (!config.stopReviewGate) { nextSteps.push("Optional: run `/codex:setup --enable-review-gate` to require a fresh review before stop."); } return { - ready: nodeStatus.available && codexStatus.available && authStatus.loggedIn, + ready: nodeStatus.available && guestReady, + guest, node: nodeStatus, npm: npmStatus, codex: codexStatus, auth: authStatus, + grok: grokStatus, + grokAuth: grokAuthStatus, sessionRuntime: getSessionRuntimeStatus(process.env, workspaceRoot), reviewGateEnabled: Boolean(config.stopReviewGate), actionsTaken, @@ -312,12 +353,13 @@ async function buildSetupReport(cwd, actionsTaken = []) { async function handleSetup(argv) { const { options } = parseCommandInput(argv, { - valueOptions: ["cwd"], + valueOptions: ["cwd", "guest"], booleanOptions: ["json", "enable-review-gate", "disable-review-gate"] }); if (maybePrintCommandHelp(options)) { return; } + const guest = resolveCommandGuest(options); if (options["enable-review-gate"] && options["disable-review-gate"]) { throw new Error("Choose either --enable-review-gate or --disable-review-gate."); @@ -335,7 +377,7 @@ async function handleSetup(argv) { actionsTaken.push(`Disabled the stop-time review gate for ${workspaceRoot}.`); } - const finalReport = await buildSetupReport(cwd, actionsTaken); + const finalReport = await buildSetupReport(cwd, actionsTaken, { guest }); outputResult(options.json ? finalReport : renderSetupReport(finalReport), options.json); } @@ -357,6 +399,38 @@ function ensureCodexAvailable(cwd) { } } +function ensureGrokReady(cwd) { + const availability = getGrokAvailability(cwd); + if (!availability.available) { + throw new Error("Grok CLI is not installed or is missing required runtime support. Install it, then rerun `/codex:setup --guest grok`."); + } + const authStatus = getGrokAuthStatus(cwd); + if (!authStatus.loggedIn) { + throw new Error("Grok CLI is not authenticated. Run `grok login` and retry."); + } +} + +function ensureGuestReady(guest, cwd) { + if (guest === "grok") { + ensureGrokReady(cwd); + return; + } + ensureCodexAvailable(cwd); +} + +function buildGrokReviewPrompt(context, reviewName, focusText) { + if (reviewName === "Adversarial Review") { + return buildAdversarialReviewPrompt(context, focusText); + } + return [ + "Review the following code changes. Provide a structured assessment.", + "You are running in read-only mode. Do not attempt to write, edit, or create any files.", + `Target: ${context.target.label}`, + "", + context.content, + ].join("\n"); +} + function buildNativeReviewTarget(target) { if (target.mode === "working-tree") { return { type: "uncommittedChanges" }; @@ -510,7 +584,67 @@ async function resolveLatestTrackedTaskThread(cwd, options = {}) { return findLatestTaskThread(workspaceRoot); } +async function executeGrokReviewRun(request) { + ensureGrokReady(request.cwd); + ensureGitRepository(request.cwd); + + const target = resolveReviewTarget(request.cwd, { + base: request.base, + scope: request.scope + }); + const focusText = request.focusText?.trim() ?? ""; + const reviewName = request.reviewName ?? "Review"; + const actor = guestActorLabel("grok"); + if (reviewName === "Review") { + validateNativeReviewRequest(target, focusText); + } + const context = collectReviewContext(request.cwd, target); + const prompt = buildGrokReviewPrompt(context, reviewName, focusText); + const result = await runGrokReview(request.cwd, prompt, { + model: request.model, + effort: request.effort, + onProgress: request.onProgress, + }); + const stdout = typeof result.result === "string" ? result.result : ""; + const payload = { + review: reviewName, + target, + threadId: result.sessionId, + guest: "grok", + sourceThreadId: null, + grok: { + status: result.status, + stderr: result.stderr, + stdout, + } + }; + const rendered = [ + `# ${actor} ${reviewName}`, + "", + `Target: ${target.label}`, + "", + stdout, + "" + ].join("\n"); + return { + exitStatus: grokExitStatus(result), + threadId: result.sessionId, + turnId: null, + resolved: result.status === "completed", + payload, + rendered, + errorMessage: result.status === "completed" ? null : (result.stderr || "Grok review failed."), + summary: firstMeaningfulLine(stdout, `${reviewName} completed.`), + jobTitle: `${actor} ${reviewName}`, + jobClass: "review", + targetLabel: target.label + }; +} + async function executeReviewRun(request) { + if ((request.guest ?? "codex") === "grok") { + return executeGrokReviewRun(request); + } ensureCodexAvailable(request.cwd); ensureGitRepository(request.cwd); @@ -623,7 +757,80 @@ async function executeReviewRun(request) { } +async function executeGrokTaskRun(request) { + const workspaceRoot = resolveWorkspaceRoot(request.cwd); + ensureGrokReady(request.cwd); + + const taskMetadata = buildTaskRunMetadata({ + prompt: request.prompt, + resumeLast: request.resumeLast, + guest: "grok", + }); + + const grokOptions = { + model: request.model ?? undefined, + effort: request.effort ?? undefined, + onProgress: request.onProgress, + }; + if (!request.write) { + grokOptions.tools = GROK_READ_ONLY_TOOLS; + } + if (request.resumeLast) { + const latestThread = await resolveLatestTrackedTaskThread(workspaceRoot, { + excludeJobId: request.jobId + }); + if (!latestThread) { + throw new Error("No previous Grok task thread was found for this repository."); + } + grokOptions.resumeSessionId = latestThread.id; + } + if (!request.prompt && !grokOptions.resumeSessionId) { + throw new Error("Provide a prompt, a prompt file, piped stdin, or use --resume-last."); + } + + const prompt = request.prompt || "Continue where you left off."; + const result = await runGrokTurn(workspaceRoot, prompt, grokOptions); + const rawOutput = typeof result.finalMessage === "string" ? result.finalMessage : ""; + const failureMessage = result.stderr ?? ""; + const rendered = renderTaskResult( + { + rawOutput, + failureMessage, + reasoningSummary: [] + }, + { + title: taskMetadata.title, + jobId: request.jobId ?? null, + write: Boolean(request.write) + } + ); + const payload = { + status: result.status, + threadId: result.sessionId, + guest: "grok", + rawOutput, + touchedFiles: Array.isArray(result.touchedFiles) ? result.touchedFiles : [] + }; + + return { + exitStatus: grokExitStatus(result), + threadId: result.sessionId, + turnId: null, + resolved: result.status === "completed", + payload, + rendered, + errorMessage: result.status === "completed" ? null : (failureMessage || "Grok task failed."), + summary: firstMeaningfulLine(rawOutput, firstMeaningfulLine(failureMessage, `${taskMetadata.title} finished.`)), + jobTitle: taskMetadata.title, + jobClass: "task", + write: Boolean(request.write) + }; +} + async function executeTaskRun(request) { + if ((request.guest ?? "codex") === "grok") { + return executeGrokTaskRun(request); + } const workspaceRoot = resolveWorkspaceRoot(request.cwd); ensureCodexAvailable(request.cwd); @@ -701,23 +908,27 @@ async function executeTaskRun(request) { }; } -function buildReviewJobMetadata(reviewName, target) { +function buildReviewJobMetadata(reviewName, target, guest = "codex") { + const actor = guestActorLabel(guest); return { kind: reviewName === "Adversarial Review" ? "adversarial-review" : "review", - title: reviewName === "Review" ? "Codex Review" : `Codex ${reviewName}`, + title: reviewName === "Review" ? `${actor} Review` : `${actor} ${reviewName}`, summary: `${reviewName} ${target.label}` }; } -function buildTaskRunMetadata({ prompt, resumeLast = false }) { +function buildTaskRunMetadata({ prompt, resumeLast = false, guest = "codex" }) { + const actor = guestActorLabel(guest); if (!resumeLast && String(prompt ?? "").includes(STOP_REVIEW_TASK_MARKER)) { return { - title: "Codex Stop Gate Review", - summary: "Stop-gate review of previous Claude turn" + title: `${actor} Stop Gate Review`, + summary: guest === "grok" + ? "Stop-gate review of previous Grok turn" + : "Stop-gate review of previous Claude turn" }; } - const title = resumeLast ? "Codex Resume" : "Codex Task"; + const title = resumeLast ? `${actor} Resume` : `${actor} Task`; const fallbackSummary = resumeLast ? DEFAULT_CONTINUE_PROMPT : "Task"; return { title, @@ -776,7 +987,7 @@ function buildTaskJob(workspaceRoot, taskMetadata, write) { }); } -function buildTaskRequest({ cwd, model, effort, config, prompt, promptRaw, write, resumeLast, turnTimeoutMs, jobId }) { +function buildTaskRequest({ cwd, model, effort, config, prompt, promptRaw, write, resumeLast, turnTimeoutMs, jobId, guest }) { return { cwd, model, @@ -789,7 +1000,8 @@ function buildTaskRequest({ cwd, model, effort, config, prompt, promptRaw, write // Persisted so the detached worker runs under the same budget: it is a // separate process and never sees this command's flags. turnTimeoutMs, - jobId + jobId, + guest: guest ?? "codex" }; } @@ -934,7 +1146,7 @@ function enqueueBackgroundTask(cwd, job, request) { async function handleReviewCommand(argv, config) { const { options, positionals } = parseCommandInput(argv, { - valueOptions: ["base", "scope", "model", "effort", "cwd", "turn-timeout-ms"], + valueOptions: ["base", "scope", "model", "effort", "cwd", "turn-timeout-ms", "guest"], booleanOptions: ["json", "background", "wait"], repeatableOptions: ["config"], // Only the adversarial variant takes free-form focus text; stop option @@ -948,10 +1160,15 @@ async function handleReviewCommand(argv, config) { return; } + const guest = resolveCommandGuest(options); const cwd = resolveCommandCwd(options); const workspaceRoot = resolveCommandWorkspace(options); - const model = normalizeRequestedModel(options.model); - const effort = normalizeReasoningEffort(options.effort); + const model = guest === "grok" + ? resolveGrokModel(options.model) + : normalizeRequestedModel(options.model); + const effort = guest === "grok" + ? resolveGrokEffort(options.effort) + : normalizeReasoningEffort(options.effort); const configOverrides = parseConfigOverrides(options.config); const turnTimeoutMs = parseTimeoutOption(options["turn-timeout-ms"], "--turn-timeout-ms"); const focusText = positionals.join(" ").trim(); @@ -961,7 +1178,7 @@ async function handleReviewCommand(argv, config) { }); config.validateRequest?.(target, focusText); - const metadata = buildReviewJobMetadata(config.reviewName, target); + const metadata = buildReviewJobMetadata(config.reviewName, target, guest); const job = createCompanionJob({ prefix: "review", kind: metadata.kind, @@ -986,7 +1203,8 @@ async function handleReviewCommand(argv, config) { focusText, reviewName: config.reviewName, turnTimeoutMs, - onProgress: progress + onProgress: progress, + guest }), { json: options.json } ); @@ -1001,7 +1219,7 @@ async function handleReview(argv) { async function handleTask(argv) { const { options, positionals } = parseCommandInput(argv, { - valueOptions: ["model", "effort", "cwd", "prompt-file", "await-timeout-ms", "turn-timeout-ms"], + valueOptions: ["model", "effort", "cwd", "prompt-file", "await-timeout-ms", "turn-timeout-ms", "guest"], booleanOptions: ["json", "write", "resume-last", "resume", "fresh", "background", "await", "prompt-stdin"], repeatableOptions: ["config"], stopAtFirstPositional: true, @@ -1013,10 +1231,15 @@ async function handleTask(argv) { return; } + const guest = resolveCommandGuest(options); const cwd = resolveCommandCwd(options); const workspaceRoot = resolveCommandWorkspace(options); - const model = normalizeRequestedModel(options.model); - const effort = normalizeReasoningEffort(options.effort); + const model = guest === "grok" + ? resolveGrokModel(options.model) + : normalizeRequestedModel(options.model); + const effort = guest === "grok" + ? resolveGrokEffort(options.effort) + : normalizeReasoningEffort(options.effort); const configOverrides = parseConfigOverrides(options.config); // Every flag conflict is decided before the prompt is read: `--prompt-stdin` // blocks on an open stdin, so a usage error must never wait for EOF. @@ -1041,14 +1264,15 @@ async function handleTask(argv) { const write = Boolean(options.write); const taskMetadata = buildTaskRunMetadata({ prompt, - resumeLast + resumeLast, + guest, }); // `--await` runs the same detached worker as `--background` — same job // record, so status/result/cancel work on it — and only differs in waiting for // it here instead of returning the queued line. if (options.background || options.await) { - ensureCodexAvailable(cwd); + ensureGuestReady(guest, cwd); requireTaskRequest(prompt, resumeLast); const job = buildTaskJob(workspaceRoot, taskMetadata, write); @@ -1062,7 +1286,8 @@ async function handleTask(argv) { write, resumeLast, turnTimeoutMs, - jobId: job.id + jobId: job.id, + guest, }); const { payload } = enqueueBackgroundTask(cwd, job, request); if (!options.await) { @@ -1095,7 +1320,8 @@ async function handleTask(argv) { resumeLast, turnTimeoutMs, jobId: job.id, - onProgress: progress + onProgress: progress, + guest }), { json: options.json } ); diff --git a/plugins/codex/scripts/lib/grok-cli.mjs b/plugins/codex/scripts/lib/grok-cli.mjs new file mode 100644 index 00000000..80b458f1 --- /dev/null +++ b/plugins/codex/scripts/lib/grok-cli.mjs @@ -0,0 +1,498 @@ +/** + * Copyright 2026 Sendbird, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +/** + * Grok CLI wrapper for Claude Code `/codex:* --guest grok`. + * Spawns `grok --prompt-file` with `--output-format json`. + */ + +import { spawn, spawnSync } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const GROK_BIN = "grok"; +export const MAX_STDERR_BYTES = 64 * 1024; + +export const GROK_REVIEW_TOOLS = Object.freeze([ + "read_file", + "grep", + "list_dir", + "web_search", + "web_fetch", +]); + +export const GROK_READ_ONLY_TOOLS = GROK_REVIEW_TOOLS; + +export const GROK_VALID_EFFORTS = Object.freeze([ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +]); + +const GROK_AUTH_RE = + /\bnot (?:logged|signed) in\b|\bnot authenticated\b|\bgrok login\b|\binvalid api key\b/i; +const GROK_LIMIT_RE = /\brate[_ -]?limit\b|\b429\b|\busage limit\b/i; + +function sliceTextTailByBytes(text, maxBytes) { + const normalized = typeof text === "string" ? text : String(text ?? ""); + if (!normalized || maxBytes <= 0) { + return ""; + } + if (Buffer.byteLength(normalized, "utf8") <= maxBytes) { + return normalized; + } + + let low = 0; + let high = normalized.length; + while (low < high) { + const mid = Math.floor((low + high) / 2); + if (Buffer.byteLength(normalized.slice(mid), "utf8") > maxBytes) { + low = mid + 1; + } else { + high = mid; + } + } + + let start = low; + let retained = normalized.slice(start); + while (start < normalized.length && Buffer.byteLength(retained, "utf8") > maxBytes) { + start += 1; + retained = normalized.slice(start); + } + return retained; +} + +function appendTextTail(existing, chunk, maxBytes) { + return sliceTextTailByBytes(`${existing ?? ""}${chunk ?? ""}`, maxBytes); +} + +export function resolveGrokCommand(platform = process.platform, env = process.env) { + if (platform !== "win32") { + return { executable: GROK_BIN, prefixArgs: [] }; + } + + const searchPath = env.PATH ?? env.Path ?? ""; + for (const entry of searchPath.split(";")) { + const directory = entry.trim().replace(/^"(.*)"$/u, "$1"); + if (!directory) { + continue; + } + const nativeExecutable = path.join(directory, `${GROK_BIN}.exe`); + try { + if (fs.statSync(nativeExecutable).isFile()) { + return { executable: nativeExecutable, prefixArgs: [] }; + } + } catch { + // Keep searching PATH. + } + } + + return { executable: GROK_BIN, prefixArgs: [] }; +} + +export function resolveGrokModel(model) { + if (model == null) { + return undefined; + } + const normalized = String(model).trim(); + return normalized ? normalized : undefined; +} + +export function resolveGrokEffort(effort) { + if (effort == null) { + return undefined; + } + const normalized = String(effort).trim().toLowerCase(); + if (!normalized) { + return undefined; + } + if (!GROK_VALID_EFFORTS.includes(normalized)) { + throw new Error( + `Unsupported effort "${effort}". Use one of: ${GROK_VALID_EFFORTS.join(", ")}.` + ); + } + return normalized; +} + +export function getGrokAvailability(cwd) { + try { + const command = resolveGrokCommand(); + if (command.error) { + return { available: false, detail: command.error }; + } + const result = spawnSync(command.executable, [...command.prefixArgs, "--version"], { + cwd, + encoding: "utf8", + timeout: 10_000, + windowsHide: true, + }); + if (result.status !== 0) { + throw new Error("non-zero exit"); + } + return { available: true, detail: (result.stdout ?? "").trim() }; + } catch { + return { available: false, detail: "grok CLI not found in PATH" }; + } +} + +export function getGrokAuthStatus(cwd, env = process.env, options = {}) { + if (env?.XAI_API_KEY || env?.GROK_API_KEY) { + return { available: true, loggedIn: true, detail: "API key configured" }; + } + const grokHome = + options.grokHome ?? env?.GROK_HOME ?? path.join(os.homedir(), ".grok"); + try { + if (fs.statSync(path.join(grokHome, "auth.json")).isFile()) { + return { available: true, loggedIn: true, detail: "authenticated" }; + } + } catch { + // Fall through. + } + return { + available: true, + loggedIn: false, + detail: "not authenticated — run `grok login`", + }; +} + +export function classifyGrokFailure(value = {}) { + const finalMessage = typeof value.finalMessage === "string" ? value.finalMessage.trim() : ""; + const stderr = typeof value.stderr === "string" ? value.stderr.trim() : ""; + const message = [finalMessage, stderr].filter(Boolean).join("\n").trim(); + if (!message) { + return null; + } + if (GROK_LIMIT_RE.test(message)) { + return { kind: "grok_rate_limit", message, resetText: null }; + } + if (GROK_AUTH_RE.test(message) && value.exitCode !== 0) { + return { kind: "grok_auth", message, resetText: null }; + } + return null; +} + +export function parseGrokJsonResult(raw) { + const text = String(raw ?? "").trim(); + if (!text) { + throw new Error("Grok JSON output was empty."); + } + let value; + try { + value = JSON.parse(text); + } catch { + throw new Error("Grok JSON output was not valid JSON."); + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Grok JSON output was not an object."); + } + + const resultText = typeof value.text === "string" ? value.text : ""; + const sessionId = + typeof value.sessionId === "string" && value.sessionId.trim() + ? value.sessionId.trim() + : null; + const modelUsage = + value.modelUsage && typeof value.modelUsage === "object" && !Array.isArray(value.modelUsage) + ? value.modelUsage + : null; + const usageKeys = modelUsage + ? Object.keys(modelUsage).filter((key) => key.trim()) + : []; + const finalModel = usageKeys.length === 1 ? usageKeys[0] : null; + const usage = finalModel ? modelUsage[finalModel] : null; + const contextWindow = + Number.isSafeInteger(usage?.contextWindow) && usage.contextWindow > 0 + ? usage.contextWindow + : null; + + let structuredOutput = null; + const trimmedText = resultText.trim(); + if ( + (trimmedText.startsWith("{") && trimmedText.endsWith("}")) || + (trimmedText.startsWith("[") && trimmedText.endsWith("]")) + ) { + try { + structuredOutput = JSON.parse(trimmedText); + } catch { + structuredOutput = null; + } + } + + return { + text: resultText, + sessionId, + finalModel, + contextWindow, + structuredOutput, + value, + }; +} + +export function buildGrokArgs(options = {}) { + if (!options.promptFile) { + throw new Error("buildGrokArgs requires promptFile."); + } + const args = [ + "--prompt-file", + options.promptFile, + "--output-format", + options.outputFormat ?? "json", + ]; + if (options.alwaysApprove !== false) { + args.push("--always-approve"); + } + const model = resolveGrokModel(options.model); + if (model) { + args.push("--model", model); + } + const effort = resolveGrokEffort(options.effort); + if (effort) { + args.push("--effort", effort); + } + if (options.sessionId) { + args.push("--session-id", options.sessionId); + } + if (options.resumeSessionId) { + args.push("--resume", options.resumeSessionId); + } + if (options.tools?.length) { + const tools = Array.isArray(options.tools) ? options.tools.join(",") : String(options.tools); + args.push("--tools", tools); + } + if (options.disallowedTools?.length) { + const tools = Array.isArray(options.disallowedTools) + ? options.disallowedTools.join(",") + : String(options.disallowedTools); + args.push("--disallowed-tools", tools); + } + if (options.maxTurns) { + args.push("--max-turns", String(options.maxTurns)); + } + if (options.jsonSchema) { + args.push( + "--json-schema", + typeof options.jsonSchema === "string" + ? options.jsonSchema + : JSON.stringify(options.jsonSchema) + ); + } + if (options.permissionMode) { + args.push("--permission-mode", options.permissionMode); + } + if (options.cwd) { + args.push("--cwd", options.cwd); + } + return args; +} + +function createGrokPromptFile(prompt) { + const dir = path.join(os.tmpdir(), "codex-plugin-cc-grok-prompts"); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + const tmpFile = path.join( + dir, + `cc-grok-${process.pid}-${Date.now().toString(36)}-${randomBytes(6).toString("hex")}.txt` + ); + fs.writeFileSync(tmpFile, String(prompt ?? ""), { + encoding: "utf8", + mode: 0o600, + }); + return tmpFile; +} + +function cleanupGrokPromptFile(filePath) { + if (filePath) { + try { + fs.unlinkSync(filePath); + } catch {} + } +} + +function failedGrokResult({ + stderr, + exitCode = -1, + requestedModel = null, + pid = null, + pidIdentity = null, +}) { + return { + status: "failed", + exitCode, + sessionId: null, + finalMessage: "", + structuredOutput: null, + toolUses: [], + touchedFiles: [], + requestedModel, + finalModel: null, + contextWindow: null, + modelEvents: [], + parseErrors: [], + unresolvedParseErrors: 0, + failure: classifyGrokFailure({ + stderr, + exitCode, + }), + stderr, + pid, + pidIdentity, + }; +} + +export async function runGrokTurn(cwd, prompt, options = {}) { + const requestedModel = resolveGrokModel(options.model) ?? null; + const command = resolveGrokCommand(); + if (command.error) { + return failedGrokResult({ stderr: command.error, requestedModel }); + } + + const promptFile = createGrokPromptFile(prompt); + const args = buildGrokArgs({ + ...options, + promptFile, + }); + const executableArgs = [...command.prefixArgs, ...args]; + + try { + return await new Promise((resolve) => { + const proc = spawn(command.executable, executableArgs, { + cwd, + detached: true, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + + const pidIdentity = null; + if (options.onSpawn) { + options.onSpawn({ pid: proc.pid, pidIdentity }); + } + + let stdout = ""; + let stderr = ""; + proc.stdout.setEncoding("utf8"); + proc.stderr.setEncoding("utf8"); + proc.stdout.on("data", (chunk) => { + stdout += chunk; + }); + proc.stderr.on("data", (chunk) => { + stderr = appendTextTail(stderr, chunk, MAX_STDERR_BYTES); + }); + + proc.on("error", (err) => { + resolve( + failedGrokResult({ + stderr: err.message, + requestedModel, + pid: proc.pid, + pidIdentity, + }) + ); + }); + + proc.on("close", (code) => { + let parsed = null; + let parseError = null; + try { + parsed = parseGrokJsonResult(stdout); + } catch (error) { + parseError = error instanceof Error ? error.message : String(error); + } + + if (parseError) { + const combined = appendTextTail( + stderr, + stderr ? `\n${parseError}` : parseError, + MAX_STDERR_BYTES + ); + resolve({ + ...failedGrokResult({ + stderr: combined, + exitCode: code ?? 1, + requestedModel, + pid: proc.pid, + pidIdentity, + }), + parseErrors: [{ error: parseError }], + unresolvedParseErrors: 1, + }); + return; + } + + const exitCode = code ?? 1; + const status = exitCode === 0 ? "completed" : "failed"; + if (options.onProgress) { + options.onProgress({ + kind: "result", + data: parsed.value, + message: parsed.text, + threadId: parsed.sessionId, + }); + } + resolve({ + status, + warning: undefined, + exitCode, + sessionId: parsed.sessionId, + finalMessage: parsed.text, + structuredOutput: parsed.structuredOutput, + toolUses: [], + touchedFiles: [], + requestedModel, + finalModel: parsed.finalModel, + contextWindow: parsed.contextWindow, + modelEvents: [], + parseErrors: [], + unresolvedParseErrors: 0, + failure: + status === "failed" + ? classifyGrokFailure({ + finalMessage: parsed.text, + stderr, + exitCode, + }) + : null, + stderr, + pid: proc.pid, + pidIdentity, + }); + }); + + if (options.background) { + proc.unref(); + } + }); + } finally { + cleanupGrokPromptFile(promptFile); + } +} + +export async function runGrokReview(cwd, prompt, options = {}) { + const result = await runGrokTurn(cwd, prompt, { + tools: GROK_REVIEW_TOOLS, + ...options, + }); + return { + status: result.status, + exitCode: result.exitCode, + warning: result.warning, + result: result.finalMessage, + structuredOutput: result.structuredOutput ?? null, + sessionId: result.sessionId, + requestedModel: result.requestedModel, + finalModel: result.finalModel, + contextWindow: result.contextWindow, + modelEvents: result.modelEvents, + parseErrors: result.parseErrors, + unresolvedParseErrors: result.unresolvedParseErrors, + failure: result.failure, + stderr: result.stderr, + pid: result.pid, + pidIdentity: result.pidIdentity, + }; +} diff --git a/plugins/codex/scripts/lib/guest.mjs b/plugins/codex/scripts/lib/guest.mjs new file mode 100644 index 00000000..64ee904d --- /dev/null +++ b/plugins/codex/scripts/lib/guest.mjs @@ -0,0 +1,26 @@ +export const DEFAULT_GUEST = "codex"; +export const SUPPORTED_GUESTS = Object.freeze(["codex", "grok"]); + +function firstNonEmpty(...values) { + for (const value of values) { + if (value == null) { + continue; + } + const trimmed = String(value).trim(); + if (trimmed) { + return trimmed; + } + } + return ""; +} + +export function resolveGuest(value, env = process.env) { + const raw = firstNonEmpty(value, env?.CC_GUEST) || DEFAULT_GUEST; + const normalized = raw.toLowerCase(); + if (!SUPPORTED_GUESTS.includes(normalized)) { + throw new Error( + `Unsupported guest "${raw}". Use one of: ${SUPPORTED_GUESTS.join(", ")}.` + ); + } + return normalized; +} diff --git a/plugins/codex/scripts/lib/render.mjs b/plugins/codex/scripts/lib/render.mjs index 2ec18523..b5490aa6 100644 --- a/plugins/codex/scripts/lib/render.mjs +++ b/plugins/codex/scripts/lib/render.mjs @@ -185,6 +185,12 @@ export function renderSetupReport(report) { `- npm: ${report.npm.detail}`, `- codex: ${report.codex.detail}`, `- auth: ${report.auth.detail}`, + ...(report.guest === "grok" && report.grok && report.grokAuth + ? [ + `- grok: ${report.grok.detail}`, + `- grok auth: ${report.grokAuth.detail}`, + ] + : []), `- session runtime: ${report.sessionRuntime.label}`, `- review gate: ${report.reviewGateEnabled ? "enabled" : "disabled"}`, "" diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index 0a1205ae..ed768b8b 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -245,7 +245,7 @@ test("setup command can offer Codex install and still points users to codex logi const setup = read("commands/setup.md"); const readme = fs.readFileSync(path.join(ROOT, "README.md"), "utf8"); - assert.match(setup, /argument-hint:\s*'\[--enable-review-gate\|--disable-review-gate\]'/); + assert.match(setup, /argument-hint:\s*'\[--guest \] \[--enable-review-gate\|--disable-review-gate\]'/); assert.match(setup, /AskUserQuestion/); assert.match(setup, /npm install -g @openai\/codex/); assert.match(setup, /codex-companion\.mjs" setup --json --args-stdin <<'CODEX_ARGS'/); diff --git a/tests/grok-cli.test.mjs b/tests/grok-cli.test.mjs new file mode 100644 index 00000000..024a0779 --- /dev/null +++ b/tests/grok-cli.test.mjs @@ -0,0 +1,326 @@ +/** + * Copyright 2026 Sendbird, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { + GROK_READ_ONLY_TOOLS, + GROK_REVIEW_TOOLS, + buildGrokArgs, + classifyGrokFailure, + getGrokAuthStatus, + getGrokAvailability, + parseGrokJsonResult, + resolveGrokCommand, + resolveGrokEffort, + resolveGrokModel, + runGrokReview, + runGrokTurn, +} from "../plugins/codex/scripts/lib/grok-cli.mjs"; + +function createFakeGrokCommand(tmpDir, source) { + const fakeGrok = path.join(tmpDir, "grok.js"); + fs.writeFileSync(fakeGrok, source); + const launcher = path.join(tmpDir, process.platform === "win32" ? "grok.cmd" : "grok"); + if (process.platform === "win32") { + fs.writeFileSync( + launcher, + `@ECHO off\r\n"${process.execPath}" "${fakeGrok}" %*\r\n` + ); + } else { + fs.writeFileSync( + launcher, + `#!/bin/sh\nexec "${process.execPath}" "${fakeGrok}" "$@"\n` + ); + fs.chmodSync(launcher, 0o755); + } + return { fakeGrok, launcher }; +} + +describe("resolveGrokModel / resolveGrokEffort", () => { + it("omits empty models instead of defaulting to Claude aliases", () => { + assert.equal(resolveGrokModel(undefined), undefined); + assert.equal(resolveGrokModel(" "), undefined); + assert.equal(resolveGrokModel("grok-4.6"), "grok-4.6"); + }); + + it("passes Grok effort values through without Claude remapping", () => { + assert.equal(resolveGrokEffort(undefined), undefined); + assert.equal(resolveGrokEffort("none"), "none"); + assert.equal(resolveGrokEffort("minimal"), "minimal"); + assert.equal(resolveGrokEffort("xhigh"), "xhigh"); + assert.throws(() => resolveGrokEffort("ludicrous"), /Unsupported effort/); + }); +}); + +describe("buildGrokArgs", () => { + it("uses --prompt-file and json output instead of claude -p stdin flags", () => { + const args = buildGrokArgs({ + promptFile: "/tmp/prompt.txt", + model: "grok-4.6", + effort: "high", + tools: GROK_REVIEW_TOOLS, + }); + + assert.equal(args.includes("-p"), false); + assert.deepEqual(args.slice(0, 4), [ + "--prompt-file", + "/tmp/prompt.txt", + "--output-format", + "json", + ]); + assert.ok(args.includes("--always-approve")); + assert.ok(args.includes("--model")); + assert.equal(args[args.indexOf("--model") + 1], "grok-4.6"); + assert.equal(args[args.indexOf("--effort") + 1], "high"); + assert.equal( + args[args.indexOf("--tools") + 1], + GROK_REVIEW_TOOLS.join(",") + ); + assert.equal(args.includes("--output-format") && args.includes("stream-json"), false); + assert.equal(args.includes("--allowedTools"), false); + assert.equal(args.includes("--verbose"), false); + }); + + it("forwards resume, schema, and max-turns when provided", () => { + const args = buildGrokArgs({ + promptFile: "/tmp/prompt.txt", + resumeSessionId: "sess-1", + jsonSchema: { type: "object" }, + maxTurns: 8, + alwaysApprove: false, + }); + + assert.equal(args[args.indexOf("--resume") + 1], "sess-1"); + assert.equal(args[args.indexOf("--max-turns") + 1], "8"); + assert.equal( + args[args.indexOf("--json-schema") + 1], + JSON.stringify({ type: "object" }) + ); + assert.equal(args.includes("--always-approve"), false); + }); +}); + +describe("parseGrokJsonResult", () => { + it("reads text, sessionId, and a single modelUsage key", () => { + const parsed = parseGrokJsonResult( + JSON.stringify({ + text: "looks good", + sessionId: "abc", + stopReason: "end_turn", + modelUsage: { + "grok-4.6": { inputTokens: 10, outputTokens: 4, contextWindow: 2000000 }, + }, + }) + ); + + assert.equal(parsed.text, "looks good"); + assert.equal(parsed.sessionId, "abc"); + assert.equal(parsed.finalModel, "grok-4.6"); + assert.equal(parsed.contextWindow, 2000000); + assert.equal(parsed.structuredOutput, null); + }); + + it("prefers structured JSON text as structuredOutput when parseable", () => { + const parsed = parseGrokJsonResult( + JSON.stringify({ + text: '{"summary":"risk"}', + sessionId: "s2", + }) + ); + assert.deepEqual(parsed.structuredOutput, { summary: "risk" }); + }); + + it("throws on empty or non-JSON stdout", () => { + assert.throws(() => parseGrokJsonResult(""), /Grok JSON output/); + assert.throws(() => parseGrokJsonResult("not json"), /Grok JSON output/); + }); +}); + +describe("classifyGrokFailure", () => { + it("classifies authentication and rate-limit stderr", () => { + assert.equal( + classifyGrokFailure({ stderr: "Not logged in. Run grok login.", exitCode: 1 }) + ?.kind, + "grok_auth" + ); + assert.equal( + classifyGrokFailure({ stderr: "HTTP 429 rate limit", exitCode: 1 })?.kind, + "grok_rate_limit" + ); + assert.equal(classifyGrokFailure({ stderr: "boom", exitCode: 1 }), null); + }); +}); + +describe("resolveGrokCommand / availability", () => { + it("resolves grok on non-Windows without searching PATH", () => { + assert.deepEqual(resolveGrokCommand("linux", { PATH: "/missing" }), { + executable: "grok", + prefixArgs: [], + }); + }); + + it("resolves a native grok.exe on Windows PATH", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cc-plugin-grok-native-")); + try { + const nativeExecutable = path.join(tmpDir, "grok.exe"); + fs.writeFileSync(nativeExecutable, ""); + assert.deepEqual(resolveGrokCommand("win32", { PATH: tmpDir }), { + executable: nativeExecutable, + prefixArgs: [], + }); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("reports availability and API-key auth from the same PATH-resolved command", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cc-plugin-grok-status-")); + const oldPath = process.env.PATH ?? ""; + const oldKey = process.env.XAI_API_KEY; + delete process.env.XAI_API_KEY; + try { + createFakeGrokCommand( + tmpDir, + `const args = process.argv.slice(2);\nif (args[0] === "--version") process.stdout.write("1.0.13\\n");\nprocess.exit(args[0] === "--version" ? 0 : 1);\n` + ); + process.env.PATH = `${tmpDir}${path.delimiter}${oldPath}`; + + assert.deepEqual(getGrokAvailability(process.cwd()), { + available: true, + detail: "1.0.13", + }); + assert.deepEqual( + getGrokAuthStatus(process.cwd(), { XAI_API_KEY: "test-key" }), + { available: true, loggedIn: true, detail: "API key configured" } + ); + const grokHome = path.join(tmpDir, "home"); + fs.mkdirSync(grokHome); + fs.writeFileSync(path.join(grokHome, "auth.json"), "{}"); + assert.deepEqual(getGrokAuthStatus(process.cwd(), {}, { grokHome }), { + available: true, + loggedIn: true, + detail: "authenticated", + }); + assert.equal( + getGrokAuthStatus(process.cwd(), {}, { grokHome: path.join(tmpDir, "empty") }) + .loggedIn, + false + ); + } finally { + process.env.PATH = oldPath; + if (oldKey === undefined) { + delete process.env.XAI_API_KEY; + } else { + process.env.XAI_API_KEY = oldKey; + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); + +describe("runGrokTurn", () => { + it("writes the prompt to a file and parses Grok json stdout", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cc-plugin-grok-run-")); + const oldPath = process.env.PATH ?? ""; + const argvFile = path.join(tmpDir, "argv.json"); + try { + createFakeGrokCommand( + tmpDir, + `const fs = require("node:fs"); +const args = process.argv.slice(2); +fs.writeFileSync(${JSON.stringify(argvFile)}, JSON.stringify(args)); +const promptFile = args[args.indexOf("--prompt-file") + 1]; +const prompt = fs.readFileSync(promptFile, "utf8"); +process.stdout.write(JSON.stringify({ + text: "echo:" + prompt, + sessionId: "grok-sess-1", + modelUsage: { "grok-4.6": { inputTokens: 1, outputTokens: 2 } }, +})); +` + ); + process.env.PATH = `${tmpDir}${path.delimiter}${oldPath}`; + + const result = await runGrokTurn(process.cwd(), "review this diff", { + model: "grok-4.6", + tools: GROK_REVIEW_TOOLS, + }); + const argv = JSON.parse(fs.readFileSync(argvFile, "utf8")); + + assert.equal(result.status, "completed"); + assert.equal(result.sessionId, "grok-sess-1"); + assert.equal(result.finalMessage, "echo:review this diff"); + assert.equal(result.finalModel, "grok-4.6"); + assert.equal(result.requestedModel, "grok-4.6"); + assert.equal(typeof result.pid, "number"); + assert.equal(argv.includes("-p"), false); + assert.equal(argv[argv.indexOf("--prompt-file") + 1].includes("cc-grok-"), true); + assert.equal(argv.includes("--always-approve"), true); + assert.equal(argv[argv.indexOf("--tools") + 1], GROK_REVIEW_TOOLS.join(",")); + } finally { + process.env.PATH = oldPath; + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("maps a review turn onto the Grok read-only tool allowlist", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cc-plugin-grok-review-")); + const oldPath = process.env.PATH ?? ""; + const argvFile = path.join(tmpDir, "argv.json"); + try { + createFakeGrokCommand( + tmpDir, + `const fs = require("node:fs"); +const args = process.argv.slice(2); +fs.writeFileSync(${JSON.stringify(argvFile)}, JSON.stringify(args)); +process.stdout.write(JSON.stringify({ text: "ok", sessionId: "rev-1" })); +` + ); + process.env.PATH = `${tmpDir}${path.delimiter}${oldPath}`; + + const result = await runGrokReview(process.cwd(), "review"); + const argv = JSON.parse(fs.readFileSync(argvFile, "utf8")); + + assert.equal(result.status, "completed"); + assert.equal(result.result, "ok"); + assert.equal(result.sessionId, "rev-1"); + assert.equal(argv[argv.indexOf("--tools") + 1], GROK_REVIEW_TOOLS.join(",")); + assert.deepEqual(GROK_REVIEW_TOOLS, [ + "read_file", + "grep", + "list_dir", + "web_search", + "web_fetch", + ]); + assert.deepEqual(GROK_READ_ONLY_TOOLS, GROK_REVIEW_TOOLS); + } finally { + process.env.PATH = oldPath; + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("fails closed when Grok prints non-JSON stdout", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cc-plugin-grok-badjson-")); + const oldPath = process.env.PATH ?? ""; + try { + createFakeGrokCommand( + tmpDir, + `process.stdout.write("plain text");\nprocess.exit(0);\n` + ); + process.env.PATH = `${tmpDir}${path.delimiter}${oldPath}`; + + const result = await runGrokTurn(process.cwd(), "prompt"); + assert.equal(result.status, "failed"); + assert.match(result.stderr, /Grok JSON output/); + } finally { + process.env.PATH = oldPath; + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); + diff --git a/tests/grok-guest-cli.test.mjs b/tests/grok-guest-cli.test.mjs new file mode 100644 index 00000000..0441176d --- /dev/null +++ b/tests/grok-guest-cli.test.mjs @@ -0,0 +1,46 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const PROJECT_ROOT = path.resolve(fileURLToPath(new URL("../", import.meta.url))); +const COMPANION = path.join(PROJECT_ROOT, "plugins", "codex", "scripts", "codex-companion.mjs"); + +function runCompanion(args, env = {}) { + return spawnSync(process.execPath, [COMPANION, ...args], { + cwd: PROJECT_ROOT, + encoding: "utf8", + timeout: 15_000, + env: { ...process.env, ...env }, + }); +} + +describe("codex-companion --guest", () => { + it("documents --guest on review, adversarial-review, task, and setup", () => { + const result = runCompanion(["--help"]); + assert.equal(result.status, 0); + assert.match(result.stdout, /--guest /); + assert.match(result.stdout, /setup \[.*--guest/); + assert.match(result.stdout, /review \[.*--guest/); + assert.match(result.stdout, /adversarial-review \[.*--guest/); + assert.match(result.stdout, /task \[.*--guest/); + }); + + it("rejects an unknown --guest before touching git or Codex", () => { + const result = runCompanion(["review", "--guest", "chatgpt", "--json"]); + assert.notEqual(result.status, 0); + assert.match(`${result.stderr}${result.stdout}`, /Unsupported guest "chatgpt"/); + assert.doesNotMatch(`${result.stderr}${result.stdout}`, /not a git repository/i); + }); + + it("does not treat grok as the default guest", () => { + const command = fs.readFileSync( + path.join(PROJECT_ROOT, "plugins", "codex", "commands", "review.md"), + "utf8" + ); + assert.match(command, /--guest /); + assert.match(command, /default guest is codex/i); + }); +}); diff --git a/tests/guest.test.mjs b/tests/guest.test.mjs new file mode 100644 index 00000000..6c39d704 --- /dev/null +++ b/tests/guest.test.mjs @@ -0,0 +1,47 @@ +/** + * Copyright 2026 Sendbird, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { + DEFAULT_GUEST, + SUPPORTED_GUESTS, + resolveGuest, +} from "../plugins/codex/scripts/lib/guest.mjs"; + +describe("resolveGuest", () => { + it("defaults to codex when flag and env are unset", () => { + assert.equal(DEFAULT_GUEST, "codex"); + assert.deepEqual([...SUPPORTED_GUESTS], ["codex", "grok"]); + assert.equal(resolveGuest(undefined, {}), "codex"); + assert.equal(resolveGuest(null, {}), "codex"); + assert.equal(resolveGuest("", {}), "codex"); + }); + + it("reads CC_GUEST when the CLI flag is omitted", () => { + assert.equal(resolveGuest(undefined, { CC_GUEST: "grok" }), "grok"); + assert.equal(resolveGuest(" ", { CC_GUEST: "GROK" }), "grok"); + }); + + it("lets an explicit --guest value override CC_GUEST", () => { + assert.equal(resolveGuest("codex", { CC_GUEST: "grok" }), "codex"); + assert.equal(resolveGuest("Grok", { CC_GUEST: "codex" }), "grok"); + }); + + it("ignores a blank CC_GUEST and keeps the default", () => { + assert.equal(resolveGuest(undefined, { CC_GUEST: " " }), "codex"); + }); + + it("rejects unknown guests", () => { + assert.throws( + () => resolveGuest("claude", {}), + /Unsupported guest "claude"/ + ); + assert.throws( + () => resolveGuest(undefined, { CC_GUEST: "chatgpt" }), + /Unsupported guest "chatgpt"/ + ); + }); +});