@scrappycoco/cli 0.3.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 +46 -15
  2. package/dist/index.js +595 -94
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # Scrappycoco CLI
2
2
 
3
- Discover, run, and compare scraper capabilities from a terminal or automation environment.
3
+ Discover configurations and run scraper capabilities from a terminal or
4
+ automation environment. The calling AI agent owns all provider and result
5
+ judgment.
4
6
 
5
7
  Run the published package directly with `npx`:
6
8
 
@@ -10,34 +12,63 @@ npx --yes @scrappycoco/cli setup
10
12
 
11
13
  `setup` opens OAuth in the browser. New users can create an account in that
12
14
  flow. After authentication, it installs the public Scrappycoco skill, verifies
13
- 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.
14
32
 
15
33
  Individual commands remain available:
16
34
 
17
35
  ```sh
18
36
  npx --yes @scrappycoco/cli auth login
19
- npx --yes @scrappycoco/cli scrapers list --available --json
20
- npx --yes @scrappycoco/cli scrapers inspect web.extract_content --json
21
- npx --yes @scrappycoco/cli scrapers run web.extract_content --file request.json --json
22
- npx --yes @scrappycoco/cli scrapers compare web.extract_content --file request.json --json
37
+ npx --yes @scrappycoco/cli catalog list --available --json
38
+ npx --yes @scrappycoco/cli catalog inspect web.extract_content --json
39
+ npx --yes @scrappycoco/cli run web.extract_content --file request.json --json
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
42
+ npx --yes @scrappycoco/cli discover --id DISCOVERY_ID --finalize --json
43
+ npx --yes @scrappycoco/cli run --config DISCOVERY_ID --input '{}' --json
23
44
  ```
24
45
 
25
46
  From a repository checkout, use `npm ci`, `npm run build`, and
26
47
  `node dist/index.js <command>` instead.
27
48
 
28
- Use `providers list` to inspect routes and
29
- `discoveries create|list|get|update|run|delete` for saved multi-capability
30
- configurations. The CLI does not expose legacy assistant, agent, workflow, or
31
- schedule commands.
49
+ Discover is optional. Use Run directly when the current capability, provider,
50
+ and native options are already clear. The CLI does not expose legacy
51
+ assistant, agent, workflow, or schedule commands.
32
52
 
33
53
  Node.js 20 or newer is required. Interactive use authenticates with Clerk OAuth Authorization Code + PKCE. CI can set `SCRAPPYCOCO_API_KEY`.
34
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
+
35
59
  Use `--json` for machine-readable responses. Execution commands support
36
- `--format json|jsonl|csv` with `--output`, repeatable `--provider` flags, and an
37
- explicit `--idempotency-key` for safe identical retries. They submit durable
38
- jobs and poll for completion; set `SCRAPPYCOCO_JOB_TIMEOUT_MS` to change the
39
- 20-minute local wait. If a command times out while its job continues, inspect
40
- it with `scrappycoco jobs get <job-id>`.
60
+ `--format json|jsonl|csv` with `--output`, provider-native
61
+ `--provider-options`, batch `--concurrency`, and an explicit
62
+ `--idempotency-key` for safe identical retries. They submit durable jobs and
63
+ poll for completion; set `SCRAPPYCOCO_JOB_TIMEOUT_MS` to change the 20-minute
64
+ local wait. If a command times out while its job continues, inspect it with
65
+ `scrappycoco jobs get <job-id>`.
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.
41
72
 
42
73
  Use `scrappycoco --help` for the complete command reference. See the
43
74
  [Scrappycoco API documentation](https://scrappycoco.ai/docs) for the public
package/dist/index.js CHANGED
@@ -1,14 +1,15 @@
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 open from "open";
11
+ import { createInterface } from "readline/promises";
12
+ import open2 from "open";
12
13
 
13
14
  // src/errors.ts
14
15
  var EXIT = {
@@ -32,16 +33,65 @@ var CliError = class extends Error {
32
33
  };
33
34
 
34
35
  // src/storage.ts
35
- import { chmod, mkdir, readFile, rm, 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
  }
49
+ function credentialRefreshLockPath() {
50
+ return `${fallbackCredentialPath()}.refresh.lock`;
51
+ }
52
+ function credentialStatePath() {
53
+ return `${fallbackCredentialPath()}.state`;
54
+ }
55
+ var LOCK_RETRY_MS = 100;
56
+ var LOCK_TIMEOUT_MS = 3e4;
57
+ var LOCK_STALE_MS = 6e4;
58
+ function wait(milliseconds) {
59
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
60
+ }
61
+ async function withCredentialRefreshLock(operation) {
62
+ const path = credentialRefreshLockPath();
63
+ await mkdir(dirname(path), { recursive: true, mode: 448 });
64
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
65
+ while (true) {
66
+ try {
67
+ const handle = await open(path, "wx", 384);
68
+ try {
69
+ await handle.writeFile(JSON.stringify({ pid: process.pid, created_at: Date.now() }));
70
+ return await operation();
71
+ } finally {
72
+ await handle.close();
73
+ await rm(path, { force: true });
74
+ }
75
+ } catch (error) {
76
+ const code = error.code;
77
+ if (code !== "EEXIST") throw error;
78
+ try {
79
+ const lock = await stat(path);
80
+ if (Date.now() - lock.mtimeMs > LOCK_STALE_MS) {
81
+ await rm(path, { force: true });
82
+ continue;
83
+ }
84
+ } catch (statError) {
85
+ if (statError.code === "ENOENT") continue;
86
+ throw statError;
87
+ }
88
+ if (Date.now() >= deadline) {
89
+ throw new Error("Timed out waiting for the Scrappycoco credential refresh lock.");
90
+ }
91
+ await wait(LOCK_RETRY_MS);
92
+ }
93
+ }
94
+ }
45
95
  async function readFallback() {
46
96
  try {
47
97
  const value = JSON.parse(await readFile(fallbackCredentialPath(), "utf8"));
@@ -50,23 +100,89 @@ async function readFallback() {
50
100
  return null;
51
101
  }
52
102
  }
53
- async function loadRefreshToken() {
103
+ async function readKeychain() {
54
104
  try {
55
105
  const value = await getPassword(SERVICE, ACCOUNT);
56
- 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;
57
115
  } catch {
116
+ return null;
58
117
  }
59
- return readFallback();
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";
160
+ } catch {
161
+ await assertFallbackWritable();
162
+ return "file";
163
+ }
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 });
60
172
  }
61
173
  async function saveRefreshToken(refreshToken) {
62
174
  try {
63
175
  await setPassword(SERVICE, ACCOUNT, refreshToken);
176
+ await rm(fallbackCredentialPath(), { force: true });
177
+ await writeCredentialState("keychain");
64
178
  return "keychain";
65
179
  } catch {
66
- const path = fallbackCredentialPath();
67
- await mkdir(dirname(path), { recursive: true, mode: 448 });
68
- await writeFile(path, JSON.stringify({ refresh_token: refreshToken }), { mode: 384 });
69
- 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
+ }
70
186
  return "file";
71
187
  }
72
188
  }
@@ -76,9 +192,42 @@ async function clearRefreshToken() {
76
192
  } catch {
77
193
  }
78
194
  await rm(fallbackCredentialPath(), { force: true });
195
+ await writeCredentialState("none");
79
196
  }
80
197
 
81
198
  // src/auth.ts
199
+ var cachedAccessToken;
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
+ }
82
231
  async function oauthConfig(apiUrl) {
83
232
  const clientId = process.env.SCRAPPYCOCO_OAUTH_CLIENT_ID;
84
233
  if (clientId) {
@@ -88,7 +237,12 @@ async function oauthConfig(apiUrl) {
88
237
  scopes: ["openid", "profile", "email"]
89
238
  };
90
239
  }
91
- 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
+ );
92
246
  const payload = await response.json().catch(() => ({}));
93
247
  if (!response.ok || !payload.issuer || !payload.client_id || !Array.isArray(payload.scopes)) {
94
248
  throw new CliError(payload.detail || "Scrappycoco CLI OAuth is not configured.", EXIT.auth);
@@ -111,49 +265,99 @@ function statesMatch(expected, received) {
111
265
  const right = Buffer.from(received);
112
266
  return left.length === right.length && timingSafeEqual(left, right);
113
267
  }
114
- async function tokenRequest(issuer, body) {
115
- const response = await fetch(`${issuer.replace(/\/$/, "")}/oauth/token`, {
116
- method: "POST",
117
- headers: { "content-type": "application/x-www-form-urlencoded" },
118
- body
119
- });
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
+ );
120
279
  const payload = await response.json().catch(() => ({}));
121
280
  if (!response.ok || typeof payload.access_token !== "string") {
122
- 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
+ );
123
290
  }
124
291
  return payload;
125
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
+ }
126
312
  async function login(options) {
127
313
  const config = await oauthConfig(options.apiUrl);
128
314
  const clientId = config.client_id;
129
315
  const pkce = createPkce();
130
316
  let timeout;
317
+ let manualReader;
131
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
+ };
132
329
  const server = createServer((request, response) => {
133
- const current = new URL(request.url || "/", "http://127.0.0.1");
134
- if (current.pathname !== "/callback") {
135
- 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));
136
334
  return;
137
335
  }
138
- const state = current.searchParams.get("state") || "";
139
- const code2 = current.searchParams.get("code") || "";
140
- if (!statesMatch(pkce.state, state) || !code2) {
141
- response.writeHead(400, { "content-type": "text/plain" }).end("Invalid OAuth callback. You may close this window.");
142
- clearTimeout(timeout);
143
- server.close();
144
- 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");
145
340
  return;
146
341
  }
147
- const address = server.address();
148
- if (!address || typeof address === "string") return reject(new CliError("OAuth callback failed.", EXIT.auth));
149
- response.writeHead(200, { "content-type": "text/plain" }).end("Scrappycoco login complete. You may close this window.");
150
- clearTimeout(timeout);
151
- server.close();
152
- 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
+ }
153
354
  });
154
355
  server.listen(0, "127.0.0.1", async () => {
155
356
  const address = server.address();
156
- 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
+ }
157
361
  const redirectUri2 = `http://127.0.0.1:${address.port}/callback`;
158
362
  const authorize = new URL(`${config.issuer.replace(/\/$/, "")}/oauth/authorize`);
159
363
  authorize.search = new URLSearchParams({
@@ -168,11 +372,32 @@ async function login(options) {
168
372
  process.stderr.write(`Open this URL to authenticate:
169
373
  ${authorize.toString()}
170
374
  `);
171
- if (!options.noBrowser) await open(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
+ }
172
395
  });
173
396
  timeout = setTimeout(() => {
174
- server.close();
175
- 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
+ ));
176
401
  }, 5 * 6e4);
177
402
  });
178
403
  const { code, redirectUri } = await callback;
@@ -182,31 +407,81 @@ ${authorize.toString()}
182
407
  code,
183
408
  code_verifier: pkce.verifier,
184
409
  redirect_uri: redirectUri
185
- }));
410
+ }), "authorization-code exchange");
186
411
  if (!tokens.refresh_token) throw new CliError("OAuth response did not include a refresh token.", EXIT.auth);
412
+ invalidateAccessToken();
187
413
  return { storage: await saveRefreshToken(tokens.refresh_token) };
188
414
  }
415
+ function invalidateAccessToken() {
416
+ cachedAccessToken = void 0;
417
+ }
418
+ async function clearRefreshToken2() {
419
+ invalidateAccessToken();
420
+ await clearRefreshToken();
421
+ }
189
422
  async function accessToken(apiUrl) {
190
- const config = await oauthConfig(apiUrl);
191
- const clientId = config.client_id;
192
- const refreshToken = await loadRefreshToken();
193
- if (!refreshToken) throw new CliError("Not logged in. Run `scrappycoco auth login`.", EXIT.auth);
194
- const tokens = await tokenRequest(config.issuer, new URLSearchParams({
195
- grant_type: "refresh_token",
196
- client_id: clientId,
197
- refresh_token: refreshToken
198
- }));
199
- if (tokens.refresh_token && tokens.refresh_token !== refreshToken) await saveRefreshToken(tokens.refresh_token);
200
- return tokens.access_token;
423
+ const normalizedApiUrl = apiUrl.replace(/\/$/, "");
424
+ if (cachedAccessToken && cachedAccessToken.apiUrl === normalizedApiUrl && cachedAccessToken.expiresAt - 3e4 > Date.now()) {
425
+ return cachedAccessToken.value;
426
+ }
427
+ const existingRefresh = refreshesInFlight.get(normalizedApiUrl);
428
+ if (existingRefresh) return existingRefresh;
429
+ const refresh = withCredentialRefreshLock(async () => {
430
+ if (cachedAccessToken && cachedAccessToken.apiUrl === normalizedApiUrl && cachedAccessToken.expiresAt - 3e4 > Date.now()) {
431
+ return cachedAccessToken.value;
432
+ }
433
+ const config = await oauthConfig(normalizedApiUrl);
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
+ }
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);
457
+ cachedAccessToken = {
458
+ apiUrl: normalizedApiUrl,
459
+ value: tokens.access_token,
460
+ expiresAt: Date.now() + Math.max(tokens.expires_in ?? 300, 60) * 1e3
461
+ };
462
+ return tokens.access_token;
463
+ });
464
+ refreshesInFlight.set(normalizedApiUrl, refresh);
465
+ try {
466
+ return await refresh;
467
+ } finally {
468
+ if (refreshesInFlight.get(normalizedApiUrl) === refresh) {
469
+ refreshesInFlight.delete(normalizedApiUrl);
470
+ }
471
+ }
472
+ }
473
+ async function loadRefreshToken() {
474
+ return (await loadRefreshTokenCandidates())[0]?.value || null;
201
475
  }
202
476
 
203
477
  // src/client.ts
204
- import { randomUUID } from "crypto";
478
+ import { randomUUID as randomUUID2 } from "crypto";
205
479
  var DEFAULT_API_URL = process.env.SCRAPPYCOCO_API_URL || "https://api.scrappycoco.ai";
206
480
  var DEFAULT_JOB_TIMEOUT_MS = 20 * 60 * 1e3;
207
481
  var DEFAULT_JOB_POLL_INITIAL_MS = 250;
208
482
  var DEFAULT_JOB_POLL_MAX_MS = 5e3;
209
- function positiveInteger(value, fallback) {
483
+ var DEFAULT_API_REQUEST_TIMEOUT_MS = 3e4;
484
+ function positiveInteger2(value, fallback) {
210
485
  if (!value) return fallback;
211
486
  const parsed = Number(value);
212
487
  return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
@@ -235,12 +510,35 @@ var ApiClient = class {
235
510
  };
236
511
  }
237
512
  async request(method, path, body, headers, signal) {
238
- const response = await fetch(`${this.baseUrl.replace(/\/$/, "")}/api/v1${path}`, {
239
- method,
240
- headers: await this.headers(headers),
241
- body: body === void 0 ? void 0 : JSON.stringify(body),
242
- signal
243
- });
513
+ const url = `${this.baseUrl.replace(/\/$/, "")}/api/v1${path}`;
514
+ const requestBody = body === void 0 ? void 0 : JSON.stringify(body);
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
+ };
537
+ let response = await send();
538
+ if (response.status === 401 && !process.env.SCRAPPYCOCO_API_KEY) {
539
+ invalidateAccessToken();
540
+ response = await send();
541
+ }
244
542
  if (response.status === 204) return void 0;
245
543
  const payload = await response.json().catch(() => ({}));
246
544
  if (!response.ok) {
@@ -253,17 +551,17 @@ var ApiClient = class {
253
551
  get(path, signal) {
254
552
  return this.request("GET", path, void 0, void 0, signal);
255
553
  }
256
- post(path, body, key = randomUUID()) {
554
+ post(path, body, key = randomUUID2()) {
257
555
  return this.request("POST", path, body, { "Idempotency-Key": key });
258
556
  }
259
- async postJob(path, body, key = randomUUID()) {
557
+ async postJob(path, body, key = randomUUID2()) {
260
558
  const submitted = await this.post(path, body, key);
261
- const timeoutMs = positiveInteger(process.env.SCRAPPYCOCO_JOB_TIMEOUT_MS, DEFAULT_JOB_TIMEOUT_MS);
262
- const initialDelayMs = positiveInteger(
559
+ const timeoutMs = positiveInteger2(process.env.SCRAPPYCOCO_JOB_TIMEOUT_MS, DEFAULT_JOB_TIMEOUT_MS);
560
+ const initialDelayMs = positiveInteger2(
263
561
  process.env.SCRAPPYCOCO_JOB_POLL_INITIAL_MS,
264
562
  DEFAULT_JOB_POLL_INITIAL_MS
265
563
  );
266
- 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);
267
565
  const deadline = Date.now() + timeoutMs;
268
566
  let delayMs = Math.min(initialDelayMs, maxDelayMs);
269
567
  let job = submitted;
@@ -369,6 +667,7 @@ function errorPayload(error) {
369
667
 
370
668
  // src/setup.ts
371
669
  import { spawn } from "child_process";
670
+ import { determineAgent } from "@vercel/detect-agent";
372
671
  var SKILL_SOURCE = "https://scrappycoco.ai";
373
672
  var SKILL_INSTALL_ARGS = [
374
673
  "--yes",
@@ -380,28 +679,92 @@ var SKILL_INSTALL_ARGS = [
380
679
  "-g",
381
680
  "-y"
382
681
  ];
682
+ var SKILL_INSTALLER_AGENTS = {
683
+ antigravity: "antigravity",
684
+ "augment-cli": "augment",
685
+ claude: "claude-code",
686
+ codex: "codex",
687
+ cowork: "claude-code",
688
+ cursor: "cursor",
689
+ "cursor-cli": "cursor",
690
+ devin: "universal",
691
+ gemini: "gemini-cli",
692
+ "github-copilot": "github-copilot",
693
+ opencode: "opencode",
694
+ replit: "replit"
695
+ };
696
+ var MAX_INSTALLER_OUTPUT_LENGTH = 512 * 1024;
697
+ var ANSI_ESCAPE = /\u001B\[[0-?]*[ -/]*[@-~]/g;
383
698
  function npxCommand() {
384
699
  return process.platform === "win32" ? "npx.cmd" : "npx";
385
700
  }
386
- function skillInstallInvocation() {
387
- return { command: npxCommand(), args: SKILL_INSTALL_ARGS };
701
+ async function detectSkillInstallerAgent(detector = determineAgent) {
702
+ const detected = await detector();
703
+ if (!detected.isAgent || !detected.agent) return "universal";
704
+ return SKILL_INSTALLER_AGENTS[detected.agent.name] || "universal";
705
+ }
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
+ }
720
+ return {
721
+ command: npxCommand(),
722
+ args: [...SKILL_INSTALL_ARGS, "--agent", agent]
723
+ };
724
+ }
725
+ function skillInstallerReportedFailures(output) {
726
+ const plainOutput = output.replace(ANSI_ESCAPE, "");
727
+ if (/Failed to install\s+1/i.test(plainOutput) && /PromptScript does not support global installation/i.test(plainOutput) && !/Failed to install\s+[2-9]\d*/i.test(plainOutput)) {
728
+ return false;
729
+ }
730
+ return /Failed to install\s+[1-9]\d*/i.test(plainOutput);
388
731
  }
389
732
  function runCommand(command, args) {
390
733
  return new Promise((resolve, reject) => {
734
+ let installerOutput = "";
735
+ const capture = (chunk) => {
736
+ installerOutput += chunk.toString();
737
+ if (installerOutput.length > MAX_INSTALLER_OUTPUT_LENGTH) {
738
+ installerOutput = installerOutput.slice(-MAX_INSTALLER_OUTPUT_LENGTH);
739
+ }
740
+ };
391
741
  const child = spawn(command, [...args], {
392
742
  stdio: ["ignore", "pipe", "pipe"],
393
743
  windowsHide: true
394
744
  });
395
- child.stdout.on("data", (chunk) => process.stderr.write(chunk));
396
- child.stderr.on("data", (chunk) => process.stderr.write(chunk));
745
+ child.stdout.on("data", (chunk) => {
746
+ capture(chunk);
747
+ process.stderr.write(chunk);
748
+ });
749
+ child.stderr.on("data", (chunk) => {
750
+ capture(chunk);
751
+ process.stderr.write(chunk);
752
+ });
397
753
  child.on("error", (error) => reject(
398
754
  new CliError(`Could not start the skill installer: ${error.message}`, EXIT.api)
399
755
  ));
400
756
  child.on("close", (code) => {
401
- if (code === 0) {
757
+ if (code === 0 && !skillInstallerReportedFailures(installerOutput)) {
402
758
  resolve();
403
759
  return;
404
760
  }
761
+ if (code === 0) {
762
+ reject(new CliError(
763
+ "Skill installer reported one or more failed targets.",
764
+ EXIT.api
765
+ ));
766
+ return;
767
+ }
405
768
  reject(new CliError(
406
769
  `Skill installation failed with exit code ${code ?? "unknown"}.`,
407
770
  EXIT.api
@@ -409,17 +772,19 @@ function runCommand(command, args) {
409
772
  });
410
773
  });
411
774
  }
412
- async function installSkill(runner = runCommand) {
413
- const invocation = skillInstallInvocation();
775
+ async function installSkill(runner = runCommand, detector = determineAgent) {
776
+ const agent = await detectSkillInstallerAgent(detector);
777
+ const invocation = skillInstallInvocation(agent);
414
778
  await runner(invocation.command, invocation.args);
415
779
  }
416
780
  function defaultDependencies(apiUrl) {
417
781
  const client2 = new ApiClient(apiUrl);
418
782
  return {
419
783
  hasApiKey: () => Boolean(process.env.SCRAPPYCOCO_API_KEY),
420
- clearRefreshToken,
784
+ clearRefreshToken: clearRefreshToken2,
421
785
  loadRefreshToken,
422
786
  login,
787
+ prepareCredentialStorage,
423
788
  installSkill,
424
789
  listAvailableScrapers: () => client2.get("/scrapers?available_only=true")
425
790
  };
@@ -429,17 +794,22 @@ async function performSetup(options, dependencies = defaultDependencies(options.
429
794
  let authentication = usingApiKey ? "api_key" : "oauth";
430
795
  let credentialStorage = usingApiKey ? "environment" : "existing";
431
796
  const hadStoredToken = !usingApiKey && Boolean(await dependencies.loadRefreshToken());
797
+ if (!usingApiKey && !hadStoredToken) {
798
+ await dependencies.prepareCredentialStorage();
799
+ }
800
+ await dependencies.installSkill();
432
801
  if (!usingApiKey && !hadStoredToken) {
433
802
  const authenticated = await dependencies.login({
434
803
  apiUrl: options.apiUrl,
435
- noBrowser: options.noBrowser
804
+ noBrowser: options.noBrowser,
805
+ manualCallback: options.manualCallback
436
806
  });
437
807
  authentication = "oauth";
438
808
  credentialStorage = authenticated.storage;
439
809
  }
440
- let catalog;
810
+ let catalog2;
441
811
  try {
442
- catalog = await dependencies.listAvailableScrapers();
812
+ catalog2 = await dependencies.listAvailableScrapers();
443
813
  } catch (error) {
444
814
  if (usingApiKey || !hadStoredToken || !(error instanceof CliError) || error.exitCode !== EXIT.auth) {
445
815
  throw error;
@@ -447,12 +817,12 @@ async function performSetup(options, dependencies = defaultDependencies(options.
447
817
  await dependencies.clearRefreshToken();
448
818
  const authenticated = await dependencies.login({
449
819
  apiUrl: options.apiUrl,
450
- noBrowser: options.noBrowser
820
+ noBrowser: options.noBrowser,
821
+ manualCallback: options.manualCallback
451
822
  });
452
823
  credentialStorage = authenticated.storage;
453
- catalog = await dependencies.listAvailableScrapers();
824
+ catalog2 = await dependencies.listAvailableScrapers();
454
825
  }
455
- await dependencies.installSkill();
456
826
  return {
457
827
  authenticated: true,
458
828
  authentication,
@@ -463,9 +833,9 @@ async function performSetup(options, dependencies = defaultDependencies(options.
463
833
  },
464
834
  verification: {
465
835
  catalog_reachable: true,
466
- available_capabilities: catalog.length
836
+ available_capabilities: catalog2.length
467
837
  },
468
- next_step: "Reload or restart your agent, then ask it to list available Scrappycoco scrapers."
838
+ next_step: "Reload or restart your agent, then ask it to inspect the Scrappycoco catalog."
469
839
  };
470
840
  }
471
841
 
@@ -474,7 +844,7 @@ var packageMetadata = JSON.parse(
474
844
  readFileSync(new URL("../package.json", import.meta.url), "utf8")
475
845
  );
476
846
  var program = new Command();
477
- program.name("scrappycoco").description("Discover, run, and compare scraper capabilities through one API").version(packageMetadata.version).option("--json", "emit machine-readable JSON to stdout").option("--api-url <url>", "API base URL", process.env.SCRAPPYCOCO_API_URL || "https://api.scrappycoco.ai").showHelpAfterError().exitOverride();
847
+ program.name("scrappycoco").description("Discover configurations and run external-data capabilities through one deterministic API").version(packageMetadata.version).option("--json", "emit machine-readable JSON to stdout").option("--api-url <url>", "API base URL", process.env.SCRAPPYCOCO_API_URL || "https://api.scrappycoco.ai").showHelpAfterError().exitOverride();
478
848
  function globals(command) {
479
849
  return command.optsWithGlobals();
480
850
  }
@@ -484,10 +854,11 @@ function client(command) {
484
854
  function collect(value, previous) {
485
855
  return [...previous, value];
486
856
  }
487
- 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) => {
488
858
  const result = await performSetup({
489
859
  apiUrl: globals(command).apiUrl,
490
- noBrowser: options.browser === false
860
+ noBrowser: options.browser === false,
861
+ manualCallback: options.manualCallback
491
862
  });
492
863
  await emit(result, globals(command).json || false);
493
864
  });
@@ -509,6 +880,8 @@ async function requestPayload(options, scraperId) {
509
880
  capability,
510
881
  input,
511
882
  ...options.provider?.length ? { providers: options.provider } : {},
883
+ ...options.providerOptions ? { provider_options: parseJsonObject(options.providerOptions, "provider options JSON") } : {},
884
+ ...options.concurrency !== void 0 ? { concurrency: Number(options.concurrency) } : {},
512
885
  ...options.limit !== void 0 ? { limit: Number(options.limit) } : {}
513
886
  };
514
887
  }
@@ -524,8 +897,12 @@ async function emitExecution(response, options, command) {
524
897
  await emit(response, jsonMode);
525
898
  }
526
899
  var auth = program.command("auth").description("Manage Clerk OAuth credentials");
527
- auth.command("login").option("--no-browser", "print the authorization URL without opening it").action(async (options, command) => {
528
- 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
+ });
529
906
  await emit({ authenticated: true, storage: result.storage }, globals(command).json || false);
530
907
  });
531
908
  auth.command("status").action(async (_options, command) => {
@@ -536,17 +913,34 @@ auth.command("status").action(async (_options, command) => {
536
913
  process.exitCode = EXIT.auth;
537
914
  return;
538
915
  }
539
- 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
+ }
540
934
  await emit(
541
935
  { authenticated: true, method: usingApiKey ? "api_key" : "oauth", account: me },
542
936
  globals(command).json || false
543
937
  );
544
938
  });
545
939
  auth.command("logout").action(async (_options, command) => {
546
- await clearRefreshToken();
940
+ await clearRefreshToken2();
547
941
  await emit({ authenticated: false }, globals(command).json || false);
548
942
  });
549
- var scrapers = program.command("scrapers").description("Inspect and execute scraper capabilities");
943
+ var scrapers = program.command("scrapers", { hidden: true }).description("Legacy scraper commands");
550
944
  scrapers.command("list").option("--source <source>", "filter by web, x, reddit, or filings").option("--provider <provider>", "filter by provider implementation").option("--available", "only include available providers").action(async (options, command) => {
551
945
  const query = new URLSearchParams();
552
946
  if (options.source) query.set("source", options.source);
@@ -564,20 +958,83 @@ scrapers.command("inspect <scraper-id>").action(async (scraperId, _options, comm
564
958
  globals(command).json || false
565
959
  );
566
960
  });
961
+ var catalog = program.command("catalog").description("Inspect capabilities, provider schemas, options, and pricing");
962
+ catalog.command("list").option("--source <source>", "filter by web, x, reddit, or filings").option("--provider <provider>", "filter by provider implementation").option("--available", "only include available providers").action(async (options, command) => {
963
+ const query = new URLSearchParams();
964
+ if (options.source) query.set("source", options.source);
965
+ if (options.provider) query.set("provider", options.provider);
966
+ if (options.available) query.set("available_only", "true");
967
+ await emit(
968
+ await client(command).get(`/scrapers${query.size ? `?${query}` : ""}`),
969
+ globals(command).json || false
970
+ );
971
+ });
972
+ catalog.command("inspect <capability-id>").action(async (capabilityId, _options, command) => {
973
+ const { source, capability } = splitScraperId(capabilityId);
974
+ await emit(
975
+ await client(command).get(
976
+ `/scrapers/${encodeURIComponent(source)}/${encodeURIComponent(capability)}`
977
+ ),
978
+ globals(command).json || false
979
+ );
980
+ });
567
981
  function executionCommand(name) {
568
- return scrapers.command(`${name} <scraper-id>`).description(name === "run" ? "Run one capability through a provider waterfall" : "Compare providers for one capability").option("-f, --file <path>", "canonical request JSON file").option("--input <json>", "capability input JSON").option("--provider <id>", "provider ID; repeat to choose and order providers", collect, []).option("--limit <number>", "maximum records").option("--idempotency-key <key>", "stable retry key").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 (scraperId, options, command) => {
982
+ return scrapers.command(`${name} <scraper-id>`).description(name === "run" ? "Run one capability through a provider waterfall" : "Compare providers for one capability").option("-f, --file <path>", "canonical request JSON file").option("--input <json>", "capability input JSON").option("--provider <id>", "provider ID; repeat to choose and order providers", 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").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 (scraperId, options, command) => {
569
983
  const payload = await requestPayload(options, scraperId);
570
984
  if (payload.limit === void 0) payload.limit = 10;
571
985
  const response = await client(command).postJob(
572
986
  name === "run" ? "/scrapers/jobs" : "/scrapers/compare/jobs",
573
987
  payload,
574
- options.idempotencyKey || randomUUID2()
988
+ options.idempotencyKey || randomUUID3()
575
989
  );
576
990
  await emitExecution(response, options, command);
577
991
  });
578
992
  }
579
993
  executionCommand("run");
580
994
  executionCommand("compare");
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) => {
996
+ if (options.retryFailed) {
997
+ if (capabilityId || options.config) {
998
+ throw new CliError("Do not combine --retry-failed with a capability ID or --config.", EXIT.usage);
999
+ }
1000
+ const response2 = await client(command).postJob(
1001
+ `/runs/${encodeURIComponent(options.retryFailed)}/retry-failed`,
1002
+ {},
1003
+ options.idempotencyKey || randomUUID3()
1004
+ );
1005
+ await emitExecution(response2, options, command);
1006
+ return;
1007
+ }
1008
+ if (options.config) {
1009
+ if (capabilityId) {
1010
+ throw new CliError("Choose either a capability ID or --config.", EXIT.usage);
1011
+ }
1012
+ const fromFile = options.file ? await readJsonFile(options.file) : {};
1013
+ const input = options.input ? parseJsonObject(options.input, "runtime input JSON") : fromFile.input || {};
1014
+ const response2 = await client(command).postJob(
1015
+ `/discoveries/${encodeURIComponent(options.config)}/jobs`,
1016
+ {
1017
+ ...fromFile,
1018
+ input,
1019
+ limit: Number(options.limit ?? fromFile.limit ?? 25)
1020
+ },
1021
+ options.idempotencyKey || randomUUID3()
1022
+ );
1023
+ await emitExecution(response2, options, command);
1024
+ return;
1025
+ }
1026
+ if (!capabilityId) {
1027
+ throw new CliError("Provide a capability ID or --retry-failed <run-id>.", EXIT.usage);
1028
+ }
1029
+ const payload = await requestPayload(options, capabilityId);
1030
+ if (payload.limit === void 0) payload.limit = 10;
1031
+ const response = await client(command).postJob(
1032
+ "/scrapers/jobs",
1033
+ payload,
1034
+ options.idempotencyKey || randomUUID3()
1035
+ );
1036
+ await emitExecution(response, options, command);
1037
+ });
581
1038
  var jobs = program.command("jobs").description("Inspect durable queued jobs");
582
1039
  jobs.command("get <job-id>").action(async (jobId, _options, command) => {
583
1040
  await emit(
@@ -585,17 +1042,17 @@ jobs.command("get <job-id>").action(async (jobId, _options, command) => {
585
1042
  globals(command).json || false
586
1043
  );
587
1044
  });
588
- var providers = program.command("providers").description("Inspect integrated scraper providers");
1045
+ var providers = program.command("providers", { hidden: true }).description("Legacy provider commands");
589
1046
  providers.command("list").option("--available", "only include available provider-capability routes").action(async (options, command) => {
590
1047
  await emit(
591
1048
  await client(command).get(`/providers${options.available ? "?available_only=true" : ""}`),
592
1049
  globals(command).json || false
593
1050
  );
594
1051
  });
595
- var discoveries = program.command("discoveries").description("Design and run saved multi-capability scraper configurations");
596
- discoveries.command("create").requiredOption("--goal <text>", "natural-language data goal").addOption(new Option("--priority <priority>", "comparison priority").choices(["balanced", "quality", "coverage", "cost", "speed"]).default("balanced")).action(async (options, command) => {
1052
+ var discoveries = program.command("discoveries", { hidden: true }).description("Legacy discovery commands");
1053
+ discoveries.command("create").requiredOption("-f, --file <path>", "agent-authored discovery JSON with goal and configuration").action(async (options, command) => {
597
1054
  await emit(
598
- await client(command).post("/discoveries", { goal: options.goal, priority: options.priority }),
1055
+ await client(command).post("/discoveries", await readJsonFile(options.file)),
599
1056
  globals(command).json || false
600
1057
  );
601
1058
  });
@@ -629,7 +1086,7 @@ discoveries.command("run <discovery-id>").option("-f, --file <path>", "request J
629
1086
  const response = await client(command).postJob(
630
1087
  `/discoveries/${encodeURIComponent(discoveryId)}/jobs`,
631
1088
  payload,
632
- options.idempotencyKey || randomUUID2()
1089
+ options.idempotencyKey || randomUUID3()
633
1090
  );
634
1091
  await emitExecution(response, options, command);
635
1092
  });
@@ -637,6 +1094,50 @@ discoveries.command("delete <discovery-id>").requiredOption("--yes", "confirm pe
637
1094
  await client(command).delete(`/discoveries/${encodeURIComponent(discoveryId)}`);
638
1095
  await emit({ deleted: true, discovery_id: discoveryId }, globals(command).json || false);
639
1096
  });
1097
+ program.command("discover").description("Save, sample-test, or finalize an agent-authored configuration").option("-f, --file <path>", "create from agent-authored discovery JSON").option("--id <discovery-id>", "existing discovery ID").option("--test", "approve and run a paid sample test").option("--input <json>", "sample runtime input JSON").option("--update <path>", "replace fields or configuration from agent-authored JSON").option("--finalize", "mark the current explicit configuration finalized").option("--idempotency-key <key>", "stable sample retry key").action(async (options, command) => {
1098
+ const selected = Number(Boolean(options.file)) + Number(Boolean(options.test)) + Number(Boolean(options.update)) + Number(Boolean(options.finalize));
1099
+ if (selected !== 1) {
1100
+ throw new CliError(
1101
+ "Choose exactly one action: --file, --id --update, --id --test, or --id --finalize.",
1102
+ EXIT.usage
1103
+ );
1104
+ }
1105
+ if (options.file) {
1106
+ await emit(
1107
+ await client(command).post("/discoveries", await readJsonFile(options.file)),
1108
+ globals(command).json || false
1109
+ );
1110
+ return;
1111
+ }
1112
+ if (!options.id) throw new CliError("--id is required for --update, --test, and --finalize.", EXIT.usage);
1113
+ if (options.update) {
1114
+ await emit(
1115
+ await client(command).patch(
1116
+ `/discoveries/${encodeURIComponent(options.id)}`,
1117
+ await readJsonFile(options.update)
1118
+ ),
1119
+ globals(command).json || false
1120
+ );
1121
+ return;
1122
+ }
1123
+ if (options.finalize) {
1124
+ await emit(
1125
+ await client(command).post(
1126
+ `/discoveries/${encodeURIComponent(options.id)}/finalize`,
1127
+ {}
1128
+ ),
1129
+ globals(command).json || false
1130
+ );
1131
+ return;
1132
+ }
1133
+ const input = options.input ? parseJsonObject(options.input, "sample input JSON") : {};
1134
+ const response = await client(command).postJob(
1135
+ `/discoveries/${encodeURIComponent(options.id)}/jobs`,
1136
+ { input, limit: 25 },
1137
+ options.idempotencyKey || randomUUID3()
1138
+ );
1139
+ await emit(response, globals(command).json || false);
1140
+ });
640
1141
  program.configureOutput({ writeErr: (text) => process.stderr.write(text) });
641
1142
  program.parseAsync(process.argv).catch(async (error) => {
642
1143
  if (error instanceof CommanderError) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@scrappycoco/cli",
3
- "version": "0.3.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"
@@ -22,6 +22,7 @@
22
22
  "prepack": "npm run build"
23
23
  },
24
24
  "dependencies": {
25
+ "@vercel/detect-agent": "^1.2.3",
25
26
  "commander": "^14.0.0",
26
27
  "cross-keychain": "^1.1.0",
27
28
  "open": "^10.2.0"