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
4 changes: 2 additions & 2 deletions src/cli/asset-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ function explainCmd(type: AssetType, argv: string[], ctx: RunContext): number {
if (!name) throw usageErr(type, "explain <name>");
const asset = requireAsset(type, name);
if (ctx.json) {
log.out(JSON.stringify({ ...asset, runnable: assetRunnable(asset) }, null, 2));
log.out(JSON.stringify({ ok: true, ...asset, runnable: assetRunnable(asset) }, null, 2));
return 0;
}
log.info(c(ANSI.bold, `${asset.name} (${asset.type}, ${asset.status})`));
Expand Down Expand Up @@ -257,7 +257,7 @@ function validateSkill(argv: string[], ctx: RunContext): number {
if (/apikey=|ZENROWS_API_KEY=[A-Za-z0-9]/.test(body)) errors.push("SKILL.md may contain a secret");
}
if (ctx.json) {
log.out(JSON.stringify({ name, valid: errors.length === 0, errors }, null, 2));
log.out(JSON.stringify({ ok: errors.length === 0, name, valid: errors.length === 0, errors }, null, 2));
} else if (errors.length === 0) {
log.success(`Skill "${name}" is valid.`);
} else {
Expand Down
1 change: 1 addition & 0 deletions src/cli/commands/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export const account: Command = {

if (ctx.json) {
log.out(JSON.stringify({
ok: true,
hasKey: auth.hasKey,
source: auth.source,
account: acct ?? null,
Expand Down
29 changes: 22 additions & 7 deletions src/cli/commands/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ export const batch: Command = {
" --js-render job-level: render JavaScript",
" --premium-proxy job-level: use residential IPs",
" --proxy-country <cc> job-level: geo-target (needs --premium-proxy)",
" --output <fmt> job-level response_type (markdown|plaintext|pdf)",
" --wait poll until the run finishes",
" --output <fmt> job-level response_type (markdown|plaintext|pdf|html)",
" --follow poll until the run finishes (alias: --wait)",
" --no-signup do not auto-create a Free plan account if no key exists",
" status <id> show run status + stats",
" results <id> [--status s] list results (successful|failed|all); paginated",
Expand Down Expand Up @@ -84,7 +84,7 @@ function estimateCmd(rest: string[], ctx: RunContext): number {
const v = validateJsonl(file);
const est = estimateCredits(v.jobs);
if (ctx.json) {
log.out(JSON.stringify({ ...v, estimatedCredits: est.credits }, null, 2));
log.out(JSON.stringify({ ok: v.errors.length === 0, ...v, estimatedCredits: est.credits }, null, 2));
} else {
log.info(c(ANSI.bold, `Job spec: ${file}`));
log.info(`valid jobs: ${v.validJobs}/${v.totalLines}`);
Expand All @@ -105,10 +105,14 @@ async function createCmd(rest: string[], ctx: RunContext): Promise<number> {
"premium-proxy": { type: "boolean" },
"proxy-country": { type: "string" },
output: { type: "string" },
wait: { type: "boolean" },
follow: { type: "boolean" },
wait: { type: "boolean" }, // back-compat alias for --follow
"no-signup": { type: "boolean" },
json: { type: "boolean" },
});
// `--follow` is the clear name (poll until the run finishes); `--wait` is kept
// as an alias because fetch/extract use `--wait <ms>` for a different meaning.
const follow = values.follow === true || values.wait === true;
const json = ctx.json || values.json === true;
const file = positionals[0];
if (!file) throw needFile();
Expand Down Expand Up @@ -163,7 +167,7 @@ async function createCmd(rest: string[], ctx: RunContext): Promise<number> {
log.step(`Submitting batch job (${body.tasks.length} tasks, ~${est.credits} credits)…`);
try {
const job = await createJob(body, { apiKey });
const finished = values.wait === true ? await waitForJob(job.job_id, { apiKey }) : job;
const finished = follow ? await waitForJob(job.job_id, { apiKey }) : job;
const runDir = writeRun({
runId,
command: "zenrows batch create",
Expand Down Expand Up @@ -342,8 +346,19 @@ function normalizeOutput(v?: string): string | undefined {
pdf: "pdf",
html: "", // raw HTML is the default; no response_type
};
const mapped = map[v.toLowerCase()];
return mapped ? mapped : undefined;
const key = v.toLowerCase();
if (!(key in map)) {
// Fail loudly rather than silently dropping an unrecognized format (house rule).
throw new ToolkitError({
code: "INVALID_USAGE",
message: `Unknown --output format: ${v}.`,
likely_cause: "Only markdown | plaintext | pdf | html are supported for batch --output.",
next_action: "Use --output md|markdown | text|plaintext | pdf | html (html = raw HTML, the default).",
suggested_commands: ["zenrows batch create jobs.jsonl --output markdown"],
});
}
const mapped = map[key];
return mapped ? mapped : undefined; // html → undefined (no response_type)
}

function requireId(id: string | undefined): string {
Expand Down
2 changes: 1 addition & 1 deletion src/cli/commands/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ function infoCmd(ctx: RunContext): number {
const policy = loadPolicy();
assertBrowserAllowed(policy); // throws POLICY_BROWSER_DISABLED (→ exit 2) only if opted out
if (ctx.json) {
log.out(JSON.stringify({ capability: "browser", escalationOnly: true, backend: "REST session API (mcp.zenrows.com/browser/sessions)" }, null, 2));
log.out(JSON.stringify({ ok: true, capability: "browser", escalationOnly: true, backend: "REST session API (mcp.zenrows.com/browser/sessions)" }, null, 2));
return 0;
}
log.info(c(ANSI.bold, "Browser sessions (escalation-only)"));
Expand Down
2 changes: 1 addition & 1 deletion src/cli/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export const config: Command = {
};

function out(cfg: ToolkitConfig, _ctx: RunContext): number {
log.out(JSON.stringify(cfg, null, 2));
log.out(JSON.stringify({ ok: true, ...cfg }, null, 2));
return 0;
}
function unknownKey(key?: string): ToolkitError {
Expand Down
4 changes: 2 additions & 2 deletions src/cli/commands/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export const mcp: Command = {
}
const { client, snippet } = buildMcpConfig(clientId, transport);
if (ctx.json) {
log.out(JSON.stringify({ client: client.id, transport, configFile: client.configFile, autoConfigurable: client.autoConfigurable, snippet }, null, 2));
log.out(JSON.stringify({ ok: true, client: client.id, transport, configFile: client.configFile, autoConfigurable: client.autoConfigurable, snippet }, null, 2));
return 0;
}
log.info(c(ANSI.bold, `MCP config for ${client.label} (${transport})`));
Expand Down Expand Up @@ -76,7 +76,7 @@ export const mcp: Command = {
function statusCmd(ctx: RunContext): number {
const cap = loadCapabilities().mcp;
if (ctx.json) {
log.out(JSON.stringify({ capability: cap, remote: REMOTE_URL, local: "npx -y @zenrows/mcp", clients: Object.values(MCP_CLIENTS) }, null, 2));
log.out(JSON.stringify({ ok: true, capability: cap, remote: REMOTE_URL, local: "npx -y @zenrows/mcp", clients: Object.values(MCP_CLIENTS) }, null, 2));
return 0;
}
log.info(c(ANSI.bold, "Zenrows MCP"));
Expand Down
4 changes: 2 additions & 2 deletions src/cli/commands/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export const plugin: Command = {
const [sub, name] = argv;
if (!sub || sub === "list") {
if (ctx.json) {
log.out(JSON.stringify({ clients: Object.values(MCP_CLIENTS) }, null, 2));
log.out(JSON.stringify({ ok: true, clients: Object.values(MCP_CLIENTS) }, null, 2));
return 0;
}
log.info(c(ANSI.bold, "Installable agent plugins:"));
Expand All @@ -33,7 +33,7 @@ export const plugin: Command = {
if (sub === "status") {
const skills = listInstalled("skill");
if (ctx.json) {
log.out(JSON.stringify({ installedSkills: skills }, null, 2));
log.out(JSON.stringify({ ok: true, installedSkills: skills }, null, 2));
return 0;
}
log.info(`Installed skills: ${skills.join(", ") || "none"}`);
Expand Down
2 changes: 1 addition & 1 deletion src/cli/commands/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export const policy: Command = {
const pol = loadPolicy();
if (!sub || sub === "show") {
if (ctx.json) {
log.out(JSON.stringify(pol, null, 2));
log.out(JSON.stringify({ ok: true, ...pol }, null, 2));
return 0;
}
for (const [k, v] of Object.entries(pol)) kv(k, Array.isArray(v) ? `[${v.join(", ")}]` : String(v), 24);
Expand Down
6 changes: 3 additions & 3 deletions src/cli/commands/trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export const trace: Command = {
const rec = loadRun(runId);

if (sub === "inspect") {
log.out(JSON.stringify(rec, null, 2));
log.out(JSON.stringify({ ok: true, ...rec }, null, 2));
return 0;
}
if (sub === "export") {
Expand All @@ -52,7 +52,7 @@ export const trace: Command = {
if (sub === "replay") {
const cmd = rebuildCommand(rec);
if (ctx.json) {
log.out(JSON.stringify({ runId, replay: cmd }, null, 2));
log.out(JSON.stringify({ ok: true, runId, replay: cmd }, null, 2));
} else {
log.info("Replay this run with:");
log.out(cmd);
Expand Down Expand Up @@ -102,7 +102,7 @@ function explain(rec: RunRecord, ctx: RunContext): number {
: [rebuildCommand(rec) + " --manual --js-render --premium-proxy"];

if (ctx.json) {
log.out(JSON.stringify({ runId: rec.runId, what_happened: what, likely_failure_reason: reason, evidence: rec, recommended_next_action: nextAction, suggested_commands: suggested }, null, 2));
log.out(JSON.stringify({ ok: true, runId: rec.runId, what_happened: what, likely_failure_reason: reason, evidence: rec, recommended_next_action: nextAction, suggested_commands: suggested }, null, 2));
return 0;
}
log.info(c(ANSI.bold, `Trace explain · ${rec.runId}`));
Expand Down
2 changes: 1 addition & 1 deletion src/cli/commands/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const usage: Command = {
const u = await fetchUsage(config.apiBase, apiKey);

if (ctx.json) {
log.out(JSON.stringify(u, null, 2));
log.out(JSON.stringify({ ok: true, ...u }, null, 2));
return 0;
}

Expand Down
6 changes: 5 additions & 1 deletion src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,11 @@ export async function main(rawArgv: string[]): Promise<number> {
return await cmd.run(argv, ctx);
} catch (err) {
printError(err, json);
return err instanceof ToolkitError && err.code === "CAPABILITY_UNAVAILABLE" ? 2 : 1;
// Any error exits 1. We do not overload the exit code to signal error kind:
// the capability-gated commands catch internally and return 1 already, so a
// CAPABILITY_UNAVAILABLE→2 mapping here never fired. Machine consumers read
// the precise `error.code` from --json output instead.
return 1;
}
}

Expand Down
42 changes: 42 additions & 0 deletions tests/batch-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,45 @@ test("batch create submits when the run is within policy caps", async () => {
assert.equal(didFetch(), true, "an in-policy run must reach the Batch API");
});
});

test("batch create rejects an unknown --output format before any network call", async () => {
await withBatchWorkspace({}, async (didFetch) => {
const file = writeSpec(["https://ok.example/a"]);
const code = await batch.run(["create", file, "--output", "bogus"], ctx);
assert.equal(code, 1);
assert.equal(didFetch(), false, "an unknown --output must fail loudly, not silently drop");
});
});

/** Capture stdout (log.out) for the duration of `fn`. */
async function captureOut(fn: () => unknown): Promise<string> {
const orig = process.stdout.write.bind(process.stdout);
let buf = "";
process.stdout.write = ((s: string | Uint8Array) => {
buf += typeof s === "string" ? s : Buffer.from(s).toString();
return true;
}) as typeof process.stdout.write;
try {
await fn();
} finally {
process.stdout.write = orig;
}
return buf;
}

test("batch estimate --json emits an {ok,...} envelope (ok reflects spec validity)", async () => {
await withBatchWorkspace({}, async () => {
const good = writeSpec(["https://ok.example/a"]);
const okOut = await captureOut(() => batch.run(["estimate", good], ctx));
const okJson = JSON.parse(okOut) as { ok: boolean; estimatedCredits: number };
assert.equal(okJson.ok, true);
assert.equal(typeof okJson.estimatedCredits, "number");

// A spec with a bad line → ok:false (and the command's exit code is 1).
const bad = join(process.cwd(), "bad.jsonl");
writeFileSync(bad, '{"url":"https://ok.example/a"}\nnot-json\n');
const badOut = await captureOut(() => batch.run(["estimate", bad], ctx));
const badJson = JSON.parse(badOut) as { ok: boolean };
assert.equal(badJson.ok, false);
});
});