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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,12 @@ unclaimed Zenrows Free plan account**, stores the key in `.zenrows/secrets.json`
(no secret — just the accountId, Free-period info, and a claim link).

When you (or your agent) want to keep the usage and history, **claim** the
account — this sets an email + password; the same key keeps working:
account — this confirms an email (Google, GitHub or a 6-digit code); the same
key keeps working:

```bash
zenrows account status # show account + Free period + claim link
zenrows account claim # open the claim URL in the browser
zenrows account claim # open the claim URL (fresh one if the stored link is near its 30-day expiry)
zenrows usage # plan usage, credits & concurrency (does not count against your limit)
```

Expand Down
3 changes: 2 additions & 1 deletion agent-plugin/skills/zenrows/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ You do **not** need an API key in the plugin package.
- **stdio** (`npx -y @zenrows/mcp`): on first cloud call the server can
auto-create a **free, unclaimed** Zenrows Free plan account and persist the
key locally. When the Free plan nears its limit (or the user asks), surface
the **claim URL** so a human can claim the account (email + password). The
the **claim URL** so a human can claim the account (confirming an email
with Google, GitHub or a 6-digit code). Claim links expire after 30 days. The
same key keeps working after claim. Never handle payment or upgrades —
only show the claim link.
- **Remote** (`https://mcp.zenrows.com/mcp`): the client runs **OAuth**
Expand Down
4 changes: 3 additions & 1 deletion skills/zenrows/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ key locally — no human step required. To opt out, pass `--no-signup` or set

When the Free plan nears its limit (or the user asks), tell the human they can **claim** the
account to keep their usage and history: run `zenrows account claim` (or open the claim URL
shown on quota errors). Claiming sets an email + password; the same key keeps working.
shown on quota errors). It prints a valid link, requesting a fresh one when the stored link is
near its 30-day expiry. Claiming confirms an email (Google, GitHub or a 6-digit code); the same
key keeps working.
Never attempt to handle payment or upgrades — only surface the claim link.

## The decision tree (follow this exactly)
Expand Down
57 changes: 35 additions & 22 deletions src/cli/commands/account.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
/**
* `zenrows account <status|claim>` — inspect the auto-provisioned Zenrows
* account and surface the human-facing claim link. Never handles payment or
* upgrades; it only reports status and opens the claim URL.
* upgrades; it only reports status and opens the claim URL, asking the API for
* a fresh one when the stored link is missing or close to its 30-day expiry.
*/
import { fetchAccountStatus, readAccount, writeAccount } from "../../core/agent-account.ts";
import { authState, ENV_KEY, resolveApiKey } from "../../core/auth.ts";
import { refreshClaimState, usableClaimUrl } from "../../core/claim-link.ts";
import { loadConfig } from "../../core/config.ts";
import { ToolkitError } from "../../core/errors.ts";
import { log } from "../../core/logger.ts";
import { openUrl } from "../../core/open-url.ts";
import { fetchUsage } from "../../core/usage.ts";
Expand All @@ -18,12 +21,38 @@ export const account: Command = {
async run(argv: string[], ctx: RunContext): Promise<number> {
const { values, positionals } = parse(argv, { "no-open": { type: "boolean" } });
const sub = positionals[0] ?? "status";
let acct = readAccount();
const auth = authState();

// Claiming happens in the browser, out-of-band from the CLI. If we hold an
// unclaimed record and a key, ask the backend whether it has since been
// claimed and refresh account.json. Best-effort — stay on local state offline.
if (sub === "claim") {
const state = await refreshClaimState({ force: true });
if (state.status === "none") {
log.info("No agent-created account to claim. Use `zenrows login --api-key` if you already have a key.");
return 0;
}
if (state.status === "claimed") {
log.success("This account is already claimed. Nothing to do: the same API key keeps working.");
if (ctx.json) log.out(JSON.stringify({ ok: true, claimed: true }, null, 2));
return 0;
}
if (!state.claimUrl) {
throw new ToolkitError({
code: "FETCH_FAILED",
message: "No valid claim link is available.",
likely_cause: auth.hasKey
? "The stored claim link expired and a fresh one could not be requested."
: "The stored claim link expired, and no API key is configured to request a fresh one.",
next_action: "Check connectivity and run `zenrows account claim` again.",
suggested_commands: ["zenrows account claim"],
});
}
log.info("Claim this Zenrows account: open the link and confirm your email (Google, GitHub or a 6-digit code).");
log.info(state.claimUrl);
if (!values["no-open"]) await openUrl(state.claimUrl);
if (ctx.json) log.out(JSON.stringify({ ok: true, claimUrl: state.claimUrl, refreshed: state.refreshed }, null, 2));
return 0;
}

let acct = readAccount();
if (acct && acct.unclaimed && auth.hasKey) {
const key = resolveApiKey().key;
if (key) {
Expand All @@ -34,26 +63,10 @@ export const account: Command = {
writeAccount(acct);
}
} catch {
// endpoint unavailable / offline — keep the local record
}
}
}

if (sub === "claim") {
if (!acct) { log.info("No agent-created account to claim. Use `zenrows login --api-key` if you already have a key."); return 0; }
if (!acct.unclaimed) {
log.success("This account is already claimed.");
if (ctx.json) log.out(JSON.stringify({ ok: true, claimed: true }, null, 2));
return 0;
}
log.info("Claim this Zenrows account (set your email + password):");
log.info(acct.claimUrl);
if (!values["no-open"]) await openUrl(acct.claimUrl);
if (ctx.json) log.out(JSON.stringify({ ok: true, claimUrl: acct.claimUrl }, null, 2));
return 0;
}

// status — cycle dates from subscriptions/self/details
let periodEndsAt: string | undefined;
if (auth.hasKey) {
const key = resolveApiKey().key;
Expand All @@ -79,7 +92,7 @@ export const account: Command = {
log.info(`Account: ${acct.accountId} (${acct.unclaimed ? "unclaimed" : "claimed"})`);
if (auth.hasKey) log.dim(`Key source: ${auth.source}`);
if (periodEndsAt) log.dim(`Billing period ends: ${periodEndsAt}`);
if (acct.unclaimed) log.dim(`Claim it: ${acct.claimUrl}`);
if (acct.unclaimed) log.dim(`Claim it: ${usableClaimUrl(acct) ?? "run `zenrows account claim` for a fresh link"}`);
} else {
if (auth.hasKey) {
log.info(
Expand Down
5 changes: 4 additions & 1 deletion src/cli/commands/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { createJob, downloadResults, getJob, listResults, rerunJob, stopJob, wai
import { asNumber, asString, parse, type Command, type RunContext } from "../command.ts";
import { ToolkitError, isKeyCapReached, keyCapReached } from "../../core/errors.ts";
import { printError, writeOut } from "../output.ts";
import { refreshClaimAdvice } from "../../core/claim-link.ts";

export const batch: Command = {
name: "batch",
Expand Down Expand Up @@ -74,7 +75,9 @@ export const batch: Command = {
next_action: "Use estimate | create <file.jsonl> | status <id> | results <id> | cancel <id> | wait <id> | retry-failed <id>.",
});
}
} catch (err) {
} catch (caught) {
// A quota error's claim advice is built from local state; check it is still current.
const err = await refreshClaimAdvice(caught);
printError(err, ctx.json);
return 1;
}
Expand Down
5 changes: 4 additions & 1 deletion src/cli/commands/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { newRunId, writeRun } from "../../core/artifacts.ts";
import { ToolkitError } from "../../core/errors.ts";
import { parse, asString, asNumber, type Command, type RunContext } from "../command.ts";
import { printError, writeOut } from "../output.ts";
import { refreshClaimAdvice } from "../../core/claim-link.ts";
import {
createSession,
sessionCall,
Expand Down Expand Up @@ -180,7 +181,9 @@ export const browser: Command = {
next_action: "Run `zenrows browser --help` to see the available subcommands.",
});
}
} catch (err) {
} catch (caught) {
// A quota error's claim advice is built from local state; check it is still current.
const err = await refreshClaimAdvice(caught);
printError(err, ctx.json);
return err instanceof ToolkitError && err.code === "POLICY_BROWSER_DISABLED" ? 2 : 1;
}
Expand Down
7 changes: 5 additions & 2 deletions src/cli/commands/crawl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { asNumber, asString, parse, type Command, type RunContext } from "../com
import { ToolkitError, isKeyCapReached, keyCapReached, quotaExhausted } from "../../core/errors.ts";
import { readAccount } from "../../core/agent-account.ts";
import { printError, writeOut, writeOutStream } from "../output.ts";
import { refreshClaimAdvice, storedClaimHint } from "../../core/claim-link.ts";

/** The API's default for `max_pages` (and `max_items`) when the flag is unset. */
const DEFAULT_MAX_PAGES = 100;
Expand Down Expand Up @@ -113,7 +114,9 @@ export const crawl: Command = {
suggested_commands: ["zenrows crawl --help"],
});
}
} catch (err) {
} catch (caught) {
// A quota error's claim advice is built from local state; check it is still current.
const err = await refreshClaimAdvice(caught);
printError(err, ctx.json);
return 1;
}
Expand Down Expand Up @@ -487,7 +490,7 @@ export function crawlFailure(c: Crawl | CrawlStop): ToolkitError | null {
}
if (code === "insufficient_credits") {
const acct = readAccount();
const quota = quotaExhausted(where, acct?.unclaimed ? acct.claimUrl : undefined, { status: null, detail }).toJSON();
const quota = quotaExhausted(where, storedClaimHint(acct), { status: null, detail }).toJSON();
return new ToolkitError({ ...quota, ...ids, code: "CRAWL_INSUFFICIENT_CREDITS", suggested_commands: [...(quota.suggested_commands ?? []), getCmd] });
}
const why = [code, c.error?.detail].filter(Boolean).join(": ");
Expand Down
5 changes: 4 additions & 1 deletion src/cli/commands/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { runExtract, type ExtractMethod, type ExtractOptions } from "../../adapt
import { formatRequestCost } from "../../core/http.ts";
import { parse, asString, asNumber, type Command, type RunContext } from "../command.ts";
import { printError, writeOut } from "../output.ts";
import { refreshClaimAdvice } from "../../core/claim-link.ts";

export const extract: Command = {
name: "extract",
Expand Down Expand Up @@ -200,7 +201,9 @@ export const extract: Command = {
}
await maybeNudgeClaim(config, {});
return 0;
} catch (err) {
} catch (caught) {
// A quota error's claim advice is built from local state; check it is still current.
const err = await refreshClaimAdvice(caught);
writeRun({
runId,
command: "zenrows extract",
Expand Down
5 changes: 4 additions & 1 deletion src/cli/commands/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { runFetch, type FetchOptions, type ResponseFormat } from "../../adapters
import { formatRequestCost } from "../../core/http.ts";
import { parse, asString, asNumber, type Command, type RunContext } from "../command.ts";
import { printError, writeOut } from "../output.ts";
import { refreshClaimAdvice } from "../../core/claim-link.ts";

export const fetch_: Command = {
name: "fetch",
Expand Down Expand Up @@ -166,7 +167,9 @@ export const fetch_: Command = {
}
await maybeNudgeClaim(config, {});
return 0;
} catch (err) {
} catch (caught) {
// A quota error's claim advice is built from local state; check it is still current.
const err = await refreshClaimAdvice(caught);
writeRun({
runId,
command: "zenrows fetch",
Expand Down
5 changes: 3 additions & 2 deletions src/cli/commands/signup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,11 @@ export const signup: Command = {

if (values.agent) {
const account = readAccount();
if (account) { log.info("An agent account already exists. Claim it: " + account.claimUrl); return 0; }
if (account) { log.info("An agent account already exists. Claim it with: zenrows account claim"); return 0; }
const res = await signupAgent();
saveApiKey(res.apiKey);
writeAccount({ accountId: res.accountId, unclaimed: true, claimUrl: res.claimUrl, createdAt: new Date().toISOString() });
const now = new Date().toISOString();
writeAccount({ accountId: res.accountId, unclaimed: true, claimUrl: res.claimUrl, createdAt: now, claimUrlIssuedAt: now });
log.success("Created a Zenrows Free plan account.");
log.dim(`Claim it anytime: ${res.claimUrl}`);
if (ctx.json) log.out(JSON.stringify({ ok: true, accountId: res.accountId, claimUrl: res.claimUrl }, null, 2));
Expand Down
3 changes: 2 additions & 1 deletion src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*/
import { CLI_VERSION } from "../core/config.ts";
import { ToolkitError } from "../core/errors.ts";
import { refreshClaimAdvice } from "../core/claim-link.ts";
import { log, ANSI, c } from "../core/logger.ts";
import { makeAssetCommand } from "./asset-command.ts";
import { printError } from "./output.ts";
Expand Down Expand Up @@ -94,7 +95,7 @@ export async function main(rawArgv: string[]): Promise<number> {
try {
return await cmd.run(argv, ctx);
} catch (err) {
printError(err, json);
printError(await refreshClaimAdvice(err), json);
// Any error exits 1 — the exit code doesn't encode error kind. Machine
// consumers read the precise `error.code` from the --json output instead.
return 1;
Expand Down
Loading
Loading