🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

inspectrum

Package Overview
Dependencies
Maintainers
1
Versions
10
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

inspectrum - npm Package Compare versions

Comparing version
0.2.0
to
0.2.1
+3
dist/hook/stdin.d.ts
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) {

@@ -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 {};
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>;

@@ -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({

{
"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) | [![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.png)](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?**