inspectrum
Advanced tools
| export declare const HOOK_STDIN_MAX_BYTES: number; | ||
| /** Read hook stdin without ever buffering more than the accepted cap plus one byte. */ | ||
| export declare function readHookStdin(fd?: number): string; |
| import { readSync } from "node:fs"; | ||
| export const HOOK_STDIN_MAX_BYTES = 128 * 1024; | ||
| const READ_CHUNK_BYTES = 16 * 1024; | ||
| /** Read hook stdin without ever buffering more than the accepted cap plus one byte. */ | ||
| export function readHookStdin(fd = 0) { | ||
| const chunks = []; | ||
| let total = 0; | ||
| while (true) { | ||
| const remaining = HOOK_STDIN_MAX_BYTES - total; | ||
| const chunk = Buffer.allocUnsafe(Math.min(READ_CHUNK_BYTES, remaining + 1)); | ||
| const bytesRead = readSync(fd, chunk, 0, chunk.length, null); | ||
| if (bytesRead === 0) | ||
| break; | ||
| total += bytesRead; | ||
| if (total > HOOK_STDIN_MAX_BYTES) { | ||
| throw new Error(`hook stdin exceeds ${HOOK_STDIN_MAX_BYTES} byte limit`); | ||
| } | ||
| chunks.push(chunk.subarray(0, bytesRead)); | ||
| } | ||
| return Buffer.concat(chunks, total).toString("utf8"); | ||
| } |
+2
-2
@@ -35,6 +35,6 @@ #!/usr/bin/env node | ||
| try { | ||
| const { readFileSync } = await import("node:fs"); | ||
| const { loadConfig } = await import("./config.js"); | ||
| const { runPlanGate } = await import("./hook/plan-gate.js"); | ||
| out = await runPlanGate(readFileSync(0, "utf8"), loadConfig()); | ||
| const { readHookStdin } = await import("./hook/stdin.js"); | ||
| out = await runPlanGate(readHookStdin(), loadConfig()); | ||
| } | ||
@@ -41,0 +41,0 @@ catch (err) { |
+18
-7
@@ -5,3 +5,3 @@ import * as fs from "node:fs"; | ||
| import TOML from "@iarna/toml"; | ||
| import { checkReviewer } from "./reviewers/health.js"; | ||
| import { checkClaudePlugin, checkReviewer } from "./reviewers/health.js"; | ||
| import { resolveReviewerBackend } from "./reviewers/common.js"; | ||
@@ -121,10 +121,11 @@ import { loadConfig, getConfigPath, defaultConfig } from "./config.js"; | ||
| const result = await checkReviewer(id, reviewerConfig); | ||
| const label = `${id}${result.version ? ` ${result.version}` : ""}`; | ||
| if (result.ok) { | ||
| if (result.warning) | ||
| warn(`${id} — ${result.warning}`); | ||
| warn(`${label} — ${result.warning}`); | ||
| else | ||
| pass(id); | ||
| pass(label); | ||
| } | ||
| else { | ||
| failMsg(`${id}${result.reason ? ` — ${result.reason}` : ""}`); | ||
| failMsg(`${label}${result.reason ? ` — ${result.reason}` : ""}`); | ||
| if (result.fix) | ||
@@ -144,11 +145,12 @@ hint(`Fix: ${result.fix}`); | ||
| const result = await checkReviewer(id, reviewerConfig); | ||
| const label = `${id}${result.version ? ` ${result.version}` : ""}`; | ||
| if (result.ok) { | ||
| if (result.warning) | ||
| warn(`${id} — ${result.warning}`); | ||
| warn(`${label} — ${result.warning}`); | ||
| else | ||
| pass(id); | ||
| pass(label); | ||
| } | ||
| else { | ||
| // Optional: print status but do NOT toggle allOk. | ||
| process.stdout.write(` ${R}○${Z} ${id}${result.reason ? ` — ${result.reason}` : ""}\n`); | ||
| process.stdout.write(` ${R}○${Z} ${label}${result.reason ? ` — ${result.reason}` : ""}\n`); | ||
| if (result.fix) | ||
@@ -160,2 +162,11 @@ hint(`Fix: ${result.fix}`); | ||
| } | ||
| section("Claude Code plugin (optional for MCP-only users)"); | ||
| const plugin = checkClaudePlugin(); | ||
| const pluginLabel = `inspectrum@inspectrum${plugin.version ? ` ${plugin.version}` : ""}`; | ||
| if (plugin.warning || !plugin.ok) | ||
| warn(`${pluginLabel}${plugin.warning ? ` — ${plugin.warning}` : ""}`); | ||
| else | ||
| pass(pluginLabel); | ||
| if (plugin.fix) | ||
| hint(`Fix: ${plugin.fix}`); | ||
| // Summary | ||
@@ -162,0 +173,0 @@ process.stdout.write("\n"); |
@@ -8,2 +8,4 @@ import { reviewPlan } from "../tool/review-plan.js"; | ||
| deadlineMs?: number; | ||
| plansDir?: string; | ||
| planFileUid?: number; | ||
| } | ||
@@ -10,0 +12,0 @@ /** |
@@ -1,2 +0,3 @@ | ||
| import { readFileSync } from "node:fs"; | ||
| import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync, } from "node:fs"; | ||
| import { extname, isAbsolute, relative, resolve, sep } from "node:path"; | ||
| import { reviewPlan } from "../tool/review-plan.js"; | ||
@@ -35,3 +36,3 @@ import { truncatePlan } from "../reviewers/common.js"; | ||
| if (!plan && input.tool_input?.planFilePath) { | ||
| plan = readFileSync(input.tool_input.planFilePath, "utf8").slice(0, PLAN_FILE_MAX_BYTES); | ||
| plan = readPlanFile(input.tool_input.planFilePath, deps); | ||
| } | ||
@@ -89,2 +90,60 @@ if (!plan.trim()) | ||
| } | ||
| function readPlanFile(planFilePath, deps) { | ||
| const plansDir = resolve(deps.plansDir ?? defaultPlansDir()); | ||
| const candidate = resolve(planFilePath); | ||
| assertContained(plansDir, candidate); | ||
| if (extname(candidate).toLowerCase() !== ".md") | ||
| throw new Error("plan file must have a .md extension"); | ||
| const before = lstatSync(candidate); | ||
| if (before.isSymbolicLink() || !before.isFile()) | ||
| throw new Error("plan path is not a regular file"); | ||
| if (before.size > PLAN_FILE_MAX_BYTES) | ||
| throw new Error(`plan file exceeds ${PLAN_FILE_MAX_BYTES} byte limit`); | ||
| const realPlansDir = realpathSync(plansDir); | ||
| const realCandidate = realpathSync(candidate); | ||
| assertContained(realPlansDir, realCandidate); | ||
| const fd = openSync(candidate, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW); | ||
| try { | ||
| const opened = fstatSync(fd); | ||
| if (!opened.isFile()) | ||
| throw new Error("plan path is not a regular file"); | ||
| if (opened.dev !== before.dev || opened.ino !== before.ino) | ||
| throw new Error("plan file changed while opening"); | ||
| if (opened.size > PLAN_FILE_MAX_BYTES) | ||
| throw new Error(`plan file exceeds ${PLAN_FILE_MAX_BYTES} byte limit`); | ||
| const expectedUid = deps.planFileUid ?? process.getuid?.(); | ||
| if (expectedUid !== undefined && opened.uid !== expectedUid) | ||
| throw new Error("plan file has an unexpected owner"); | ||
| const chunks = []; | ||
| let total = 0; | ||
| while (true) { | ||
| const remaining = PLAN_FILE_MAX_BYTES - total; | ||
| const chunk = Buffer.allocUnsafe(Math.min(16 * 1024, remaining + 1)); | ||
| const bytesRead = readSync(fd, chunk, 0, chunk.length, null); | ||
| if (bytesRead === 0) | ||
| break; | ||
| total += bytesRead; | ||
| if (total > PLAN_FILE_MAX_BYTES) | ||
| throw new Error(`plan file exceeds ${PLAN_FILE_MAX_BYTES} byte limit`); | ||
| chunks.push(chunk.subarray(0, bytesRead)); | ||
| } | ||
| return Buffer.concat(chunks, total).toString("utf8"); | ||
| } | ||
| finally { | ||
| closeSync(fd); | ||
| } | ||
| } | ||
| function defaultPlansDir() { | ||
| if (process.env["CLAUDE_CONFIG_DIR"]) | ||
| return resolve(process.env["CLAUDE_CONFIG_DIR"], "plans"); | ||
| if (!process.env["HOME"]) | ||
| throw new Error("HOME is not set"); | ||
| return resolve(process.env["HOME"], ".claude", "plans"); | ||
| } | ||
| function assertContained(parent, child) { | ||
| const rel = relative(parent, child); | ||
| if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { | ||
| throw new Error("plan file is outside the Claude plans directory"); | ||
| } | ||
| } | ||
| function allow(systemMessage) { | ||
@@ -91,0 +150,0 @@ return serialize({ systemMessage }); |
@@ -1,2 +0,2 @@ | ||
| import type { ReviewerConfig } from "../schemas.js"; | ||
| import { type ReviewerConfig } from "../schemas.js"; | ||
| export interface HealthResult { | ||
@@ -8,3 +8,15 @@ ok: boolean; | ||
| warning?: string; | ||
| version?: string; | ||
| } | ||
| export declare const MIN_CODEX_VERSION = "0.99.0"; | ||
| export declare function checkReviewer(id: string, config: ReviewerConfig): Promise<HealthResult>; | ||
| interface ParsedCodexVersion { | ||
| version: string; | ||
| major: number; | ||
| minor: number; | ||
| patch: number; | ||
| prerelease?: string; | ||
| } | ||
| export declare function parseCodexVersion(output: string): ParsedCodexVersion | undefined; | ||
| export declare function checkClaudePlugin(binary?: string): HealthResult; | ||
| export {}; |
+113
-8
| import { execFileSync, spawnSync } from "node:child_process"; | ||
| import { ClaudePluginListSchema } from "../schemas.js"; | ||
| import { resolveReviewerBackend } from "./common.js"; | ||
| export const MIN_CODEX_VERSION = "0.99.0"; | ||
| // Env vars each CLI backend will look for when actually invoked. Entries are | ||
@@ -21,3 +24,12 @@ // alternatives — if any one is set to a truthy value, auth is presumed present. | ||
| } | ||
| return checkCli(id, config.binary ?? id); | ||
| let backend = id; | ||
| try { | ||
| backend = resolveReviewerBackend(id, config); | ||
| } | ||
| catch { | ||
| // Preserve the existing health hint for unsupported/misconfigured CLI ids. | ||
| } | ||
| // Default to the backend's binary, exactly like the review path in common.ts. | ||
| // Falling back to `id` would probe `codex-high` for an aliased Codex reviewer. | ||
| return checkCli(backend, config.binary ?? backend); | ||
| } | ||
@@ -35,9 +47,10 @@ function resolveHealthEndpoint(_id, config) { | ||
| function checkCli(id, binary) { | ||
| let versionOutput = ""; | ||
| try { | ||
| // stdio: ignore stderr — some CLIs (e.g. codex) print noise on --version | ||
| execFileSync(binary, ["--version"], { | ||
| versionOutput = String(execFileSync(binary, ["--version"], { | ||
| timeout: 5000, | ||
| encoding: "utf8", | ||
| stdio: ["ignore", "pipe", "ignore"], | ||
| }); | ||
| })); | ||
| } | ||
@@ -53,4 +66,32 @@ catch (err) { | ||
| } | ||
| if (id === "codex") { | ||
| return { | ||
| ok: false, | ||
| reason: "could not determine Codex CLI version", | ||
| fix: installFix("codex"), | ||
| }; | ||
| } | ||
| // Binary present but version check failed (auth, permissions, etc.) — optimistic, fall through. | ||
| } | ||
| let version; | ||
| if (id === "codex") { | ||
| const parsed = parseCodexVersion(versionOutput); | ||
| if (!parsed) { | ||
| return { | ||
| ok: false, | ||
| reason: "could not parse Codex CLI version", | ||
| fix: installFix("codex"), | ||
| }; | ||
| } | ||
| version = parsed.version; | ||
| if (!isSupportedCodexVersion(parsed)) { | ||
| return { | ||
| ok: false, | ||
| reason: `Codex CLI ${version} is incompatible; inspectrum requires Codex CLI >= ${MIN_CODEX_VERSION}`, | ||
| fix: installFix("codex"), | ||
| version, | ||
| }; | ||
| } | ||
| } | ||
| const versionDetails = version ? { version } : {}; | ||
| // Binary appears to exist. Warn if the peer-LLM auth env var is missing AND we can't detect | ||
@@ -62,3 +103,3 @@ // an interactive OAuth login — Desktop MCP hosts often don't propagate API keys. | ||
| if (oauthStatus === "logged-in") | ||
| return { ok: true }; | ||
| return { ok: true, ...versionDetails }; | ||
| if (id === "codex" && oauthStatus === "logged-out") { | ||
@@ -69,2 +110,3 @@ return { | ||
| fix: "Run `codex` and complete the ChatGPT sign-in, or set OPENAI_API_KEY", | ||
| ...versionDetails, | ||
| }; | ||
@@ -76,6 +118,69 @@ } | ||
| warning: `binary found but ${envList} not set — reviews will fail unless ${binary} has an OAuth login`, | ||
| ...versionDetails, | ||
| }; | ||
| } | ||
| return { ok: true }; | ||
| return { ok: true, ...versionDetails }; | ||
| } | ||
| const ANSI_ESCAPE = /\x1b\[[0-9;]*m/g; | ||
| export function parseCodexVersion(output) { | ||
| // Search rather than anchor: a colour code, a wrapper banner, or an npm update | ||
| // notice around the version line must not turn a healthy Codex into a red doctor. | ||
| const match = output | ||
| .replace(ANSI_ESCAPE, "") | ||
| .match(/\bcodex(?:-cli)?\s+v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?\b/i); | ||
| if (!match) | ||
| return undefined; | ||
| const prerelease = match[4]; | ||
| return { | ||
| version: `${match[1]}.${match[2]}.${match[3]}${prerelease ? `-${prerelease}` : ""}`, | ||
| major: Number(match[1]), | ||
| minor: Number(match[2]), | ||
| patch: Number(match[3]), | ||
| ...(prerelease ? { prerelease } : {}), | ||
| }; | ||
| } | ||
| function isSupportedCodexVersion(version) { | ||
| const current = [version.major, version.minor, version.patch]; | ||
| const minimum = [0, 99, 0]; | ||
| for (let i = 0; i < current.length; i += 1) { | ||
| if (current[i] > minimum[i]) | ||
| return true; | ||
| if (current[i] < minimum[i]) | ||
| return false; | ||
| } | ||
| return version.prerelease === undefined; | ||
| } | ||
| export function checkClaudePlugin(binary = "claude") { | ||
| try { | ||
| const output = String(execFileSync(binary, ["plugin", "list", "--json"], { | ||
| timeout: 5000, | ||
| encoding: "utf8", | ||
| stdio: ["ignore", "pipe", "ignore"], | ||
| })); | ||
| const plugins = ClaudePluginListSchema.parse(JSON.parse(output)); | ||
| const plugin = plugins.find((entry) => entry.id === "inspectrum@inspectrum"); | ||
| if (!plugin) { | ||
| return { | ||
| ok: true, | ||
| warning: "inspectrum@inspectrum is not installed (optional for MCP-only users)", | ||
| fix: "claude plugin marketplace add yannmenec/inspectrum && claude plugin install inspectrum@inspectrum", | ||
| }; | ||
| } | ||
| if (!plugin.enabled) { | ||
| return { | ||
| ok: true, | ||
| version: plugin.version, | ||
| warning: "inspectrum@inspectrum is installed but disabled", | ||
| fix: "claude plugin enable inspectrum@inspectrum", | ||
| }; | ||
| } | ||
| return { ok: true, version: plugin.version }; | ||
| } | ||
| catch { | ||
| return { | ||
| ok: true, | ||
| warning: "could not inspect the Claude Code plugin (optional for MCP-only users)", | ||
| }; | ||
| } | ||
| } | ||
| // Detect when a CLI is authenticated via its own OAuth flow (no env var set). Only | ||
@@ -93,7 +198,7 @@ // implemented per-backend where the CLI exposes a quick non-interactive probe. | ||
| }); | ||
| if (out.status !== 0) | ||
| return "unknown"; | ||
| const output = `${out.stdout ?? ""}${out.stderr ?? ""}`; | ||
| if (/not logged in/i.test(output)) | ||
| return "logged-out"; | ||
| if (out.status !== 0) | ||
| return "unknown"; | ||
| return /logged in/i.test(output) ? "logged-in" : "logged-out"; | ||
@@ -128,3 +233,3 @@ } | ||
| claude: "Install Claude Code: https://claude.ai/download", | ||
| codex: "Install Codex CLI: npm install -g @openai/codex", | ||
| codex: "npm install -g @openai/codex@latest", | ||
| gemini: "Install Gemini CLI: npm install -g @google/gemini-cli", | ||
@@ -131,0 +236,0 @@ kimi: "Install Kimi CLI: uv tool install --python 3.13 kimi-cli", |
@@ -144,2 +144,7 @@ import { z } from "zod"; | ||
| }, z.core.$strip>; | ||
| export declare const ClaudePluginListSchema: z.ZodArray<z.ZodObject<{ | ||
| id: z.ZodString; | ||
| version: z.ZodOptional<z.ZodString>; | ||
| enabled: z.ZodBoolean; | ||
| }, z.core.$loose>>; | ||
| export declare const ReviewerConfigSchema: z.ZodObject<{ | ||
@@ -201,3 +206,2 @@ type: z.ZodDefault<z.ZodEnum<{ | ||
| limits: z.ZodDefault<z.ZodObject<{ | ||
| plan_max_chars: z.ZodDefault<z.ZodNumber>; | ||
| report_max_chars: z.ZodDefault<z.ZodNumber>; | ||
@@ -204,0 +208,0 @@ timeout_seconds: z.ZodDefault<z.ZodNumber>; |
+6
-2
@@ -67,2 +67,7 @@ import { z } from "zod"; | ||
| }); | ||
| export const ClaudePluginListSchema = z.array(z.looseObject({ | ||
| id: z.string(), | ||
| version: z.string().optional(), | ||
| enabled: z.boolean(), | ||
| })); | ||
| export const ReviewerConfigSchema = z.object({ | ||
@@ -98,7 +103,6 @@ type: z.enum(["cli", "http"]).default("cli"), | ||
| .object({ | ||
| plan_max_chars: z.number().int().default(16000), | ||
| report_max_chars: z.number().int().default(8000), | ||
| timeout_seconds: z.number().int().default(300), | ||
| }) | ||
| .default({ plan_max_chars: 16000, report_max_chars: 8000, timeout_seconds: 300 }), | ||
| .default({ report_max_chars: 8000, timeout_seconds: 300 }), | ||
| plan_gate: z | ||
@@ -105,0 +109,0 @@ .object({ |
+17
-3
| { | ||
| "name": "inspectrum", | ||
| "version": "0.2.0", | ||
| "version": "0.2.1", | ||
| "description": "Universal MCP server for multi-LLM plan review", | ||
@@ -20,3 +20,4 @@ "type": "module", | ||
| "build": "tsc", | ||
| "build:mcpb": "mcpb pack mcpb dist-mcpb/inspectrum-$npm_package_version.mcpb", | ||
| "build:mcpb": "node scripts/build-mcpb.mjs", | ||
| "verify:mcpb": "node scripts/verify-mcpb.mjs", | ||
| "test": "vitest run", | ||
@@ -36,6 +37,19 @@ "test:coverage": "vitest run --coverage", | ||
| "gemini", | ||
| "multi-llm" | ||
| "multi-llm", | ||
| "claude-code", | ||
| "codex-cli", | ||
| "plan-mode", | ||
| "coding-agent" | ||
| ], | ||
| "author": "Yann Menec", | ||
| "license": "MIT", | ||
| "repository": { | ||
| "type": "git", | ||
| "url": "git+https://github.com/yannmenec/inspectrum.git" | ||
| }, | ||
| "homepage": "https://github.com/yannmenec/inspectrum#readme", | ||
| "bugs": { | ||
| "url": "https://github.com/yannmenec/inspectrum/issues" | ||
| }, | ||
| "mcpName": "io.github.yannmenec/inspectrum", | ||
| "engines": { | ||
@@ -42,0 +56,0 @@ "node": ">=20" |
+13
-14
@@ -24,5 +24,5 @@ <div align="center"> | ||
| ## ⚡ 60 seconds to your first gated plan | ||
| ## Quick start | ||
| You need [Node 20+](https://nodejs.org), Claude Code, and the Codex CLI with a [ChatGPT subscription](https://chatgpt.com/pricing) (no API key): | ||
| You need [Node 20+](https://nodejs.org), Claude Code, and Codex CLI >= 0.99.0 authenticated with a [ChatGPT subscription](https://chatgpt.com/pricing) (no API key): | ||
@@ -63,4 +63,4 @@ ```bash | ||
| from https://nodejs.org first. | ||
| 2. Run `codex --version`. If "command not found", run | ||
| `npm install -g @openai/codex` and verify again. | ||
| 2. Run `codex --version`. If it is missing or older than 0.99.0, run | ||
| `npm install -g @openai/codex@latest` and verify again. | ||
| 3. Run `claude plugin marketplace add yannmenec/inspectrum`, then | ||
@@ -121,3 +121,3 @@ `claude plugin install inspectrum@inspectrum`. | ||
| - **Claude and GPT disagree usefully.** Different training, different failure modes, different objections. That disagreement is the product. | ||
| - **You already pay for both.** The gate runs on your existing ChatGPT subscription — no API key, no per-token bill. | ||
| - **Use an existing subscription.** No separate API bill when using your ChatGPT subscription; reviews consume your existing Codex subscription allowance. API-key backends are billed by their provider. | ||
@@ -140,10 +140,10 @@ ## What's in the box | ||
| | **Claude Code** | Codex (GPT) | Plugin — 2 commands above | | ||
| | **Codex Desktop** | Claude | `codex mcp add inspectrum -- npx -y inspectrum@latest` + config below | | ||
| | **Claude Desktop** | Codex (GPT) | Download [`inspectrum.mcpb`](https://github.com/yannmenec/inspectrum/releases/latest/download/inspectrum.mcpb), open, confirm | | ||
| | **Codex app / CLI** | Claude | `codex mcp add inspectrum -- npx -y inspectrum@latest` + config below | | ||
| | **Claude Desktop** (macOS) | Codex (GPT) | Download [`inspectrum-0.2.1.mcpb`](https://github.com/yannmenec/inspectrum/releases/download/v0.2.1/inspectrum-0.2.1.mcpb), open, confirm. The earlier v0.2.0 bundle was incomplete — use the v0.2.1 asset, not npm/stdio. | | ||
| | **Cursor** | Codex (GPT) | [](https://cursor.com/en/install-mcp?name=inspectrum&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsImluc3BlY3RydW1AbGF0ZXN0Il19) | | ||
| <details> | ||
| <summary><b>Codex Desktop setup</b> — use Claude as your reviewer</summary> | ||
| <summary><b>Codex app / CLI setup</b> — use Claude as your reviewer</summary> | ||
| Paste this into Codex Desktop: | ||
| Paste this into the Codex app or CLI: | ||
@@ -243,5 +243,4 @@ ````text | ||
| # Manual hook install (no plugin) — add to ~/.claude/settings.json: | ||
| # "hooks": { "PreToolUse": [ { "matcher": "ExitPlanMode", "hooks": | ||
| # [ { "type": "command", "command": "npx -y inspectrum@latest plan-gate", "timeout": 600 } ] } ] } | ||
| # The automatic plan gate must use the plugin's pinned fail-open shim; do not | ||
| # register plan-gate through a mutable npm tag. | ||
@@ -259,3 +258,3 @@ # Verify everything: | ||
| - **Never paste secrets into a plan or context.** The plan is written to the local session log, and both the plan and context are sent to every active reviewer. | ||
| - Cloud routes: **claude** → Anthropic (OAuth keychain or `ANTHROPIC_API_KEY`); **codex** → OpenAI (ChatGPT login or `OPENAI_API_KEY`); **gemini** → Google (personal-account CLI login or `GEMINI_API_KEY`); **openrouter** → openrouter.ai; **ollama** → localhost only, zero egress unless you change `endpoint`. | ||
| - Cloud routes: **claude** → Anthropic (OAuth keychain or `ANTHROPIC_API_KEY`); **codex** → OpenAI (ChatGPT login or `OPENAI_API_KEY`); **gemini** → Google (personal-account CLI login or `GEMINI_API_KEY`); **openrouter** → openrouter.ai; **kimi** → Moonshot AI; **qwen** → Alibaba Cloud; **ollama** → localhost only, zero egress unless you change `endpoint`. | ||
| - Codex is invoked as `codex exec --ephemeral --skip-git-repo-check -s read-only …` in a throwaway temp directory — the sandbox is pinned read-only, sandbox-weakening and cwd-override args from your config are stripped, and codex persists no session files. | ||
@@ -277,3 +276,3 @@ | ||
| **Do I need API keys?** | ||
| No. Codex reviews run on your ChatGPT Plus/Pro subscription; Claude reviews on your Claude Pro/Max subscription. API keys are supported for headless/CI setups. | ||
| Not for subscription-backed Codex or Claude CLI logins. Reviews consume the allowance of the existing subscription. API keys are supported for headless/CI setups and are billed by their provider. | ||
@@ -280,0 +279,0 @@ **My prompt/plan is sensitive — where does it go?** |
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 2 instances
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
No bug tracker
MaintenancePackage does not have a linked bug tracker in package.json.
No repository
Supply chain riskPackage does not have a linked source code repository. Without this field, a package will have no reference to the location of the source code use to generate the package.
No website
QualityPackage does not have a website.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
154218
6.63%62
3.33%2907
8.23%0
-100%1
-50%295
-0.34%18
28.57%