@scrappycoco/cli 0.2.1 → 0.4.0

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 +29 -15
  2. package/dist/index.js +380 -24
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -1,33 +1,47 @@
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
 
7
9
  ```sh
8
- npx @scrappycoco/cli auth login
9
- npx @scrappycoco/cli scrapers list --available --json
10
- npx @scrappycoco/cli scrapers inspect web.extract_content --json
11
- npx @scrappycoco/cli scrapers run web.extract_content --file request.json --json
12
- npx @scrappycoco/cli scrapers compare web.extract_content --file request.json --json
10
+ npx --yes @scrappycoco/cli setup
11
+ ```
12
+
13
+ `setup` opens OAuth in the browser. New users can create an account in that
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.
16
+
17
+ Individual commands remain available:
18
+
19
+ ```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
13
27
  ```
14
28
 
15
29
  From a repository checkout, use `npm ci`, `npm run build`, and
16
30
  `node dist/index.js <command>` instead.
17
31
 
18
- Use `providers list` to inspect routes and
19
- `discoveries create|list|get|update|run|delete` for saved multi-capability
20
- configurations. The CLI does not expose legacy assistant, agent, workflow, or
21
- schedule commands.
32
+ Discover is optional. Use Run directly when the current capability, provider,
33
+ and native options are already clear. The CLI does not expose legacy
34
+ assistant, agent, workflow, or schedule commands.
22
35
 
23
36
  Node.js 20 or newer is required. Interactive use authenticates with Clerk OAuth Authorization Code + PKCE. CI can set `SCRAPPYCOCO_API_KEY`.
24
37
 
25
38
  Use `--json` for machine-readable responses. Execution commands support
26
- `--format json|jsonl|csv` with `--output`, repeatable `--provider` flags, and an
27
- explicit `--idempotency-key` for safe identical retries. They submit durable
28
- jobs and poll for completion; set `SCRAPPYCOCO_JOB_TIMEOUT_MS` to change the
29
- 20-minute local wait. If a command times out while its job continues, inspect
30
- it with `scrappycoco jobs get <job-id>`.
39
+ `--format json|jsonl|csv` with `--output`, provider-native
40
+ `--provider-options`, batch `--concurrency`, and an explicit
41
+ `--idempotency-key` for safe identical retries. They submit durable jobs and
42
+ poll for completion; set `SCRAPPYCOCO_JOB_TIMEOUT_MS` to change the 20-minute
43
+ local wait. If a command times out while its job continues, inspect it with
44
+ `scrappycoco jobs get <job-id>`.
31
45
 
32
46
  Use `scrappycoco --help` for the complete command reference. See the
33
47
  [Scrappycoco API documentation](https://scrappycoco.ai/docs) for the public
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ import { Command, CommanderError, Option } from "commander";
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 open2 from "open";
12
12
 
13
13
  // src/errors.ts
14
14
  var EXIT = {
@@ -32,7 +32,7 @@ var CliError = class extends Error {
32
32
  };
33
33
 
34
34
  // src/storage.ts
35
- import { chmod, mkdir, readFile, rm, writeFile } from "fs/promises";
35
+ import { chmod, mkdir, open, readFile, rm, stat, writeFile } from "fs/promises";
36
36
  import { homedir } from "os";
37
37
  import { dirname, join } from "path";
38
38
  import { deletePassword, getPassword, setPassword } from "cross-keychain";
@@ -42,6 +42,49 @@ function fallbackCredentialPath() {
42
42
  const base = process.platform === "win32" ? process.env.APPDATA || join(homedir(), "AppData", "Roaming") : process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
43
43
  return join(base, "scrappycoco", "credentials.json");
44
44
  }
45
+ function credentialRefreshLockPath() {
46
+ return `${fallbackCredentialPath()}.refresh.lock`;
47
+ }
48
+ var LOCK_RETRY_MS = 100;
49
+ var LOCK_TIMEOUT_MS = 3e4;
50
+ var LOCK_STALE_MS = 6e4;
51
+ function wait(milliseconds) {
52
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
53
+ }
54
+ async function withCredentialRefreshLock(operation) {
55
+ const path = credentialRefreshLockPath();
56
+ await mkdir(dirname(path), { recursive: true, mode: 448 });
57
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
58
+ while (true) {
59
+ try {
60
+ const handle = await open(path, "wx", 384);
61
+ try {
62
+ await handle.writeFile(JSON.stringify({ pid: process.pid, created_at: Date.now() }));
63
+ return await operation();
64
+ } finally {
65
+ await handle.close();
66
+ await rm(path, { force: true });
67
+ }
68
+ } catch (error) {
69
+ const code = error.code;
70
+ if (code !== "EEXIST") throw error;
71
+ try {
72
+ const lock = await stat(path);
73
+ if (Date.now() - lock.mtimeMs > LOCK_STALE_MS) {
74
+ await rm(path, { force: true });
75
+ continue;
76
+ }
77
+ } catch (statError) {
78
+ if (statError.code === "ENOENT") continue;
79
+ throw statError;
80
+ }
81
+ if (Date.now() >= deadline) {
82
+ throw new Error("Timed out waiting for the Scrappycoco credential refresh lock.");
83
+ }
84
+ await wait(LOCK_RETRY_MS);
85
+ }
86
+ }
87
+ }
45
88
  async function readFallback() {
46
89
  try {
47
90
  const value = JSON.parse(await readFile(fallbackCredentialPath(), "utf8"));
@@ -79,6 +122,8 @@ async function clearRefreshToken() {
79
122
  }
80
123
 
81
124
  // src/auth.ts
125
+ var cachedAccessToken;
126
+ var refreshesInFlight = /* @__PURE__ */ new Map();
82
127
  async function oauthConfig(apiUrl) {
83
128
  const clientId = process.env.SCRAPPYCOCO_OAUTH_CLIENT_ID;
84
129
  if (clientId) {
@@ -168,7 +213,7 @@ async function login(options) {
168
213
  process.stderr.write(`Open this URL to authenticate:
169
214
  ${authorize.toString()}
170
215
  `);
171
- if (!options.noBrowser) await open(authorize.toString());
216
+ if (!options.noBrowser) await open2(authorize.toString());
172
217
  });
173
218
  timeout = setTimeout(() => {
174
219
  server.close();
@@ -184,20 +229,53 @@ ${authorize.toString()}
184
229
  redirect_uri: redirectUri
185
230
  }));
186
231
  if (!tokens.refresh_token) throw new CliError("OAuth response did not include a refresh token.", EXIT.auth);
232
+ invalidateAccessToken();
187
233
  return { storage: await saveRefreshToken(tokens.refresh_token) };
188
234
  }
235
+ function invalidateAccessToken() {
236
+ cachedAccessToken = void 0;
237
+ }
238
+ async function clearRefreshToken2() {
239
+ invalidateAccessToken();
240
+ await clearRefreshToken();
241
+ }
189
242
  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;
243
+ const normalizedApiUrl = apiUrl.replace(/\/$/, "");
244
+ if (cachedAccessToken && cachedAccessToken.apiUrl === normalizedApiUrl && cachedAccessToken.expiresAt - 3e4 > Date.now()) {
245
+ return cachedAccessToken.value;
246
+ }
247
+ const existingRefresh = refreshesInFlight.get(normalizedApiUrl);
248
+ if (existingRefresh) return existingRefresh;
249
+ const refresh = withCredentialRefreshLock(async () => {
250
+ if (cachedAccessToken && cachedAccessToken.apiUrl === normalizedApiUrl && cachedAccessToken.expiresAt - 3e4 > Date.now()) {
251
+ return cachedAccessToken.value;
252
+ }
253
+ 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);
263
+ }
264
+ cachedAccessToken = {
265
+ apiUrl: normalizedApiUrl,
266
+ value: tokens.access_token,
267
+ expiresAt: Date.now() + Math.max(tokens.expires_in ?? 300, 60) * 1e3
268
+ };
269
+ return tokens.access_token;
270
+ });
271
+ refreshesInFlight.set(normalizedApiUrl, refresh);
272
+ try {
273
+ return await refresh;
274
+ } finally {
275
+ if (refreshesInFlight.get(normalizedApiUrl) === refresh) {
276
+ refreshesInFlight.delete(normalizedApiUrl);
277
+ }
278
+ }
201
279
  }
202
280
 
203
281
  // src/client.ts
@@ -235,12 +313,19 @@ var ApiClient = class {
235
313
  };
236
314
  }
237
315
  async request(method, path, body, headers, signal) {
238
- const response = await fetch(`${this.baseUrl.replace(/\/$/, "")}/api/v1${path}`, {
316
+ const url = `${this.baseUrl.replace(/\/$/, "")}/api/v1${path}`;
317
+ const requestBody = body === void 0 ? void 0 : JSON.stringify(body);
318
+ const send = async () => fetch(url, {
239
319
  method,
240
320
  headers: await this.headers(headers),
241
- body: body === void 0 ? void 0 : JSON.stringify(body),
321
+ body: requestBody,
242
322
  signal
243
323
  });
324
+ let response = await send();
325
+ if (response.status === 401 && !process.env.SCRAPPYCOCO_API_KEY) {
326
+ invalidateAccessToken();
327
+ response = await send();
328
+ }
244
329
  if (response.status === 204) return void 0;
245
330
  const payload = await response.json().catch(() => ({}));
246
331
  if (!response.ok) {
@@ -367,12 +452,167 @@ function errorPayload(error) {
367
452
  return { error: error.message, details: error.details ?? null };
368
453
  }
369
454
 
455
+ // src/setup.ts
456
+ import { spawn } from "child_process";
457
+ import { determineAgent } from "@vercel/detect-agent";
458
+ var SKILL_SOURCE = "https://scrappycoco.ai";
459
+ var SKILL_INSTALL_ARGS = [
460
+ "--yes",
461
+ "skills",
462
+ "add",
463
+ SKILL_SOURCE,
464
+ "--skill",
465
+ "scrappycoco",
466
+ "-g",
467
+ "-y"
468
+ ];
469
+ var SKILL_INSTALLER_AGENTS = {
470
+ antigravity: "antigravity",
471
+ "augment-cli": "augment",
472
+ claude: "claude-code",
473
+ codex: "codex",
474
+ cowork: "claude-code",
475
+ cursor: "cursor",
476
+ "cursor-cli": "cursor",
477
+ devin: "universal",
478
+ gemini: "gemini-cli",
479
+ "github-copilot": "github-copilot",
480
+ opencode: "opencode",
481
+ replit: "replit"
482
+ };
483
+ var MAX_INSTALLER_OUTPUT_LENGTH = 512 * 1024;
484
+ var ANSI_ESCAPE = /\u001B\[[0-?]*[ -/]*[@-~]/g;
485
+ function npxCommand() {
486
+ return process.platform === "win32" ? "npx.cmd" : "npx";
487
+ }
488
+ async function detectSkillInstallerAgent(detector = determineAgent) {
489
+ const detected = await detector();
490
+ if (!detected.isAgent || !detected.agent) return "universal";
491
+ return SKILL_INSTALLER_AGENTS[detected.agent.name] || "universal";
492
+ }
493
+ function skillInstallInvocation(agent = "universal") {
494
+ return {
495
+ command: npxCommand(),
496
+ args: [...SKILL_INSTALL_ARGS, "--agent", agent]
497
+ };
498
+ }
499
+ function skillInstallerReportedFailures(output) {
500
+ const plainOutput = output.replace(ANSI_ESCAPE, "");
501
+ 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)) {
502
+ return false;
503
+ }
504
+ return /Failed to install\s+[1-9]\d*/i.test(plainOutput);
505
+ }
506
+ function runCommand(command, args) {
507
+ return new Promise((resolve, reject) => {
508
+ let installerOutput = "";
509
+ const capture = (chunk) => {
510
+ installerOutput += chunk.toString();
511
+ if (installerOutput.length > MAX_INSTALLER_OUTPUT_LENGTH) {
512
+ installerOutput = installerOutput.slice(-MAX_INSTALLER_OUTPUT_LENGTH);
513
+ }
514
+ };
515
+ const child = spawn(command, [...args], {
516
+ stdio: ["ignore", "pipe", "pipe"],
517
+ windowsHide: true
518
+ });
519
+ child.stdout.on("data", (chunk) => {
520
+ capture(chunk);
521
+ process.stderr.write(chunk);
522
+ });
523
+ child.stderr.on("data", (chunk) => {
524
+ capture(chunk);
525
+ process.stderr.write(chunk);
526
+ });
527
+ child.on("error", (error) => reject(
528
+ new CliError(`Could not start the skill installer: ${error.message}`, EXIT.api)
529
+ ));
530
+ child.on("close", (code) => {
531
+ if (code === 0 && !skillInstallerReportedFailures(installerOutput)) {
532
+ resolve();
533
+ return;
534
+ }
535
+ if (code === 0) {
536
+ reject(new CliError(
537
+ "Skill installer reported one or more failed targets.",
538
+ EXIT.api
539
+ ));
540
+ return;
541
+ }
542
+ reject(new CliError(
543
+ `Skill installation failed with exit code ${code ?? "unknown"}.`,
544
+ EXIT.api
545
+ ));
546
+ });
547
+ });
548
+ }
549
+ async function installSkill(runner = runCommand, detector = determineAgent) {
550
+ const agent = await detectSkillInstallerAgent(detector);
551
+ const invocation = skillInstallInvocation(agent);
552
+ await runner(invocation.command, invocation.args);
553
+ }
554
+ function defaultDependencies(apiUrl) {
555
+ const client2 = new ApiClient(apiUrl);
556
+ return {
557
+ hasApiKey: () => Boolean(process.env.SCRAPPYCOCO_API_KEY),
558
+ clearRefreshToken: clearRefreshToken2,
559
+ loadRefreshToken,
560
+ login,
561
+ installSkill,
562
+ listAvailableScrapers: () => client2.get("/scrapers?available_only=true")
563
+ };
564
+ }
565
+ async function performSetup(options, dependencies = defaultDependencies(options.apiUrl)) {
566
+ const usingApiKey = dependencies.hasApiKey();
567
+ let authentication = usingApiKey ? "api_key" : "oauth";
568
+ let credentialStorage = usingApiKey ? "environment" : "existing";
569
+ const hadStoredToken = !usingApiKey && Boolean(await dependencies.loadRefreshToken());
570
+ if (!usingApiKey && !hadStoredToken) {
571
+ const authenticated = await dependencies.login({
572
+ apiUrl: options.apiUrl,
573
+ noBrowser: options.noBrowser
574
+ });
575
+ authentication = "oauth";
576
+ credentialStorage = authenticated.storage;
577
+ }
578
+ let catalog2;
579
+ try {
580
+ catalog2 = await dependencies.listAvailableScrapers();
581
+ } catch (error) {
582
+ if (usingApiKey || !hadStoredToken || !(error instanceof CliError) || error.exitCode !== EXIT.auth) {
583
+ throw error;
584
+ }
585
+ await dependencies.clearRefreshToken();
586
+ const authenticated = await dependencies.login({
587
+ apiUrl: options.apiUrl,
588
+ noBrowser: options.noBrowser
589
+ });
590
+ credentialStorage = authenticated.storage;
591
+ catalog2 = await dependencies.listAvailableScrapers();
592
+ }
593
+ await dependencies.installSkill();
594
+ return {
595
+ authenticated: true,
596
+ authentication,
597
+ credential_storage: credentialStorage,
598
+ skill: {
599
+ installed: true,
600
+ source: SKILL_SOURCE
601
+ },
602
+ verification: {
603
+ catalog_reachable: true,
604
+ available_capabilities: catalog2.length
605
+ },
606
+ next_step: "Reload or restart your agent, then ask it to inspect the Scrappycoco catalog."
607
+ };
608
+ }
609
+
370
610
  // src/index.ts
371
611
  var packageMetadata = JSON.parse(
372
612
  readFileSync(new URL("../package.json", import.meta.url), "utf8")
373
613
  );
374
614
  var program = new Command();
375
- 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();
615
+ 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();
376
616
  function globals(command) {
377
617
  return command.optsWithGlobals();
378
618
  }
@@ -382,6 +622,13 @@ function client(command) {
382
622
  function collect(value, previous) {
383
623
  return [...previous, value];
384
624
  }
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) => {
626
+ const result = await performSetup({
627
+ apiUrl: globals(command).apiUrl,
628
+ noBrowser: options.browser === false
629
+ });
630
+ await emit(result, globals(command).json || false);
631
+ });
385
632
  function splitScraperId(value) {
386
633
  const separator = value.indexOf(".");
387
634
  if (separator <= 0 || separator === value.length - 1) {
@@ -400,6 +647,8 @@ async function requestPayload(options, scraperId) {
400
647
  capability,
401
648
  input,
402
649
  ...options.provider?.length ? { providers: options.provider } : {},
650
+ ...options.providerOptions ? { provider_options: parseJsonObject(options.providerOptions, "provider options JSON") } : {},
651
+ ...options.concurrency !== void 0 ? { concurrency: Number(options.concurrency) } : {},
403
652
  ...options.limit !== void 0 ? { limit: Number(options.limit) } : {}
404
653
  };
405
654
  }
@@ -434,10 +683,10 @@ auth.command("status").action(async (_options, command) => {
434
683
  );
435
684
  });
436
685
  auth.command("logout").action(async (_options, command) => {
437
- await clearRefreshToken();
686
+ await clearRefreshToken2();
438
687
  await emit({ authenticated: false }, globals(command).json || false);
439
688
  });
440
- var scrapers = program.command("scrapers").description("Inspect and execute scraper capabilities");
689
+ var scrapers = program.command("scrapers", { hidden: true }).description("Legacy scraper commands");
441
690
  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) => {
442
691
  const query = new URLSearchParams();
443
692
  if (options.source) query.set("source", options.source);
@@ -455,8 +704,28 @@ scrapers.command("inspect <scraper-id>").action(async (scraperId, _options, comm
455
704
  globals(command).json || false
456
705
  );
457
706
  });
707
+ var catalog = program.command("catalog").description("Inspect capabilities, provider schemas, options, and pricing");
708
+ 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) => {
709
+ const query = new URLSearchParams();
710
+ if (options.source) query.set("source", options.source);
711
+ if (options.provider) query.set("provider", options.provider);
712
+ if (options.available) query.set("available_only", "true");
713
+ await emit(
714
+ await client(command).get(`/scrapers${query.size ? `?${query}` : ""}`),
715
+ globals(command).json || false
716
+ );
717
+ });
718
+ catalog.command("inspect <capability-id>").action(async (capabilityId, _options, command) => {
719
+ const { source, capability } = splitScraperId(capabilityId);
720
+ await emit(
721
+ await client(command).get(
722
+ `/scrapers/${encodeURIComponent(source)}/${encodeURIComponent(capability)}`
723
+ ),
724
+ globals(command).json || false
725
+ );
726
+ });
458
727
  function executionCommand(name) {
459
- 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) => {
728
+ 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) => {
460
729
  const payload = await requestPayload(options, scraperId);
461
730
  if (payload.limit === void 0) payload.limit = 10;
462
731
  const response = await client(command).postJob(
@@ -469,6 +738,49 @@ function executionCommand(name) {
469
738
  }
470
739
  executionCommand("run");
471
740
  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) => {
742
+ if (options.retryFailed) {
743
+ if (capabilityId || options.config) {
744
+ throw new CliError("Do not combine --retry-failed with a capability ID or --config.", EXIT.usage);
745
+ }
746
+ const response2 = await client(command).postJob(
747
+ `/runs/${encodeURIComponent(options.retryFailed)}/retry-failed`,
748
+ {},
749
+ options.idempotencyKey || randomUUID2()
750
+ );
751
+ await emitExecution(response2, options, command);
752
+ return;
753
+ }
754
+ if (options.config) {
755
+ if (capabilityId) {
756
+ throw new CliError("Choose either a capability ID or --config.", EXIT.usage);
757
+ }
758
+ const fromFile = options.file ? await readJsonFile(options.file) : {};
759
+ const input = options.input ? parseJsonObject(options.input, "runtime input JSON") : fromFile.input || {};
760
+ const response2 = await client(command).postJob(
761
+ `/discoveries/${encodeURIComponent(options.config)}/jobs`,
762
+ {
763
+ ...fromFile,
764
+ input,
765
+ limit: Number(options.limit ?? fromFile.limit ?? 25)
766
+ },
767
+ options.idempotencyKey || randomUUID2()
768
+ );
769
+ await emitExecution(response2, options, command);
770
+ return;
771
+ }
772
+ if (!capabilityId) {
773
+ throw new CliError("Provide a capability ID or --retry-failed <run-id>.", EXIT.usage);
774
+ }
775
+ const payload = await requestPayload(options, capabilityId);
776
+ if (payload.limit === void 0) payload.limit = 10;
777
+ const response = await client(command).postJob(
778
+ "/scrapers/jobs",
779
+ payload,
780
+ options.idempotencyKey || randomUUID2()
781
+ );
782
+ await emitExecution(response, options, command);
783
+ });
472
784
  var jobs = program.command("jobs").description("Inspect durable queued jobs");
473
785
  jobs.command("get <job-id>").action(async (jobId, _options, command) => {
474
786
  await emit(
@@ -476,17 +788,17 @@ jobs.command("get <job-id>").action(async (jobId, _options, command) => {
476
788
  globals(command).json || false
477
789
  );
478
790
  });
479
- var providers = program.command("providers").description("Inspect integrated scraper providers");
791
+ var providers = program.command("providers", { hidden: true }).description("Legacy provider commands");
480
792
  providers.command("list").option("--available", "only include available provider-capability routes").action(async (options, command) => {
481
793
  await emit(
482
794
  await client(command).get(`/providers${options.available ? "?available_only=true" : ""}`),
483
795
  globals(command).json || false
484
796
  );
485
797
  });
486
- var discoveries = program.command("discoveries").description("Design and run saved multi-capability scraper configurations");
487
- 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) => {
798
+ var discoveries = program.command("discoveries", { hidden: true }).description("Legacy discovery commands");
799
+ discoveries.command("create").requiredOption("-f, --file <path>", "agent-authored discovery JSON with goal and configuration").action(async (options, command) => {
488
800
  await emit(
489
- await client(command).post("/discoveries", { goal: options.goal, priority: options.priority }),
801
+ await client(command).post("/discoveries", await readJsonFile(options.file)),
490
802
  globals(command).json || false
491
803
  );
492
804
  });
@@ -528,6 +840,50 @@ discoveries.command("delete <discovery-id>").requiredOption("--yes", "confirm pe
528
840
  await client(command).delete(`/discoveries/${encodeURIComponent(discoveryId)}`);
529
841
  await emit({ deleted: true, discovery_id: discoveryId }, globals(command).json || false);
530
842
  });
843
+ 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) => {
844
+ const selected = Number(Boolean(options.file)) + Number(Boolean(options.test)) + Number(Boolean(options.update)) + Number(Boolean(options.finalize));
845
+ if (selected !== 1) {
846
+ throw new CliError(
847
+ "Choose exactly one action: --file, --id --update, --id --test, or --id --finalize.",
848
+ EXIT.usage
849
+ );
850
+ }
851
+ if (options.file) {
852
+ await emit(
853
+ await client(command).post("/discoveries", await readJsonFile(options.file)),
854
+ globals(command).json || false
855
+ );
856
+ return;
857
+ }
858
+ if (!options.id) throw new CliError("--id is required for --update, --test, and --finalize.", EXIT.usage);
859
+ if (options.update) {
860
+ await emit(
861
+ await client(command).patch(
862
+ `/discoveries/${encodeURIComponent(options.id)}`,
863
+ await readJsonFile(options.update)
864
+ ),
865
+ globals(command).json || false
866
+ );
867
+ return;
868
+ }
869
+ if (options.finalize) {
870
+ await emit(
871
+ await client(command).post(
872
+ `/discoveries/${encodeURIComponent(options.id)}/finalize`,
873
+ {}
874
+ ),
875
+ globals(command).json || false
876
+ );
877
+ return;
878
+ }
879
+ const input = options.input ? parseJsonObject(options.input, "sample input JSON") : {};
880
+ const response = await client(command).postJob(
881
+ `/discoveries/${encodeURIComponent(options.id)}/jobs`,
882
+ { input, limit: 25 },
883
+ options.idempotencyKey || randomUUID2()
884
+ );
885
+ await emit(response, globals(command).json || false);
886
+ });
531
887
  program.configureOutput({ writeErr: (text) => process.stderr.write(text) });
532
888
  program.parseAsync(process.argv).catch(async (error) => {
533
889
  if (error instanceof CommanderError) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scrappycoco/cli",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "CLI for Scrappycoco scraper discovery, execution, and provider comparison",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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"