Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,6 @@ dist/

# Claude Code worktree state (agent isolation runs)
.claude/

# CodeGraph local index (not committed)
.codegraph/
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/host-command-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion packages/polycli-runtime/src/constants.js
Original file line number Diff line number Diff line change
@@ -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"];
255 changes: 255 additions & 0 deletions packages/polycli-runtime/src/grok.js
Original file line number Diff line number Diff line change
@@ -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 <model>` 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 <prompt>` 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: <m>" 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" }),
};
});
}
1 change: 1 addition & 0 deletions packages/polycli-runtime/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
23 changes: 23 additions & 0 deletions packages/polycli-runtime/src/registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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({
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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;
}

Expand Down
8 changes: 8 additions & 0 deletions packages/polycli-runtime/src/review-flags.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}),
});
2 changes: 2 additions & 0 deletions packages/polycli-runtime/src/timing.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);

Expand Down Expand Up @@ -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 "";
}

Expand Down
Loading
Loading