@zenrows/cli 1.3.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -93,11 +93,13 @@ export function buildParams(opts, config) {
93
93
  params.response_type = RESPONSE_TYPE[opts.output];
94
94
  return params;
95
95
  }
96
- export async function runFetch(opts, config, policy, apiKey) {
96
+ export async function runFetch(opts, config, policy, apiKey,
97
+ /** The workspace `config` came from, when it is not the one above the cwd. */
98
+ projectRoot) {
97
99
  assertDomainAllowed(opts.url, policy);
98
100
  validateAutoManual(opts, config);
99
101
  const params = buildParams(opts, config);
100
102
  const mode = params.mode === "auto" ? "auto" : "manual";
101
- const result = await scrape(config.apiBase, apiKey, params, { timeoutMs: opts.timeoutMs });
103
+ const result = await scrape(config.apiBase, apiKey, params, { timeoutMs: opts.timeoutMs, projectRoot });
102
104
  return { result, params, mode };
103
105
  }
@@ -141,7 +141,7 @@ export const init = {
141
141
  section("Test Protected Fetch");
142
142
  try {
143
143
  const apiKey = requireApiKey(root);
144
- const { result } = await runFetch({ url: SMOKE_URL }, loadConfig(root), loadPolicy(root), apiKey);
144
+ const { result } = await runFetch({ url: SMOKE_URL }, loadConfig(root), loadPolicy(root), apiKey, root);
145
145
  log.success(`Protected Fetch OK — HTTP ${result.status}, ${result.body.length} bytes, ${formatRequestCost(result.costUsd, result.costCredits)}.`);
146
146
  }
147
147
  catch (err) {
@@ -80,6 +80,7 @@ export const status = {
80
80
  },
81
81
  };
82
82
  /** Reachability probe that does not consume credits. */
83
+ // Deliberately sends no X-ZenRows-Client: unauthenticated, non-billable reachability check.
83
84
  async function probe(apiBase) {
84
85
  try {
85
86
  const controller = new AbortController();
@@ -6,7 +6,8 @@
6
6
  * accountId, the human-facing claim URL, and claim metadata.
7
7
  */
8
8
  import { chmodSync, existsSync, unlinkSync } from "node:fs";
9
- import { attributionEnabled, getOrCreateTelemetryId, loadConfig, CLI_VERSION } from "./config.js";
9
+ import { attributionEnabled, getOrCreateTelemetryId, loadConfig, CLI_USER_AGENT, CLI_VERSION } from "./config.js";
10
+ import { agentClientHeader } from "./agent-client.js";
10
11
  import { ToolkitError } from "./errors.js";
11
12
  import { AGENT_SIGNUP_API_URL, WELL_KNOWN_PROTECTED_RESOURCE } from "./open-url.js";
12
13
  import { detectClient } from "./provenance.js";
@@ -42,7 +43,7 @@ export async function discoverSignupUrl(projectRoot, opts = {}) {
42
43
  const doFetch = opts.fetchImpl ?? fetch;
43
44
  const res = await doFetch(url, {
44
45
  method: "GET",
45
- headers: { Accept: "application/json", "User-Agent": "zenrows-cli" },
46
+ headers: { Accept: "application/json", "User-Agent": CLI_USER_AGENT, ...agentClientHeader({ projectRoot }) },
46
47
  });
47
48
  if (!res.ok)
48
49
  return null;
@@ -128,7 +129,8 @@ export async function signupAgent(opts = {}) {
128
129
  // `telemetry:"off"` / ZENROWS_TELEMETRY=off suppresses every X-ZR-* header.
129
130
  const headers = {
130
131
  "content-type": "application/json",
131
- "User-Agent": "zenrows-cli",
132
+ "User-Agent": CLI_USER_AGENT,
133
+ ...agentClientHeader(),
132
134
  };
133
135
  if (attributionEnabled()) {
134
136
  const p = detectClient();
@@ -211,7 +213,7 @@ export async function fetchAccountStatus(apiKey, opts = {}) {
211
213
  const doFetch = opts.fetchImpl ?? fetch;
212
214
  const res = await doFetch(url, {
213
215
  method: "GET",
214
- headers: { "X-API-Key": apiKey, Accept: "application/json", "User-Agent": "zenrows-cli" },
216
+ headers: { "X-API-Key": apiKey, Accept: "application/json", "User-Agent": CLI_USER_AGENT, ...agentClientHeader() },
215
217
  });
216
218
  if (res.status !== 200) {
217
219
  const body = await res.text();
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Which AI coding agent is running the CLI, sent to Zenrows as the
3
+ * `X-ZenRows-Client` header so the dashboard Activity Log can say "CLI, driven
4
+ * by Claude Code" instead of just "CLI".
5
+ *
6
+ * Only the derived name is sent, never an environment value: the header carries
7
+ * one of the fixed names below, or the user's own `ZENROWS_CLIENT` override.
8
+ * When no agent is detected, no header is sent at all. `ZENROWS_TELEMETRY=off`
9
+ * (or config `telemetry: "off"`) suppresses it, override included.
10
+ *
11
+ * The gateway resolves the value against its own list of client names and
12
+ * stores anything it does not know as "other", so a name that is new here is
13
+ * harmless there.
14
+ */
15
+ import { attributionEnabled } from "./config.js";
16
+ /** The request header the Zenrows gateway reads the client name from. */
17
+ export const CLIENT_HEADER = "X-ZenRows-Client";
18
+ /** Env var a user sets to name the client themselves. It wins over detection. */
19
+ export const CLIENT_OVERRIDE_ENV = "ZENROWS_CLIENT";
20
+ /**
21
+ * The gateway considers at most 32 bytes of the header.
22
+ * Anything we send must also be a legal header value, or `fetch` throws on
23
+ * every request, so the override is held to a plain-token charset.
24
+ */
25
+ const CLIENT_NAME = /^[a-z0-9][a-z0-9._-]{0,31}$/;
26
+ /**
27
+ * Each agent's signal is a variable the agent itself sets in the shells it runs
28
+ * commands in, verified against the agent's docs or source. Agents we found no
29
+ * such variable for (Windsurf, Aider, Copilot CLI, the Copilot cloud agent) are
30
+ * left out rather than guessed.
31
+ *
32
+ * The first match wins. Every variable here is set by the agent itself when it
33
+ * launches a command, never by an IDE or extension in a terminal a person types
34
+ * in, so a human at the keyboard is never labelled as an agent.
35
+ */
36
+ const AGENT_SIGNALS = [
37
+ // Cursor agent terminals: "Use the CURSOR_AGENT environment variable in your
38
+ // shell config to detect when Cursor is running".
39
+ // https://cursor.com/docs/agent/tools/terminal
40
+ { client: "cursor", vars: ["CURSOR_AGENT"] },
41
+ // GitHub Copilot agent mode in VS Code sets COPILOT_AGENT=1 in agent terminals.
42
+ // Sent as "vscode", the name the gateway already has for VS Code's agent.
43
+ // https://github.com/microsoft/vscode/pull/316267
44
+ // https://github.com/microsoft/vscode/blob/45373f06ff77cc97a7754a376548d8937fb3af54/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/toolTerminalCreator.ts#L156-L159
45
+ { client: "vscode", vars: ["COPILOT_AGENT"] },
46
+ // OpenAI Codex CLI injects CODEX_THREAD_ID into every shell-tool environment,
47
+ // and CODEX_SANDBOX when the command runs sandboxed.
48
+ // https://github.com/openai/codex/blob/b741e480e203f037ca726bc2a76d99a8e8668e66/codex-rs/protocol/src/shell_environment.rs#L151-L154
49
+ // https://github.com/openai/codex/blob/b741e480e203f037ca726bc2a76d99a8e8668e66/codex-rs/core/src/spawn.rs#L23-L26
50
+ { client: "codex", vars: ["CODEX_THREAD_ID", "CODEX_SANDBOX"] },
51
+ // Gemini CLI sets GEMINI_CLI=1 for every shell command it executes.
52
+ // https://google-gemini.github.io/gemini-cli/docs/cli/commands.html
53
+ // https://github.com/google-gemini/gemini-cli/blob/fb972b2f87fe7d5b06d37eac711490162d98de2c/packages/core/src/services/shellExecutionService.ts#L582-L585
54
+ { client: "gemini-cli", vars: ["GEMINI_CLI"] },
55
+ // Claude Code sets CLAUDE_CODE_CHILD_SESSION=1 in what its Bash, PowerShell and
56
+ // Monitor tools, hooks and status line spawn, "only set by Claude Code itself
57
+ // ... and not by IDE extensions" (v2.1.172+). Not CLAUDECODE: the IDE extensions
58
+ // export that into every integrated terminal, so a person typing `zenrows` there
59
+ // would read as Claude Code. Older versions send no name rather than a wrong one,
60
+ // and so does a stdio MCP server, which gets neither variable's guarantee.
61
+ // https://code.claude.com/docs/en/env-vars
62
+ { client: "claude-code", vars: ["CLAUDE_CODE_CHILD_SESSION"] },
63
+ ];
64
+ /** Every variable detection reads, so tests can keep the ambient shell out. */
65
+ export const AGENT_ENV_VARS = AGENT_SIGNALS.flatMap((s) => s.vars);
66
+ /**
67
+ * The client name to send, or `undefined` to send nothing. Pure: reads only
68
+ * `env`. A valid `ZENROWS_CLIENT` override is sent even when no agent is
69
+ * detected: setting it is an explicit opt-in. It replaces detection entirely, so
70
+ * an override that is not a valid name sends nothing rather than a detected name
71
+ * the user asked not to send.
72
+ */
73
+ export function detectAgentClient(env) {
74
+ const override = env[CLIENT_OVERRIDE_ENV]?.trim();
75
+ if (override) {
76
+ // Same normalisation the gateway applies: lower-case, spaces to dashes.
77
+ const name = override.toLowerCase().split(/\s+/).join("-");
78
+ return CLIENT_NAME.test(name) ? name : undefined;
79
+ }
80
+ return AGENT_SIGNALS.find((s) => s.vars.some((v) => isSet(env[v])))?.client;
81
+ }
82
+ /** Empty, `0` and `false` (any case) count as unset, the way `CLAUDE_CODE_CHILD_SESSION=0` reads. */
83
+ function isSet(value) {
84
+ const v = value?.trim().toLowerCase();
85
+ return Boolean(v) && v !== "0" && v !== "false";
86
+ }
87
+ /**
88
+ * The header to spread into a request to a Zenrows API: `{ "X-ZenRows-Client":
89
+ * name }` when there is a name and attribution is on, `{}` otherwise. Pass
90
+ * `projectRoot` when the caller works on a workspace other than the one above
91
+ * the cwd (`init --workspace`), so that workspace's `telemetry: "off"` applies.
92
+ */
93
+ export function agentClientHeader(opts = {}) {
94
+ if (!attributionEnabled(opts.projectRoot))
95
+ return {};
96
+ const client = detectAgentClient(opts.env ?? process.env);
97
+ return client ? { [CLIENT_HEADER]: client } : {};
98
+ }
@@ -19,6 +19,8 @@ import { join } from "node:path";
19
19
  import { ToolkitError, isKeyCapReached, keyCapReached, quotaExhausted } from "./errors.js";
20
20
  import { readAccount } from "./agent-account.js";
21
21
  import { registerSecret } from "./logger.js";
22
+ import { CLI_USER_AGENT } from "./config.js";
23
+ import { agentClientHeader } from "./agent-client.js";
22
24
  /** Confirmed Batch API base (no trailing slash). */
23
25
  export const DEFAULT_BATCH_API_BASE = "https://async.api.zenrows.com/v1";
24
26
  /** Env var to override the Batch API base (local/staging testing). */
@@ -50,7 +52,8 @@ export async function batchRequest(method, path, opts) {
50
52
  const headers = {
51
53
  "X-API-Key": opts.apiKey,
52
54
  Accept: "application/json",
53
- "User-Agent": "zenrows-cli",
55
+ "User-Agent": CLI_USER_AGENT,
56
+ ...agentClientHeader(),
54
57
  };
55
58
  if (opts.body !== undefined)
56
59
  headers["Content-Type"] = "application/json";
@@ -14,7 +14,8 @@
14
14
  import { ToolkitError, isKeyCapReached, keyCapReached, quotaExhausted } from "./errors.js";
15
15
  import { readAccount } from "./agent-account.js";
16
16
  import { registerSecret } from "./logger.js";
17
- import { CLI_VERSION } from "./config.js";
17
+ import { CLI_USER_AGENT } from "./config.js";
18
+ import { agentClientHeader } from "./agent-client.js";
18
19
  /** Managed Browser session API base (no trailing slash). Tied to the MCP host. */
19
20
  export const DEFAULT_BROWSER_BASE = "https://mcp.zenrows.com";
20
21
  /** Env var to override the Browser API base (local/staging testing). */
@@ -42,7 +43,8 @@ export async function browserRequest(method, path, opts) {
42
43
  const headers = {
43
44
  Authorization: `Bearer ${opts.apiKey}`,
44
45
  Accept: "application/json",
45
- "User-Agent": `zenrows-cli/${CLI_VERSION}`,
46
+ "User-Agent": CLI_USER_AGENT,
47
+ ...agentClientHeader(),
46
48
  };
47
49
  if (opts.body !== undefined)
48
50
  headers["Content-Type"] = "application/json";
@@ -11,19 +11,23 @@ export const CONFIG_VERSION = "0.1.0";
11
11
  * entrypoint) so core modules — telemetry, signup provenance — can read it
12
12
  * without an import cycle. `VERSION` in `cli/index.ts` re-exports this.
13
13
  */
14
- export const CLI_VERSION = "1.3.0";
14
+ export const CLI_VERSION = "1.3.1";
15
+ /** The User-Agent on every request to a Zenrows API. The gateway reads the version from it. */
16
+ export const CLI_USER_AGENT = `zenrows-cli/${CLI_VERSION}`;
15
17
  /** Env var to override the Fetch and Extract API base (local/staging testing). */
16
18
  export const API_BASE_ENV = "ZENROWS_API_BASE";
17
19
  /**
18
20
  * Env var to opt out of anonymous attribution. The toolkit never POSTs to a
19
21
  * telemetry endpoint; attribution is only anonymous provenance headers on the
20
- * signup request + `utm_*` params on the browser URLs a human opens. Setting
21
- * this to `off` (or config `telemetry: "off"`) suppresses all of it.
22
+ * signup request, the `X-ZenRows-Client` agent name on API requests
23
+ * (`agent-client.ts`), and `utm_*` params on the browser URLs a human opens.
24
+ * Setting this to `off` (or config `telemetry: "off"`) suppresses all of it.
22
25
  */
23
26
  export const TELEMETRY_ENV = "ZENROWS_TELEMETRY";
24
27
  /**
25
- * Whether to attach anonymous attribution (signup provenance headers + `utm_*`
26
- * on browser URLs). Off when `ZENROWS_TELEMETRY=off` or config `telemetry:"off"`.
28
+ * Whether to attach anonymous attribution (signup provenance headers, the
29
+ * `X-ZenRows-Client` agent name, `utm_*` on browser URLs). Off when
30
+ * `ZENROWS_TELEMETRY=off` or config `telemetry:"off"`.
27
31
  * There is no telemetry beacon — this only gates what rides on requests/URLs
28
32
  * the toolkit already makes.
29
33
  */
@@ -9,7 +9,8 @@ import { ToolkitError, isKeyCapReached, keyCapReached, quotaExhausted } from "./
9
9
  import { readAccount } from "./agent-account.js";
10
10
  import { ENV_KEY, resolveApiKey } from "./auth.js";
11
11
  import { registerSecret } from "./logger.js";
12
- import { CLI_VERSION } from "./config.js";
12
+ import { CLI_USER_AGENT } from "./config.js";
13
+ import { agentClientHeader } from "./agent-client.js";
13
14
  /**
14
15
  * Client-side timeout for a Fetch and Extract request, deliberately ABOVE the
15
16
  * gateway's own 90s request budget.
@@ -56,7 +57,7 @@ export async function scrape(apiBase, apiKey, params, opts = {}) {
56
57
  try {
57
58
  res = await fetch(full, {
58
59
  method: "GET",
59
- headers: { "User-Agent": `zenrows-cli/${CLI_VERSION}`, "Accept-Encoding": "gzip, deflate" },
60
+ headers: { "User-Agent": CLI_USER_AGENT, "Accept-Encoding": "gzip, deflate", ...agentClientHeader({ projectRoot: opts.projectRoot }) },
60
61
  signal: controller.signal,
61
62
  });
62
63
  }
@@ -11,6 +11,8 @@ import { isQuotaError, zrErrorDetail } from "./http.js";
11
11
  import { readAccount } from "./agent-account.js";
12
12
  import { ENV_KEY, resolveApiKey } from "./auth.js";
13
13
  import { registerSecret } from "./logger.js";
14
+ import { CLI_USER_AGENT } from "./config.js";
15
+ import { agentClientHeader } from "./agent-client.js";
14
16
  /** Build the plan-usage URL from the configured API base. */
15
17
  export function usageUrl(apiBase) {
16
18
  const base = apiBase.endsWith("/") ? apiBase : apiBase + "/";
@@ -27,7 +29,7 @@ export async function fetchUsage(apiBase, apiKey, opts = {}) {
27
29
  try {
28
30
  res = await doFetch(url, {
29
31
  method: "GET",
30
- headers: { "X-API-Key": apiKey, Accept: "application/json", "User-Agent": "zenrows-cli" },
32
+ headers: { "X-API-Key": apiKey, Accept: "application/json", "User-Agent": CLI_USER_AGENT, ...agentClientHeader() },
31
33
  signal: controller.signal,
32
34
  });
33
35
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenrows/cli",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "Open-source CLI, MCP, skills, templates, workflows, recipes, and evals layer for giving AI agents reliable access to protected web data through Zenrows cloud infrastructure.",
5
5
  "type": "module",
6
6
  "license": "MIT",