@scrappycoco/cli 0.4.0 → 0.4.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.
Files changed (3) hide show
  1. package/README.md +28 -1
  2. package/dist/index.js +330 -76
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -12,7 +12,23 @@ npx --yes @scrappycoco/cli setup
12
12
 
13
13
  `setup` opens OAuth in the browser. New users can create an account in that
14
14
  flow. After authentication, it installs the public Scrappycoco skill, verifies
15
- the live catalog, and tells you when to reload or restart your agent.
15
+ the live catalog, and tells you when to reload or restart your agent. Browser
16
+ authorization is not complete until the terminal confirms that the credential
17
+ was saved and the catalog check succeeded.
18
+
19
+ When the CLI runs on a remote or headless host whose `127.0.0.1` is not the
20
+ browser's localhost, keep the command running and use the manual callback
21
+ flow:
22
+
23
+ ```sh
24
+ npx --yes @scrappycoco/cli setup --no-browser --manual-callback
25
+ ```
26
+
27
+ Open the printed authorization URL, then paste the browser's final callback
28
+ URL into that same terminal. The callback contains a short-lived
29
+ authorization code: never paste it into chat, logs, or a saved file. A timed
30
+ out or exited login cannot be resumed; start a new command and use its new
31
+ URL.
16
32
 
17
33
  Individual commands remain available:
18
34
 
@@ -22,6 +38,7 @@ npx --yes @scrappycoco/cli catalog list --available --json
22
38
  npx --yes @scrappycoco/cli catalog inspect web.extract_content --json
23
39
  npx --yes @scrappycoco/cli run web.extract_content --file request.json --json
24
40
  npx --yes @scrappycoco/cli discover --file discovery.json --json
41
+ npx --yes @scrappycoco/cli discover --id DISCOVERY_ID --test --input '{"url":"https://example.com"}' --json
25
42
  npx --yes @scrappycoco/cli discover --id DISCOVERY_ID --finalize --json
26
43
  npx --yes @scrappycoco/cli run --config DISCOVERY_ID --input '{}' --json
27
44
  ```
@@ -35,6 +52,10 @@ assistant, agent, workflow, or schedule commands.
35
52
 
36
53
  Node.js 20 or newer is required. Interactive use authenticates with Clerk OAuth Authorization Code + PKCE. CI can set `SCRAPPYCOCO_API_KEY`.
37
54
 
55
+ OAuth and API requests have bounded timeouts. Set
56
+ `SCRAPPYCOCO_AUTH_TIMEOUT_MS` or `SCRAPPYCOCO_API_TIMEOUT_MS` only when a slow
57
+ network requires a longer window.
58
+
38
59
  Use `--json` for machine-readable responses. Execution commands support
39
60
  `--format json|jsonl|csv` with `--output`, provider-native
40
61
  `--provider-options`, batch `--concurrency`, and an explicit
@@ -43,6 +64,12 @@ poll for completion; set `SCRAPPYCOCO_JOB_TIMEOUT_MS` to change the 20-minute
43
64
  local wait. If a command times out while its job continues, inspect it with
44
65
  `scrappycoco jobs get <job-id>`.
45
66
 
67
+ When provider or configuration choice is uncertain, define 2 to 5 named
68
+ `candidates` in a Discover route. Each candidate has its own `id`, `provider`,
69
+ and `options`, so one discovery can test both `zyte-http` with
70
+ `{"browser_html":false}` and `zyte-browser` with
71
+ `{"browser_html":true}` against the same input.
72
+
46
73
  Use `scrappycoco --help` for the complete command reference. See the
47
74
  [Scrappycoco API documentation](https://scrappycoco.ai/docs) for the public
48
75
  contract.
package/dist/index.js CHANGED
@@ -1,13 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { randomUUID as randomUUID2 } from "crypto";
4
+ import { randomUUID as randomUUID3 } from "crypto";
5
5
  import { readFileSync } from "fs";
6
6
  import { Command, CommanderError, Option } from "commander";
7
7
 
8
8
  // src/auth.ts
9
9
  import { createHash, randomBytes, timingSafeEqual } from "crypto";
10
10
  import { createServer } from "http";
11
+ import { createInterface } from "readline/promises";
11
12
  import open2 from "open";
12
13
 
13
14
  // src/errors.ts
@@ -32,19 +33,25 @@ var CliError = class extends Error {
32
33
  };
33
34
 
34
35
  // src/storage.ts
35
- import { chmod, mkdir, open, readFile, rm, stat, writeFile } from "fs/promises";
36
+ import { randomUUID } from "crypto";
37
+ import { chmod, mkdir, open, readFile, rename, rm, stat, writeFile } from "fs/promises";
36
38
  import { homedir } from "os";
37
39
  import { dirname, join } from "path";
38
40
  import { deletePassword, getPassword, setPassword } from "cross-keychain";
39
41
  var SERVICE = "scrappycoco-cli";
40
42
  var ACCOUNT = "refresh-token";
41
43
  function fallbackCredentialPath() {
44
+ const configured = process.env.SCRAPPYCOCO_CONFIG_HOME;
45
+ if (configured) return join(configured, "credentials.json");
42
46
  const base = process.platform === "win32" ? process.env.APPDATA || join(homedir(), "AppData", "Roaming") : process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
43
47
  return join(base, "scrappycoco", "credentials.json");
44
48
  }
45
49
  function credentialRefreshLockPath() {
46
50
  return `${fallbackCredentialPath()}.refresh.lock`;
47
51
  }
52
+ function credentialStatePath() {
53
+ return `${fallbackCredentialPath()}.state`;
54
+ }
48
55
  var LOCK_RETRY_MS = 100;
49
56
  var LOCK_TIMEOUT_MS = 3e4;
50
57
  var LOCK_STALE_MS = 6e4;
@@ -93,23 +100,89 @@ async function readFallback() {
93
100
  return null;
94
101
  }
95
102
  }
96
- async function loadRefreshToken() {
103
+ async function readKeychain() {
97
104
  try {
98
105
  const value = await getPassword(SERVICE, ACCOUNT);
99
- if (value) return value;
106
+ return value || null;
107
+ } catch {
108
+ return null;
109
+ }
110
+ }
111
+ async function readCredentialState() {
112
+ try {
113
+ const value = JSON.parse(await readFile(credentialStatePath(), "utf8"));
114
+ return value.storage === "keychain" || value.storage === "file" || value.storage === "none" ? value.storage : null;
115
+ } catch {
116
+ return null;
117
+ }
118
+ }
119
+ async function writePrivateJson(path, value) {
120
+ await mkdir(dirname(path), { recursive: true, mode: 448 });
121
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
122
+ try {
123
+ await writeFile(temporary, JSON.stringify(value), { mode: 384 });
124
+ if (process.platform !== "win32") await chmod(temporary, 384);
125
+ await rename(temporary, path);
126
+ if (process.platform !== "win32") await chmod(path, 384);
127
+ } finally {
128
+ await rm(temporary, { force: true });
129
+ }
130
+ }
131
+ async function writeCredentialState(storage) {
132
+ await writePrivateJson(credentialStatePath(), { storage });
133
+ }
134
+ async function loadRefreshTokenCandidates() {
135
+ const state = await readCredentialState();
136
+ if (state === "none") return [];
137
+ if (state === "file") {
138
+ const value = await readFallback();
139
+ return value ? [{ value, storage: "file" }] : [];
140
+ }
141
+ if (state === "keychain") {
142
+ const value = await readKeychain();
143
+ return value ? [{ value, storage: "keychain" }] : [];
144
+ }
145
+ const [keychain, file] = await Promise.all([readKeychain(), readFallback()]);
146
+ const candidates = [];
147
+ if (keychain) candidates.push({ value: keychain, storage: "keychain" });
148
+ if (file && file !== keychain) candidates.push({ value: file, storage: "file" });
149
+ return candidates;
150
+ }
151
+ async function prepareCredentialStorage() {
152
+ const state = await readCredentialState();
153
+ if (state === "file") {
154
+ await assertFallbackWritable();
155
+ return "file";
156
+ }
157
+ try {
158
+ await getPassword(SERVICE, ACCOUNT);
159
+ return "keychain";
100
160
  } catch {
161
+ await assertFallbackWritable();
162
+ return "file";
101
163
  }
102
- return readFallback();
164
+ }
165
+ async function assertFallbackWritable() {
166
+ const path = fallbackCredentialPath();
167
+ await mkdir(dirname(path), { recursive: true, mode: 448 });
168
+ const probe = `${path}.write-test-${process.pid}-${randomUUID()}`;
169
+ const handle = await open(probe, "w", 384);
170
+ await handle.close();
171
+ await rm(probe, { force: true });
103
172
  }
104
173
  async function saveRefreshToken(refreshToken) {
105
174
  try {
106
175
  await setPassword(SERVICE, ACCOUNT, refreshToken);
176
+ await rm(fallbackCredentialPath(), { force: true });
177
+ await writeCredentialState("keychain");
107
178
  return "keychain";
108
179
  } catch {
109
- const path = fallbackCredentialPath();
110
- await mkdir(dirname(path), { recursive: true, mode: 448 });
111
- await writeFile(path, JSON.stringify({ refresh_token: refreshToken }), { mode: 384 });
112
- if (process.platform !== "win32") await chmod(path, 384);
180
+ await writePrivateJson(fallbackCredentialPath(), { refresh_token: refreshToken });
181
+ await writeCredentialState("file");
182
+ try {
183
+ await deletePassword(SERVICE, ACCOUNT);
184
+ } catch {
185
+ }
113
186
  return "file";
114
187
  }
115
188
  }
@@ -119,11 +192,42 @@ async function clearRefreshToken() {
119
192
  } catch {
120
193
  }
121
194
  await rm(fallbackCredentialPath(), { force: true });
195
+ await writeCredentialState("none");
122
196
  }
123
197
 
124
198
  // src/auth.ts
125
199
  var cachedAccessToken;
126
200
  var refreshesInFlight = /* @__PURE__ */ new Map();
201
+ var DEFAULT_AUTH_HTTP_TIMEOUT_MS = 15e3;
202
+ function positiveInteger(value, fallback) {
203
+ if (!value) return fallback;
204
+ const parsed = Number(value);
205
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
206
+ }
207
+ function authHttpTimeoutMs() {
208
+ return positiveInteger(process.env.SCRAPPYCOCO_AUTH_TIMEOUT_MS, DEFAULT_AUTH_HTTP_TIMEOUT_MS);
209
+ }
210
+ function networkMessage(error) {
211
+ if (error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError")) {
212
+ return `timed out after ${Math.ceil(authHttpTimeoutMs() / 1e3)} seconds`;
213
+ }
214
+ return "failed to reach the authentication service";
215
+ }
216
+ async function oauthFetch(url, init, phase, exitCode) {
217
+ try {
218
+ return await fetch(url, {
219
+ ...init,
220
+ signal: AbortSignal.timeout(authHttpTimeoutMs())
221
+ });
222
+ } catch (error) {
223
+ const timeout = error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError");
224
+ throw new CliError(
225
+ `OAuth ${phase} ${networkMessage(error)}. ${phase === "authorization-code exchange" ? "Authorization was received but was not saved; run `scrappycoco auth login` again." : "Check the connection and try again."}`,
226
+ timeout ? EXIT.timeout : exitCode,
227
+ { phase, network_error: true }
228
+ );
229
+ }
230
+ }
127
231
  async function oauthConfig(apiUrl) {
128
232
  const clientId = process.env.SCRAPPYCOCO_OAUTH_CLIENT_ID;
129
233
  if (clientId) {
@@ -133,7 +237,12 @@ async function oauthConfig(apiUrl) {
133
237
  scopes: ["openid", "profile", "email"]
134
238
  };
135
239
  }
136
- const response = await fetch(`${apiUrl.replace(/\/$/, "")}/api/v1/oauth/cli-config`);
240
+ const response = await oauthFetch(
241
+ `${apiUrl.replace(/\/$/, "")}/api/v1/oauth/cli-config`,
242
+ {},
243
+ "configuration request",
244
+ EXIT.api
245
+ );
137
246
  const payload = await response.json().catch(() => ({}));
138
247
  if (!response.ok || !payload.issuer || !payload.client_id || !Array.isArray(payload.scopes)) {
139
248
  throw new CliError(payload.detail || "Scrappycoco CLI OAuth is not configured.", EXIT.auth);
@@ -156,49 +265,99 @@ function statesMatch(expected, received) {
156
265
  const right = Buffer.from(received);
157
266
  return left.length === right.length && timingSafeEqual(left, right);
158
267
  }
159
- async function tokenRequest(issuer, body) {
160
- const response = await fetch(`${issuer.replace(/\/$/, "")}/oauth/token`, {
161
- method: "POST",
162
- headers: { "content-type": "application/x-www-form-urlencoded" },
163
- body
164
- });
268
+ async function tokenRequest(issuer, body, phase) {
269
+ const response = await oauthFetch(
270
+ `${issuer.replace(/\/$/, "")}/oauth/token`,
271
+ {
272
+ method: "POST",
273
+ headers: { "content-type": "application/x-www-form-urlencoded" },
274
+ body
275
+ },
276
+ phase,
277
+ EXIT.auth
278
+ );
165
279
  const payload = await response.json().catch(() => ({}));
166
280
  if (!response.ok || typeof payload.access_token !== "string") {
167
- throw new CliError(String(payload.error_description || payload.error || "OAuth token exchange failed."), EXIT.auth);
281
+ throw new CliError(
282
+ String(payload.error_description || payload.error || `OAuth ${phase} failed.`),
283
+ EXIT.auth,
284
+ {
285
+ phase,
286
+ status: response.status,
287
+ ...typeof payload.error === "string" ? { oauth_error: payload.error } : {}
288
+ }
289
+ );
168
290
  }
169
291
  return payload;
170
292
  }
293
+ function callbackResult(rawUrl, expectedState, redirectUri) {
294
+ const current = new URL(rawUrl, redirectUri);
295
+ const expected = new URL(redirectUri);
296
+ if (current.protocol !== expected.protocol || current.hostname !== expected.hostname || current.port !== expected.port || current.pathname !== expected.pathname) {
297
+ throw new CliError("OAuth callback URL did not match this login attempt.", EXIT.auth);
298
+ }
299
+ const state = current.searchParams.get("state") || "";
300
+ const providerError = current.searchParams.get("error");
301
+ if (!statesMatch(expectedState, state)) {
302
+ throw new CliError("OAuth state validation failed.", EXIT.auth);
303
+ }
304
+ if (providerError) {
305
+ const description = current.searchParams.get("error_description") || providerError;
306
+ throw new CliError(`OAuth authorization was denied: ${description}`, EXIT.auth);
307
+ }
308
+ const code = current.searchParams.get("code") || "";
309
+ if (!code) throw new CliError("OAuth callback did not include an authorization code.", EXIT.auth);
310
+ return { code, redirectUri };
311
+ }
171
312
  async function login(options) {
172
313
  const config = await oauthConfig(options.apiUrl);
173
314
  const clientId = config.client_id;
174
315
  const pkce = createPkce();
175
316
  let timeout;
317
+ let manualReader;
176
318
  const callback = new Promise((resolve, reject) => {
319
+ let settled = false;
320
+ const finish = (result, error) => {
321
+ if (settled) return;
322
+ settled = true;
323
+ if (timeout) clearTimeout(timeout);
324
+ manualReader?.close();
325
+ server.close();
326
+ if (error) reject(error);
327
+ else if (result) resolve(result);
328
+ };
177
329
  const server = createServer((request, response) => {
178
- const current = new URL(request.url || "/", "http://127.0.0.1");
179
- if (current.pathname !== "/callback") {
180
- response.writeHead(404).end("Not found");
330
+ const address = server.address();
331
+ if (!address || typeof address === "string") {
332
+ response.writeHead(500, { "content-type": "text/plain" }).end("OAuth callback failed.");
333
+ finish(void 0, new CliError("OAuth callback failed.", EXIT.auth));
181
334
  return;
182
335
  }
183
- const state = current.searchParams.get("state") || "";
184
- const code2 = current.searchParams.get("code") || "";
185
- if (!statesMatch(pkce.state, state) || !code2) {
186
- response.writeHead(400, { "content-type": "text/plain" }).end("Invalid OAuth callback. You may close this window.");
187
- clearTimeout(timeout);
188
- server.close();
189
- reject(new CliError("OAuth state validation failed.", EXIT.auth));
336
+ const redirectUri2 = `http://127.0.0.1:${address.port}/callback`;
337
+ const current = new URL(request.url || "/", redirectUri2);
338
+ if (current.pathname !== "/callback") {
339
+ response.writeHead(404, { "content-type": "text/plain" }).end("Not found");
190
340
  return;
191
341
  }
192
- const address = server.address();
193
- if (!address || typeof address === "string") return reject(new CliError("OAuth callback failed.", EXIT.auth));
194
- response.writeHead(200, { "content-type": "text/plain" }).end("Scrappycoco login complete. You may close this window.");
195
- clearTimeout(timeout);
196
- server.close();
197
- resolve({ code: code2, redirectUri: `http://127.0.0.1:${address.port}/callback` });
342
+ try {
343
+ const result = callbackResult(request.url || "/", pkce.state, redirectUri2);
344
+ response.writeHead(200, { "content-type": "text/plain" }).end(
345
+ "Scrappycoco authorization received. Keep the terminal open while it securely saves the login; the terminal will report when it is complete."
346
+ );
347
+ finish(result);
348
+ } catch (error) {
349
+ response.writeHead(400, { "content-type": "text/plain" }).end(
350
+ error instanceof Error ? error.message : "Invalid OAuth callback."
351
+ );
352
+ finish(void 0, error);
353
+ }
198
354
  });
199
355
  server.listen(0, "127.0.0.1", async () => {
200
356
  const address = server.address();
201
- if (!address || typeof address === "string") return reject(new CliError("Could not start OAuth callback.", EXIT.auth));
357
+ if (!address || typeof address === "string") {
358
+ finish(void 0, new CliError("Could not start OAuth callback.", EXIT.auth));
359
+ return;
360
+ }
202
361
  const redirectUri2 = `http://127.0.0.1:${address.port}/callback`;
203
362
  const authorize = new URL(`${config.issuer.replace(/\/$/, "")}/oauth/authorize`);
204
363
  authorize.search = new URLSearchParams({
@@ -213,11 +372,32 @@ async function login(options) {
213
372
  process.stderr.write(`Open this URL to authenticate:
214
373
  ${authorize.toString()}
215
374
  `);
216
- if (!options.noBrowser) await open2(authorize.toString());
375
+ if (options.manualCallback) {
376
+ process.stderr.write(
377
+ "After signing in, paste the final callback URL into this terminal. Never paste it into chat.\n"
378
+ );
379
+ manualReader = createInterface({ input: process.stdin, output: process.stderr });
380
+ void manualReader.question("Callback URL: ").then((rawUrl) => {
381
+ try {
382
+ finish(callbackResult(rawUrl.trim(), pkce.state, redirectUri2));
383
+ } catch (error) {
384
+ finish(void 0, error);
385
+ }
386
+ }).catch((error) => finish(void 0, error));
387
+ }
388
+ if (!options.noBrowser) {
389
+ try {
390
+ await open2(authorize.toString());
391
+ } catch {
392
+ process.stderr.write("Could not open a browser automatically; use the URL above.\n");
393
+ }
394
+ }
217
395
  });
218
396
  timeout = setTimeout(() => {
219
- server.close();
220
- reject(new CliError("OAuth login timed out.", EXIT.timeout));
397
+ finish(void 0, new CliError(
398
+ "OAuth login timed out. The printed URL and callback port are no longer valid; start a new login.",
399
+ EXIT.timeout
400
+ ));
221
401
  }, 5 * 6e4);
222
402
  });
223
403
  const { code, redirectUri } = await callback;
@@ -227,7 +407,7 @@ ${authorize.toString()}
227
407
  code,
228
408
  code_verifier: pkce.verifier,
229
409
  redirect_uri: redirectUri
230
- }));
410
+ }), "authorization-code exchange");
231
411
  if (!tokens.refresh_token) throw new CliError("OAuth response did not include a refresh token.", EXIT.auth);
232
412
  invalidateAccessToken();
233
413
  return { storage: await saveRefreshToken(tokens.refresh_token) };
@@ -251,16 +431,29 @@ async function accessToken(apiUrl) {
251
431
  return cachedAccessToken.value;
252
432
  }
253
433
  const config = await oauthConfig(normalizedApiUrl);
254
- const refreshToken = await loadRefreshToken();
255
- if (!refreshToken) throw new CliError("Not logged in. Run `scrappycoco auth login`.", EXIT.auth);
256
- const tokens = await tokenRequest(config.issuer, new URLSearchParams({
257
- grant_type: "refresh_token",
258
- client_id: config.client_id,
259
- refresh_token: refreshToken
260
- }));
261
- if (tokens.refresh_token && tokens.refresh_token !== refreshToken) {
262
- await saveRefreshToken(tokens.refresh_token);
434
+ const candidates = await loadRefreshTokenCandidates();
435
+ if (!candidates.length) throw new CliError("Not logged in. Run `scrappycoco auth login`.", EXIT.auth);
436
+ let tokens;
437
+ let acceptedRefreshToken;
438
+ let lastAuthError;
439
+ for (const candidate of candidates) {
440
+ try {
441
+ tokens = await tokenRequest(config.issuer, new URLSearchParams({
442
+ grant_type: "refresh_token",
443
+ client_id: config.client_id,
444
+ refresh_token: candidate.value
445
+ }), "token refresh");
446
+ acceptedRefreshToken = candidate.value;
447
+ break;
448
+ } catch (error) {
449
+ if (!(error instanceof CliError) || error.exitCode !== EXIT.auth || typeof error.details === "object" && error.details !== null && "network_error" in error.details) {
450
+ throw error;
451
+ }
452
+ lastAuthError = error;
453
+ }
263
454
  }
455
+ if (!tokens || !acceptedRefreshToken) throw lastAuthError || new CliError("Stored OAuth login is no longer valid.", EXIT.auth);
456
+ await saveRefreshToken(tokens.refresh_token || acceptedRefreshToken);
264
457
  cachedAccessToken = {
265
458
  apiUrl: normalizedApiUrl,
266
459
  value: tokens.access_token,
@@ -277,14 +470,18 @@ async function accessToken(apiUrl) {
277
470
  }
278
471
  }
279
472
  }
473
+ async function loadRefreshToken() {
474
+ return (await loadRefreshTokenCandidates())[0]?.value || null;
475
+ }
280
476
 
281
477
  // src/client.ts
282
- import { randomUUID } from "crypto";
478
+ import { randomUUID as randomUUID2 } from "crypto";
283
479
  var DEFAULT_API_URL = process.env.SCRAPPYCOCO_API_URL || "https://api.scrappycoco.ai";
284
480
  var DEFAULT_JOB_TIMEOUT_MS = 20 * 60 * 1e3;
285
481
  var DEFAULT_JOB_POLL_INITIAL_MS = 250;
286
482
  var DEFAULT_JOB_POLL_MAX_MS = 5e3;
287
- function positiveInteger(value, fallback) {
483
+ var DEFAULT_API_REQUEST_TIMEOUT_MS = 3e4;
484
+ function positiveInteger2(value, fallback) {
288
485
  if (!value) return fallback;
289
486
  const parsed = Number(value);
290
487
  return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
@@ -315,12 +512,28 @@ var ApiClient = class {
315
512
  async request(method, path, body, headers, signal) {
316
513
  const url = `${this.baseUrl.replace(/\/$/, "")}/api/v1${path}`;
317
514
  const requestBody = body === void 0 ? void 0 : JSON.stringify(body);
318
- const send = async () => fetch(url, {
319
- method,
320
- headers: await this.headers(headers),
321
- body: requestBody,
322
- signal
323
- });
515
+ const send = async () => {
516
+ const timeoutSignal = AbortSignal.timeout(
517
+ positiveInteger2(process.env.SCRAPPYCOCO_API_TIMEOUT_MS, DEFAULT_API_REQUEST_TIMEOUT_MS)
518
+ );
519
+ const requestSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
520
+ const requestHeaders = await this.headers(headers);
521
+ try {
522
+ return await fetch(url, {
523
+ method,
524
+ headers: requestHeaders,
525
+ body: requestBody,
526
+ signal: requestSignal
527
+ });
528
+ } catch (error) {
529
+ const timeout = error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError");
530
+ throw new CliError(
531
+ timeout ? `Scrappycoco API request timed out: ${method} ${path}.` : `Could not reach the Scrappycoco API: ${method} ${path}.`,
532
+ timeout ? EXIT.timeout : EXIT.api,
533
+ { method, path }
534
+ );
535
+ }
536
+ };
324
537
  let response = await send();
325
538
  if (response.status === 401 && !process.env.SCRAPPYCOCO_API_KEY) {
326
539
  invalidateAccessToken();
@@ -338,17 +551,17 @@ var ApiClient = class {
338
551
  get(path, signal) {
339
552
  return this.request("GET", path, void 0, void 0, signal);
340
553
  }
341
- post(path, body, key = randomUUID()) {
554
+ post(path, body, key = randomUUID2()) {
342
555
  return this.request("POST", path, body, { "Idempotency-Key": key });
343
556
  }
344
- async postJob(path, body, key = randomUUID()) {
557
+ async postJob(path, body, key = randomUUID2()) {
345
558
  const submitted = await this.post(path, body, key);
346
- const timeoutMs = positiveInteger(process.env.SCRAPPYCOCO_JOB_TIMEOUT_MS, DEFAULT_JOB_TIMEOUT_MS);
347
- const initialDelayMs = positiveInteger(
559
+ const timeoutMs = positiveInteger2(process.env.SCRAPPYCOCO_JOB_TIMEOUT_MS, DEFAULT_JOB_TIMEOUT_MS);
560
+ const initialDelayMs = positiveInteger2(
348
561
  process.env.SCRAPPYCOCO_JOB_POLL_INITIAL_MS,
349
562
  DEFAULT_JOB_POLL_INITIAL_MS
350
563
  );
351
- const maxDelayMs = positiveInteger(process.env.SCRAPPYCOCO_JOB_POLL_MAX_MS, DEFAULT_JOB_POLL_MAX_MS);
564
+ const maxDelayMs = positiveInteger2(process.env.SCRAPPYCOCO_JOB_POLL_MAX_MS, DEFAULT_JOB_POLL_MAX_MS);
352
565
  const deadline = Date.now() + timeoutMs;
353
566
  let delayMs = Math.min(initialDelayMs, maxDelayMs);
354
567
  let job = submitted;
@@ -490,7 +703,20 @@ async function detectSkillInstallerAgent(detector = determineAgent) {
490
703
  if (!detected.isAgent || !detected.agent) return "universal";
491
704
  return SKILL_INSTALLER_AGENTS[detected.agent.name] || "universal";
492
705
  }
493
- function skillInstallInvocation(agent = "universal") {
706
+ function skillInstallInvocation(agent = "universal", npmExecPath = process.env.npm_execpath) {
707
+ if (npmExecPath && /(?:^|[/\\])pnpm(?:\.c?js)?$/i.test(npmExecPath)) {
708
+ return {
709
+ command: process.execPath,
710
+ args: [
711
+ npmExecPath,
712
+ "dlx",
713
+ "skills",
714
+ ...SKILL_INSTALL_ARGS.slice(2),
715
+ "--agent",
716
+ agent
717
+ ]
718
+ };
719
+ }
494
720
  return {
495
721
  command: npxCommand(),
496
722
  args: [...SKILL_INSTALL_ARGS, "--agent", agent]
@@ -558,6 +784,7 @@ function defaultDependencies(apiUrl) {
558
784
  clearRefreshToken: clearRefreshToken2,
559
785
  loadRefreshToken,
560
786
  login,
787
+ prepareCredentialStorage,
561
788
  installSkill,
562
789
  listAvailableScrapers: () => client2.get("/scrapers?available_only=true")
563
790
  };
@@ -567,10 +794,15 @@ async function performSetup(options, dependencies = defaultDependencies(options.
567
794
  let authentication = usingApiKey ? "api_key" : "oauth";
568
795
  let credentialStorage = usingApiKey ? "environment" : "existing";
569
796
  const hadStoredToken = !usingApiKey && Boolean(await dependencies.loadRefreshToken());
797
+ if (!usingApiKey && !hadStoredToken) {
798
+ await dependencies.prepareCredentialStorage();
799
+ }
800
+ await dependencies.installSkill();
570
801
  if (!usingApiKey && !hadStoredToken) {
571
802
  const authenticated = await dependencies.login({
572
803
  apiUrl: options.apiUrl,
573
- noBrowser: options.noBrowser
804
+ noBrowser: options.noBrowser,
805
+ manualCallback: options.manualCallback
574
806
  });
575
807
  authentication = "oauth";
576
808
  credentialStorage = authenticated.storage;
@@ -585,12 +817,12 @@ async function performSetup(options, dependencies = defaultDependencies(options.
585
817
  await dependencies.clearRefreshToken();
586
818
  const authenticated = await dependencies.login({
587
819
  apiUrl: options.apiUrl,
588
- noBrowser: options.noBrowser
820
+ noBrowser: options.noBrowser,
821
+ manualCallback: options.manualCallback
589
822
  });
590
823
  credentialStorage = authenticated.storage;
591
824
  catalog2 = await dependencies.listAvailableScrapers();
592
825
  }
593
- await dependencies.installSkill();
594
826
  return {
595
827
  authenticated: true,
596
828
  authentication,
@@ -622,10 +854,11 @@ function client(command) {
622
854
  function collect(value, previous) {
623
855
  return [...previous, value];
624
856
  }
625
- program.command("setup").description("Authenticate, install the Scrappycoco skill, and verify the connection").option("--no-browser", "print the authorization URL without opening it").action(async (options, command) => {
857
+ program.command("setup").description("Authenticate, install the Scrappycoco skill, and verify the connection").option("--no-browser", "print the authorization URL without opening it").option("--manual-callback", "paste the final callback URL in this terminal for remote/headless login").action(async (options, command) => {
626
858
  const result = await performSetup({
627
859
  apiUrl: globals(command).apiUrl,
628
- noBrowser: options.browser === false
860
+ noBrowser: options.browser === false,
861
+ manualCallback: options.manualCallback
629
862
  });
630
863
  await emit(result, globals(command).json || false);
631
864
  });
@@ -664,8 +897,12 @@ async function emitExecution(response, options, command) {
664
897
  await emit(response, jsonMode);
665
898
  }
666
899
  var auth = program.command("auth").description("Manage Clerk OAuth credentials");
667
- auth.command("login").option("--no-browser", "print the authorization URL without opening it").action(async (options, command) => {
668
- const result = await login({ noBrowser: options.browser === false, apiUrl: globals(command).apiUrl });
900
+ auth.command("login").option("--no-browser", "print the authorization URL without opening it").option("--manual-callback", "paste the final callback URL in this terminal for remote/headless login").action(async (options, command) => {
901
+ const result = await login({
902
+ noBrowser: options.browser === false,
903
+ manualCallback: options.manualCallback,
904
+ apiUrl: globals(command).apiUrl
905
+ });
669
906
  await emit({ authenticated: true, storage: result.storage }, globals(command).json || false);
670
907
  });
671
908
  auth.command("status").action(async (_options, command) => {
@@ -676,7 +913,24 @@ auth.command("status").action(async (_options, command) => {
676
913
  process.exitCode = EXIT.auth;
677
914
  return;
678
915
  }
679
- const me = await client(command).get("/me");
916
+ let me;
917
+ try {
918
+ me = await client(command).get("/me");
919
+ } catch (error) {
920
+ if (error instanceof CliError && error.exitCode === EXIT.auth) {
921
+ await emit(
922
+ {
923
+ authenticated: false,
924
+ reason: error.message,
925
+ next_action: usingApiKey ? "Verify or replace the existing SCRAPPYCOCO_API_KEY environment variable." : "Run `scrappycoco auth login` to replace the stored OAuth login."
926
+ },
927
+ globals(command).json || false
928
+ );
929
+ process.exitCode = EXIT.auth;
930
+ return;
931
+ }
932
+ throw error;
933
+ }
680
934
  await emit(
681
935
  { authenticated: true, method: usingApiKey ? "api_key" : "oauth", account: me },
682
936
  globals(command).json || false
@@ -731,14 +985,14 @@ function executionCommand(name) {
731
985
  const response = await client(command).postJob(
732
986
  name === "run" ? "/scrapers/jobs" : "/scrapers/compare/jobs",
733
987
  payload,
734
- options.idempotencyKey || randomUUID2()
988
+ options.idempotencyKey || randomUUID3()
735
989
  );
736
990
  await emitExecution(response, options, command);
737
991
  });
738
992
  }
739
993
  executionCommand("run");
740
994
  executionCommand("compare");
741
- program.command("run [capability-id]").description("Run a capability directly; discovery is optional").option("--config <discovery-id>", "run a finalized multi-step configuration").option("-f, --file <path>", "canonical request JSON file").option("--input <json>", "capability input JSON; use url or urls for web.extract_content").option("--provider <id>", "one provider selected by the calling agent", collect, []).option("--provider-options <json>", "provider-native options keyed by provider ID").option("--concurrency <number>", "batch concurrency (default 3, maximum 10)").option("--limit <number>", "maximum records").option("--idempotency-key <key>", "stable retry key").option("--retry-failed <run-id>", "retry only failed URLs from a partial batch run").addOption(new Option("--format <format>", "output record format").choices(["json", "jsonl", "csv"]).default("json")).option("-o, --output <path>", "write records to a file").action(async (capabilityId, options, command) => {
995
+ program.command("run [capability-id]").description("Run a capability directly; discovery is optional").option("--config <discovery-id>", "run a finalized multi-step configuration").option("-f, --file <path>", "canonical request JSON file").option("--input <json>", "capability input JSON; use url or urls for web.extract_content").option("--provider <id>", "provider ID; repeat for an ordered fallback waterfall", collect, []).option("--provider-options <json>", "provider-native options keyed by provider ID").option("--concurrency <number>", "batch concurrency (default 3, maximum 10)").option("--limit <number>", "maximum records").option("--idempotency-key <key>", "stable retry key").option("--retry-failed <run-id>", "retry only failed URLs from a partial batch run").addOption(new Option("--format <format>", "output record format").choices(["json", "jsonl", "csv"]).default("json")).option("-o, --output <path>", "write records to a file").action(async (capabilityId, options, command) => {
742
996
  if (options.retryFailed) {
743
997
  if (capabilityId || options.config) {
744
998
  throw new CliError("Do not combine --retry-failed with a capability ID or --config.", EXIT.usage);
@@ -746,7 +1000,7 @@ program.command("run [capability-id]").description("Run a capability directly; d
746
1000
  const response2 = await client(command).postJob(
747
1001
  `/runs/${encodeURIComponent(options.retryFailed)}/retry-failed`,
748
1002
  {},
749
- options.idempotencyKey || randomUUID2()
1003
+ options.idempotencyKey || randomUUID3()
750
1004
  );
751
1005
  await emitExecution(response2, options, command);
752
1006
  return;
@@ -764,7 +1018,7 @@ program.command("run [capability-id]").description("Run a capability directly; d
764
1018
  input,
765
1019
  limit: Number(options.limit ?? fromFile.limit ?? 25)
766
1020
  },
767
- options.idempotencyKey || randomUUID2()
1021
+ options.idempotencyKey || randomUUID3()
768
1022
  );
769
1023
  await emitExecution(response2, options, command);
770
1024
  return;
@@ -777,7 +1031,7 @@ program.command("run [capability-id]").description("Run a capability directly; d
777
1031
  const response = await client(command).postJob(
778
1032
  "/scrapers/jobs",
779
1033
  payload,
780
- options.idempotencyKey || randomUUID2()
1034
+ options.idempotencyKey || randomUUID3()
781
1035
  );
782
1036
  await emitExecution(response, options, command);
783
1037
  });
@@ -832,7 +1086,7 @@ discoveries.command("run <discovery-id>").option("-f, --file <path>", "request J
832
1086
  const response = await client(command).postJob(
833
1087
  `/discoveries/${encodeURIComponent(discoveryId)}/jobs`,
834
1088
  payload,
835
- options.idempotencyKey || randomUUID2()
1089
+ options.idempotencyKey || randomUUID3()
836
1090
  );
837
1091
  await emitExecution(response, options, command);
838
1092
  });
@@ -880,7 +1134,7 @@ program.command("discover").description("Save, sample-test, or finalize an agent
880
1134
  const response = await client(command).postJob(
881
1135
  `/discoveries/${encodeURIComponent(options.id)}/jobs`,
882
1136
  { input, limit: 25 },
883
- options.idempotencyKey || randomUUID2()
1137
+ options.idempotencyKey || randomUUID3()
884
1138
  );
885
1139
  await emit(response, globals(command).json || false);
886
1140
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@scrappycoco/cli",
3
- "version": "0.4.0",
4
- "description": "CLI for Scrappycoco scraper discovery, execution, and provider comparison",
3
+ "version": "0.4.1",
4
+ "description": "CLI for Scrappycoco scraper discovery and execution",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "scrappycoco": "dist/index.js"