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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,16 @@ matrix.
## 4. Quickstart

```bash
npx -y @zenrows/cli init
npm install -g @zenrows/cli # puts `zenrows` on PATH (466 ms, zero deps)
zenrows init
zenrows fetch https://httpbin.io/html # auto-provisions a Free plan account on first use
zenrows extract https://www.owler.com/company/meltwater # extract=auto on an enabled domain
zenrows extract https://www.scrapingcourse.com/ecommerce/ --autoparse # Autoparse (any domain)
```

> Prefer no global install? Prefix each command with `npx -y @zenrows/cli`
> (e.g. `npx -y @zenrows/cli fetch <url>`) — `npx` does not put `zenrows` on PATH.

No API key up front: on your first cloud call the toolkit creates a free,
unclaimed Zenrows Free plan account for you (see §6).

Expand Down
9 changes: 6 additions & 3 deletions skills/zenrows/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,11 @@ If the user has a known URL and wants page content:
If the user has a known URL and wants structured data:
→ Use Extract. (zenrows extract <url> | --autoparse | --css)

If the user has many URLs:
→ Fan out fetch/extract per URL (validate on one page first, then iterate).
If the user has many URLs (bulk):
→ Use Batch. (validate one page with fetch/extract first,
then `zenrows batch create <jobs.jsonl>`)
Batch is the cheap path at scale — do NOT
fan out fetch/extract per URL for bulk work.

If the user needs login, clicks, forms, sessions, or persistent state:
→ Use Interact / Browser Sessions.(zenrows browser) [escalation-only]
Expand Down Expand Up @@ -66,7 +69,7 @@ Run `zenrows status` for the live capability matrix. As of this toolkit:
| --- | --- | --- |
| Protected Fetch | `zenrows fetch` | available (`GET /v1/`) |
| Extract (extract=auto / Autoparse/CSS/Markdown) | `zenrows extract` | beta (same `/v1/`; extract=auto falls back to autoparse) |
| Batch | `zenrows batch` | beta (validate specs locally) |
| Batch | `zenrows batch` | beta — runs cloud jobs (create/status/results); cheapest path at scale. Estimate specs locally with no key |
| Browser | `zenrows browser` | available (Browser Sessions REST API / MCP) |
| MCP | `zenrows mcp` | available (remote + local server) |

Expand Down
12 changes: 6 additions & 6 deletions src/cli/asset-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,8 @@ export function makeAssetCommand(type: AssetType, summary: string): Command {
function extraUsage(type: AssetType): string {
if (type === "skill") return "|validate|generate";
if (type === "template") return "|create";
if (type === "recipe") return "|run|explain";
if (type === "workflow") return "|run|explain";
if (type === "recipe") return "|run";
if (type === "workflow") return "|run";
if (type === "eval") return "|run|report";
return "";
}
Expand All @@ -97,9 +97,9 @@ function helpFor(type: AssetType): string {
const lines = [`Manage ${type}s from the installable asset registry.`, ""];
lines.push("Subcommands:");
lines.push(` list list all ${type}s in the registry (status-aware)`);
lines.push(` install <name> copy a ${type} into .zenrows/`);
lines.push(` install <name> copy ${type === "eval" ? "an" : "a"} ${type} into .zenrows/`);
if (type === "skill") lines.push(" install --all install every available skill");
lines.push(` explain <name> print metadata + docs for a ${type}`);
lines.push(` explain <name> print metadata + docs for ${type === "eval" ? "an" : "a"} ${type}`);
lines.push(` update [name] reinstall (refresh) installed ${type}s`);
lines.push(` remove <name> remove an installed ${type}`);
if (type === "template") lines.push(" create <name> --output <dir> instantiate a template into <dir>");
Expand All @@ -121,7 +121,7 @@ function listCmd(type: AssetType, _argv: string[], ctx: RunContext): number {
if (ctx.json) {
log.out(
JSON.stringify(
{ type, assets: assets.map((a) => ({ ...a, installed: installed.has(a.name), runnable: assetRunnable(a) })) },
{ ok: true, type, assets: assets.map((a) => ({ ...a, installed: installed.has(a.name), runnable: assetRunnable(a) })) },
null,
2,
),
Expand All @@ -145,7 +145,7 @@ function listCmd(type: AssetType, _argv: string[], ctx: RunContext): number {
function installCmd(type: AssetType, argv: string[], ctx: RunContext): number {
const all = argv.includes("--all");
const targets = all
? loadRegistry(type).filter((a) => a.status === "available" || a.status === "experimental")
? loadRegistry(type).filter(assetRunnable) // everything whose backend deps are usable (incl. beta) — matches `plugin install`
: argv.filter((a) => !a.startsWith("-")).map((name) => requireAsset(type, name));
if (targets.length === 0) {
throw new ToolkitError({
Expand Down
60 changes: 58 additions & 2 deletions src/cli/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
* Command contract + shared helpers for the CLI.
*/
import { parseArgs, type ParseArgsConfig } from "node:util";
import { ToolkitError } from "../core/errors.ts";

/** Global flags stripped by the top-level router before a command parses. */
const GLOBAL_FLAGS = new Set(["json", "yes", "help", "version"]);

export interface Command {
name: string;
Expand All @@ -21,20 +25,72 @@ export interface RunContext {
yes: boolean;
}

/** Thin wrapper around parseArgs that keeps positionals + options typed-ish. */
/**
* Thin wrapper around parseArgs that keeps positionals + options typed-ish.
*
* Rejects unrecognized flags loudly (UNKNOWN_FLAG) instead of silently swallowing
* them: an agent that mistypes or hallucinates a flag must fail here, not get a
* green result on a request the CLI never actually honored. We parse non-strict
* with tokens so we can name the exact offending flag and suggest a correction,
* rather than surface node's raw parseArgs throw.
*/
export function parse(
argv: string[],
options: ParseArgsConfig["options"],
): { values: Record<string, unknown>; positionals: string[] } {
const { values, positionals } = parseArgs({
const { values, positionals, tokens } = parseArgs({
args: argv,
options,
allowPositionals: true,
strict: false,
tokens: true,
});
const declared = new Set(Object.keys(options ?? {}));
const unknown = tokens.filter(
(t): t is Extract<typeof t, { kind: "option" }> =>
t.kind === "option" && !declared.has(t.name) && !GLOBAL_FLAGS.has(t.name),
);
if (unknown.length > 0) {
const flag = unknown[0]!.rawName;
const guess = suggestFlag(unknown[0]!.name, [...declared]);
throw new ToolkitError({
code: "UNKNOWN_FLAG",
message: `Unknown flag: ${flag}`,
likely_cause: "This flag is not recognized by this command.",
next_action: guess
? `Did you mean --${guess}? Run the command with --help for the full flag list.`
: "Run the command with --help for the full flag list.",
});
}
return { values: values as Record<string, unknown>, positionals };
}

/** Closest declared flag within edit distance 2, for a "did you mean" hint. */
function suggestFlag(input: string, candidates: string[]): string | undefined {
let best: string | undefined;
let bestDist = 3;
for (const c of candidates) {
const d = editDistance(input, c);
if (d < bestDist) {
bestDist = d;
best = c;
}
}
return best;
}

function editDistance(a: string, b: string): number {
const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
for (let j = 0; j <= b.length; j++) dp[0]![j] = j;
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
dp[i]![j] = Math.min(dp[i - 1]![j]! + 1, dp[i]![j - 1]! + 1, dp[i - 1]![j - 1]! + cost);
}
}
return dp[a.length]![b.length]!;
}

export function asString(v: unknown): string | undefined {
return typeof v === "string" ? v : undefined;
}
Expand Down
3 changes: 2 additions & 1 deletion src/cli/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* show
* get <key>
* set <key> <value>
* reset
*/
import { defaultConfig, loadConfig, saveConfig } from "../../core/config.ts";
import { log } from "../../core/logger.ts";
Expand All @@ -26,7 +27,7 @@ const SETTABLE: Record<string, (c: ToolkitConfig, v: string) => void> = {
export const config: Command = {
name: "config",
summary: "View or update toolkit configuration (non-secret).",
usage: "zenrows config <show|get <key>|set <key> <value>>",
usage: "zenrows config <show|get <key>|set <key> <value>|reset>",
run(argv: string[], ctx: RunContext): number {
const [sub, key, value] = argv;
const cfg = loadConfig();
Expand Down
3 changes: 3 additions & 0 deletions src/cli/commands/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ export const extract: Command = {
" --out <file> write output to a file",
" --no-signup do not auto-create a Free plan account if no key exists",
" --json structured result",
"",
"Cost (credits per request): 1x normal · 5x --js-render · 10x --premium-proxy · 25x both.",
"The exact charge is reported after every request (costCredits / X-Request-Credits).",
].join("\n"),
async run(argv: string[], ctx: RunContext): Promise<number> {
const { values, positionals } = parse(argv, {
Expand Down
4 changes: 4 additions & 0 deletions src/cli/commands/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ export const fetch_: Command = {
" --out <file> write the response body to a file",
" --no-signup do not auto-create a Free plan account if no key exists",
" --json print a structured result",
"",
"Cost (credits per request): 1x normal · 5x --js-render · 10x --premium-proxy · 25x both.",
"In auto mode you pay only for the configuration that succeeds; the exact charge is",
"reported after every request (costCredits / X-Request-Credits).",
].join("\n"),
async run(argv: string[], ctx: RunContext): Promise<number> {
const { values, positionals } = parse(argv, {
Expand Down
24 changes: 11 additions & 13 deletions src/cli/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import { defaultConfig, loadConfig, saveConfig } from "../../core/config.ts";
import { defaultPolicy, loadPolicy, savePolicy } from "../../core/policy.ts";
import { log, ANSI, c } from "../../core/logger.ts";
import { mask } from "../../core/redact.ts";
import { createWorkspace } from "../../core/workspace.ts";
import { existsSync } from "node:fs";
import { createWorkspace, workspacePaths } from "../../core/workspace.ts";
import { installAsset, loadRegistry } from "../../core/registry.ts";
import { buildMcpConfig, MCP_CLIENTS } from "../../installers/mcp/index.ts";
import { runFetch } from "../../adapters/protected-fetch.ts";
Expand Down Expand Up @@ -71,7 +72,11 @@ export const init: Command = {
section("Workspace");
const paths = createWorkspace(root);
log.success(`Created ${paths.dir}`);
if (!loadConfigSafe(root)) {
// Write config.json when absent (loadConfig always returns merged defaults,
// so a "does it load?" check never writes — check the file itself). Writing
// it here makes the "Wrote config.json" line true and lets `--no-telemetry`
// actually persist, rather than being silently dropped until first signup.
if (!existsSync(workspacePaths(root).config)) {
const cfg = defaultConfig();
if (values["no-telemetry"]) cfg.telemetry = "off";
saveConfig(cfg, root);
Expand All @@ -92,7 +97,9 @@ export const init: Command = {
} else {
const st = authState(root);
if (st.hasKey) log.success(`Using existing key (${st.masked}, ${st.source}).`);
else log.warn("No API key configured. Run `zenrows login --api-key <key>` or `zenrows signup`.");
else if (pol.auto_signup)
log.success("No API key needed — a free Zenrows account is provisioned automatically on your first cloud call (e.g. `zenrows fetch <url>`).");
else log.warn("No API key configured (auto-signup is off). Run `zenrows login --api-key <key>` or `zenrows signup`.");
}

// 3. Assets
Expand Down Expand Up @@ -138,7 +145,7 @@ export const init: Command = {
log.dim("This is non-fatal for init. Check `zenrows status --check`.");
}
} else {
log.dim("Skipping test fetch (no credentials).");
log.dim("Skipping live test fetch — a key is provisioned automatically on your first fetch.");
}
}

Expand All @@ -158,15 +165,6 @@ export const init: Command = {
},
};

function loadConfigSafe(root?: string): boolean {
try {
const cfg = loadConfig(root);
return Boolean(cfg && cfg.version);
} catch {
return false;
}
}

function installSet(type: AssetType, root?: string): void {
const assets = loadRegistry(type).filter((a) => a.status === "available" || a.status === "experimental" || a.status === "beta");
if (assets.length === 0) return;
Expand Down
1 change: 1 addition & 0 deletions src/cli/commands/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const status: Command = {
log.out(
JSON.stringify(
{
ok: true,
auth: { hasKey: auth.hasKey, source: auth.source, masked: auth.masked ?? null },
workspace: { initialized: Boolean(ws), root: ws?.root ?? null, dir: ws?.dir ?? null },
backend: { apiBase: cfg.apiBase, reachable },
Expand Down
Loading
Loading