diff --git a/.gitignore b/.gitignore index 039a37e..16be833 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ dist/ # Claude Code worktree state (agent isolation runs) .claude/ + +# CodeGraph local index (not committed) +.codegraph/ diff --git a/README.md b/README.md index 4a0f236..b1a6d7b 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ codex plugin marketplace add bbingz/polycli Then open a new Codex TUI session, run `/plugins`, choose the `polycli-hosts` marketplace, install `Polycli`, and start a new thread so the bundled skill is available. -After install, prefer the installed `Polycli` plugin or bundled `polycli` skill over direct official CLI shell calls when Codex needs `claude`, `copilot`, `opencode`, `pi`, `cmd`, `agy`, `gemini`, `kimi`, `qwen`, or `minimax`. Raw provider CLIs are the fallback only when the plugin is unavailable or the user explicitly asks for raw shell. +After install, prefer the installed `Polycli` plugin or bundled `polycli` skill over direct official CLI shell calls when Codex needs `claude`, `copilot`, `opencode`, `pi`, `cmd`, `agy`, `gemini`, `kimi`, `qwen`, `minimax`, or `grok`. Raw provider CLIs are the fallback only when the plugin is unavailable or the user explicitly asks for raw shell. ### GitHub Copilot CLI diff --git a/docs/host-command-map.md b/docs/host-command-map.md index fcbd94e..e6dda2e 100644 --- a/docs/host-command-map.md +++ b/docs/host-command-map.md @@ -16,7 +16,7 @@ If you are switching between hosts, read the first two sections (identity + samp All five dispatch to the same `polycli-companion.bundle.mjs` underneath. Differences are at the surface only; behavior, output format, exit codes, and `--json` shape are identical. -Codex-specific rule: when the installed `polycli` skill from `polycli-codex` is available, prefer the skill over direct official CLI shell calls for `claude`, `copilot`, `opencode`, `pi`, `cmd`, `agy`, `gemini`, `kimi`, `qwen`, or `minimax`. Raw provider CLIs are the fallback only when the plugin is unavailable or the user explicitly asks for raw shell. Use `health`, `status`, `result`, and `timing` as the observable control plane around prompt-bearing work. +Codex-specific rule: when the installed `polycli` skill from `polycli-codex` is available, prefer the skill over direct official CLI shell calls for `claude`, `copilot`, `opencode`, `pi`, `cmd`, `agy`, `gemini`, `kimi`, `qwen`, `minimax`, or `grok`. Raw provider CLIs are the fallback only when the plugin is unavailable or the user explicitly asks for raw shell. Use `health`, `status`, `result`, and `timing` as the observable control plane around prompt-bearing work. ## Command-by-command mapping diff --git a/packages/polycli-runtime/src/constants.js b/packages/polycli-runtime/src/constants.js index 7aaeef6..ec5ebc5 100644 --- a/packages/polycli-runtime/src/constants.js +++ b/packages/polycli-runtime/src/constants.js @@ -1,2 +1,2 @@ -export const PROVIDER_IDS = ["gemini", "kimi", "qwen", "minimax", "claude", "copilot", "opencode", "pi", "cmd", "agy"]; +export const PROVIDER_IDS = ["gemini", "kimi", "qwen", "minimax", "claude", "copilot", "opencode", "pi", "cmd", "agy", "grok"]; export const PROVIDER_OPERATION_NAMES = ["prompt"]; diff --git a/packages/polycli-runtime/src/grok.js b/packages/polycli-runtime/src/grok.js new file mode 100644 index 0000000..9f2b736 --- /dev/null +++ b/packages/polycli-runtime/src/grok.js @@ -0,0 +1,255 @@ +import { binaryAvailable, runCommand } from "@bbingz/polycli-utils/process"; + +import { classifyProviderFailure, formatProviderExitError } from "./errors.js"; +import { spawnStreamingCommand } from "./spawn.js"; + +const GROK_BIN = process.env.GROK_CLI_BIN || "grok"; +const DEFAULT_TIMEOUT_MS = 900_000; +const AUTH_CHECK_TIMEOUT_MS = 30_000; +// `grok models` reports `Default model: grok-composer-2.5-fast` ("compose 2.5"); `grok-build` is +// the other available model. Callers pass `-m ` to switch. +const DEFAULT_GROK_MODEL = "grok-composer-2.5-fast"; +const GROK_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|not logged in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; +export const TRANSIENT_PROBE_ERROR_PATTERNS = [ + /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i, +]; + +export function buildGrokInvocation({ + prompt, + model = null, + outputFormat = "json", + permissionMode = null, + alwaysApprove = false, + effort = null, + resumeSessionId = null, + continueLast = false, + extraArgs = [], + bin = GROK_BIN, +} = {}) { + // grok one-shot: `-p ` prints the response and exits. Unlike kimi-code, `-p` composes + // with --permission-mode/--always-approve/--effort (verified). json => single object; + // streaming-json => line events. + const args = ["-p", String(prompt ?? ""), "--output-format", outputFormat]; + if (model) args.push("-m", model); + if (effort) args.push("--effort", effort); + if (permissionMode) args.push("--permission-mode", permissionMode); + if (alwaysApprove) args.push("--always-approve"); + if (continueLast) { + args.push("-c"); + } else if (resumeSessionId) { + args.push("-r", resumeSessionId); + } + if (extraArgs.length > 0) args.push(...extraArgs); + return { bin, args }; +} + +export function extractGrokText(event) { + if (!event || typeof event !== "object") return ""; + // streaming-json: {type:"text",data:"..."} carries the answer; thought events are reasoning. + if (event.type === "text" && typeof event.data === "string") return event.data; + return ""; +} + +export function parseGrokStreamText(text) { + const events = []; + let response = ""; + let sessionId = null; + let stopReason = null; + + for (const rawLine of String(text ?? "").split(/\r?\n/)) { + const trimmed = rawLine.trim(); + if (!trimmed.startsWith("{")) continue; + let event; + try { + event = JSON.parse(trimmed); + } catch { + continue; + } + events.push(event); + if (event.type === "text" && typeof event.data === "string") { + response += event.data; + } else if (event.type === "end") { + if (typeof event.sessionId === "string") sessionId = event.sessionId; + stopReason = event.stopReason ?? stopReason; + } + } + + return { events, response, sessionId, stopReason }; +} + +export function parseGrokJsonResult(stdout, stderr, status, { defaultModel = null } = {}) { + const text = String(stdout ?? ""); + const jsonStart = text.indexOf("{"); + if (jsonStart < 0 || status !== 0) { + return { + ok: false, + error: String(stderr ?? "").trim() || formatProviderExitError("grok", status), + status, + }; + } + try { + const parsed = JSON.parse(text.slice(jsonStart)); + const response = typeof parsed.text === "string" ? parsed.text : ""; + const hasVisibleText = Boolean(response.trim()); + return { + ok: hasVisibleText, + response, + // grok emits the session id structurally; never scan prose for a UUID. + sessionId: typeof parsed.sessionId === "string" ? parsed.sessionId : null, + model: defaultModel ?? DEFAULT_GROK_MODEL, + stopReason: parsed.stopReason ?? null, + error: hasVisibleText ? null : "grok produced no visible text", + status, + }; + } catch (error) { + return { ok: false, error: `JSON parse failed: ${error.message}`, status }; + } +} + +export function getGrokAvailability(cwd) { + return binaryAvailable(GROK_BIN, ["--version"], { cwd }); +} + +function buildGrokAuthStatus(result) { + // Inferred from `grok models` (no dedicated auth-status subcommand). It prints + // "You are logged in with grok.com." + "Default model: " when authed — zero LLM/token cost. + if (result.error) { + const detail = result.error.code === "ETIMEDOUT" + ? `grok auth probe timed out after ${Math.round(AUTH_CHECK_TIMEOUT_MS / 1000)}s` + : result.error.message; + if (TRANSIENT_PROBE_ERROR_PATTERNS.some((pattern) => pattern.test(detail))) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: null }; + } + return { loggedIn: false, detail }; + } + + const text = `${result.stdout ?? ""}\n${result.stderr ?? ""}`; + const defaultModel = (text.match(/Default model:\s*(\S+)/) || [])[1] ?? null; + // Check explicit auth-failure phrasing BEFORE the "logged in" banner: the logged-out message + // "not logged in" contains the substring "logged in", so the banner test must not win first. + if (GROK_EXPLICIT_AUTH_ERROR_RE.test(text)) { + return { loggedIn: false, detail: text.trim() || "grok is not logged in" }; + } + if (/\blogged in\b/i.test(text)) { + return { loggedIn: true, detail: "authenticated", model: defaultModel }; + } + if (result.status !== 0) { + const detail = text.trim() || `grok models exited with code ${result.status}`; + if (TRANSIENT_PROBE_ERROR_PATTERNS.some((pattern) => pattern.test(detail))) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: defaultModel }; + } + return { loggedIn: false, detail }; + } + // Exit 0 with a model listing but no explicit "logged in" banner → treat as authenticated. + return { loggedIn: true, detail: "authenticated", model: defaultModel }; +} + +export function getGrokAuthStatus(cwd, { runner = runCommand } = {}) { + const result = runner(GROK_BIN, ["models"], { cwd, timeout: AUTH_CHECK_TIMEOUT_MS }); + return buildGrokAuthStatus(result); +} + +export function runGrokPrompt({ + prompt, + model = null, + cwd, + timeout = DEFAULT_TIMEOUT_MS, + alwaysApprove = true, + permissionMode = null, + effort = null, + resumeSessionId = null, + continueLast = false, + extraArgs = [], + defaultModel = null, + bin = GROK_BIN, +} = {}) { + const invocation = buildGrokInvocation({ + prompt, + model, + outputFormat: "json", + permissionMode, + alwaysApprove, + effort, + resumeSessionId, + continueLast, + extraArgs, + bin, + }); + + const result = runCommand(invocation.bin, invocation.args, { cwd, timeout }); + if (result.error) { + const error = result.error.code === "ETIMEDOUT" + ? `grok timed out after ${Math.round(timeout / 1000)}s` + : result.error.message; + return { ok: false, error, errorCode: classifyProviderFailure(error, { provider: "grok" }) }; + } + + // grok prints transient "ERROR worker quit ... UnexpectedContentType" lines to STDERR even on a + // successful run, so success is judged ONLY by exit status + a valid stdout JSON envelope with + // visible text — stderr content is never treated as failure here. + const parsed = parseGrokJsonResult(result.stdout, result.stderr, result.status, { + defaultModel: model ?? defaultModel, + }); + return { ...parsed, errorCode: classifyProviderFailure(parsed.error, { provider: "grok" }) }; +} + +export function runGrokPromptStreaming({ + prompt, + model = null, + cwd, + timeout = DEFAULT_TIMEOUT_MS, + alwaysApprove = true, + permissionMode = null, + effort = null, + resumeSessionId = null, + continueLast = false, + extraArgs = [], + defaultModel = null, + onEvent = () => {}, + bin = GROK_BIN, + spawnImpl, +} = {}) { + const invocation = buildGrokInvocation({ + prompt, + model, + outputFormat: "streaming-json", + permissionMode, + alwaysApprove, + effort, + resumeSessionId, + continueLast, + extraArgs, + bin, + }); + + return spawnStreamingCommand({ + bin: invocation.bin, + args: invocation.args, + cwd, + env: { ...process.env }, + timeout, + spawnImpl, + onStdoutLine(line) { + const trimmed = line.trim(); + if (!trimmed.startsWith("{")) return; + try { + onEvent(JSON.parse(trimmed)); + } catch {} + }, + }).then((result) => { + const parsed = parseGrokStreamText(result.stdout); + const hasVisibleText = Boolean(parsed.response.trim()); + const ok = result.ok && hasVisibleText; + const error = ok + ? null + : (result.ok ? "grok produced no visible text" : result.error); + return { + ...result, + ...parsed, + model: model ?? defaultModel ?? DEFAULT_GROK_MODEL, + ok, + error, + errorCode: classifyProviderFailure(error, { provider: "grok" }), + }; + }); +} diff --git a/packages/polycli-runtime/src/index.js b/packages/polycli-runtime/src/index.js index 6a8a57d..3f1f03e 100644 --- a/packages/polycli-runtime/src/index.js +++ b/packages/polycli-runtime/src/index.js @@ -9,5 +9,6 @@ export * from "./opencode.js"; export * from "./pi.js"; export * from "./cmd.js"; export * from "./agy.js"; +export * from "./grok.js"; export * from "./registry.js"; export * from "./review-flags.js"; diff --git a/packages/polycli-runtime/src/registry.js b/packages/polycli-runtime/src/registry.js index 04a0c3c..c709885 100644 --- a/packages/polycli-runtime/src/registry.js +++ b/packages/polycli-runtime/src/registry.js @@ -61,6 +61,12 @@ import { runAgyPrompt, runAgyPromptStreaming, } from "./agy.js"; +import { + getGrokAvailability, + getGrokAuthStatus, + runGrokPrompt, + runGrokPromptStreaming, +} from "./grok.js"; import { attachPromptTiming, extractProviderEventText } from "./timing.js"; const TIMING_SUPPORT = { @@ -74,6 +80,7 @@ const TIMING_SUPPORT = { pi: { ttft: true, gen: true, tail: true, tool: false, runtimePersistence: "session" }, cmd: { ttft: true, gen: true, tail: true, tool: false, runtimePersistence: "ephemeral" }, agy: { ttft: true, gen: true, tail: true, tool: false, runtimePersistence: "session" }, + grok: { ttft: true, gen: true, tail: true, tool: false, runtimePersistence: "session" }, }; const RUNTIMES = Object.freeze({ @@ -207,6 +214,19 @@ const RUNTIMES = Object.freeze({ runPrompt: runAgyPrompt, runPromptStreaming: runAgyPromptStreaming, }, + grok: { + id: "grok", + capabilities: { + streaming: true, + sessionResume: true, + structuredOutput: true, + operations: PROVIDER_OPERATION_NAMES, + }, + getAvailability: getGrokAvailability, + getAuthStatus: getGrokAuthStatus, + runPrompt: runGrokPrompt, + runPromptStreaming: runGrokPromptStreaming, + }, }); for (const runtime of Object.values(RUNTIMES)) { @@ -276,6 +296,9 @@ function isTerminalSummaryEvent(provider, event) { if (provider === "pi") { return event.type === "agent_end"; } + if (provider === "grok") { + return event.type === "end"; + } return false; } diff --git a/packages/polycli-runtime/src/review-flags.js b/packages/polycli-runtime/src/review-flags.js index 53d6d7b..3884483 100644 --- a/packages/polycli-runtime/src/review-flags.js +++ b/packages/polycli-runtime/src/review-flags.js @@ -110,4 +110,12 @@ export const REVIEW_FLAG_EXPECTATIONS = Object.freeze({ Object.freeze({ helpArgs: Object.freeze(["--help"]), expect: Object.freeze(["--output", "--non-interactive"]) }), ]), }), + grok: Object.freeze({ + // grok review enforces read-only via the --permission-mode plan runtimeOption (composes with + // the -p one-shot mode, verified). It carries no review extraArgs of its own. + expectFlags: Object.freeze(["--permission-mode"]), + extraArgTokens: Object.freeze([]), + readOnlyOptionKey: "permissionMode", + readOnlyValue: "plan", + }), }); diff --git a/packages/polycli-runtime/src/timing.js b/packages/polycli-runtime/src/timing.js index 9f9ce0b..427042b 100644 --- a/packages/polycli-runtime/src/timing.js +++ b/packages/polycli-runtime/src/timing.js @@ -10,6 +10,7 @@ import { extractOpenCodeText } from "./opencode.js"; import { extractPiText } from "./pi.js"; import { extractCmdText } from "./cmd.js"; import { extractAgyText } from "./agy.js"; +import { extractGrokText } from "./grok.js"; const TIMING_OUTCOMES = new Set(["success", "failure", "timeout", "terminated", "cancelled"]); @@ -114,6 +115,7 @@ export function extractProviderEventText(provider, event) { if (provider === "pi") return extractPiText(event); if (provider === "cmd") return extractCmdText(event); if (provider === "agy") return extractAgyText(event); + if (provider === "grok") return extractGrokText(event); return ""; } diff --git a/packages/polycli-runtime/test/exports.test.js b/packages/polycli-runtime/test/exports.test.js index 51bc7b7..c50fbf2 100644 --- a/packages/polycli-runtime/test/exports.test.js +++ b/packages/polycli-runtime/test/exports.test.js @@ -15,6 +15,7 @@ test("runtime index exports expected surface", () => { "buildCopilotInvocation", "buildGeminiEnv", "buildGeminiInvocation", + "buildGrokInvocation", "buildKimiInvocation", "buildMiniMaxInvocation", "buildOpenCodeInvocation", @@ -26,6 +27,7 @@ test("runtime index exports expected surface", () => { "extractCmdText", "extractCopilotText", "extractGeminiText", + "extractGrokText", "extractKimiText", "extractMiniMaxEventText", "extractMiniMaxLogPath", @@ -44,6 +46,8 @@ test("runtime index exports expected surface", () => { "getCopilotAvailability", "getGeminiAuthStatus", "getGeminiAvailability", + "getGrokAuthStatus", + "getGrokAvailability", "getKimiAuthStatus", "getKimiAvailability", "getMiniMaxAuthStatus", @@ -62,6 +66,8 @@ test("runtime index exports expected surface", () => { "parseCmdTextResult", "parseCopilotStreamText", "parseGeminiStreamText", + "parseGrokJsonResult", + "parseGrokStreamText", "parseKimiStreamText", "parseMiniMaxResponseBlocks", "parseOpenCodeJsonResult", @@ -79,6 +85,8 @@ test("runtime index exports expected surface", () => { "runCopilotPromptStreaming", "runGeminiPrompt", "runGeminiPromptStreaming", + "runGrokPrompt", + "runGrokPromptStreaming", "runKimiPrompt", "runKimiPromptStreaming", "runMiniMaxPrompt", diff --git a/packages/polycli-runtime/test/grok.test.js b/packages/polycli-runtime/test/grok.test.js new file mode 100644 index 0000000..98452f5 --- /dev/null +++ b/packages/polycli-runtime/test/grok.test.js @@ -0,0 +1,165 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { + buildGrokInvocation, + extractGrokText, + getGrokAuthStatus, + parseGrokJsonResult, + parseGrokStreamText, + runGrokPrompt, + runGrokPromptStreaming, +} from "../src/index.js"; + +function withFakeGrokBin(source, fn) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "polycli-grok-sync-")); + const bin = path.join(root, "grok"); + fs.writeFileSync(bin, source, { mode: 0o755 }); + try { + return fn({ root, bin }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +test("buildGrokInvocation builds the -p one-shot with model/effort/permission/approve/resume", () => { + assert.deepEqual( + buildGrokInvocation({ prompt: "hello", model: "grok-build", effort: "high", alwaysApprove: true }).args, + ["-p", "hello", "--output-format", "json", "-m", "grok-build", "--effort", "high", "--always-approve"] + ); + assert.deepEqual( + buildGrokInvocation({ prompt: "rev", outputFormat: "streaming-json", permissionMode: "plan" }).args, + ["-p", "rev", "--output-format", "streaming-json", "--permission-mode", "plan"] + ); + assert.deepEqual( + buildGrokInvocation({ prompt: "x", resumeSessionId: "019e8685-1031-70a0-9ac4-37dcbcefc163" }).args, + ["-p", "x", "--output-format", "json", "-r", "019e8685-1031-70a0-9ac4-37dcbcefc163"] + ); + assert.deepEqual( + buildGrokInvocation({ prompt: "x", continueLast: true }).args, + ["-p", "x", "--output-format", "json", "-c"] + ); +}); + +test("parseGrokJsonResult reads text + structured sessionId, never scanning prose", () => { + const parsed = parseGrokJsonResult( + JSON.stringify({ text: "answer with a uuid 123e4567-e89b-42d3-a456-426614174000", stopReason: "EndTurn", sessionId: "019e8685-1031-70a0-9ac4-37dcbcefc163", requestId: "r1" }), + "", + 0 + ); + assert.equal(parsed.ok, true); + assert.match(parsed.response, /123e4567/); + assert.equal(parsed.sessionId, "019e8685-1031-70a0-9ac4-37dcbcefc163"); +}); + +test("parseGrokJsonResult fails on a non-zero exit even with valid JSON", () => { + const parsed = parseGrokJsonResult(JSON.stringify({ text: "partial" }), "boom", 2); + assert.equal(parsed.ok, false); +}); + +test("parseGrokStreamText concatenates text deltas and reads sessionId from the end event", () => { + const parsed = parseGrokStreamText( + [ + '{"type":"thought","data":"thinking"}', + '{"type":"text","data":"OK"}', + '{"type":"text","data":"!"}', + '{"type":"end","stopReason":"EndTurn","sessionId":"019e862e-63fd-7333-8f4c-4add60220323","requestId":"r2"}', + ].join("\n") + ); + assert.equal(parsed.response, "OK!"); + assert.equal(parsed.sessionId, "019e862e-63fd-7333-8f4c-4add60220323"); + assert.equal(extractGrokText({ type: "text", data: "z" }), "z"); + assert.equal(extractGrokText({ type: "thought", data: "z" }), ""); +}); + +test("runGrokPrompt parses json output and ignores transient stderr worker noise on success", () => { + withFakeGrokBin( + `#!/usr/bin/env node +process.stderr.write("ERROR worker quit with fatal: Transport channel closed\\n"); +process.stdout.write(JSON.stringify({ text: "OK", stopReason: "EndTurn", sessionId: "019e8685-1031-70a0-9ac4-37dcbcefc163" }) + "\\n"); +`, + ({ root, bin }) => { + const result = runGrokPrompt({ prompt: "ping", cwd: root, bin }); + assert.equal(result.ok, true); + assert.equal(result.response, "OK"); + assert.equal(result.sessionId, "019e8685-1031-70a0-9ac4-37dcbcefc163"); + } + ); +}); + +test("runGrokPrompt does not leak stdout on a non-zero exit", () => { + withFakeGrokBin( + `#!/usr/bin/env node +process.stdout.write(JSON.stringify({ text: "secret" }) + "\\n"); +process.exit(2); +`, + ({ root, bin }) => { + const result = runGrokPrompt({ prompt: "ping", cwd: root, bin }); + assert.equal(result.ok, false); + assert.match(result.error, /exited with code 2/i); + } + ); +}); + +test("getGrokAuthStatus infers login state from `grok models` without spending a model call", () => { + const authed = getGrokAuthStatus(process.cwd(), { + runner: () => ({ error: null, status: 0, stdout: "You are logged in with grok.com.\n\nDefault model: grok-composer-2.5-fast\n", stderr: "" }), + }); + assert.equal(authed.loggedIn, true); + assert.equal(authed.model, "grok-composer-2.5-fast"); + + const loggedOut = getGrokAuthStatus(process.cwd(), { + runner: () => ({ error: null, status: 1, stdout: "", stderr: "Please log in with `grok login`." }), + }); + assert.equal(loggedOut.loggedIn, false); +}); + +test("getGrokAuthStatus reads `not logged in` as logged out (banner substring must not win)", () => { + // "not logged in" contains the substring "logged in"; the explicit auth-error check must run + // before the generic "logged in" banner test, or this logged-out state flips to loggedIn:true. + const auth = getGrokAuthStatus(process.cwd(), { + runner: () => ({ error: null, status: 1, stdout: "You are not logged in. Run `grok login`.\n", stderr: "" }), + }); + assert.equal(auth.loggedIn, false); +}); + +test("getGrokAuthStatus keeps loggedIn=true for a transient probe timeout", () => { + const auth = getGrokAuthStatus(process.cwd(), { + runner: () => ({ error: { code: "ETIMEDOUT", message: "spawnSync grok ETIMEDOUT" }, status: null, stdout: "", stderr: "" }), + }); + assert.equal(auth.loggedIn, true); + assert.match(auth.detail, /inconclusive/i); +}); + +test("runGrokPromptStreaming emits events and reads the structured session id from end", async () => { + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdin = { write() {}, end() {}, on() {} }; + child.kill = () => {}; + const events = []; + + const result = await runGrokPromptStreaming({ + prompt: "ping", + onEvent(event) { + events.push(event); + }, + spawnImpl() { + queueMicrotask(() => { + child.stdout.emit("data", '{"type":"text","data":"OK"}\n'); + child.stdout.emit("data", '{"type":"end","stopReason":"EndTurn","sessionId":"019e862e-63fd-7333-8f4c-4add60220323"}\n'); + child.emit("close", 0, null); + }); + return child; + }, + }); + + assert.equal(result.ok, true); + assert.equal(result.response, "OK"); + assert.equal(result.sessionId, "019e862e-63fd-7333-8f4c-4add60220323"); + assert.equal(events.length, 2); +}); diff --git a/packages/polycli-runtime/test/registry.test.js b/packages/polycli-runtime/test/registry.test.js index 7da7cef..138fb71 100644 --- a/packages/polycli-runtime/test/registry.test.js +++ b/packages/polycli-runtime/test/registry.test.js @@ -11,8 +11,8 @@ import { runProviderPromptStreaming, } from "../src/index.js"; -test("provider registry exposes the ten integrated runtimes", () => { - assert.deepEqual(PROVIDER_IDS, ["gemini", "kimi", "qwen", "minimax", "claude", "copilot", "opencode", "pi", "cmd", "agy"]); +test("provider registry exposes the eleven integrated runtimes", () => { + assert.deepEqual(PROVIDER_IDS, ["gemini", "kimi", "qwen", "minimax", "claude", "copilot", "opencode", "pi", "cmd", "agy", "grok"]); assert.deepEqual(PROVIDER_OPERATION_NAMES, ["prompt"]); const runtimes = listProviderRuntimes(); diff --git a/packages/polycli-runtime/test/review-flags.test.js b/packages/polycli-runtime/test/review-flags.test.js index 5a61fa3..ccf6d2a 100644 --- a/packages/polycli-runtime/test/review-flags.test.js +++ b/packages/polycli-runtime/test/review-flags.test.js @@ -7,12 +7,12 @@ test("REVIEW_FLAG_EXPECTATIONS is frozen and keyed by every constrained provider assert.equal(Object.isFrozen(REVIEW_FLAG_EXPECTATIONS), true); assert.deepEqual( Object.keys(REVIEW_FLAG_EXPECTATIONS).sort(), - ["agy", "claude", "cmd", "copilot", "gemini", "kimi", "minimax", "opencode", "pi", "qwen"] + ["agy", "claude", "cmd", "copilot", "gemini", "grok", "kimi", "minimax", "opencode", "pi", "qwen"] ); }); test("every provider declares expectFlags + extraArgTokens as `--`-flag arrays (exact-match vs review.mjs lives in the host consistency test)", () => { - for (const provider of ["claude", "gemini", "qwen", "copilot", "opencode", "pi", "cmd", "kimi", "agy", "minimax"]) { + for (const provider of ["claude", "gemini", "qwen", "copilot", "opencode", "pi", "cmd", "kimi", "agy", "minimax", "grok"]) { const entry = REVIEW_FLAG_EXPECTATIONS[provider]; assert.ok(Array.isArray(entry.expectFlags), `${provider} expectFlags is an array`); assert.ok(Array.isArray(entry.extraArgTokens), `${provider} extraArgTokens is an array`); diff --git a/packages/polycli-terminal/bin/polycli-companion.bundle.mjs b/packages/polycli-terminal/bin/polycli-companion.bundle.mjs index 2e4cd02..17c7100 100755 --- a/packages/polycli-terminal/bin/polycli-companion.bundle.mjs +++ b/packages/polycli-terminal/bin/polycli-companion.bundle.mjs @@ -85,7 +85,7 @@ function parseArgs(argv, config = {}) { } // packages/polycli-runtime/src/constants.js -var PROVIDER_IDS = ["gemini", "kimi", "qwen", "minimax", "claude", "copilot", "opencode", "pi", "cmd", "agy"]; +var PROVIDER_IDS = ["gemini", "kimi", "qwen", "minimax", "claude", "copilot", "opencode", "pi", "cmd", "agy", "grok"]; var PROVIDER_OPERATION_NAMES = ["prompt"]; // packages/polycli-utils/src/parse-stream-json.js @@ -3402,6 +3402,226 @@ function runAgyPromptStreaming({ }); } +// packages/polycli-runtime/src/grok.js +var GROK_BIN = process.env.GROK_CLI_BIN || "grok"; +var DEFAULT_TIMEOUT_MS11 = 9e5; +var AUTH_CHECK_TIMEOUT_MS11 = 3e4; +var DEFAULT_GROK_MODEL = "grok-composer-2.5-fast"; +var GROK_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|not logged in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; +var TRANSIENT_PROBE_ERROR_PATTERNS8 = [ + /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i +]; +function buildGrokInvocation({ + prompt, + model = null, + outputFormat = "json", + permissionMode = null, + alwaysApprove = false, + effort = null, + resumeSessionId = null, + continueLast = false, + extraArgs = [], + bin = GROK_BIN +} = {}) { + const args = ["-p", String(prompt ?? ""), "--output-format", outputFormat]; + if (model) args.push("-m", model); + if (effort) args.push("--effort", effort); + if (permissionMode) args.push("--permission-mode", permissionMode); + if (alwaysApprove) args.push("--always-approve"); + if (continueLast) { + args.push("-c"); + } else if (resumeSessionId) { + args.push("-r", resumeSessionId); + } + if (extraArgs.length > 0) args.push(...extraArgs); + return { bin, args }; +} +function extractGrokText(event) { + if (!event || typeof event !== "object") return ""; + if (event.type === "text" && typeof event.data === "string") return event.data; + return ""; +} +function parseGrokStreamText(text) { + const events = []; + let response = ""; + let sessionId = null; + let stopReason = null; + for (const rawLine of String(text ?? "").split(/\r?\n/)) { + const trimmed = rawLine.trim(); + if (!trimmed.startsWith("{")) continue; + let event; + try { + event = JSON.parse(trimmed); + } catch { + continue; + } + events.push(event); + if (event.type === "text" && typeof event.data === "string") { + response += event.data; + } else if (event.type === "end") { + if (typeof event.sessionId === "string") sessionId = event.sessionId; + stopReason = event.stopReason ?? stopReason; + } + } + return { events, response, sessionId, stopReason }; +} +function parseGrokJsonResult(stdout, stderr, status, { defaultModel = null } = {}) { + const text = String(stdout ?? ""); + const jsonStart = text.indexOf("{"); + if (jsonStart < 0 || status !== 0) { + return { + ok: false, + error: String(stderr ?? "").trim() || formatProviderExitError("grok", status), + status + }; + } + try { + const parsed = JSON.parse(text.slice(jsonStart)); + const response = typeof parsed.text === "string" ? parsed.text : ""; + const hasVisibleText = Boolean(response.trim()); + return { + ok: hasVisibleText, + response, + // grok emits the session id structurally; never scan prose for a UUID. + sessionId: typeof parsed.sessionId === "string" ? parsed.sessionId : null, + model: defaultModel ?? DEFAULT_GROK_MODEL, + stopReason: parsed.stopReason ?? null, + error: hasVisibleText ? null : "grok produced no visible text", + status + }; + } catch (error) { + return { ok: false, error: `JSON parse failed: ${error.message}`, status }; + } +} +function getGrokAvailability(cwd) { + return binaryAvailable(GROK_BIN, ["--version"], { cwd }); +} +function buildGrokAuthStatus(result) { + if (result.error) { + const detail = result.error.code === "ETIMEDOUT" ? `grok auth probe timed out after ${Math.round(AUTH_CHECK_TIMEOUT_MS11 / 1e3)}s` : result.error.message; + if (TRANSIENT_PROBE_ERROR_PATTERNS8.some((pattern) => pattern.test(detail))) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: null }; + } + return { loggedIn: false, detail }; + } + const text = `${result.stdout ?? ""} +${result.stderr ?? ""}`; + const defaultModel = (text.match(/Default model:\s*(\S+)/) || [])[1] ?? null; + if (GROK_EXPLICIT_AUTH_ERROR_RE.test(text)) { + return { loggedIn: false, detail: text.trim() || "grok is not logged in" }; + } + if (/\blogged in\b/i.test(text)) { + return { loggedIn: true, detail: "authenticated", model: defaultModel }; + } + if (result.status !== 0) { + const detail = text.trim() || `grok models exited with code ${result.status}`; + if (TRANSIENT_PROBE_ERROR_PATTERNS8.some((pattern) => pattern.test(detail))) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: defaultModel }; + } + return { loggedIn: false, detail }; + } + return { loggedIn: true, detail: "authenticated", model: defaultModel }; +} +function getGrokAuthStatus(cwd, { runner = runCommand } = {}) { + const result = runner(GROK_BIN, ["models"], { cwd, timeout: AUTH_CHECK_TIMEOUT_MS11 }); + return buildGrokAuthStatus(result); +} +function runGrokPrompt({ + prompt, + model = null, + cwd, + timeout = DEFAULT_TIMEOUT_MS11, + alwaysApprove = true, + permissionMode = null, + effort = null, + resumeSessionId = null, + continueLast = false, + extraArgs = [], + defaultModel = null, + bin = GROK_BIN +} = {}) { + const invocation = buildGrokInvocation({ + prompt, + model, + outputFormat: "json", + permissionMode, + alwaysApprove, + effort, + resumeSessionId, + continueLast, + extraArgs, + bin + }); + const result = runCommand(invocation.bin, invocation.args, { cwd, timeout }); + if (result.error) { + const error = result.error.code === "ETIMEDOUT" ? `grok timed out after ${Math.round(timeout / 1e3)}s` : result.error.message; + return { ok: false, error, errorCode: classifyProviderFailure(error, { provider: "grok" }) }; + } + const parsed = parseGrokJsonResult(result.stdout, result.stderr, result.status, { + defaultModel: model ?? defaultModel + }); + return { ...parsed, errorCode: classifyProviderFailure(parsed.error, { provider: "grok" }) }; +} +function runGrokPromptStreaming({ + prompt, + model = null, + cwd, + timeout = DEFAULT_TIMEOUT_MS11, + alwaysApprove = true, + permissionMode = null, + effort = null, + resumeSessionId = null, + continueLast = false, + extraArgs = [], + defaultModel = null, + onEvent = () => { + }, + bin = GROK_BIN, + spawnImpl +} = {}) { + const invocation = buildGrokInvocation({ + prompt, + model, + outputFormat: "streaming-json", + permissionMode, + alwaysApprove, + effort, + resumeSessionId, + continueLast, + extraArgs, + bin + }); + return spawnStreamingCommand({ + bin: invocation.bin, + args: invocation.args, + cwd, + env: { ...process.env }, + timeout, + spawnImpl, + onStdoutLine(line) { + const trimmed = line.trim(); + if (!trimmed.startsWith("{")) return; + try { + onEvent(JSON.parse(trimmed)); + } catch { + } + } + }).then((result) => { + const parsed = parseGrokStreamText(result.stdout); + const hasVisibleText = Boolean(parsed.response.trim()); + const ok = result.ok && hasVisibleText; + const error = ok ? null : result.ok ? "grok produced no visible text" : result.error; + return { + ...result, + ...parsed, + model: model ?? defaultModel ?? DEFAULT_GROK_MODEL, + ok, + error, + errorCode: classifyProviderFailure(error, { provider: "grok" }) + }; + }); +} + // packages/polycli-runtime/src/registry.js import { performance } from "node:perf_hooks"; @@ -3694,6 +3914,7 @@ function extractProviderEventText(provider, event) { if (provider === "pi") return extractPiText(event); if (provider === "cmd") return extractCmdText(event); if (provider === "agy") return extractAgyText(event); + if (provider === "grok") return extractGrokText(event); return ""; } function buildPromptTimingRecord({ @@ -3805,7 +4026,8 @@ var TIMING_SUPPORT = { opencode: { ttft: true, gen: true, tail: true, tool: false, runtimePersistence: "session" }, pi: { ttft: true, gen: true, tail: true, tool: false, runtimePersistence: "session" }, cmd: { ttft: true, gen: true, tail: true, tool: false, runtimePersistence: "ephemeral" }, - agy: { ttft: true, gen: true, tail: true, tool: false, runtimePersistence: "session" } + agy: { ttft: true, gen: true, tail: true, tool: false, runtimePersistence: "session" }, + grok: { ttft: true, gen: true, tail: true, tool: false, runtimePersistence: "session" } }; var RUNTIMES = Object.freeze({ claude: { @@ -3937,6 +4159,19 @@ var RUNTIMES = Object.freeze({ getAuthStatus: getAgyAuthStatus, runPrompt: runAgyPrompt, runPromptStreaming: runAgyPromptStreaming + }, + grok: { + id: "grok", + capabilities: { + streaming: true, + sessionResume: true, + structuredOutput: true, + operations: PROVIDER_OPERATION_NAMES + }, + getAvailability: getGrokAvailability, + getAuthStatus: getGrokAuthStatus, + runPrompt: runGrokPrompt, + runPromptStreaming: runGrokPromptStreaming } }); for (const runtime of Object.values(RUNTIMES)) { @@ -3999,6 +4234,9 @@ function isTerminalSummaryEvent(provider, event) { if (provider === "pi") { return event.type === "agent_end"; } + if (provider === "grok") { + return event.type === "end"; + } return false; } function shouldCountEventTextForTiming(provider, event, firstTextAt) { @@ -4151,6 +4389,14 @@ var REVIEW_FLAG_EXPECTATIONS = Object.freeze({ Object.freeze({ helpArgs: Object.freeze(["text", "chat", "--help"]), expect: Object.freeze(["--message"]) }), Object.freeze({ helpArgs: Object.freeze(["--help"]), expect: Object.freeze(["--output", "--non-interactive"]) }) ]) + }), + grok: Object.freeze({ + // grok review enforces read-only via the --permission-mode plan runtimeOption (composes with + // the -p one-shot mode, verified). It carries no review extraArgs of its own. + expectFlags: Object.freeze(["--permission-mode"]), + extraArgTokens: Object.freeze([]), + readOnlyOptionKey: "permissionMode", + readOnlyValue: "plan" }) }); @@ -5056,6 +5302,8 @@ function deriveSessionArtifactCandidate({ provider, sessionId, workspaceRoot, ho case "minimax": case "cmd": return { path: null, reason: "ephemeral, no per-session store" }; + case "grok": + return { path: null, reason: "grok session files live under a url-encoded cwd dir; exact path is not derivable without a scan" }; default: return { path: null, reason: `no artifact derivation for provider ${provider ?? "?"}` }; } @@ -5743,6 +5991,9 @@ var REVIEW_HARD_CONSTRAINTS = { }, minimax() { return {}; + }, + grok() { + return { permissionMode: "plan", alwaysApprove: false }; } }; function buildReviewRuntimeOptions({ @@ -6324,6 +6575,21 @@ function buildProviderFlagRuntimeOptions(provider, options) { } return { runtimeOptions, notes }; } + if (provider === "grok") { + if (resumeFlags.length > 1) { + throw new Error("Choose only one of --resume-last, --resume, or --fresh."); + } + if (options["resume-last"]) runtimeOptions.continueLast = true; + if (options.resume) runtimeOptions.resumeSessionId = options.resume; + if (options.fresh) { + notes.push("--fresh is already grok's default for non-resumed -p runs."); + } + if (options.effort) runtimeOptions.effort = options.effort; + if (options.write) { + notes.push("--write is gemini-specific; grok will proceed without it."); + } + return { runtimeOptions, notes }; + } if (options.write) { notes.push(`--write is gemini-specific; ${provider} will proceed without it.`); } diff --git a/plugins/polycli-codex/.codex-plugin/plugin.json b/plugins/polycli-codex/.codex-plugin/plugin.json index 6994e3f..d6340f0 100644 --- a/plugins/polycli-codex/.codex-plugin/plugin.json +++ b/plugins/polycli-codex/.codex-plugin/plugin.json @@ -18,7 +18,7 @@ "interface": { "displayName": "Polycli", "shortDescription": "Prefer polycli for provider CLI work in Codex", - "longDescription": "Prefer Polycli over direct shell calls to official provider CLIs when Codex needs claude, copilot, opencode, pi, cmd, agy, gemini, kimi, qwen, or minimax. Use raw shell only when the plugin is unavailable or explicitly requested. Polycli provides health, status, result, and timing observability around ask, review, and rescue flows.", + "longDescription": "Prefer Polycli over direct shell calls to official provider CLIs when Codex needs claude, copilot, opencode, pi, cmd, agy, gemini, kimi, qwen, minimax, or grok. Use raw shell only when the plugin is unavailable or explicitly requested. Polycli provides health, status, result, and timing observability around ask, review, and rescue flows.", "developerName": "bing", "category": "Developer Tools", "capabilities": [ diff --git a/plugins/polycli-codex/README.md b/plugins/polycli-codex/README.md index 7611e2d..8b43394 100644 --- a/plugins/polycli-codex/README.md +++ b/plugins/polycli-codex/README.md @@ -1,6 +1,6 @@ # polycli Codex Plugin -Codex host adapter for the shared `polycli` companion. Prefer the installed Codex skill over direct official CLI shell calls when using `claude`, `copilot`, `opencode`, `pi`, `cmd`, `agy`, `gemini`, `kimi`, `qwen`, or `minimax`; fall back to raw provider CLIs only when the plugin is unavailable or the user explicitly asks for raw shell. +Codex host adapter for the shared `polycli` companion. Prefer the installed Codex skill over direct official CLI shell calls when using `claude`, `copilot`, `opencode`, `pi`, `cmd`, `agy`, `gemini`, `kimi`, `qwen`, `minimax`, or `grok`; fall back to raw provider CLIs only when the plugin is unavailable or the user explicitly asks for raw shell. ## Install diff --git a/plugins/polycli-codex/scripts/polycli-companion.bundle.mjs b/plugins/polycli-codex/scripts/polycli-companion.bundle.mjs index 2e4cd02..17c7100 100755 --- a/plugins/polycli-codex/scripts/polycli-companion.bundle.mjs +++ b/plugins/polycli-codex/scripts/polycli-companion.bundle.mjs @@ -85,7 +85,7 @@ function parseArgs(argv, config = {}) { } // packages/polycli-runtime/src/constants.js -var PROVIDER_IDS = ["gemini", "kimi", "qwen", "minimax", "claude", "copilot", "opencode", "pi", "cmd", "agy"]; +var PROVIDER_IDS = ["gemini", "kimi", "qwen", "minimax", "claude", "copilot", "opencode", "pi", "cmd", "agy", "grok"]; var PROVIDER_OPERATION_NAMES = ["prompt"]; // packages/polycli-utils/src/parse-stream-json.js @@ -3402,6 +3402,226 @@ function runAgyPromptStreaming({ }); } +// packages/polycli-runtime/src/grok.js +var GROK_BIN = process.env.GROK_CLI_BIN || "grok"; +var DEFAULT_TIMEOUT_MS11 = 9e5; +var AUTH_CHECK_TIMEOUT_MS11 = 3e4; +var DEFAULT_GROK_MODEL = "grok-composer-2.5-fast"; +var GROK_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|not logged in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; +var TRANSIENT_PROBE_ERROR_PATTERNS8 = [ + /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i +]; +function buildGrokInvocation({ + prompt, + model = null, + outputFormat = "json", + permissionMode = null, + alwaysApprove = false, + effort = null, + resumeSessionId = null, + continueLast = false, + extraArgs = [], + bin = GROK_BIN +} = {}) { + const args = ["-p", String(prompt ?? ""), "--output-format", outputFormat]; + if (model) args.push("-m", model); + if (effort) args.push("--effort", effort); + if (permissionMode) args.push("--permission-mode", permissionMode); + if (alwaysApprove) args.push("--always-approve"); + if (continueLast) { + args.push("-c"); + } else if (resumeSessionId) { + args.push("-r", resumeSessionId); + } + if (extraArgs.length > 0) args.push(...extraArgs); + return { bin, args }; +} +function extractGrokText(event) { + if (!event || typeof event !== "object") return ""; + if (event.type === "text" && typeof event.data === "string") return event.data; + return ""; +} +function parseGrokStreamText(text) { + const events = []; + let response = ""; + let sessionId = null; + let stopReason = null; + for (const rawLine of String(text ?? "").split(/\r?\n/)) { + const trimmed = rawLine.trim(); + if (!trimmed.startsWith("{")) continue; + let event; + try { + event = JSON.parse(trimmed); + } catch { + continue; + } + events.push(event); + if (event.type === "text" && typeof event.data === "string") { + response += event.data; + } else if (event.type === "end") { + if (typeof event.sessionId === "string") sessionId = event.sessionId; + stopReason = event.stopReason ?? stopReason; + } + } + return { events, response, sessionId, stopReason }; +} +function parseGrokJsonResult(stdout, stderr, status, { defaultModel = null } = {}) { + const text = String(stdout ?? ""); + const jsonStart = text.indexOf("{"); + if (jsonStart < 0 || status !== 0) { + return { + ok: false, + error: String(stderr ?? "").trim() || formatProviderExitError("grok", status), + status + }; + } + try { + const parsed = JSON.parse(text.slice(jsonStart)); + const response = typeof parsed.text === "string" ? parsed.text : ""; + const hasVisibleText = Boolean(response.trim()); + return { + ok: hasVisibleText, + response, + // grok emits the session id structurally; never scan prose for a UUID. + sessionId: typeof parsed.sessionId === "string" ? parsed.sessionId : null, + model: defaultModel ?? DEFAULT_GROK_MODEL, + stopReason: parsed.stopReason ?? null, + error: hasVisibleText ? null : "grok produced no visible text", + status + }; + } catch (error) { + return { ok: false, error: `JSON parse failed: ${error.message}`, status }; + } +} +function getGrokAvailability(cwd) { + return binaryAvailable(GROK_BIN, ["--version"], { cwd }); +} +function buildGrokAuthStatus(result) { + if (result.error) { + const detail = result.error.code === "ETIMEDOUT" ? `grok auth probe timed out after ${Math.round(AUTH_CHECK_TIMEOUT_MS11 / 1e3)}s` : result.error.message; + if (TRANSIENT_PROBE_ERROR_PATTERNS8.some((pattern) => pattern.test(detail))) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: null }; + } + return { loggedIn: false, detail }; + } + const text = `${result.stdout ?? ""} +${result.stderr ?? ""}`; + const defaultModel = (text.match(/Default model:\s*(\S+)/) || [])[1] ?? null; + if (GROK_EXPLICIT_AUTH_ERROR_RE.test(text)) { + return { loggedIn: false, detail: text.trim() || "grok is not logged in" }; + } + if (/\blogged in\b/i.test(text)) { + return { loggedIn: true, detail: "authenticated", model: defaultModel }; + } + if (result.status !== 0) { + const detail = text.trim() || `grok models exited with code ${result.status}`; + if (TRANSIENT_PROBE_ERROR_PATTERNS8.some((pattern) => pattern.test(detail))) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: defaultModel }; + } + return { loggedIn: false, detail }; + } + return { loggedIn: true, detail: "authenticated", model: defaultModel }; +} +function getGrokAuthStatus(cwd, { runner = runCommand } = {}) { + const result = runner(GROK_BIN, ["models"], { cwd, timeout: AUTH_CHECK_TIMEOUT_MS11 }); + return buildGrokAuthStatus(result); +} +function runGrokPrompt({ + prompt, + model = null, + cwd, + timeout = DEFAULT_TIMEOUT_MS11, + alwaysApprove = true, + permissionMode = null, + effort = null, + resumeSessionId = null, + continueLast = false, + extraArgs = [], + defaultModel = null, + bin = GROK_BIN +} = {}) { + const invocation = buildGrokInvocation({ + prompt, + model, + outputFormat: "json", + permissionMode, + alwaysApprove, + effort, + resumeSessionId, + continueLast, + extraArgs, + bin + }); + const result = runCommand(invocation.bin, invocation.args, { cwd, timeout }); + if (result.error) { + const error = result.error.code === "ETIMEDOUT" ? `grok timed out after ${Math.round(timeout / 1e3)}s` : result.error.message; + return { ok: false, error, errorCode: classifyProviderFailure(error, { provider: "grok" }) }; + } + const parsed = parseGrokJsonResult(result.stdout, result.stderr, result.status, { + defaultModel: model ?? defaultModel + }); + return { ...parsed, errorCode: classifyProviderFailure(parsed.error, { provider: "grok" }) }; +} +function runGrokPromptStreaming({ + prompt, + model = null, + cwd, + timeout = DEFAULT_TIMEOUT_MS11, + alwaysApprove = true, + permissionMode = null, + effort = null, + resumeSessionId = null, + continueLast = false, + extraArgs = [], + defaultModel = null, + onEvent = () => { + }, + bin = GROK_BIN, + spawnImpl +} = {}) { + const invocation = buildGrokInvocation({ + prompt, + model, + outputFormat: "streaming-json", + permissionMode, + alwaysApprove, + effort, + resumeSessionId, + continueLast, + extraArgs, + bin + }); + return spawnStreamingCommand({ + bin: invocation.bin, + args: invocation.args, + cwd, + env: { ...process.env }, + timeout, + spawnImpl, + onStdoutLine(line) { + const trimmed = line.trim(); + if (!trimmed.startsWith("{")) return; + try { + onEvent(JSON.parse(trimmed)); + } catch { + } + } + }).then((result) => { + const parsed = parseGrokStreamText(result.stdout); + const hasVisibleText = Boolean(parsed.response.trim()); + const ok = result.ok && hasVisibleText; + const error = ok ? null : result.ok ? "grok produced no visible text" : result.error; + return { + ...result, + ...parsed, + model: model ?? defaultModel ?? DEFAULT_GROK_MODEL, + ok, + error, + errorCode: classifyProviderFailure(error, { provider: "grok" }) + }; + }); +} + // packages/polycli-runtime/src/registry.js import { performance } from "node:perf_hooks"; @@ -3694,6 +3914,7 @@ function extractProviderEventText(provider, event) { if (provider === "pi") return extractPiText(event); if (provider === "cmd") return extractCmdText(event); if (provider === "agy") return extractAgyText(event); + if (provider === "grok") return extractGrokText(event); return ""; } function buildPromptTimingRecord({ @@ -3805,7 +4026,8 @@ var TIMING_SUPPORT = { opencode: { ttft: true, gen: true, tail: true, tool: false, runtimePersistence: "session" }, pi: { ttft: true, gen: true, tail: true, tool: false, runtimePersistence: "session" }, cmd: { ttft: true, gen: true, tail: true, tool: false, runtimePersistence: "ephemeral" }, - agy: { ttft: true, gen: true, tail: true, tool: false, runtimePersistence: "session" } + agy: { ttft: true, gen: true, tail: true, tool: false, runtimePersistence: "session" }, + grok: { ttft: true, gen: true, tail: true, tool: false, runtimePersistence: "session" } }; var RUNTIMES = Object.freeze({ claude: { @@ -3937,6 +4159,19 @@ var RUNTIMES = Object.freeze({ getAuthStatus: getAgyAuthStatus, runPrompt: runAgyPrompt, runPromptStreaming: runAgyPromptStreaming + }, + grok: { + id: "grok", + capabilities: { + streaming: true, + sessionResume: true, + structuredOutput: true, + operations: PROVIDER_OPERATION_NAMES + }, + getAvailability: getGrokAvailability, + getAuthStatus: getGrokAuthStatus, + runPrompt: runGrokPrompt, + runPromptStreaming: runGrokPromptStreaming } }); for (const runtime of Object.values(RUNTIMES)) { @@ -3999,6 +4234,9 @@ function isTerminalSummaryEvent(provider, event) { if (provider === "pi") { return event.type === "agent_end"; } + if (provider === "grok") { + return event.type === "end"; + } return false; } function shouldCountEventTextForTiming(provider, event, firstTextAt) { @@ -4151,6 +4389,14 @@ var REVIEW_FLAG_EXPECTATIONS = Object.freeze({ Object.freeze({ helpArgs: Object.freeze(["text", "chat", "--help"]), expect: Object.freeze(["--message"]) }), Object.freeze({ helpArgs: Object.freeze(["--help"]), expect: Object.freeze(["--output", "--non-interactive"]) }) ]) + }), + grok: Object.freeze({ + // grok review enforces read-only via the --permission-mode plan runtimeOption (composes with + // the -p one-shot mode, verified). It carries no review extraArgs of its own. + expectFlags: Object.freeze(["--permission-mode"]), + extraArgTokens: Object.freeze([]), + readOnlyOptionKey: "permissionMode", + readOnlyValue: "plan" }) }); @@ -5056,6 +5302,8 @@ function deriveSessionArtifactCandidate({ provider, sessionId, workspaceRoot, ho case "minimax": case "cmd": return { path: null, reason: "ephemeral, no per-session store" }; + case "grok": + return { path: null, reason: "grok session files live under a url-encoded cwd dir; exact path is not derivable without a scan" }; default: return { path: null, reason: `no artifact derivation for provider ${provider ?? "?"}` }; } @@ -5743,6 +5991,9 @@ var REVIEW_HARD_CONSTRAINTS = { }, minimax() { return {}; + }, + grok() { + return { permissionMode: "plan", alwaysApprove: false }; } }; function buildReviewRuntimeOptions({ @@ -6324,6 +6575,21 @@ function buildProviderFlagRuntimeOptions(provider, options) { } return { runtimeOptions, notes }; } + if (provider === "grok") { + if (resumeFlags.length > 1) { + throw new Error("Choose only one of --resume-last, --resume, or --fresh."); + } + if (options["resume-last"]) runtimeOptions.continueLast = true; + if (options.resume) runtimeOptions.resumeSessionId = options.resume; + if (options.fresh) { + notes.push("--fresh is already grok's default for non-resumed -p runs."); + } + if (options.effort) runtimeOptions.effort = options.effort; + if (options.write) { + notes.push("--write is gemini-specific; grok will proceed without it."); + } + return { runtimeOptions, notes }; + } if (options.write) { notes.push(`--write is gemini-specific; ${provider} will proceed without it.`); } diff --git a/plugins/polycli-codex/skills/polycli/SKILL.md b/plugins/polycli-codex/skills/polycli/SKILL.md index 3b35132..f2c8863 100644 --- a/plugins/polycli-codex/skills/polycli/SKILL.md +++ b/plugins/polycli-codex/skills/polycli/SKILL.md @@ -1,6 +1,6 @@ --- name: polycli -description: Use when Codex should ask, review, rescue, health-check, or compare provider CLIs through Polycli. Prefer this skill over direct shell calls to official CLIs for claude, copilot, opencode, pi, cmd, agy, gemini, kimi, qwen, and minimax unless the user explicitly asks for the raw CLI or the plugin is unavailable. +description: Use when Codex should ask, review, rescue, health-check, or compare provider CLIs through Polycli. Prefer this skill over direct shell calls to official CLIs for claude, copilot, opencode, pi, cmd, agy, gemini, kimi, qwen, minimax, and grok unless the user explicitly asks for the raw CLI or the plugin is unavailable. --- Interpret `$ARGUMENTS` as raw companion arguments. @@ -23,7 +23,7 @@ node "$PLUGIN_ROOT_DIR/scripts/polycli-companion.bundle.mjs" $ARGUMENTS Supported subcommands: -- `setup [--provider ] [--json]` +- `setup [--provider ] [--json]` - `health [--provider ] [--model ] [--timeout-ms ] [--json]` - `ask --provider [--model ] [--background] [--json] ` - `rescue --provider [--model ] [--background] [--json] ` diff --git a/plugins/polycli-copilot/scripts/polycli-companion.bundle.mjs b/plugins/polycli-copilot/scripts/polycli-companion.bundle.mjs index 2e4cd02..17c7100 100755 --- a/plugins/polycli-copilot/scripts/polycli-companion.bundle.mjs +++ b/plugins/polycli-copilot/scripts/polycli-companion.bundle.mjs @@ -85,7 +85,7 @@ function parseArgs(argv, config = {}) { } // packages/polycli-runtime/src/constants.js -var PROVIDER_IDS = ["gemini", "kimi", "qwen", "minimax", "claude", "copilot", "opencode", "pi", "cmd", "agy"]; +var PROVIDER_IDS = ["gemini", "kimi", "qwen", "minimax", "claude", "copilot", "opencode", "pi", "cmd", "agy", "grok"]; var PROVIDER_OPERATION_NAMES = ["prompt"]; // packages/polycli-utils/src/parse-stream-json.js @@ -3402,6 +3402,226 @@ function runAgyPromptStreaming({ }); } +// packages/polycli-runtime/src/grok.js +var GROK_BIN = process.env.GROK_CLI_BIN || "grok"; +var DEFAULT_TIMEOUT_MS11 = 9e5; +var AUTH_CHECK_TIMEOUT_MS11 = 3e4; +var DEFAULT_GROK_MODEL = "grok-composer-2.5-fast"; +var GROK_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|not logged in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; +var TRANSIENT_PROBE_ERROR_PATTERNS8 = [ + /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i +]; +function buildGrokInvocation({ + prompt, + model = null, + outputFormat = "json", + permissionMode = null, + alwaysApprove = false, + effort = null, + resumeSessionId = null, + continueLast = false, + extraArgs = [], + bin = GROK_BIN +} = {}) { + const args = ["-p", String(prompt ?? ""), "--output-format", outputFormat]; + if (model) args.push("-m", model); + if (effort) args.push("--effort", effort); + if (permissionMode) args.push("--permission-mode", permissionMode); + if (alwaysApprove) args.push("--always-approve"); + if (continueLast) { + args.push("-c"); + } else if (resumeSessionId) { + args.push("-r", resumeSessionId); + } + if (extraArgs.length > 0) args.push(...extraArgs); + return { bin, args }; +} +function extractGrokText(event) { + if (!event || typeof event !== "object") return ""; + if (event.type === "text" && typeof event.data === "string") return event.data; + return ""; +} +function parseGrokStreamText(text) { + const events = []; + let response = ""; + let sessionId = null; + let stopReason = null; + for (const rawLine of String(text ?? "").split(/\r?\n/)) { + const trimmed = rawLine.trim(); + if (!trimmed.startsWith("{")) continue; + let event; + try { + event = JSON.parse(trimmed); + } catch { + continue; + } + events.push(event); + if (event.type === "text" && typeof event.data === "string") { + response += event.data; + } else if (event.type === "end") { + if (typeof event.sessionId === "string") sessionId = event.sessionId; + stopReason = event.stopReason ?? stopReason; + } + } + return { events, response, sessionId, stopReason }; +} +function parseGrokJsonResult(stdout, stderr, status, { defaultModel = null } = {}) { + const text = String(stdout ?? ""); + const jsonStart = text.indexOf("{"); + if (jsonStart < 0 || status !== 0) { + return { + ok: false, + error: String(stderr ?? "").trim() || formatProviderExitError("grok", status), + status + }; + } + try { + const parsed = JSON.parse(text.slice(jsonStart)); + const response = typeof parsed.text === "string" ? parsed.text : ""; + const hasVisibleText = Boolean(response.trim()); + return { + ok: hasVisibleText, + response, + // grok emits the session id structurally; never scan prose for a UUID. + sessionId: typeof parsed.sessionId === "string" ? parsed.sessionId : null, + model: defaultModel ?? DEFAULT_GROK_MODEL, + stopReason: parsed.stopReason ?? null, + error: hasVisibleText ? null : "grok produced no visible text", + status + }; + } catch (error) { + return { ok: false, error: `JSON parse failed: ${error.message}`, status }; + } +} +function getGrokAvailability(cwd) { + return binaryAvailable(GROK_BIN, ["--version"], { cwd }); +} +function buildGrokAuthStatus(result) { + if (result.error) { + const detail = result.error.code === "ETIMEDOUT" ? `grok auth probe timed out after ${Math.round(AUTH_CHECK_TIMEOUT_MS11 / 1e3)}s` : result.error.message; + if (TRANSIENT_PROBE_ERROR_PATTERNS8.some((pattern) => pattern.test(detail))) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: null }; + } + return { loggedIn: false, detail }; + } + const text = `${result.stdout ?? ""} +${result.stderr ?? ""}`; + const defaultModel = (text.match(/Default model:\s*(\S+)/) || [])[1] ?? null; + if (GROK_EXPLICIT_AUTH_ERROR_RE.test(text)) { + return { loggedIn: false, detail: text.trim() || "grok is not logged in" }; + } + if (/\blogged in\b/i.test(text)) { + return { loggedIn: true, detail: "authenticated", model: defaultModel }; + } + if (result.status !== 0) { + const detail = text.trim() || `grok models exited with code ${result.status}`; + if (TRANSIENT_PROBE_ERROR_PATTERNS8.some((pattern) => pattern.test(detail))) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: defaultModel }; + } + return { loggedIn: false, detail }; + } + return { loggedIn: true, detail: "authenticated", model: defaultModel }; +} +function getGrokAuthStatus(cwd, { runner = runCommand } = {}) { + const result = runner(GROK_BIN, ["models"], { cwd, timeout: AUTH_CHECK_TIMEOUT_MS11 }); + return buildGrokAuthStatus(result); +} +function runGrokPrompt({ + prompt, + model = null, + cwd, + timeout = DEFAULT_TIMEOUT_MS11, + alwaysApprove = true, + permissionMode = null, + effort = null, + resumeSessionId = null, + continueLast = false, + extraArgs = [], + defaultModel = null, + bin = GROK_BIN +} = {}) { + const invocation = buildGrokInvocation({ + prompt, + model, + outputFormat: "json", + permissionMode, + alwaysApprove, + effort, + resumeSessionId, + continueLast, + extraArgs, + bin + }); + const result = runCommand(invocation.bin, invocation.args, { cwd, timeout }); + if (result.error) { + const error = result.error.code === "ETIMEDOUT" ? `grok timed out after ${Math.round(timeout / 1e3)}s` : result.error.message; + return { ok: false, error, errorCode: classifyProviderFailure(error, { provider: "grok" }) }; + } + const parsed = parseGrokJsonResult(result.stdout, result.stderr, result.status, { + defaultModel: model ?? defaultModel + }); + return { ...parsed, errorCode: classifyProviderFailure(parsed.error, { provider: "grok" }) }; +} +function runGrokPromptStreaming({ + prompt, + model = null, + cwd, + timeout = DEFAULT_TIMEOUT_MS11, + alwaysApprove = true, + permissionMode = null, + effort = null, + resumeSessionId = null, + continueLast = false, + extraArgs = [], + defaultModel = null, + onEvent = () => { + }, + bin = GROK_BIN, + spawnImpl +} = {}) { + const invocation = buildGrokInvocation({ + prompt, + model, + outputFormat: "streaming-json", + permissionMode, + alwaysApprove, + effort, + resumeSessionId, + continueLast, + extraArgs, + bin + }); + return spawnStreamingCommand({ + bin: invocation.bin, + args: invocation.args, + cwd, + env: { ...process.env }, + timeout, + spawnImpl, + onStdoutLine(line) { + const trimmed = line.trim(); + if (!trimmed.startsWith("{")) return; + try { + onEvent(JSON.parse(trimmed)); + } catch { + } + } + }).then((result) => { + const parsed = parseGrokStreamText(result.stdout); + const hasVisibleText = Boolean(parsed.response.trim()); + const ok = result.ok && hasVisibleText; + const error = ok ? null : result.ok ? "grok produced no visible text" : result.error; + return { + ...result, + ...parsed, + model: model ?? defaultModel ?? DEFAULT_GROK_MODEL, + ok, + error, + errorCode: classifyProviderFailure(error, { provider: "grok" }) + }; + }); +} + // packages/polycli-runtime/src/registry.js import { performance } from "node:perf_hooks"; @@ -3694,6 +3914,7 @@ function extractProviderEventText(provider, event) { if (provider === "pi") return extractPiText(event); if (provider === "cmd") return extractCmdText(event); if (provider === "agy") return extractAgyText(event); + if (provider === "grok") return extractGrokText(event); return ""; } function buildPromptTimingRecord({ @@ -3805,7 +4026,8 @@ var TIMING_SUPPORT = { opencode: { ttft: true, gen: true, tail: true, tool: false, runtimePersistence: "session" }, pi: { ttft: true, gen: true, tail: true, tool: false, runtimePersistence: "session" }, cmd: { ttft: true, gen: true, tail: true, tool: false, runtimePersistence: "ephemeral" }, - agy: { ttft: true, gen: true, tail: true, tool: false, runtimePersistence: "session" } + agy: { ttft: true, gen: true, tail: true, tool: false, runtimePersistence: "session" }, + grok: { ttft: true, gen: true, tail: true, tool: false, runtimePersistence: "session" } }; var RUNTIMES = Object.freeze({ claude: { @@ -3937,6 +4159,19 @@ var RUNTIMES = Object.freeze({ getAuthStatus: getAgyAuthStatus, runPrompt: runAgyPrompt, runPromptStreaming: runAgyPromptStreaming + }, + grok: { + id: "grok", + capabilities: { + streaming: true, + sessionResume: true, + structuredOutput: true, + operations: PROVIDER_OPERATION_NAMES + }, + getAvailability: getGrokAvailability, + getAuthStatus: getGrokAuthStatus, + runPrompt: runGrokPrompt, + runPromptStreaming: runGrokPromptStreaming } }); for (const runtime of Object.values(RUNTIMES)) { @@ -3999,6 +4234,9 @@ function isTerminalSummaryEvent(provider, event) { if (provider === "pi") { return event.type === "agent_end"; } + if (provider === "grok") { + return event.type === "end"; + } return false; } function shouldCountEventTextForTiming(provider, event, firstTextAt) { @@ -4151,6 +4389,14 @@ var REVIEW_FLAG_EXPECTATIONS = Object.freeze({ Object.freeze({ helpArgs: Object.freeze(["text", "chat", "--help"]), expect: Object.freeze(["--message"]) }), Object.freeze({ helpArgs: Object.freeze(["--help"]), expect: Object.freeze(["--output", "--non-interactive"]) }) ]) + }), + grok: Object.freeze({ + // grok review enforces read-only via the --permission-mode plan runtimeOption (composes with + // the -p one-shot mode, verified). It carries no review extraArgs of its own. + expectFlags: Object.freeze(["--permission-mode"]), + extraArgTokens: Object.freeze([]), + readOnlyOptionKey: "permissionMode", + readOnlyValue: "plan" }) }); @@ -5056,6 +5302,8 @@ function deriveSessionArtifactCandidate({ provider, sessionId, workspaceRoot, ho case "minimax": case "cmd": return { path: null, reason: "ephemeral, no per-session store" }; + case "grok": + return { path: null, reason: "grok session files live under a url-encoded cwd dir; exact path is not derivable without a scan" }; default: return { path: null, reason: `no artifact derivation for provider ${provider ?? "?"}` }; } @@ -5743,6 +5991,9 @@ var REVIEW_HARD_CONSTRAINTS = { }, minimax() { return {}; + }, + grok() { + return { permissionMode: "plan", alwaysApprove: false }; } }; function buildReviewRuntimeOptions({ @@ -6324,6 +6575,21 @@ function buildProviderFlagRuntimeOptions(provider, options) { } return { runtimeOptions, notes }; } + if (provider === "grok") { + if (resumeFlags.length > 1) { + throw new Error("Choose only one of --resume-last, --resume, or --fresh."); + } + if (options["resume-last"]) runtimeOptions.continueLast = true; + if (options.resume) runtimeOptions.resumeSessionId = options.resume; + if (options.fresh) { + notes.push("--fresh is already grok's default for non-resumed -p runs."); + } + if (options.effort) runtimeOptions.effort = options.effort; + if (options.write) { + notes.push("--write is gemini-specific; grok will proceed without it."); + } + return { runtimeOptions, notes }; + } if (options.write) { notes.push(`--write is gemini-specific; ${provider} will proceed without it.`); } diff --git a/plugins/polycli-copilot/skills/polycli/SKILL.md b/plugins/polycli-copilot/skills/polycli/SKILL.md index 3fbc37e..85981c6 100644 --- a/plugins/polycli-copilot/skills/polycli/SKILL.md +++ b/plugins/polycli-copilot/skills/polycli/SKILL.md @@ -15,7 +15,7 @@ node "$PLUGIN_ROOT_DIR/scripts/polycli-companion.bundle.mjs" $ARGUMENTS Supported subcommands: -- `setup [--provider ] [--json]` +- `setup [--provider ] [--json]` - `health [--provider ] [--model ] [--timeout-ms ] [--json]` - `ask --provider [--model ] [--background] [--json] ` - `rescue --provider [--model ] [--background] [--json] ` diff --git a/plugins/polycli-opencode/scripts/polycli-companion.bundle.mjs b/plugins/polycli-opencode/scripts/polycli-companion.bundle.mjs index 2e4cd02..17c7100 100755 --- a/plugins/polycli-opencode/scripts/polycli-companion.bundle.mjs +++ b/plugins/polycli-opencode/scripts/polycli-companion.bundle.mjs @@ -85,7 +85,7 @@ function parseArgs(argv, config = {}) { } // packages/polycli-runtime/src/constants.js -var PROVIDER_IDS = ["gemini", "kimi", "qwen", "minimax", "claude", "copilot", "opencode", "pi", "cmd", "agy"]; +var PROVIDER_IDS = ["gemini", "kimi", "qwen", "minimax", "claude", "copilot", "opencode", "pi", "cmd", "agy", "grok"]; var PROVIDER_OPERATION_NAMES = ["prompt"]; // packages/polycli-utils/src/parse-stream-json.js @@ -3402,6 +3402,226 @@ function runAgyPromptStreaming({ }); } +// packages/polycli-runtime/src/grok.js +var GROK_BIN = process.env.GROK_CLI_BIN || "grok"; +var DEFAULT_TIMEOUT_MS11 = 9e5; +var AUTH_CHECK_TIMEOUT_MS11 = 3e4; +var DEFAULT_GROK_MODEL = "grok-composer-2.5-fast"; +var GROK_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|not logged in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; +var TRANSIENT_PROBE_ERROR_PATTERNS8 = [ + /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i +]; +function buildGrokInvocation({ + prompt, + model = null, + outputFormat = "json", + permissionMode = null, + alwaysApprove = false, + effort = null, + resumeSessionId = null, + continueLast = false, + extraArgs = [], + bin = GROK_BIN +} = {}) { + const args = ["-p", String(prompt ?? ""), "--output-format", outputFormat]; + if (model) args.push("-m", model); + if (effort) args.push("--effort", effort); + if (permissionMode) args.push("--permission-mode", permissionMode); + if (alwaysApprove) args.push("--always-approve"); + if (continueLast) { + args.push("-c"); + } else if (resumeSessionId) { + args.push("-r", resumeSessionId); + } + if (extraArgs.length > 0) args.push(...extraArgs); + return { bin, args }; +} +function extractGrokText(event) { + if (!event || typeof event !== "object") return ""; + if (event.type === "text" && typeof event.data === "string") return event.data; + return ""; +} +function parseGrokStreamText(text) { + const events = []; + let response = ""; + let sessionId = null; + let stopReason = null; + for (const rawLine of String(text ?? "").split(/\r?\n/)) { + const trimmed = rawLine.trim(); + if (!trimmed.startsWith("{")) continue; + let event; + try { + event = JSON.parse(trimmed); + } catch { + continue; + } + events.push(event); + if (event.type === "text" && typeof event.data === "string") { + response += event.data; + } else if (event.type === "end") { + if (typeof event.sessionId === "string") sessionId = event.sessionId; + stopReason = event.stopReason ?? stopReason; + } + } + return { events, response, sessionId, stopReason }; +} +function parseGrokJsonResult(stdout, stderr, status, { defaultModel = null } = {}) { + const text = String(stdout ?? ""); + const jsonStart = text.indexOf("{"); + if (jsonStart < 0 || status !== 0) { + return { + ok: false, + error: String(stderr ?? "").trim() || formatProviderExitError("grok", status), + status + }; + } + try { + const parsed = JSON.parse(text.slice(jsonStart)); + const response = typeof parsed.text === "string" ? parsed.text : ""; + const hasVisibleText = Boolean(response.trim()); + return { + ok: hasVisibleText, + response, + // grok emits the session id structurally; never scan prose for a UUID. + sessionId: typeof parsed.sessionId === "string" ? parsed.sessionId : null, + model: defaultModel ?? DEFAULT_GROK_MODEL, + stopReason: parsed.stopReason ?? null, + error: hasVisibleText ? null : "grok produced no visible text", + status + }; + } catch (error) { + return { ok: false, error: `JSON parse failed: ${error.message}`, status }; + } +} +function getGrokAvailability(cwd) { + return binaryAvailable(GROK_BIN, ["--version"], { cwd }); +} +function buildGrokAuthStatus(result) { + if (result.error) { + const detail = result.error.code === "ETIMEDOUT" ? `grok auth probe timed out after ${Math.round(AUTH_CHECK_TIMEOUT_MS11 / 1e3)}s` : result.error.message; + if (TRANSIENT_PROBE_ERROR_PATTERNS8.some((pattern) => pattern.test(detail))) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: null }; + } + return { loggedIn: false, detail }; + } + const text = `${result.stdout ?? ""} +${result.stderr ?? ""}`; + const defaultModel = (text.match(/Default model:\s*(\S+)/) || [])[1] ?? null; + if (GROK_EXPLICIT_AUTH_ERROR_RE.test(text)) { + return { loggedIn: false, detail: text.trim() || "grok is not logged in" }; + } + if (/\blogged in\b/i.test(text)) { + return { loggedIn: true, detail: "authenticated", model: defaultModel }; + } + if (result.status !== 0) { + const detail = text.trim() || `grok models exited with code ${result.status}`; + if (TRANSIENT_PROBE_ERROR_PATTERNS8.some((pattern) => pattern.test(detail))) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: defaultModel }; + } + return { loggedIn: false, detail }; + } + return { loggedIn: true, detail: "authenticated", model: defaultModel }; +} +function getGrokAuthStatus(cwd, { runner = runCommand } = {}) { + const result = runner(GROK_BIN, ["models"], { cwd, timeout: AUTH_CHECK_TIMEOUT_MS11 }); + return buildGrokAuthStatus(result); +} +function runGrokPrompt({ + prompt, + model = null, + cwd, + timeout = DEFAULT_TIMEOUT_MS11, + alwaysApprove = true, + permissionMode = null, + effort = null, + resumeSessionId = null, + continueLast = false, + extraArgs = [], + defaultModel = null, + bin = GROK_BIN +} = {}) { + const invocation = buildGrokInvocation({ + prompt, + model, + outputFormat: "json", + permissionMode, + alwaysApprove, + effort, + resumeSessionId, + continueLast, + extraArgs, + bin + }); + const result = runCommand(invocation.bin, invocation.args, { cwd, timeout }); + if (result.error) { + const error = result.error.code === "ETIMEDOUT" ? `grok timed out after ${Math.round(timeout / 1e3)}s` : result.error.message; + return { ok: false, error, errorCode: classifyProviderFailure(error, { provider: "grok" }) }; + } + const parsed = parseGrokJsonResult(result.stdout, result.stderr, result.status, { + defaultModel: model ?? defaultModel + }); + return { ...parsed, errorCode: classifyProviderFailure(parsed.error, { provider: "grok" }) }; +} +function runGrokPromptStreaming({ + prompt, + model = null, + cwd, + timeout = DEFAULT_TIMEOUT_MS11, + alwaysApprove = true, + permissionMode = null, + effort = null, + resumeSessionId = null, + continueLast = false, + extraArgs = [], + defaultModel = null, + onEvent = () => { + }, + bin = GROK_BIN, + spawnImpl +} = {}) { + const invocation = buildGrokInvocation({ + prompt, + model, + outputFormat: "streaming-json", + permissionMode, + alwaysApprove, + effort, + resumeSessionId, + continueLast, + extraArgs, + bin + }); + return spawnStreamingCommand({ + bin: invocation.bin, + args: invocation.args, + cwd, + env: { ...process.env }, + timeout, + spawnImpl, + onStdoutLine(line) { + const trimmed = line.trim(); + if (!trimmed.startsWith("{")) return; + try { + onEvent(JSON.parse(trimmed)); + } catch { + } + } + }).then((result) => { + const parsed = parseGrokStreamText(result.stdout); + const hasVisibleText = Boolean(parsed.response.trim()); + const ok = result.ok && hasVisibleText; + const error = ok ? null : result.ok ? "grok produced no visible text" : result.error; + return { + ...result, + ...parsed, + model: model ?? defaultModel ?? DEFAULT_GROK_MODEL, + ok, + error, + errorCode: classifyProviderFailure(error, { provider: "grok" }) + }; + }); +} + // packages/polycli-runtime/src/registry.js import { performance } from "node:perf_hooks"; @@ -3694,6 +3914,7 @@ function extractProviderEventText(provider, event) { if (provider === "pi") return extractPiText(event); if (provider === "cmd") return extractCmdText(event); if (provider === "agy") return extractAgyText(event); + if (provider === "grok") return extractGrokText(event); return ""; } function buildPromptTimingRecord({ @@ -3805,7 +4026,8 @@ var TIMING_SUPPORT = { opencode: { ttft: true, gen: true, tail: true, tool: false, runtimePersistence: "session" }, pi: { ttft: true, gen: true, tail: true, tool: false, runtimePersistence: "session" }, cmd: { ttft: true, gen: true, tail: true, tool: false, runtimePersistence: "ephemeral" }, - agy: { ttft: true, gen: true, tail: true, tool: false, runtimePersistence: "session" } + agy: { ttft: true, gen: true, tail: true, tool: false, runtimePersistence: "session" }, + grok: { ttft: true, gen: true, tail: true, tool: false, runtimePersistence: "session" } }; var RUNTIMES = Object.freeze({ claude: { @@ -3937,6 +4159,19 @@ var RUNTIMES = Object.freeze({ getAuthStatus: getAgyAuthStatus, runPrompt: runAgyPrompt, runPromptStreaming: runAgyPromptStreaming + }, + grok: { + id: "grok", + capabilities: { + streaming: true, + sessionResume: true, + structuredOutput: true, + operations: PROVIDER_OPERATION_NAMES + }, + getAvailability: getGrokAvailability, + getAuthStatus: getGrokAuthStatus, + runPrompt: runGrokPrompt, + runPromptStreaming: runGrokPromptStreaming } }); for (const runtime of Object.values(RUNTIMES)) { @@ -3999,6 +4234,9 @@ function isTerminalSummaryEvent(provider, event) { if (provider === "pi") { return event.type === "agent_end"; } + if (provider === "grok") { + return event.type === "end"; + } return false; } function shouldCountEventTextForTiming(provider, event, firstTextAt) { @@ -4151,6 +4389,14 @@ var REVIEW_FLAG_EXPECTATIONS = Object.freeze({ Object.freeze({ helpArgs: Object.freeze(["text", "chat", "--help"]), expect: Object.freeze(["--message"]) }), Object.freeze({ helpArgs: Object.freeze(["--help"]), expect: Object.freeze(["--output", "--non-interactive"]) }) ]) + }), + grok: Object.freeze({ + // grok review enforces read-only via the --permission-mode plan runtimeOption (composes with + // the -p one-shot mode, verified). It carries no review extraArgs of its own. + expectFlags: Object.freeze(["--permission-mode"]), + extraArgTokens: Object.freeze([]), + readOnlyOptionKey: "permissionMode", + readOnlyValue: "plan" }) }); @@ -5056,6 +5302,8 @@ function deriveSessionArtifactCandidate({ provider, sessionId, workspaceRoot, ho case "minimax": case "cmd": return { path: null, reason: "ephemeral, no per-session store" }; + case "grok": + return { path: null, reason: "grok session files live under a url-encoded cwd dir; exact path is not derivable without a scan" }; default: return { path: null, reason: `no artifact derivation for provider ${provider ?? "?"}` }; } @@ -5743,6 +5991,9 @@ var REVIEW_HARD_CONSTRAINTS = { }, minimax() { return {}; + }, + grok() { + return { permissionMode: "plan", alwaysApprove: false }; } }; function buildReviewRuntimeOptions({ @@ -6324,6 +6575,21 @@ function buildProviderFlagRuntimeOptions(provider, options) { } return { runtimeOptions, notes }; } + if (provider === "grok") { + if (resumeFlags.length > 1) { + throw new Error("Choose only one of --resume-last, --resume, or --fresh."); + } + if (options["resume-last"]) runtimeOptions.continueLast = true; + if (options.resume) runtimeOptions.resumeSessionId = options.resume; + if (options.fresh) { + notes.push("--fresh is already grok's default for non-resumed -p runs."); + } + if (options.effort) runtimeOptions.effort = options.effort; + if (options.write) { + notes.push("--write is gemini-specific; grok will proceed without it."); + } + return { runtimeOptions, notes }; + } if (options.write) { notes.push(`--write is gemini-specific; ${provider} will proceed without it.`); } diff --git a/plugins/polycli/commands/adversarial-review.md b/plugins/polycli/commands/adversarial-review.md index 0af7f46..5373983 100644 --- a/plugins/polycli/commands/adversarial-review.md +++ b/plugins/polycli/commands/adversarial-review.md @@ -1,6 +1,6 @@ --- description: Run an adversarial provider-backed review on the current diff through polycli -argument-hint: '--provider [--model ] [--background] [--base ] [--scope ] [--max-diff-bytes ] [focus ...]' +argument-hint: '--provider [--model ] [--background] [--base ] [--scope ] [--max-diff-bytes ] [focus ...]' disable-model-invocation: true allowed-tools: Read, Glob, Grep, Bash(node:*), Bash(git:*) --- diff --git a/plugins/polycli/commands/ask.md b/plugins/polycli/commands/ask.md index fb232d9..002bdd7 100644 --- a/plugins/polycli/commands/ask.md +++ b/plugins/polycli/commands/ask.md @@ -1,6 +1,6 @@ --- description: Ask one provider a question through polycli, optionally in the background -argument-hint: '--provider [--model ] [--background] [--resume-last|--resume |--fresh] [--write] [--effort low|medium|high] ' +argument-hint: '--provider [--model ] [--background] [--resume-last|--resume |--fresh] [--write] [--effort low|medium|high] ' disable-model-invocation: true allowed-tools: Bash(node:*) --- diff --git a/plugins/polycli/commands/health.md b/plugins/polycli/commands/health.md index f023ee8..8304c08 100644 --- a/plugins/polycli/commands/health.md +++ b/plugins/polycli/commands/health.md @@ -1,6 +1,6 @@ --- description: Run end-to-end health probes and report healthy polycli providers -argument-hint: '[--provider ] [--model ] [--timeout-ms ]' +argument-hint: '[--provider ] [--model ] [--timeout-ms ]' disable-model-invocation: true allowed-tools: Bash(node:*) --- diff --git a/plugins/polycli/commands/rescue.md b/plugins/polycli/commands/rescue.md index ace188e..c003912 100644 --- a/plugins/polycli/commands/rescue.md +++ b/plugins/polycli/commands/rescue.md @@ -1,6 +1,6 @@ --- description: Run a long provider-backed task through polycli, in foreground or background -argument-hint: '--provider [--model ] [--background] [--resume-last|--resume |--fresh] [--write] [--effort low|medium|high] ' +argument-hint: '--provider [--model ] [--background] [--resume-last|--resume |--fresh] [--write] [--effort low|medium|high] ' disable-model-invocation: true allowed-tools: Bash(node:*) --- diff --git a/plugins/polycli/commands/review.md b/plugins/polycli/commands/review.md index 2409caa..e1a85f3 100644 --- a/plugins/polycli/commands/review.md +++ b/plugins/polycli/commands/review.md @@ -1,6 +1,6 @@ --- description: Run a provider-backed code review on the current diff through polycli -argument-hint: '--provider [--model ] [--background] [--base ] [--scope ] [--max-diff-bytes ] [focus ...]' +argument-hint: '--provider [--model ] [--background] [--base ] [--scope ] [--max-diff-bytes ] [focus ...]' disable-model-invocation: true allowed-tools: Read, Glob, Grep, Bash(node:*), Bash(git:*) --- diff --git a/plugins/polycli/commands/setup.md b/plugins/polycli/commands/setup.md index 2ca22e8..4144a61 100644 --- a/plugins/polycli/commands/setup.md +++ b/plugins/polycli/commands/setup.md @@ -1,6 +1,6 @@ --- description: Check which polycli providers are installed and authenticated -argument-hint: '[--provider ] [--enable-review-gate|--disable-review-gate]' +argument-hint: '[--provider ] [--enable-review-gate|--disable-review-gate]' allowed-tools: Bash(node:*) --- diff --git a/plugins/polycli/commands/timing.md b/plugins/polycli/commands/timing.md index 6fa3fce..1e3afdb 100644 --- a/plugins/polycli/commands/timing.md +++ b/plugins/polycli/commands/timing.md @@ -1,6 +1,6 @@ --- description: Show stored polycli timing history and aggregates for this repository -argument-hint: '[--provider ] [--history ]' +argument-hint: '[--provider ] [--history ]' disable-model-invocation: true allowed-tools: Bash(node:*) --- diff --git a/plugins/polycli/scripts/lib/review.mjs b/plugins/polycli/scripts/lib/review.mjs index d4f1df3..49a87bd 100644 --- a/plugins/polycli/scripts/lib/review.mjs +++ b/plugins/polycli/scripts/lib/review.mjs @@ -153,6 +153,10 @@ const REVIEW_HARD_CONSTRAINTS = { minimax() { return {}; }, + grok() { + // --permission-mode plan is grok's read-only mode and composes with the -p one-shot runner. + return { permissionMode: "plan", alwaysApprove: false }; + }, }; export function buildReviewRuntimeOptions({ diff --git a/plugins/polycli/scripts/lib/sessions.mjs b/plugins/polycli/scripts/lib/sessions.mjs index 177edd5..94f097d 100644 --- a/plugins/polycli/scripts/lib/sessions.mjs +++ b/plugins/polycli/scripts/lib/sessions.mjs @@ -77,6 +77,11 @@ export function deriveSessionArtifactCandidate({ provider, sessionId, workspaceR case "minimax": case "cmd": return { path: null, reason: "ephemeral, no per-session store" }; + case "grok": + // grok stores sessions under ~/.grok/sessions// keyed by a per-session + // file whose exact name is not derivable from the sessionId alone; per the NO-glob rule + // (invariant #6) skip rather than wildcard-scan. + return { path: null, reason: "grok session files live under a url-encoded cwd dir; exact path is not derivable without a scan" }; default: return { path: null, reason: `no artifact derivation for provider ${provider ?? "?"}` }; } diff --git a/plugins/polycli/scripts/polycli-companion.bundle.mjs b/plugins/polycli/scripts/polycli-companion.bundle.mjs index 2e4cd02..17c7100 100755 --- a/plugins/polycli/scripts/polycli-companion.bundle.mjs +++ b/plugins/polycli/scripts/polycli-companion.bundle.mjs @@ -85,7 +85,7 @@ function parseArgs(argv, config = {}) { } // packages/polycli-runtime/src/constants.js -var PROVIDER_IDS = ["gemini", "kimi", "qwen", "minimax", "claude", "copilot", "opencode", "pi", "cmd", "agy"]; +var PROVIDER_IDS = ["gemini", "kimi", "qwen", "minimax", "claude", "copilot", "opencode", "pi", "cmd", "agy", "grok"]; var PROVIDER_OPERATION_NAMES = ["prompt"]; // packages/polycli-utils/src/parse-stream-json.js @@ -3402,6 +3402,226 @@ function runAgyPromptStreaming({ }); } +// packages/polycli-runtime/src/grok.js +var GROK_BIN = process.env.GROK_CLI_BIN || "grok"; +var DEFAULT_TIMEOUT_MS11 = 9e5; +var AUTH_CHECK_TIMEOUT_MS11 = 3e4; +var DEFAULT_GROK_MODEL = "grok-composer-2.5-fast"; +var GROK_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|not logged in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; +var TRANSIENT_PROBE_ERROR_PATTERNS8 = [ + /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i +]; +function buildGrokInvocation({ + prompt, + model = null, + outputFormat = "json", + permissionMode = null, + alwaysApprove = false, + effort = null, + resumeSessionId = null, + continueLast = false, + extraArgs = [], + bin = GROK_BIN +} = {}) { + const args = ["-p", String(prompt ?? ""), "--output-format", outputFormat]; + if (model) args.push("-m", model); + if (effort) args.push("--effort", effort); + if (permissionMode) args.push("--permission-mode", permissionMode); + if (alwaysApprove) args.push("--always-approve"); + if (continueLast) { + args.push("-c"); + } else if (resumeSessionId) { + args.push("-r", resumeSessionId); + } + if (extraArgs.length > 0) args.push(...extraArgs); + return { bin, args }; +} +function extractGrokText(event) { + if (!event || typeof event !== "object") return ""; + if (event.type === "text" && typeof event.data === "string") return event.data; + return ""; +} +function parseGrokStreamText(text) { + const events = []; + let response = ""; + let sessionId = null; + let stopReason = null; + for (const rawLine of String(text ?? "").split(/\r?\n/)) { + const trimmed = rawLine.trim(); + if (!trimmed.startsWith("{")) continue; + let event; + try { + event = JSON.parse(trimmed); + } catch { + continue; + } + events.push(event); + if (event.type === "text" && typeof event.data === "string") { + response += event.data; + } else if (event.type === "end") { + if (typeof event.sessionId === "string") sessionId = event.sessionId; + stopReason = event.stopReason ?? stopReason; + } + } + return { events, response, sessionId, stopReason }; +} +function parseGrokJsonResult(stdout, stderr, status, { defaultModel = null } = {}) { + const text = String(stdout ?? ""); + const jsonStart = text.indexOf("{"); + if (jsonStart < 0 || status !== 0) { + return { + ok: false, + error: String(stderr ?? "").trim() || formatProviderExitError("grok", status), + status + }; + } + try { + const parsed = JSON.parse(text.slice(jsonStart)); + const response = typeof parsed.text === "string" ? parsed.text : ""; + const hasVisibleText = Boolean(response.trim()); + return { + ok: hasVisibleText, + response, + // grok emits the session id structurally; never scan prose for a UUID. + sessionId: typeof parsed.sessionId === "string" ? parsed.sessionId : null, + model: defaultModel ?? DEFAULT_GROK_MODEL, + stopReason: parsed.stopReason ?? null, + error: hasVisibleText ? null : "grok produced no visible text", + status + }; + } catch (error) { + return { ok: false, error: `JSON parse failed: ${error.message}`, status }; + } +} +function getGrokAvailability(cwd) { + return binaryAvailable(GROK_BIN, ["--version"], { cwd }); +} +function buildGrokAuthStatus(result) { + if (result.error) { + const detail = result.error.code === "ETIMEDOUT" ? `grok auth probe timed out after ${Math.round(AUTH_CHECK_TIMEOUT_MS11 / 1e3)}s` : result.error.message; + if (TRANSIENT_PROBE_ERROR_PATTERNS8.some((pattern) => pattern.test(detail))) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: null }; + } + return { loggedIn: false, detail }; + } + const text = `${result.stdout ?? ""} +${result.stderr ?? ""}`; + const defaultModel = (text.match(/Default model:\s*(\S+)/) || [])[1] ?? null; + if (GROK_EXPLICIT_AUTH_ERROR_RE.test(text)) { + return { loggedIn: false, detail: text.trim() || "grok is not logged in" }; + } + if (/\blogged in\b/i.test(text)) { + return { loggedIn: true, detail: "authenticated", model: defaultModel }; + } + if (result.status !== 0) { + const detail = text.trim() || `grok models exited with code ${result.status}`; + if (TRANSIENT_PROBE_ERROR_PATTERNS8.some((pattern) => pattern.test(detail))) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: defaultModel }; + } + return { loggedIn: false, detail }; + } + return { loggedIn: true, detail: "authenticated", model: defaultModel }; +} +function getGrokAuthStatus(cwd, { runner = runCommand } = {}) { + const result = runner(GROK_BIN, ["models"], { cwd, timeout: AUTH_CHECK_TIMEOUT_MS11 }); + return buildGrokAuthStatus(result); +} +function runGrokPrompt({ + prompt, + model = null, + cwd, + timeout = DEFAULT_TIMEOUT_MS11, + alwaysApprove = true, + permissionMode = null, + effort = null, + resumeSessionId = null, + continueLast = false, + extraArgs = [], + defaultModel = null, + bin = GROK_BIN +} = {}) { + const invocation = buildGrokInvocation({ + prompt, + model, + outputFormat: "json", + permissionMode, + alwaysApprove, + effort, + resumeSessionId, + continueLast, + extraArgs, + bin + }); + const result = runCommand(invocation.bin, invocation.args, { cwd, timeout }); + if (result.error) { + const error = result.error.code === "ETIMEDOUT" ? `grok timed out after ${Math.round(timeout / 1e3)}s` : result.error.message; + return { ok: false, error, errorCode: classifyProviderFailure(error, { provider: "grok" }) }; + } + const parsed = parseGrokJsonResult(result.stdout, result.stderr, result.status, { + defaultModel: model ?? defaultModel + }); + return { ...parsed, errorCode: classifyProviderFailure(parsed.error, { provider: "grok" }) }; +} +function runGrokPromptStreaming({ + prompt, + model = null, + cwd, + timeout = DEFAULT_TIMEOUT_MS11, + alwaysApprove = true, + permissionMode = null, + effort = null, + resumeSessionId = null, + continueLast = false, + extraArgs = [], + defaultModel = null, + onEvent = () => { + }, + bin = GROK_BIN, + spawnImpl +} = {}) { + const invocation = buildGrokInvocation({ + prompt, + model, + outputFormat: "streaming-json", + permissionMode, + alwaysApprove, + effort, + resumeSessionId, + continueLast, + extraArgs, + bin + }); + return spawnStreamingCommand({ + bin: invocation.bin, + args: invocation.args, + cwd, + env: { ...process.env }, + timeout, + spawnImpl, + onStdoutLine(line) { + const trimmed = line.trim(); + if (!trimmed.startsWith("{")) return; + try { + onEvent(JSON.parse(trimmed)); + } catch { + } + } + }).then((result) => { + const parsed = parseGrokStreamText(result.stdout); + const hasVisibleText = Boolean(parsed.response.trim()); + const ok = result.ok && hasVisibleText; + const error = ok ? null : result.ok ? "grok produced no visible text" : result.error; + return { + ...result, + ...parsed, + model: model ?? defaultModel ?? DEFAULT_GROK_MODEL, + ok, + error, + errorCode: classifyProviderFailure(error, { provider: "grok" }) + }; + }); +} + // packages/polycli-runtime/src/registry.js import { performance } from "node:perf_hooks"; @@ -3694,6 +3914,7 @@ function extractProviderEventText(provider, event) { if (provider === "pi") return extractPiText(event); if (provider === "cmd") return extractCmdText(event); if (provider === "agy") return extractAgyText(event); + if (provider === "grok") return extractGrokText(event); return ""; } function buildPromptTimingRecord({ @@ -3805,7 +4026,8 @@ var TIMING_SUPPORT = { opencode: { ttft: true, gen: true, tail: true, tool: false, runtimePersistence: "session" }, pi: { ttft: true, gen: true, tail: true, tool: false, runtimePersistence: "session" }, cmd: { ttft: true, gen: true, tail: true, tool: false, runtimePersistence: "ephemeral" }, - agy: { ttft: true, gen: true, tail: true, tool: false, runtimePersistence: "session" } + agy: { ttft: true, gen: true, tail: true, tool: false, runtimePersistence: "session" }, + grok: { ttft: true, gen: true, tail: true, tool: false, runtimePersistence: "session" } }; var RUNTIMES = Object.freeze({ claude: { @@ -3937,6 +4159,19 @@ var RUNTIMES = Object.freeze({ getAuthStatus: getAgyAuthStatus, runPrompt: runAgyPrompt, runPromptStreaming: runAgyPromptStreaming + }, + grok: { + id: "grok", + capabilities: { + streaming: true, + sessionResume: true, + structuredOutput: true, + operations: PROVIDER_OPERATION_NAMES + }, + getAvailability: getGrokAvailability, + getAuthStatus: getGrokAuthStatus, + runPrompt: runGrokPrompt, + runPromptStreaming: runGrokPromptStreaming } }); for (const runtime of Object.values(RUNTIMES)) { @@ -3999,6 +4234,9 @@ function isTerminalSummaryEvent(provider, event) { if (provider === "pi") { return event.type === "agent_end"; } + if (provider === "grok") { + return event.type === "end"; + } return false; } function shouldCountEventTextForTiming(provider, event, firstTextAt) { @@ -4151,6 +4389,14 @@ var REVIEW_FLAG_EXPECTATIONS = Object.freeze({ Object.freeze({ helpArgs: Object.freeze(["text", "chat", "--help"]), expect: Object.freeze(["--message"]) }), Object.freeze({ helpArgs: Object.freeze(["--help"]), expect: Object.freeze(["--output", "--non-interactive"]) }) ]) + }), + grok: Object.freeze({ + // grok review enforces read-only via the --permission-mode plan runtimeOption (composes with + // the -p one-shot mode, verified). It carries no review extraArgs of its own. + expectFlags: Object.freeze(["--permission-mode"]), + extraArgTokens: Object.freeze([]), + readOnlyOptionKey: "permissionMode", + readOnlyValue: "plan" }) }); @@ -5056,6 +5302,8 @@ function deriveSessionArtifactCandidate({ provider, sessionId, workspaceRoot, ho case "minimax": case "cmd": return { path: null, reason: "ephemeral, no per-session store" }; + case "grok": + return { path: null, reason: "grok session files live under a url-encoded cwd dir; exact path is not derivable without a scan" }; default: return { path: null, reason: `no artifact derivation for provider ${provider ?? "?"}` }; } @@ -5743,6 +5991,9 @@ var REVIEW_HARD_CONSTRAINTS = { }, minimax() { return {}; + }, + grok() { + return { permissionMode: "plan", alwaysApprove: false }; } }; function buildReviewRuntimeOptions({ @@ -6324,6 +6575,21 @@ function buildProviderFlagRuntimeOptions(provider, options) { } return { runtimeOptions, notes }; } + if (provider === "grok") { + if (resumeFlags.length > 1) { + throw new Error("Choose only one of --resume-last, --resume, or --fresh."); + } + if (options["resume-last"]) runtimeOptions.continueLast = true; + if (options.resume) runtimeOptions.resumeSessionId = options.resume; + if (options.fresh) { + notes.push("--fresh is already grok's default for non-resumed -p runs."); + } + if (options.effort) runtimeOptions.effort = options.effort; + if (options.write) { + notes.push("--write is gemini-specific; grok will proceed without it."); + } + return { runtimeOptions, notes }; + } if (options.write) { notes.push(`--write is gemini-specific; ${provider} will proceed without it.`); } diff --git a/plugins/polycli/scripts/polycli-companion.mjs b/plugins/polycli/scripts/polycli-companion.mjs index d039d81..1d22f41 100644 --- a/plugins/polycli/scripts/polycli-companion.mjs +++ b/plugins/polycli/scripts/polycli-companion.mjs @@ -385,6 +385,22 @@ function buildProviderFlagRuntimeOptions(provider, options) { return { runtimeOptions, notes }; } + if (provider === "grok") { + if (resumeFlags.length > 1) { + throw new Error("Choose only one of --resume-last, --resume, or --fresh."); + } + if (options["resume-last"]) runtimeOptions.continueLast = true; + if (options.resume) runtimeOptions.resumeSessionId = options.resume; + if (options.fresh) { + notes.push("--fresh is already grok's default for non-resumed -p runs."); + } + if (options.effort) runtimeOptions.effort = options.effort; + if (options.write) { + notes.push("--write is gemini-specific; grok will proceed without it."); + } + return { runtimeOptions, notes }; + } + if (options.write) { notes.push(`--write is gemini-specific; ${provider} will proceed without it.`); } diff --git a/plugins/polycli/scripts/tests/integration.test.mjs b/plugins/polycli/scripts/tests/integration.test.mjs index e44f341..f59439e 100644 --- a/plugins/polycli/scripts/tests/integration.test.mjs +++ b/plugins/polycli/scripts/tests/integration.test.mjs @@ -873,6 +873,7 @@ test("integration: health without provider returns every healthy provider", asyn CMD_CLI_BIN: missingBin, COPILOT_CLI_BIN: missingBin, GEMINI_CLI_BIN: missingBin, + GROK_CLI_BIN: missingBin, KIMI_CLI_BIN: fakeKimi.bin, MMX_CLI_BIN: missingBin, OPENCODE_CLI_BIN: missingBin, @@ -891,8 +892,8 @@ test("integration: health without provider returns every healthy provider", asyn assert.equal(payload.anyHealthy, true); assert.equal(payload.allHealthy, false); assert.deepEqual(payload.healthyProviders, ["qwen"]); - assert.deepEqual(payload.unhealthyProviders.sort(), ["agy", "claude", "cmd", "copilot", "gemini", "kimi", "minimax", "opencode", "pi"].sort()); - assert.equal(payload.results.length, 10); + assert.deepEqual(payload.unhealthyProviders.sort(), ["agy", "claude", "cmd", "copilot", "gemini", "grok", "kimi", "minimax", "opencode", "pi"].sort()); + assert.equal(payload.results.length, 11); assert.equal(payload.results.find((result) => result.provider === "qwen").ok, true); assert.equal(payload.results.find((result) => result.provider === "kimi").ok, false); assert.equal(payload.results.find((result) => result.provider === "kimi").probe.responseMatched, false); @@ -917,6 +918,7 @@ test("integration: health without provider probes providers concurrently", async CMD_CLI_BIN: missingBin, COPILOT_CLI_BIN: missingBin, GEMINI_CLI_BIN: missingBin, + GROK_CLI_BIN: missingBin, KIMI_CLI_BIN: fakeKimi.bin, MMX_CLI_BIN: missingBin, OPENCODE_CLI_BIN: missingBin, diff --git a/plugins/polycli/scripts/tests/review-flags-consistency.test.mjs b/plugins/polycli/scripts/tests/review-flags-consistency.test.mjs index 78d0d75..33a5732 100644 --- a/plugins/polycli/scripts/tests/review-flags-consistency.test.mjs +++ b/plugins/polycli/scripts/tests/review-flags-consistency.test.mjs @@ -7,7 +7,7 @@ import { buildReviewRuntimeOptions } from "../lib/review.mjs"; // Providers whose review hard constraints run through buildReviewRuntimeOptions. // (agy is review-unsupported → buildReviewRuntimeOptions throws, so it is excluded.) -const EXACT_MATCH_PROVIDERS = ["claude", "gemini", "qwen", "copilot", "opencode", "pi", "cmd", "kimi", "minimax"]; +const EXACT_MATCH_PROVIDERS = ["claude", "gemini", "qwen", "copilot", "opencode", "pi", "cmd", "kimi", "minimax", "grok"]; // The EXACT set of `--`-prefixed flag tokens review.mjs actually emits as extraArgs. function emittedFlagTokens(provider) { @@ -45,8 +45,8 @@ test("the exact-match check goes RED when a declared token is added OR removed, }); test("assertNoReviewConstraintOverride rejects a bad value on the declared readOnlyOptionKey", () => { - // plan-valued guards (claude/gemini/qwen): a non-plan value is rejected. - for (const provider of ["claude", "gemini", "qwen"]) { + // plan-valued guards (claude/gemini/qwen/grok): a non-plan value is rejected. + for (const provider of ["claude", "gemini", "qwen", "grok"]) { const key = REVIEW_FLAG_EXPECTATIONS[provider].readOnlyOptionKey; assert.throws( () => buildReviewRuntimeOptions({ provider, runtimeOptions: { [key]: "yolo" } }), diff --git a/plugins/polycli/skills/grok-cli-runtime/SKILL.md b/plugins/polycli/skills/grok-cli-runtime/SKILL.md new file mode 100644 index 0000000..391e38e --- /dev/null +++ b/plugins/polycli/skills/grok-cli-runtime/SKILL.md @@ -0,0 +1,32 @@ +--- +name: grok-cli-runtime +description: Internal helper contract for calling the polycli companion runtime for Grok (xAI Grok Build CLI) from Claude Code +--- + +# grok-cli-runtime + +polycli wraps the local **Grok Build** CLI (`grok`, xAI; v0.2.x verified). Call it through the +polycli companion (`/polycli:ask|rescue|review --provider grok`), never by spawning `grok` directly. +The runtime contract (`packages/polycli-runtime/src/grok.js`) is authoritative; this is a summary. + +## Invocation shape +- One-shot, non-interactive: `grok -p --output-format `. `-p` prints + the answer and exits. Unlike kimi-code, `-p` composes with the flags below (verified). +- `--output-format json` → a single object `{text, stopReason, sessionId, requestId, thought}` + (used for `ask`/`rescue`/`review`). `--output-format streaming-json` → line events + `{type:"thought",data}` (reasoning) / `{type:"text",data}` (answer) / `{type:"end",stopReason,sessionId,requestId}`. +- Model: `-m `. Available: `grok-composer-2.5-fast` (default; "Compose 2.5") and `grok-build`. +- Effort: grok accepts `--effort low|medium|high|xhigh|max` natively; polycli's `--effort` is gemini-only and is **not** forwarded to grok. +- **YOLO** (ask/rescue): `--always-approve`. **Review** read-only: `--permission-mode plan`. +- Resume: `--resume ` / `-r ` (resume a session), `-c` / `--continue` (last for cwd). + +## Gotchas +- **stderr noise on success**: grok prints transient `ERROR worker quit ... UnexpectedContentType` + lines to stderr even on a successful run. Success is judged ONLY by exit 0 + a valid stdout JSON + envelope with visible text — stderr content is never treated as failure. +- **session id is structured** (json `sessionId`, streaming `end.sessionId`, a UUIDv7). Never scan + prose for a UUID. +- **Auth** is inferred from `grok models` (prints "You are logged in with grok.com." + the default + model) — zero LLM/token cost. `grok login` / `grok logout` manage credentials. +- **Session purge**: grok sessions live under `~/.grok/sessions//`; the exact + per-session filename is not derivable from the id alone, so `polycli sessions purge` honest-skips grok. diff --git a/plugins/polycli/skills/grok-prompting/SKILL.md b/plugins/polycli/skills/grok-prompting/SKILL.md new file mode 100644 index 0000000..445bbad --- /dev/null +++ b/plugins/polycli/skills/grok-prompting/SKILL.md @@ -0,0 +1,26 @@ +--- +name: grok-prompting +description: Internal guidance for composing Grok (xAI) prompts for coding, review, diagnosis, and research tasks inside the polycli plugin +--- + +# grok-prompting + +Guidance for prompts sent to the Grok Build CLI via `/polycli:ask|rescue|review --provider grok`. + +## When to reach for grok +- A fast, capable second opinion on coding/review tasks; `grok-composer-2.5-fast` (Compose 2.5) is + the default and is tuned for code. Use `-m grok-build` for the heavier build-agent model. +- Reasoning-heavy diagnosis: pass `--effort high` (or `xhigh`/`max`) so grok spends more reasoning. + +## Prompt shape +- grok one-shot mode (`-p`) returns a single visible answer — write self-contained prompts; it does + not carry conversation unless you `--resume ` / `--continue`. +- For `/review`, polycli already forces `--permission-mode plan` (read-only) and the review prompt + forbids tools/edits — keep the focus terse (the diff is supplied for you). +- grok emits a `thought` (reasoning) channel separate from the answer `text`; ask for a concise final + answer so the visible `text` is self-sufficient, not only reasoning. + +## Avoid +- Don't ask grok to run long multi-step tool loops in `ask` — `-p` is a one-shot print mode; for + agentic work prefer a provider with a persistent session, or expect a single pass. +- Don't paste secrets; polycli redacts argv in the run ledger but the prompt body is sent verbatim. diff --git a/plugins/polycli/skills/grok-result-handling/SKILL.md b/plugins/polycli/skills/grok-result-handling/SKILL.md new file mode 100644 index 0000000..303b718 --- /dev/null +++ b/plugins/polycli/skills/grok-result-handling/SKILL.md @@ -0,0 +1,26 @@ +--- +name: grok-result-handling +description: Internal guidance for presenting Grok (xAI) helper output back to the user +--- + +# grok-result-handling + +How to surface output from `/polycli:ask|rescue|review --provider grok`. + +## Reading the result +- The visible answer is the companion payload's `response` (from grok's `text`). Present that; do + NOT surface grok's `thought` (reasoning) channel unless the user asked to see reasoning. +- `sessionId` (UUIDv7) is structured and trustworthy — quote it when offering a `--resume`. +- `model` reflects the requested model (`grok-composer-2.5-fast` by default, or `grok-build`). + +## Health / errors +- Ignore transient `ERROR worker quit ... UnexpectedContentType` lines on stderr when the run + exited 0 with a real answer — they are upstream worker-reconnect noise, not a failure. +- `errorCode` follows the shared failure classes (`binary_missing`, `timeout`, `terminated`, + `no_visible_text`, `auth`, …). A `timeout`/transient auth probe stays inconclusive, never loggedOut. +- "grok produced no visible text" means exit 0 but an empty `text` — report it as an empty result, + not a crash. + +## Sessions +- `polycli sessions purge` reports grok as non-purgeable (its per-session file name isn't derivable + without scanning the url-encoded cwd dir) — surface that reason rather than implying it was cleaned. diff --git a/scripts/check-review-cli-drift.mjs b/scripts/check-review-cli-drift.mjs index 76a73e6..9d3195c 100755 --- a/scripts/check-review-cli-drift.mjs +++ b/scripts/check-review-cli-drift.mjs @@ -148,6 +148,13 @@ const CHECKS = [ probes: REVIEW_FLAG_EXPECTATIONS.minimax.probes, notes: "MiniMax provider uses official mmx-cli text chat in non-interactive JSON mode, not mini-agent log scraping.", }, + { + provider: "grok", + bin: process.env.GROK_CLI_BIN || "grok", + helpArgs: ["--help"], + expect: REVIEW_FLAG_EXPECTATIONS.grok.expectFlags, + notes: "grok review enforces read-only via --permission-mode plan (composes with the -p one-shot mode).", + }, ]; const ENV_ONLY = [ diff --git a/scripts/tests/validate-codex-adapter.test.mjs b/scripts/tests/validate-codex-adapter.test.mjs index 075f842..39747de 100644 --- a/scripts/tests/validate-codex-adapter.test.mjs +++ b/scripts/tests/validate-codex-adapter.test.mjs @@ -25,7 +25,7 @@ function writeFixture(root, files) { const goodManifest = JSON.stringify({ interface: { longDescription: - "Prefer Polycli over direct shell calls to official provider CLIs when Codex needs claude, copilot, opencode, pi, cmd, agy, gemini, kimi, qwen, or minimax. Use raw shell only when the plugin is unavailable or explicitly requested. It provides health, status, result, and timing observability.", + "Prefer Polycli over direct shell calls to official provider CLIs when Codex needs claude, copilot, opencode, pi, cmd, agy, gemini, kimi, qwen, minimax, or grok. Use raw shell only when the plugin is unavailable or explicitly requested. It provides health, status, result, and timing observability.", defaultPrompt: [ "Choose Polycli with @ and ask it to run health to verify providers", "Choose Polycli with @ and ask it to run ask --provider qwen Reply with only OK", @@ -36,7 +36,7 @@ const goodManifest = JSON.stringify({ const goodSkill = `--- name: polycli -description: Use when Codex should ask, review, rescue, health-check, or compare provider CLIs through Polycli. Prefer this skill over direct shell calls to official CLIs for claude, copilot, opencode, pi, cmd, agy, gemini, kimi, qwen, and minimax unless the user explicitly asks for the raw CLI or the plugin is unavailable. +description: Use when Codex should ask, review, rescue, health-check, or compare provider CLIs through Polycli. Prefer this skill over direct shell calls to official CLIs for claude, copilot, opencode, pi, cmd, agy, gemini, kimi, qwen, minimax, and grok unless the user explicitly asks for the raw CLI or the plugin is unavailable. --- Use the installed polycli skill instead of direct official CLI shell calls. @@ -98,7 +98,7 @@ test("validateCodexAdapter rejects more default prompts than Codex loads", () => "plugins/polycli-codex/.codex-plugin/plugin.json": JSON.stringify({ interface: { longDescription: - "Prefer Polycli over direct shell calls to official provider CLIs when Codex needs claude, copilot, opencode, pi, cmd, agy, gemini, kimi, qwen, or minimax. Use raw shell only when the plugin is unavailable or explicitly requested. It provides health, status, result, and timing observability.", + "Prefer Polycli over direct shell calls to official provider CLIs when Codex needs claude, copilot, opencode, pi, cmd, agy, gemini, kimi, qwen, minimax, or grok. Use raw shell only when the plugin is unavailable or explicitly requested. It provides health, status, result, and timing observability.", defaultPrompt: [ "Choose Polycli with @ and ask it to run health to verify providers", "Choose Polycli with @ and ask it to run ask --provider qwen Reply with only OK", @@ -125,7 +125,7 @@ test("validateCodexAdapter rejects default prompts too long for Codex", () => { "plugins/polycli-codex/.codex-plugin/plugin.json": JSON.stringify({ interface: { longDescription: - "Prefer Polycli over direct shell calls to official provider CLIs when Codex needs claude, copilot, opencode, pi, cmd, agy, gemini, kimi, qwen, or minimax. Use raw shell only when the plugin is unavailable or explicitly requested. It provides health, status, result, and timing observability.", + "Prefer Polycli over direct shell calls to official provider CLIs when Codex needs claude, copilot, opencode, pi, cmd, agy, gemini, kimi, qwen, minimax, or grok. Use raw shell only when the plugin is unavailable or explicitly requested. It provides health, status, result, and timing observability.", defaultPrompt: [ "Choose Polycli with @ and ask it to run health to verify providers", "Choose Polycli with @ and ask it to run ask --provider qwen Reply with only OK", @@ -152,7 +152,7 @@ test("validateCodexAdapter rejects weak Codex guidance that lets raw CLIs stay t "plugins/polycli-codex/.codex-plugin/plugin.json": JSON.stringify({ interface: { longDescription: - "Prefer Polycli over direct shell calls to official provider CLIs when Codex needs claude, copilot, opencode, pi, cmd, agy, gemini, kimi, qwen, or minimax. Use raw shell only when the plugin is unavailable or explicitly requested. It provides health, status, result, and timing observability.", + "Prefer Polycli over direct shell calls to official provider CLIs when Codex needs claude, copilot, opencode, pi, cmd, agy, gemini, kimi, qwen, minimax, or grok. Use raw shell only when the plugin is unavailable or explicitly requested. It provides health, status, result, and timing observability.", defaultPrompt: [ "Choose Polycli with @ and ask it to run health to verify providers", "Choose Polycli with @ and ask it to run ask --provider qwen Reply with only OK", diff --git a/scripts/validate-codex-adapter.mjs b/scripts/validate-codex-adapter.mjs index 087f117..4be2f27 100644 --- a/scripts/validate-codex-adapter.mjs +++ b/scripts/validate-codex-adapter.mjs @@ -11,7 +11,7 @@ const CODEX_SKILL = "plugins/polycli-codex/skills/polycli/SKILL.md"; const CODEX_README = "plugins/polycli-codex/README.md"; const ROOT_README = "README.md"; const HOST_COMMAND_MAP = "docs/host-command-map.md"; -const PROVIDERS = ["claude", "copilot", "opencode", "pi", "cmd", "agy", "gemini", "kimi", "qwen", "minimax"]; +const PROVIDERS = ["claude", "copilot", "opencode", "pi", "cmd", "agy", "gemini", "kimi", "qwen", "minimax", "grok"]; const OBSERVABILITY_COMMANDS = ["health", "status", "result", "timing"]; const DAILY_COMMANDS = ["health", "ask", "review", "timing"]; const INVALID_CODEX_SLASH = /\/polycli-codex:polycli\b/;