From eb169b0d771537f3b1e8d5c71fdbdfca3330db00 Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:46:32 +0300 Subject: [PATCH] feat: add Grok as a --guest runtime for Codex $cc commands Codex can now run Grok instead of Claude Code via `--guest grok` or `CC_GUEST=grok`. The default guest remains Claude. Grok turns use `grok --prompt-file` with `--output-format json` and a read-only tool allowlist for reviews. Claude MCP tools stay Claude-only. --- CHANGELOG.md | 4 + README.md | 7 +- internal-skills/cli-runtime/runtime.md | 3 +- internal-skills/review-runtime/runtime.md | 1 + scripts/claude-companion.mjs | 437 +++++++++++++++++-- scripts/lib/grok-cli.mjs | 505 ++++++++++++++++++++++ scripts/lib/guest.mjs | 31 ++ scripts/lib/render.mjs | 14 + skills/adversarial-review/SKILL.md | 6 +- skills/rescue/SKILL.md | 7 +- skills/review/SKILL.md | 6 +- skills/setup/SKILL.md | 3 +- tests/grok-cli.test.mjs | 326 ++++++++++++++ tests/grok-guest-cli.test.mjs | 50 +++ tests/guest.test.mjs | 47 ++ 15 files changed, 1397 insertions(+), 50 deletions(-) create mode 100644 scripts/lib/grok-cli.mjs create mode 100644 scripts/lib/guest.mjs create mode 100644 tests/grok-cli.test.mjs create mode 100644 tests/grok-guest-cli.test.mjs create mode 100644 tests/guest.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a510a44..4b7584c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Let `$cc:review`, `$cc:adversarial-review`, `$cc:rescue`, and `$cc:setup` run Grok as the guest via `--guest grok` or `CC_GUEST=grok`. The default guest remains Claude; Grok uses `--prompt-file` plus `--output-format json` and does not inherit Claude model aliases or Claude MCP tools. + ## v1.6.1 ### Added diff --git a/README.md b/README.md index 15da347..40dac87 100644 --- a/README.md +++ b/README.md @@ -133,10 +133,12 @@ $cc:review --model opus --effort high # opus with a lighter effort $cc:review --user-mcp-tool mcp__context7__resolve-library-id ``` -**Flags:** `--base `, `--scope `, `--wait`, `--background`, `--model `, `--effort `, `--user-mcp-tool `, `--allow-project-mcp-servers` +**Flags:** `--base `, `--scope `, `--wait`, `--background`, `--guest `, `--model `, `--effort `, `--user-mcp-tool `, `--allow-project-mcp-servers` **Defaults:** model `opus` is passed to Claude Code as its native alias with `xhigh` effort. `sonnet` is passed through with `high` effort; `haiku` and `fable` are passed through with no default effort setting. Claude Code resolves aliases to the current model for the active provider and account (for example, Opus 5). Pass a full model ID to pin a version; for older pinned IDs, pass `--effort` explicitly instead of inheriting a current-family default. +`--guest` selects the companion runtime. The default is `claude`. Pass `--guest grok` (or set `CC_GUEST=grok`) to run Grok headless instead of Claude Code. Grok does not inherit Claude aliases such as `opus`; omit `--model` to use Grok's default, or pass a Grok model ID such as `grok-4.6`. `--user-mcp-tool` and `--allow-project-mcp-servers` remain Claude-only. + Fable 5 has a native 1M context window, so current Claude Code only needs the bare `fable` alias; no `[1m]` suffix is required. See Claude Code's [model configuration](https://code.claude.com/docs/en/model-config). JSON task and review results keep `requestedModel` as the forwarded alias or full ID, report `finalModel` from Claude's terminal result, and expose the terminal `contextWindow` reported in `modelUsage` (`null` when Claude does not provide it). The plugin does not infer a context limit from a floating alias. @@ -197,7 +199,8 @@ $cc:rescue --model sonnet --effort medium investigate the flaky test | `--resume-last` | Alias for `--resume` | | `--fresh` | Force a new task (don't resume) | | `--write` | Allow file edits (default) | -| `--model ` | Claude model (`opus`, `sonnet`, `haiku`, `fable`, or full ID; defaults to `opus`). Aliases are resolved by Claude Code; a full ID pins a version. | +| `--guest ` | Guest runtime (default `claude`). `grok` spawns `grok --prompt-file` instead of `claude -p`. | +| `--model ` | Claude model (`opus`, `sonnet`, `haiku`, `fable`, or full ID; defaults to `opus`). Aliases are resolved by Claude Code; a full ID pins a version. For `--guest grok`, pass a Grok model ID or omit `--model`. | | `--effort ` | Reasoning effort: `low`, `medium`, `high`, `xhigh`, `max` (default: `xhigh` for opus, `high` for sonnet, unset for haiku and fable) | | `--prompt-file ` | Read task description from a file | | `--view-state on-terminal` | Mark the foreground terminal outcome as viewed | diff --git a/internal-skills/cli-runtime/runtime.md b/internal-skills/cli-runtime/runtime.md index 1eafd1f..c6cba19 100644 --- a/internal-skills/cli-runtime/runtime.md +++ b/internal-skills/cli-runtime/runtime.md @@ -23,7 +23,8 @@ Command selection: - The caller's background or foreground choice changes only subagent execution. It does not change the companion command you build. Routing controls: -- Treat `--cwd`, `--model`, `--effort`, `--resume`, `--resume-last`, `--fresh`, `--prompt-file`, `--view-state`, `--owner-session-id`, and `--job-id` as routing controls, not task text. +- Treat `--cwd`, `--guest`, `--model`, `--effort`, `--resume`, `--resume-last`, `--fresh`, `--prompt-file`, `--view-state`, `--owner-session-id`, and `--job-id` as routing controls, not task text. +- Preserve `--guest` when present. Default guest is `claude`; `--guest grok` runs Grok instead of Claude Code. - Preserve the parent helper's exact non-empty `workspaceRoot` as `--cwd ""`; never substitute the plugin root or the child's default working directory. - Leave `--model` and `--effort` unset unless the user explicitly asks for a specific model or effort. The companion command applies these defaults itself: model defaults to `opus`, effort defaults to `xhigh` for opus, `high` for sonnet, and is left unset for haiku and fable. - `--view-state on-terminal` means the user will see this companion result in the current turn, so the companion may mark any terminal outcome viewed. diff --git a/internal-skills/review-runtime/runtime.md b/internal-skills/review-runtime/runtime.md index 90f313f..74c064d 100644 --- a/internal-skills/review-runtime/runtime.md +++ b/internal-skills/review-runtime/runtime.md @@ -16,6 +16,7 @@ Execution boundary: Foreground contract: - Strip `--wait` and `--background` before building the companion command. +- Preserve `--guest` when present. Default guest is `claude`; `--guest grok` runs Grok instead of Claude Code. - Keep the shell tool in the active user workspace; do not set its working directory to the plugin path. - Foreground command: - `review --view-state on-terminal ...` diff --git a/scripts/claude-companion.mjs b/scripts/claude-companion.mjs index c98d798..fe8638e 100644 --- a/scripts/claude-companion.mjs +++ b/scripts/claude-companion.mjs @@ -10,6 +10,7 @@ * * Adapted from codex-companion.mjs: * - Uses claude-cli.mjs instead of app-server/broker + * - Optional `--guest grok` (or CC_GUEST=grok) runs grok-cli.mjs instead * - MODEL_ALIASES: Claude aliases are passed through for Claude Code to resolve * - Default model when --model is unset: opus * - Default effort by model: opus -> xhigh, sonnet -> high, haiku/fable -> unset @@ -31,6 +32,7 @@ import { fileURLToPath } from "node:url"; import { parseArgs, splitRawArgumentString } from "./lib/args.mjs"; import { resolveCodexHome } from "./lib/codex-paths.mjs"; +import { resolveGuest } from "./lib/guest.mjs"; import { getClaudeAvailability, getClaudeAuthStatus, @@ -51,6 +53,15 @@ import { pruneStaleSandboxSettings, pruneStaleReviewMcpConfigs, } from "./lib/claude-cli.mjs"; +import { + GROK_READ_ONLY_TOOLS, + getGrokAvailability, + getGrokAuthStatus, + resolveGrokEffort, + resolveGrokModel, + runGrokReview, + runGrokTurn, +} from "./lib/grok-cli.mjs"; import { createReviewIsolation, pruneStaleReviewWorktrees, @@ -150,10 +161,10 @@ function printUsage() { console.log( [ "Usage:", - " node scripts/claude-companion.mjs setup [--check] [--enable-review-gate|--disable-review-gate] [--json]", - " node scripts/claude-companion.mjs review [--wait|--background] [--base ] [--scope ] [--model ] [--effort ] [--view-state ] [--owner-session-id ] [--user-mcp-tool ...] [--allow-project-mcp-servers]", - " node scripts/claude-companion.mjs adversarial-review [--wait|--background] [--base ] [--scope ] [--model ] [--effort ] [--view-state ] [--owner-session-id ] [--user-mcp-tool ...] [--allow-project-mcp-servers] [focus text]", - " node scripts/claude-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--model ] [--effort ] [--view-state ] [--owner-session-id ] [--wait-timeout-ms ] [prompt]", + " node scripts/claude-companion.mjs setup [--check] [--guest ] [--enable-review-gate|--disable-review-gate] [--json]", + " node scripts/claude-companion.mjs review [--wait|--background] [--guest ] [--base ] [--scope ] [--model ] [--effort ] [--view-state ] [--owner-session-id ] [--user-mcp-tool ...] [--allow-project-mcp-servers]", + " node scripts/claude-companion.mjs adversarial-review [--wait|--background] [--guest ] [--base ] [--scope ] [--model ] [--effort ] [--view-state ] [--owner-session-id ] [--user-mcp-tool ...] [--allow-project-mcp-servers] [focus text]", + " node scripts/claude-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--guest ] [--model ] [--effort ] [--view-state ] [--owner-session-id ] [--wait-timeout-ms ] [prompt]", " node scripts/claude-companion.mjs transfer [--source ] [--json]", " node scripts/claude-companion.mjs status [job-id] [--all] [--wait] [--wait-timeout-ms ] [--poll-interval-ms ] [--json]", " node scripts/claude-companion.mjs result [job-id] [--json]", @@ -347,6 +358,39 @@ function parseCommandInput(argv, config = {}) { }); } +function resolveCommandGuest(options = {}) { + return resolveGuest(options.guest); +} + +function resolveGuestModelAndEffort(guest, options = {}) { + if (guest === "grok") { + return { + model: resolveGrokModel(options.model), + effort: resolveGrokEffort(options.effort), + }; + } + const requestedModel = normalizeRequestedModel(options.model); + const model = resolveDefaultModel(requestedModel); + const effort = resolveDefaultEffort(model, options.effort); + return { model, effort }; +} + +function assertGrokRejectsUserMcp(guest, options = {}) { + if (guest !== "grok") { + return; + } + const tools = normalizeUserMcpTools(options["user-mcp-tool"]); + if (tools.length > 0 || options["allow-project-mcp-servers"]) { + throw new Error( + "--user-mcp-tool and --allow-project-mcp-servers are only supported for --guest claude." + ); + } +} + +function guestActorLabel(guest) { + return guest === "grok" ? "Grok" : "Claude Code"; +} + function resolveCommandCwd(options = {}) { const resolvedCwd = options.cwd ? path.resolve(process.cwd(), options.cwd) @@ -411,6 +455,12 @@ function formatClaudeFailureSummary(failure, fallback) { if (failure?.kind === "claude_auth") { return "Claude Code authentication failed; run `claude auth login`."; } + if (failure?.kind === "grok_auth") { + return "Grok authentication failed; run `grok login`."; + } + if (failure?.kind === "grok_rate_limit") { + return "Grok usage limit reached."; + } if (failure?.kind !== "claude_rate_limit") { return fallback; } @@ -750,6 +800,29 @@ function ensureClaudeReady(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 `$cc: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; + } + ensureClaudeReady(cwd); +} + function buildSetupDiagnostics(cwd) { const pluginInfo = currentPluginCacheInstallInfo(); let packageVersion = null; @@ -769,18 +842,34 @@ function buildSetupDiagnostics(cwd) { function buildSetupReport(cwd, actionsTaken = [], hookTrust = null, options = {}) { const workspaceRoot = resolveWorkspaceRoot(cwd); + const guest = options.guest ?? "claude"; const nodeStatus = binaryAvailable("node", ["--version"], { cwd }); const claudeStatus = getClaudeAvailability(cwd); const authStatus = getClaudeAuthStatus(cwd); + const grokStatus = getGrokAvailability(cwd); + const grokAuthStatus = getGrokAuthStatus(cwd); const hooksStatus = checkHooksStatus(); const config = getConfig(workspaceRoot); + const guestReady = + guest === "grok" + ? grokStatus.available && grokAuthStatus.loggedIn + : claudeStatus.available && authStatus.loggedIn; const nextSteps = []; - if (!claudeStatus.available) { - nextSteps.push("Install Claude Code CLI."); - } - if (claudeStatus.available && !authStatus.loggedIn) { - nextSteps.push("Run `claude auth login`."); + if (guest === "grok") { + if (!grokStatus.available) { + nextSteps.push("Install Grok CLI."); + } + if (grokStatus.available && !grokAuthStatus.loggedIn) { + nextSteps.push("Run `grok login`."); + } + } else { + if (!claudeStatus.available) { + nextSteps.push("Install Claude Code CLI."); + } + if (claudeStatus.available && !authStatus.loggedIn) { + nextSteps.push("Run `claude auth login`."); + } } if (!hooksStatus.installed) { nextSteps.push( @@ -805,13 +894,15 @@ function buildSetupReport(cwd, actionsTaken = [], hookTrust = null, options = {} return { ready: nodeStatus.available && - claudeStatus.available && - authStatus.loggedIn && + guestReady && hooksStatus.installed && hookTrust?.ready !== false, + guest, node: nodeStatus, claude: claudeStatus, auth: authStatus, + grok: grokStatus, + grokAuth: grokAuthStatus, hooks: hooksStatus, hookTrust, checkOnly: Boolean(options.checkOnly), @@ -828,9 +919,10 @@ function buildSetupReport(cwd, actionsTaken = [], hookTrust = null, options = {} async function handleSetup(argv) { const { options } = parseCommandInput(argv, { - valueOptions: ["cwd"], + valueOptions: ["cwd", "guest"], booleanOptions: ["json", "check", "enable-review-gate", "disable-review-gate"] }); + 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."); @@ -883,6 +975,7 @@ async function handleSetup(argv) { const finalReport = buildSetupReport(cwd, actionsTaken, hookTrust, { checkOnly: Boolean(options.check), + guest, }); outputResult( options.json ? finalReport : renderSetupReport(finalReport), @@ -1201,7 +1294,174 @@ function renderMcpDiagnostic(report) { // Review execution // --------------------------------------------------------------------------- +async function executeGrokReviewRun(request) { + ensureGrokReady(request.cwd); + ensureGitRepository(request.cwd); + + try { pruneStaleReviewWorktrees(request.cwd); } catch {} + + 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"); + + const context = collectReviewContext(request.cwd, target); + const prompt = + reviewName === "Review" + ? buildReviewPrompt(context) + : buildAdversarialReviewPrompt(context, focusText); + const schema = + reviewName === "Review" ? null : readOutputSchema(REVIEW_SCHEMA_PATH); + const isolation = createReviewIsolation( + reviewName === "Review" ? request.cwd : context.repoRoot, + target, + { label: reviewName === "Review" ? "review" : "adversarial-review" } + ); + let result; + try { + result = await runGrokReview(isolation.cwd, prompt, { + model: request.model, + effort: request.effort, + onProgress: request.onProgress, + onSpawn: request.onSpawn, + jsonSchema: schema ?? undefined, + }); + } finally { + isolation.cleanup(); + } + + const modelFallbacks = normalizeModelFallbacks(result.modelEvents); + if (reviewName === "Review") { + const payload = { + review: reviewName, + target, + sessionId: result.sessionId, + guest: "grok", + codex: { + status: result.status, + warning: result.warning ?? null, + stderr: result.stderr, + failure: result.failure ?? null, + stdout: result.result, + requestedModel: result.requestedModel ?? null, + finalModel: result.finalModel ?? null, + contextWindow: result.contextWindow ?? null, + modelFallbacks, + parseErrors: result.parseErrors ?? [], + unresolvedParseErrors: result.unresolvedParseErrors ?? 0 + } + }; + const rendered = appendModelFallbackSummary( + [ + `# ${actor} ${reviewName}`, + "", + `Target: ${target.label}`, + "", + typeof result.result === "string" ? result.result : JSON.stringify(result.result, null, 2), + "" + ].join("\n"), + modelFallbacks + ); + return { + exitStatus: resolveClaudeExitStatus(result), + threadId: result.sessionId, + turnId: null, + payload, + rendered, + summary: formatClaudeFailureSummary( + result.failure, + firstMeaningfulLine( + typeof result.result === "string" ? result.result : "", + `${reviewName} completed.` + ) + ), + jobTitle: `${actor} ${reviewName}`, + jobClass: "review", + targetLabel: target.label + }; + } + + const parsed = parseStructuredOutput( + typeof result.result === "string" && result.result.trim() + ? result.result + : result.structuredOutput != null + ? JSON.stringify(result.structuredOutput) + : typeof result.result === "string" + ? result.result + : JSON.stringify(result.result), + { + status: result.status, + failureMessage: result.stderr + } + ); + if (result.structuredOutput != null) { + parsed.parsed = result.structuredOutput; + parsed.parseError = null; + if (!parsed.rawOutput) { + parsed.rawOutput = JSON.stringify(result.structuredOutput); + } + } + const payload = { + review: reviewName, + target, + sessionId: result.sessionId, + guest: "grok", + context: { + repoRoot: context.repoRoot, + branch: context.branch, + summary: context.summary + }, + codex: { + status: result.status, + warning: result.warning ?? null, + stderr: result.stderr, + failure: result.failure ?? null, + stdout: typeof result.result === "string" ? result.result : JSON.stringify(result.result), + requestedModel: result.requestedModel ?? null, + finalModel: result.finalModel ?? null, + contextWindow: result.contextWindow ?? null, + modelFallbacks, + parseErrors: result.parseErrors ?? [], + unresolvedParseErrors: result.unresolvedParseErrors ?? 0 + }, + result: parsed.parsed, + rawOutput: parsed.rawOutput, + parseError: parsed.parseError + }; + return { + exitStatus: resolveClaudeExitStatus(result), + threadId: result.sessionId, + turnId: null, + payload, + rendered: appendModelFallbackSummary( + renderReviewResult(parsed, { + reviewLabel: reviewName, + targetLabel: context.target.label, + reasoningSummary: null + }), + modelFallbacks + ), + summary: formatClaudeFailureSummary( + result.failure, + parsed.parsed?.summary ?? + firstMeaningfulLine( + typeof result.result === "string" ? result.result : "", + parsed.parseError ?? `${reviewName} finished.` + ) + ), + jobTitle: `${actor} ${reviewName}`, + jobClass: "review", + targetLabel: context.target.label + }; +} + async function executeReviewRun(request) { + if ((request.guest ?? "claude") === "grok") { + return executeGrokReviewRun(request); + } ensureClaudeReady(request.cwd); ensureGitRepository(request.cwd); @@ -1411,18 +1671,21 @@ async function executeReviewRun(request) { // Task execution // --------------------------------------------------------------------------- -function buildTaskRunMetadata({ prompt, resumeLast = false }) { +function buildTaskRunMetadata({ prompt, resumeLast = false, guest = "claude" }) { + const actor = guestActorLabel(guest); if ( !resumeLast && String(prompt ?? "").includes(STOP_REVIEW_TASK_MARKER) ) { return { - title: "Claude Code 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 ? "Claude Code Resume" : "Claude Code Task"; + const title = resumeLast ? `${actor} Resume` : `${actor} Task`; const fallbackSummary = resumeLast ? "Continue previous task" : "Task"; return { title, @@ -1430,7 +1693,88 @@ function buildTaskRunMetadata({ prompt, resumeLast = false }) { }; } +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, + onSpawn: request.onSpawn, + }; + if (!request.write) { + grokOptions.tools = GROK_READ_ONLY_TOOLS; + } + if (request.resumeLast && request.resumeSessionId) { + grokOptions.resumeSessionId = request.resumeSessionId; + } + if (!request.prompt && !request.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 modelFallbacks = normalizeModelFallbacks(result.modelEvents); + const rendered = appendModelFallbackSummary( + renderTaskResult({ + rawOutput, + failureMessage, + failure: result.failure ?? null + }), + modelFallbacks + ); + const payload = { + status: result.status, + warning: result.warning ?? null, + sessionId: result.sessionId, + guest: "grok", + requestedModel: result.requestedModel ?? null, + finalModel: result.finalModel ?? null, + contextWindow: result.contextWindow ?? null, + modelFallbacks, + failure: result.failure ?? null, + parseErrors: result.parseErrors ?? [], + unresolvedParseErrors: result.unresolvedParseErrors ?? 0, + rawOutput, + touchedFiles: Array.isArray(result.touchedFiles) ? result.touchedFiles : [] + }; + + return { + exitStatus: resolveClaudeExitStatus(result), + threadId: result.sessionId, + turnId: null, + payload, + rendered, + summary: formatClaudeFailureSummary( + result.failure, + firstMeaningfulLine( + rawOutput, + firstMeaningfulLine( + failureMessage, + `${taskMetadata.title} finished.` + ) + ) + ), + jobTitle: taskMetadata.title, + jobClass: "task", + write: Boolean(request.write) + }; +} + async function executeTaskRun(request) { + if ((request.guest ?? "claude") === "grok") { + return executeGrokTaskRun(request); + } const workspaceRoot = resolveWorkspaceRoot(request.cwd); ensureClaudeReady(request.cwd); @@ -1540,7 +1884,8 @@ async function executeTaskRun(request) { // Job management helpers // --------------------------------------------------------------------------- -function buildReviewJobMetadata(reviewName, target) { +function buildReviewJobMetadata(reviewName, target, guest = "claude") { + const actor = guestActorLabel(guest); return { kind: reviewName === "Adversarial Review" @@ -1548,8 +1893,8 @@ function buildReviewJobMetadata(reviewName, target) { : "review", title: reviewName === "Review" - ? "Claude Code Review" - : `Claude Code ${reviewName}`, + ? `${actor} Review` + : `${actor} ${reviewName}`, summary: `${reviewName} ${target.label}` }; } @@ -1643,7 +1988,8 @@ function buildReviewRequest({ reviewName, userMcpTools, allowProjectMcpServers, - markViewedOnTerminal + markViewedOnTerminal, + guest }) { return { cwd, @@ -1655,7 +2001,8 @@ function buildReviewRequest({ reviewName, userMcpTools: normalizeUserMcpTools(userMcpTools), allowProjectMcpServers: Boolean(allowProjectMcpServers), - markViewedOnTerminal + markViewedOnTerminal, + guest: guest ?? "claude" }; } @@ -1772,7 +2119,8 @@ function buildTaskRequest({ resumeLast, resumeSessionId, jobId, - markViewedOnTerminal + markViewedOnTerminal, + guest }) { return { cwd, @@ -1783,7 +2131,8 @@ function buildTaskRequest({ resumeLast, resumeSessionId, jobId, - markViewedOnTerminal + markViewedOnTerminal, + guest: guest ?? "claude" }; } @@ -2340,7 +2689,8 @@ async function handleReviewCommand(argv, config) { "view-state", "job-id", "owner-session-id", - "user-mcp-tool" + "user-mcp-tool", + "guest" ], repeatableOptions: ["user-mcp-tool"], booleanOptions: ["json", "background", "wait", "allow-project-mcp-servers"], @@ -2349,6 +2699,9 @@ async function handleReviewCommand(argv, config) { } }); + const guest = resolveCommandGuest(options); + assertGrokRejectsUserMcp(guest, options); + const cwd = resolveCommandCwd(options); const workspaceRoot = resolveCommandWorkspace(options); @@ -2367,9 +2720,8 @@ async function handleReviewCommand(argv, config) { Boolean(options.background) ); - const requestedModel = normalizeRequestedModel(options.model); - const resolvedModel = resolveDefaultModel(requestedModel); - const resolvedEffort = resolveDefaultEffort(resolvedModel, options.effort); + const { model: resolvedModel, effort: resolvedEffort } = + resolveGuestModelAndEffort(guest, options); await withReleasedReservation(workspaceRoot, explicitJobId, async () => { // Validate inside the reservation guard so failures do not leak markers. @@ -2386,7 +2738,7 @@ async function handleReviewCommand(argv, config) { ); } } - const metadata = buildReviewJobMetadata(config.reviewName, target); + const metadata = buildReviewJobMetadata(config.reviewName, target, guest); alignCurrentSessionToOwner(workspaceRoot, ownerSessionId); const job = createCompanionJob({ @@ -2411,7 +2763,8 @@ async function handleReviewCommand(argv, config) { reviewName: config.reviewName, userMcpTools, allowProjectMcpServers: Boolean(options["allow-project-mcp-servers"]), - markViewedOnTerminal + markViewedOnTerminal, + guest }); const { payload } = enqueueBackgroundReview(cwd, job, request); outputCommandResult( @@ -2437,6 +2790,7 @@ async function handleReviewCommand(argv, config) { allowProjectMcpServers: Boolean(options["allow-project-mcp-servers"]), onProgress: progress, onSpawn, + guest, }), { json: options.json, markViewedOnTerminal } ); @@ -2491,6 +2845,7 @@ async function handleTask(argv) { "wait-timeout-ms", "timeout-ms", "poll-interval-ms", + "guest", ], booleanOptions: [ "json", @@ -2506,13 +2861,18 @@ async function handleTask(argv) { } }); + const guest = resolveCommandGuest(options); const cwd = resolveCommandCwd(options); const workspaceRoot = resolveCommandWorkspace(options); - const requestedModel = normalizeRequestedModel(options.model); - const model = resolveDefaultModel(requestedModel); - const resolvedEffort = resolveDefaultEffort(model, options.effort); - const effort = resolvedEffort ? resolveEffort(resolvedEffort) : null; + const resolved = resolveGuestModelAndEffort(guest, options); + const model = resolved.model; + const effort = + guest === "grok" + ? resolved.effort ?? null + : resolved.effort + ? resolveEffort(resolved.effort) + : null; const prompt = readTaskPrompt(cwd, options, positionals); const foregroundTimeoutMs = parseWaitTimeoutMilliseconds(options); const markViewedOnTerminal = resolveMarkViewedOnTerminal( @@ -2539,7 +2899,7 @@ async function handleTask(argv) { if (!prompt && !resumeLast) { throw new Error("Provide a prompt, a prompt file, piped stdin, or use --resume."); } - ensureClaudeReady(cwd); + ensureGuestReady(guest, cwd); const write = Boolean(options.write); const explicitJobId = resolveExplicitJobId(options["job-id"], workspaceRoot); @@ -2547,7 +2907,8 @@ async function handleTask(argv) { assertDelegationAllowed(workspaceRoot, ownerSessionId, "task"); const taskMetadata = buildTaskRunMetadata({ prompt, - resumeLast + resumeLast, + guest, }); alignCurrentSessionToOwner(workspaceRoot, ownerSessionId); @@ -2586,7 +2947,8 @@ async function handleTask(argv) { resumeLast, resumeSessionId, jobId: job.id, - markViewedOnTerminal + markViewedOnTerminal, + guest, }); const { payload } = enqueueBackgroundTask(cwd, job, request); outputCommandResult( @@ -2606,7 +2968,8 @@ async function handleTask(argv) { resumeLast, resumeSessionId, jobId: job.id, - markViewedOnTerminal + markViewedOnTerminal, + guest, }); await runForegroundDetachedTask( cwd, diff --git a/scripts/lib/grok-cli.mjs b/scripts/lib/grok-cli.mjs new file mode 100644 index 0000000..e029556 --- /dev/null +++ b/scripts/lib/grok-cli.mjs @@ -0,0 +1,505 @@ +/** + * Copyright 2026 Sendbird, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +/** + * Grok CLI wrapper for Codex `$cc:* --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"; +import { resolvePluginRuntimeRoot } from "./codex-paths.mjs"; +import { getSpawnedProcessIdentity } from "./process.mjs"; + +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(resolvePluginRuntimeRoot(), "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"], + }); + + let pidIdentity = null; + try { + pidIdentity = getSpawnedProcessIdentity(proc.pid); + } catch { + // Best-effort — may fail on some platforms + } + 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/scripts/lib/guest.mjs b/scripts/lib/guest.mjs new file mode 100644 index 0000000..d20d850 --- /dev/null +++ b/scripts/lib/guest.mjs @@ -0,0 +1,31 @@ +/** + * Copyright 2026 Sendbird, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export const DEFAULT_GUEST = "claude"; +export const SUPPORTED_GUESTS = Object.freeze(["claude", "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/scripts/lib/render.mjs b/scripts/lib/render.mjs index d32e25d..9e79de7 100644 --- a/scripts/lib/render.mjs +++ b/scripts/lib/render.mjs @@ -346,6 +346,12 @@ export function renderSetupReport(report) { `- node: ${report.node.detail}`, `- claude: ${report.claude.detail}`, `- auth: ${report.auth.detail}`, + ...(report.guest === "grok" && report.grok && report.grokAuth + ? [ + `- grok: ${report.grok.detail}`, + `- grok auth: ${report.grokAuth.detail}`, + ] + : []), `- hooks: ${report.hooks.detail}`, ...(report.hookTrust ? [`- hook trust: ${report.hookTrust.detail}`] : []), `- review gate: ${report.reviewGateEnabled ? "enabled" : "disabled"}`, @@ -421,6 +427,14 @@ export function renderTaskResult(parsedResult) { "", ].join("\n"); } + if (parsedResult?.failure?.kind === "grok_auth") { + const original = String(parsedResult.failure.message ?? "").trim(); + return [ + "Grok authentication failed. Run `grok login` and retry.", + original ? `\nOriginal Grok message:\n${original}` : "", + "", + ].join("\n"); + } if (parsedResult?.failure?.kind === "claude_rate_limit") { const reset = parsedResult.failure.resetText ? ` Retry after ${parsedResult.failure.resetText}.` diff --git a/skills/adversarial-review/SKILL.md b/skills/adversarial-review/SKILL.md index aac856f..bb8da71 100644 --- a/skills/adversarial-review/SKILL.md +++ b/skills/adversarial-review/SKILL.md @@ -1,6 +1,6 @@ --- name: adversarial-review -description: 'Run a design-challenging Claude Code review of local git changes in this repository. Args: --wait, --background, --base , --scope , --model , --effort , --user-mcp-tool , --allow-project-mcp-servers, [focus text]. Defaults to opus + xhigh effort. Use only when the user wants stronger scrutiny than a normal review, such as explicit tradeoff challenge, risky-change review, or custom focus text.' +description: 'Run a design-challenging Claude Code review of local git changes in this repository. Args: --wait, --background, --guest , --base , --scope , --model , --effort , --user-mcp-tool , --allow-project-mcp-servers, [focus text]. Defaults to opus + xhigh effort. Default guest is claude. Use only when the user wants stronger scrutiny than a normal review, such as explicit tradeoff challenge, risky-change review, or custom focus text.' --- # Claude Code Adversarial Review @@ -16,7 +16,7 @@ Unlike `$cc:review`, this skill accepts custom focus text after the flags. The m Resolve `` as two directories above this `SKILL.md` file. Keep the shell tool in the active Codex user workspace; never set its working directory to `` or the directory used to read this skill. Parent and foreground commands use that shell's current directory directly: `node "/scripts/claude-companion.mjs" adversarial-review ...` -Supported arguments: `--wait`, `--background`, `--base `, `--scope auto|working-tree|branch`, `--model `, `--effort `, `--user-mcp-tool `, `--allow-project-mcp-servers`, plus optional focus text after the flags (defaults: model=opus, effort=xhigh; sonnet defaults to high; haiku and fable have no effort) +Supported arguments: `--wait`, `--background`, `--guest `, `--base `, `--scope auto|working-tree|branch`, `--model `, `--effort `, `--user-mcp-tool `, `--allow-project-mcp-servers`, plus optional focus text after the flags (defaults: guest=claude, model=opus, effort=xhigh; sonnet defaults to high; haiku and fable have no effort) Raw slash-command arguments: `$ARGUMENTS` @@ -24,7 +24,7 @@ Raw slash-command arguments: Rules: - This skill is review-only. Do not fix issues, apply patches, or suggest that you are about to make changes. - Before launching the review, stay in read-only inspection mode: inspect git status and diff stats only, then ask at most one user question about whether to wait or run in background. -- Preserve the user's scope flags, explicit `--user-mcp-tool ` and `--allow-project-mcp-servers` flags, and custom focus text exactly. +- Preserve the user's scope flags, explicit `--guest `, `--user-mcp-tool ` and `--allow-project-mcp-servers` flags, and custom focus text exactly. Do not add `--guest grok` unless the user asked for Grok. - Do not add user MCP tools unless the user asked for them. - Use the same review target selection as `$cc:review`. diff --git a/skills/rescue/SKILL.md b/skills/rescue/SKILL.md index 2e0ece7..24fd06a 100644 --- a/skills/rescue/SKILL.md +++ b/skills/rescue/SKILL.md @@ -1,6 +1,6 @@ --- name: rescue -description: 'Delegate a substantial diagnosis, implementation, or follow-up task to Claude Code through the tracked-job runtime. Args: --background, --wait, --resume, --resume-last, --fresh, --write, --model , --effort , --wait-timeout-ms , --prompt-file , [task text]. Defaults to opus + xhigh effort. Use when Claude should investigate or change things, not when the user only wants review findings.' +description: 'Delegate a substantial diagnosis, implementation, or follow-up task to Claude Code through the tracked-job runtime. Args: --background, --wait, --guest , --resume, --resume-last, --fresh, --write, --model , --effort , --wait-timeout-ms , --prompt-file , [task text]. Defaults to opus + xhigh effort. Default guest is claude. Use when Claude should investigate or change things, not when the user only wants review findings.' --- # Claude Code Rescue @@ -21,7 +21,7 @@ Resolve `` as two directories above this `SKILL.md` file. Keep the Raw slash-command arguments: `$ARGUMENTS` -Supported arguments: `--background`, `--wait`, `--resume`, `--resume-last`, `--fresh`, `--write`, `--model `, `--effort `, `--wait-timeout-ms `, deprecated alias `--timeout-ms `, `--prompt-file `, plus free-text task text +Supported arguments: `--background`, `--wait`, `--guest `, `--resume`, `--resume-last`, `--fresh`, `--write`, `--model `, `--effort `, `--wait-timeout-ms `, deprecated alias `--timeout-ms `, `--prompt-file `, plus free-text task text (default guest is claude) Main-thread routing rules: - If the user explicitly invoked `$cc:rescue` or `Claude Code Rescue`, do not keep the work in the main Codex thread. Delegate it. @@ -29,7 +29,8 @@ Main-thread routing rules: - Treat `--background` and `--wait` as execution controls, not task text. - `--background` and `--wait` are Codex-side execution controls only. Never forward either flag to `claude-companion.mjs task`. - The main Codex thread owns that execution-mode choice. It decides whether to wait for the subagent. The child subagent must never reinterpret those flags as companion flags. -- Treat `--model`, `--effort`, `--wait-timeout-ms`, deprecated alias `--timeout-ms`, `--resume`, `--resume-last`, `--fresh`, and `--prompt-file` as runtime or routing controls, not task text. +- Treat `--guest`, `--model`, `--effort`, `--wait-timeout-ms`, deprecated alias `--timeout-ms`, `--resume`, `--resume-last`, `--fresh`, and `--prompt-file` as runtime or routing controls, not task text. +- Preserve explicit `--guest ` exactly. Do not add `--guest grok` unless the user asked for Grok. - If the user task text itself begins with a slash command such as `/simplify`, `/fix`, or `/review`, treat that slash command as literal Claude Code task text to be forwarded unchanged. Do not execute or reinterpret it in the parent Codex thread. - `--model` selects the Claude model for the companion `task` command only. It does not select the Codex subagent model. - If the user explicitly passed `--background`, run the rescue subagent in the background. diff --git a/skills/review/SKILL.md b/skills/review/SKILL.md index 4fb9efa..13c4335 100644 --- a/skills/review/SKILL.md +++ b/skills/review/SKILL.md @@ -1,6 +1,6 @@ --- name: review -description: 'Run a standard Claude Code review of local git changes in this repository. Args: --wait, --background, --base , --scope , --model , --effort , --user-mcp-tool , --allow-project-mcp-servers. Defaults to opus + xhigh effort. Use as the default path for ordinary code-review requests when the user did not explicitly ask for stronger adversarial scrutiny or for Claude to own the implementation work.' +description: 'Run a standard Claude Code review of local git changes in this repository. Args: --wait, --background, --guest , --base , --scope , --model , --effort , --user-mcp-tool , --allow-project-mcp-servers. Defaults to opus + xhigh effort. Default guest is claude. Use as the default path for ordinary code-review requests when the user did not explicitly ask for stronger adversarial scrutiny or for Claude to own the implementation work.' --- # Claude Code Review @@ -16,7 +16,7 @@ If the overall request is "you review it too, also ask Claude to review in the b Resolve `` as two directories above this `SKILL.md` file. Keep the shell tool in the active Codex user workspace; never set its working directory to `` or the directory used to read this skill. Parent and foreground commands use that shell's current directory directly: `node "/scripts/claude-companion.mjs" review ...` -Supported arguments: `--wait`, `--background`, `--base `, `--scope auto|working-tree|branch`, `--model `, `--effort `, `--user-mcp-tool `, `--allow-project-mcp-servers` (defaults: model=opus, effort=xhigh; sonnet defaults to high; haiku and fable have no effort) +Supported arguments: `--wait`, `--background`, `--guest `, `--base `, `--scope auto|working-tree|branch`, `--model `, `--effort `, `--user-mcp-tool `, `--allow-project-mcp-servers` (defaults: guest=claude, model=opus, effort=xhigh; sonnet defaults to high; haiku and fable have no effort) Raw slash-command arguments: `$ARGUMENTS` @@ -25,7 +25,7 @@ Rules: - This skill is review-only. Do not fix issues, apply patches, or suggest that you are about to make changes. - Before launching the review, stay in read-only inspection mode: inspect git status and diff stats only, then ask at most one user question about whether to wait or run in background. - Preserve the user's review scope flags exactly. -- Preserve explicit `--user-mcp-tool ` and `--allow-project-mcp-servers` flags exactly. Do not add user MCP tools unless the user asked for them. +- Preserve explicit `--guest `, `--user-mcp-tool ` and `--allow-project-mcp-servers` flags exactly. Do not add user MCP tools unless the user asked for them. Do not add `--guest grok` unless the user asked for Grok. - Do not accept staged-only or unstaged-only review modes. - Do not add extra review instructions or focus text. Route those requests to `$cc:adversarial-review`. diff --git a/skills/setup/SKILL.md b/skills/setup/SKILL.md index c71a6a9..2a51031 100644 --- a/skills/setup/SKILL.md +++ b/skills/setup/SKILL.md @@ -1,6 +1,6 @@ --- name: setup -description: 'Check whether Claude Code CLI is ready in this environment and optionally repair setup or toggle the stop-time review gate. Args: --check, --enable-review-gate, --disable-review-gate. Use for installation, authentication, or review-gate setup requests.' +description: 'Check whether Claude Code CLI is ready in this environment and optionally repair setup or toggle the stop-time review gate. Args: --check, --guest , --enable-review-gate, --disable-review-gate. Default guest is claude. Use for installation, authentication, or review-gate setup requests.' --- # Claude Code Setup @@ -11,6 +11,7 @@ Resolve `` as two directories above this `SKILL.md` file. Keep the Supported arguments: - `--check` (read-only; do not combine with review-gate changes) +- `--guest ` (default guest is claude; `--guest grok` checks Grok CLI auth instead of Claude) - `--enable-review-gate` - `--disable-review-gate` diff --git a/tests/grok-cli.test.mjs b/tests/grok-cli.test.mjs new file mode 100644 index 0000000..e79f6a1 --- /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 "../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 0000000..d451250 --- /dev/null +++ b/tests/grok-guest-cli.test.mjs @@ -0,0 +1,50 @@ +/** + * Copyright 2026 Sendbird, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +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, "scripts", "claude-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("claude-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 Claude", () => { + 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 skill = fs.readFileSync( + path.join(PROJECT_ROOT, "skills", "review", "SKILL.md"), + "utf8" + ); + assert.match(skill, /--guest /); + assert.match(skill, /Defaults to.*claude/i); + }); +}); diff --git a/tests/guest.test.mjs b/tests/guest.test.mjs new file mode 100644 index 0000000..6023d66 --- /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 "../scripts/lib/guest.mjs"; + +describe("resolveGuest", () => { + it("defaults to claude when flag and env are unset", () => { + assert.equal(DEFAULT_GUEST, "claude"); + assert.deepEqual([...SUPPORTED_GUESTS], ["claude", "grok"]); + assert.equal(resolveGuest(undefined, {}), "claude"); + assert.equal(resolveGuest(null, {}), "claude"); + assert.equal(resolveGuest("", {}), "claude"); + }); + + 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("claude", { CC_GUEST: "grok" }), "claude"); + assert.equal(resolveGuest("Grok", { CC_GUEST: "claude" }), "grok"); + }); + + it("ignores a blank CC_GUEST and keeps the default", () => { + assert.equal(resolveGuest(undefined, { CC_GUEST: " " }), "claude"); + }); + + it("rejects unknown guests", () => { + assert.throws( + () => resolveGuest("codex", {}), + /Unsupported guest "codex"/ + ); + assert.throws( + () => resolveGuest(undefined, { CC_GUEST: "chatgpt" }), + /Unsupported guest "chatgpt"/ + ); + }); +});