@scrappycoco/cli 0.4.0 → 0.4.2

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 +46 -9
  2. package/dist/index.js +515 -77
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -7,23 +7,47 @@ judgment.
7
7
  Run the published package directly with `npx`:
8
8
 
9
9
  ```sh
10
- npx --yes @scrappycoco/cli setup
10
+ npx --yes @scrappycoco/cli@latest setup
11
11
  ```
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
+ Before every ordinary command, the CLI compares the installed Scrappycoco skill
20
+ with the digest in the hash-verified public feed. When the digest changes, it
21
+ automatically refreshes the skill and prints a reload/restart notice to stderr.
22
+ Release-check or installer failures do not block the requested CLI command.
23
+ CLI-only users without an installed skill are not modified. Set
24
+ `SCRAPPYCOCO_DISABLE_SKILL_AUTO_UPDATE=1` to opt out.
25
+
26
+ When the CLI runs on a remote or headless host whose `127.0.0.1` is not the
27
+ browser's localhost, keep the command running and use the manual callback
28
+ flow:
29
+
30
+ ```sh
31
+ npx --yes @scrappycoco/cli@latest setup --no-browser --manual-callback
32
+ ```
33
+
34
+ Open the printed authorization URL, then paste the browser's final callback
35
+ URL into that same terminal. The callback contains a short-lived
36
+ authorization code: never paste it into chat, logs, or a saved file. A timed
37
+ out or exited login cannot be resumed; start a new command and use its new
38
+ URL.
16
39
 
17
40
  Individual commands remain available:
18
41
 
19
42
  ```sh
20
- npx --yes @scrappycoco/cli auth login
21
- npx --yes @scrappycoco/cli catalog list --available --json
22
- npx --yes @scrappycoco/cli catalog inspect web.extract_content --json
23
- npx --yes @scrappycoco/cli run web.extract_content --file request.json --json
24
- npx --yes @scrappycoco/cli discover --file discovery.json --json
25
- npx --yes @scrappycoco/cli discover --id DISCOVERY_ID --finalize --json
26
- npx --yes @scrappycoco/cli run --config DISCOVERY_ID --input '{}' --json
43
+ npx --yes @scrappycoco/cli@latest auth login
44
+ npx --yes @scrappycoco/cli@latest catalog list --available --json
45
+ npx --yes @scrappycoco/cli@latest catalog inspect web.extract_content --json
46
+ npx --yes @scrappycoco/cli@latest run web.extract_content --file request.json --json
47
+ npx --yes @scrappycoco/cli@latest discover --file discovery.json --json
48
+ npx --yes @scrappycoco/cli@latest discover --id DISCOVERY_ID --test --input '{"url":"https://example.com"}' --json
49
+ npx --yes @scrappycoco/cli@latest discover --id DISCOVERY_ID --finalize --json
50
+ npx --yes @scrappycoco/cli@latest run --config DISCOVERY_ID --input '{}' --json
27
51
  ```
28
52
 
29
53
  From a repository checkout, use `npm ci`, `npm run build`, and
@@ -35,6 +59,10 @@ assistant, agent, workflow, or schedule commands.
35
59
 
36
60
  Node.js 20 or newer is required. Interactive use authenticates with Clerk OAuth Authorization Code + PKCE. CI can set `SCRAPPYCOCO_API_KEY`.
37
61
 
62
+ OAuth and API requests have bounded timeouts. Set
63
+ `SCRAPPYCOCO_AUTH_TIMEOUT_MS` or `SCRAPPYCOCO_API_TIMEOUT_MS` only when a slow
64
+ network requires a longer window.
65
+
38
66
  Use `--json` for machine-readable responses. Execution commands support
39
67
  `--format json|jsonl|csv` with `--output`, provider-native
40
68
  `--provider-options`, batch `--concurrency`, and an explicit
@@ -43,6 +71,15 @@ poll for completion; set `SCRAPPYCOCO_JOB_TIMEOUT_MS` to change the 20-minute
43
71
  local wait. If a command times out while its job continues, inspect it with
44
72
  `scrappycoco jobs get <job-id>`.
45
73
 
74
+ When provider or configuration choice is uncertain, define 2 to 5 named
75
+ `candidates` in a Discover route. Each candidate has its own `id`, `provider`,
76
+ and `options`, so one discovery can test both `zyte-http` with
77
+ `{"browser_html":false}` and `zyte-browser` with
78
+ `{"browser_html":true}` against the same input.
79
+
80
+ The distributed skill includes a Discovery workbook with complete direct-Run,
81
+ comparison, update, finalization, fallback, and audit-handoff examples.
82
+
46
83
  Use `scrappycoco --help` for the complete command reference. See the
47
84
  [Scrappycoco API documentation](https://scrappycoco.ai/docs) for the public
48
85
  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 randomUUID4 } 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;
@@ -458,7 +671,7 @@ import { determineAgent } from "@vercel/detect-agent";
458
671
  var SKILL_SOURCE = "https://scrappycoco.ai";
459
672
  var SKILL_INSTALL_ARGS = [
460
673
  "--yes",
461
- "skills",
674
+ "skills@latest",
462
675
  "add",
463
676
  SKILL_SOURCE,
464
677
  "--skill",
@@ -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@latest",
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,
@@ -607,6 +839,185 @@ async function performSetup(options, dependencies = defaultDependencies(options.
607
839
  };
608
840
  }
609
841
 
842
+ // src/skill-update.ts
843
+ import { randomUUID as randomUUID3 } from "crypto";
844
+ import { access, chmod as chmod2, mkdir as mkdir2, open as open3, readFile as readFile3, rename as rename2, rm as rm2, stat as stat2, writeFile as writeFile3 } from "fs/promises";
845
+ import { homedir as homedir2 } from "os";
846
+ import { dirname as dirname2, join as join2 } from "path";
847
+ var SKILL_NAME = "scrappycoco";
848
+ var SKILL_INDEX_URL = `${SKILL_SOURCE}/.well-known/agent-skills/index.json`;
849
+ var SKILL_UPDATE_TIMEOUT_MS = 3e3;
850
+ var SKILL_UPDATE_LOCK_STALE_MS = 5 * 6e4;
851
+ var SHA256_DIGEST = /^sha256:[a-f0-9]{64}$/i;
852
+ function skillReleaseStatePath() {
853
+ return join2(dirname2(fallbackCredentialPath()), "skill-release.json");
854
+ }
855
+ function skillUpdateLockPath() {
856
+ return `${skillReleaseStatePath()}.lock`;
857
+ }
858
+ function installedSkillCandidates() {
859
+ return [
860
+ join2(homedir2(), ".agents", "skills", SKILL_NAME, "SKILL.md"),
861
+ join2(homedir2(), ".codex", "skills", SKILL_NAME, "SKILL.md"),
862
+ join2(homedir2(), ".claude", "skills", SKILL_NAME, "SKILL.md"),
863
+ join2(homedir2(), ".cursor", "skills", SKILL_NAME, "SKILL.md")
864
+ ];
865
+ }
866
+ async function isSkillInstalled() {
867
+ for (const path of installedSkillCandidates()) {
868
+ try {
869
+ await access(path);
870
+ return true;
871
+ } catch {
872
+ }
873
+ }
874
+ return false;
875
+ }
876
+ async function loadInstalledDigest() {
877
+ try {
878
+ const state = JSON.parse(
879
+ await readFile3(skillReleaseStatePath(), "utf8")
880
+ );
881
+ return typeof state.digest === "string" && SHA256_DIGEST.test(state.digest) ? state.digest : null;
882
+ } catch {
883
+ return null;
884
+ }
885
+ }
886
+ async function writePrivateJson2(path, value) {
887
+ await mkdir2(dirname2(path), { recursive: true, mode: 448 });
888
+ const temporary = `${path}.${process.pid}.${randomUUID3()}.tmp`;
889
+ try {
890
+ await writeFile3(temporary, JSON.stringify(value), { mode: 384 });
891
+ if (process.platform !== "win32") await chmod2(temporary, 384);
892
+ await rename2(temporary, path);
893
+ if (process.platform !== "win32") await chmod2(path, 384);
894
+ } finally {
895
+ await rm2(temporary, { force: true });
896
+ }
897
+ }
898
+ async function saveInstalledDigest(digest) {
899
+ await writePrivateJson2(skillReleaseStatePath(), {
900
+ digest,
901
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
902
+ });
903
+ }
904
+ async function fetchPublishedSkillDigest(fetcher = fetch) {
905
+ const response = await fetcher(SKILL_INDEX_URL, {
906
+ headers: { accept: "application/json" },
907
+ signal: AbortSignal.timeout(SKILL_UPDATE_TIMEOUT_MS)
908
+ });
909
+ if (!response.ok) {
910
+ throw new Error(`Skill release check returned HTTP ${response.status}.`);
911
+ }
912
+ const index = await response.json();
913
+ const entry = index.skills?.find((skill) => skill.name === SKILL_NAME);
914
+ if (!entry || typeof entry.digest !== "string" || !SHA256_DIGEST.test(entry.digest)) {
915
+ throw new Error("Skill release index did not contain a valid Scrappycoco digest.");
916
+ }
917
+ return entry.digest.toLowerCase();
918
+ }
919
+ function defaultDependencies2() {
920
+ return {
921
+ isInstalled: isSkillInstalled,
922
+ loadInstalledDigest,
923
+ fetchPublishedDigest: fetchPublishedSkillDigest,
924
+ install: installSkill,
925
+ saveInstalledDigest
926
+ };
927
+ }
928
+ async function checkAndInstallSkillUpdate(dependencies = defaultDependencies2()) {
929
+ if (!await dependencies.isInstalled()) return { status: "not_installed" };
930
+ let publishedDigest;
931
+ try {
932
+ publishedDigest = await dependencies.fetchPublishedDigest();
933
+ } catch {
934
+ return { status: "unavailable" };
935
+ }
936
+ if (await dependencies.loadInstalledDigest() === publishedDigest) {
937
+ return { status: "current" };
938
+ }
939
+ try {
940
+ await dependencies.install();
941
+ } catch (error) {
942
+ return {
943
+ status: "failed",
944
+ error: error instanceof Error ? error : new Error(String(error))
945
+ };
946
+ }
947
+ let stateSaved = true;
948
+ try {
949
+ await dependencies.saveInstalledDigest(publishedDigest);
950
+ } catch {
951
+ stateSaved = false;
952
+ }
953
+ return { status: "updated", digest: publishedDigest, state_saved: stateSaved };
954
+ }
955
+ async function withSkillUpdateLock(operation) {
956
+ const path = skillUpdateLockPath();
957
+ await mkdir2(dirname2(path), { recursive: true, mode: 448 });
958
+ let handle;
959
+ try {
960
+ handle = await open3(path, "wx", 384);
961
+ } catch (error) {
962
+ if (error.code !== "EEXIST") {
963
+ return { status: "unavailable" };
964
+ }
965
+ try {
966
+ const lock = await stat2(path);
967
+ if (Date.now() - lock.mtimeMs <= SKILL_UPDATE_LOCK_STALE_MS) {
968
+ return { status: "busy" };
969
+ }
970
+ await rm2(path, { force: true });
971
+ handle = await open3(path, "wx", 384);
972
+ } catch {
973
+ return { status: "busy" };
974
+ }
975
+ }
976
+ try {
977
+ await handle.writeFile(JSON.stringify({ pid: process.pid, created_at: Date.now() }));
978
+ return await operation();
979
+ } finally {
980
+ await handle.close();
981
+ await rm2(path, { force: true });
982
+ }
983
+ }
984
+ function autoUpdateDisabled() {
985
+ return ["1", "true", "yes"].includes(
986
+ (process.env.SCRAPPYCOCO_DISABLE_SKILL_AUTO_UPDATE || "").toLowerCase()
987
+ );
988
+ }
989
+ async function autoUpdateInstalledSkill() {
990
+ if (autoUpdateDisabled()) return { status: "disabled" };
991
+ let result;
992
+ try {
993
+ result = await withSkillUpdateLock(() => checkAndInstallSkillUpdate());
994
+ } catch {
995
+ return { status: "unavailable" };
996
+ }
997
+ if (result.status === "updated") {
998
+ process.stderr.write(
999
+ "Updated the Scrappycoco skill. Reload or restart the agent to use the new instructions.\n"
1000
+ );
1001
+ if (!result.state_saved) {
1002
+ process.stderr.write(
1003
+ "Warning: the skill update succeeded, but its release state could not be saved.\n"
1004
+ );
1005
+ }
1006
+ } else if (result.status === "failed") {
1007
+ process.stderr.write(
1008
+ `Warning: could not update the Scrappycoco skill; continuing with the installed copy: ${result.error.message}
1009
+ `
1010
+ );
1011
+ }
1012
+ return result;
1013
+ }
1014
+ async function rememberInstalledSkillRelease() {
1015
+ try {
1016
+ await saveInstalledDigest(await fetchPublishedSkillDigest());
1017
+ } catch {
1018
+ }
1019
+ }
1020
+
610
1021
  // src/index.ts
611
1022
  var packageMetadata = JSON.parse(
612
1023
  readFileSync(new URL("../package.json", import.meta.url), "utf8")
@@ -622,13 +1033,19 @@ function client(command) {
622
1033
  function collect(value, previous) {
623
1034
  return [...previous, value];
624
1035
  }
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) => {
1036
+ 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
1037
  const result = await performSetup({
627
1038
  apiUrl: globals(command).apiUrl,
628
- noBrowser: options.browser === false
1039
+ noBrowser: options.browser === false,
1040
+ manualCallback: options.manualCallback
629
1041
  });
1042
+ await rememberInstalledSkillRelease();
630
1043
  await emit(result, globals(command).json || false);
631
1044
  });
1045
+ program.hook("preAction", async (_thisCommand, actionCommand) => {
1046
+ if (actionCommand.name() === "setup") return;
1047
+ await autoUpdateInstalledSkill();
1048
+ });
632
1049
  function splitScraperId(value) {
633
1050
  const separator = value.indexOf(".");
634
1051
  if (separator <= 0 || separator === value.length - 1) {
@@ -664,8 +1081,12 @@ async function emitExecution(response, options, command) {
664
1081
  await emit(response, jsonMode);
665
1082
  }
666
1083
  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 });
1084
+ 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) => {
1085
+ const result = await login({
1086
+ noBrowser: options.browser === false,
1087
+ manualCallback: options.manualCallback,
1088
+ apiUrl: globals(command).apiUrl
1089
+ });
669
1090
  await emit({ authenticated: true, storage: result.storage }, globals(command).json || false);
670
1091
  });
671
1092
  auth.command("status").action(async (_options, command) => {
@@ -676,7 +1097,24 @@ auth.command("status").action(async (_options, command) => {
676
1097
  process.exitCode = EXIT.auth;
677
1098
  return;
678
1099
  }
679
- const me = await client(command).get("/me");
1100
+ let me;
1101
+ try {
1102
+ me = await client(command).get("/me");
1103
+ } catch (error) {
1104
+ if (error instanceof CliError && error.exitCode === EXIT.auth) {
1105
+ await emit(
1106
+ {
1107
+ authenticated: false,
1108
+ reason: error.message,
1109
+ next_action: usingApiKey ? "Verify or replace the existing SCRAPPYCOCO_API_KEY environment variable." : "Run `scrappycoco auth login` to replace the stored OAuth login."
1110
+ },
1111
+ globals(command).json || false
1112
+ );
1113
+ process.exitCode = EXIT.auth;
1114
+ return;
1115
+ }
1116
+ throw error;
1117
+ }
680
1118
  await emit(
681
1119
  { authenticated: true, method: usingApiKey ? "api_key" : "oauth", account: me },
682
1120
  globals(command).json || false
@@ -731,14 +1169,14 @@ function executionCommand(name) {
731
1169
  const response = await client(command).postJob(
732
1170
  name === "run" ? "/scrapers/jobs" : "/scrapers/compare/jobs",
733
1171
  payload,
734
- options.idempotencyKey || randomUUID2()
1172
+ options.idempotencyKey || randomUUID4()
735
1173
  );
736
1174
  await emitExecution(response, options, command);
737
1175
  });
738
1176
  }
739
1177
  executionCommand("run");
740
1178
  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) => {
1179
+ 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
1180
  if (options.retryFailed) {
743
1181
  if (capabilityId || options.config) {
744
1182
  throw new CliError("Do not combine --retry-failed with a capability ID or --config.", EXIT.usage);
@@ -746,7 +1184,7 @@ program.command("run [capability-id]").description("Run a capability directly; d
746
1184
  const response2 = await client(command).postJob(
747
1185
  `/runs/${encodeURIComponent(options.retryFailed)}/retry-failed`,
748
1186
  {},
749
- options.idempotencyKey || randomUUID2()
1187
+ options.idempotencyKey || randomUUID4()
750
1188
  );
751
1189
  await emitExecution(response2, options, command);
752
1190
  return;
@@ -764,7 +1202,7 @@ program.command("run [capability-id]").description("Run a capability directly; d
764
1202
  input,
765
1203
  limit: Number(options.limit ?? fromFile.limit ?? 25)
766
1204
  },
767
- options.idempotencyKey || randomUUID2()
1205
+ options.idempotencyKey || randomUUID4()
768
1206
  );
769
1207
  await emitExecution(response2, options, command);
770
1208
  return;
@@ -777,7 +1215,7 @@ program.command("run [capability-id]").description("Run a capability directly; d
777
1215
  const response = await client(command).postJob(
778
1216
  "/scrapers/jobs",
779
1217
  payload,
780
- options.idempotencyKey || randomUUID2()
1218
+ options.idempotencyKey || randomUUID4()
781
1219
  );
782
1220
  await emitExecution(response, options, command);
783
1221
  });
@@ -832,7 +1270,7 @@ discoveries.command("run <discovery-id>").option("-f, --file <path>", "request J
832
1270
  const response = await client(command).postJob(
833
1271
  `/discoveries/${encodeURIComponent(discoveryId)}/jobs`,
834
1272
  payload,
835
- options.idempotencyKey || randomUUID2()
1273
+ options.idempotencyKey || randomUUID4()
836
1274
  );
837
1275
  await emitExecution(response, options, command);
838
1276
  });
@@ -880,7 +1318,7 @@ program.command("discover").description("Save, sample-test, or finalize an agent
880
1318
  const response = await client(command).postJob(
881
1319
  `/discoveries/${encodeURIComponent(options.id)}/jobs`,
882
1320
  { input, limit: 25 },
883
- options.idempotencyKey || randomUUID2()
1321
+ options.idempotencyKey || randomUUID4()
884
1322
  );
885
1323
  await emit(response, globals(command).json || false);
886
1324
  });
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.2",
4
+ "description": "CLI for Scrappycoco scraper discovery and execution",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "scrappycoco": "dist/index.js"