@scrappycoco/cli 0.3.0 → 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 +18 -14
  2. package/dist/index.js +284 -37
  3. package/package.json +2 -1
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
 
@@ -16,28 +18,30 @@ Individual commands remain available:
16
18
 
17
19
  ```sh
18
20
  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
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
23
27
  ```
24
28
 
25
29
  From a repository checkout, use `npm ci`, `npm run build`, and
26
30
  `node dist/index.js <command>` instead.
27
31
 
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.
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.
32
35
 
33
36
  Node.js 20 or newer is required. Interactive use authenticates with Clerk OAuth Authorization Code + PKCE. CI can set `SCRAPPYCOCO_API_KEY`.
34
37
 
35
38
  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>`.
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>`.
41
45
 
42
46
  Use `scrappycoco --help` for the complete command reference. See the
43
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) {
@@ -369,6 +454,7 @@ function errorPayload(error) {
369
454
 
370
455
  // src/setup.ts
371
456
  import { spawn } from "child_process";
457
+ import { determineAgent } from "@vercel/detect-agent";
372
458
  var SKILL_SOURCE = "https://scrappycoco.ai";
373
459
  var SKILL_INSTALL_ARGS = [
374
460
  "--yes",
@@ -380,28 +466,79 @@ var SKILL_INSTALL_ARGS = [
380
466
  "-g",
381
467
  "-y"
382
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;
383
485
  function npxCommand() {
384
486
  return process.platform === "win32" ? "npx.cmd" : "npx";
385
487
  }
386
- function skillInstallInvocation() {
387
- return { command: npxCommand(), args: SKILL_INSTALL_ARGS };
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);
388
505
  }
389
506
  function runCommand(command, args) {
390
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
+ };
391
515
  const child = spawn(command, [...args], {
392
516
  stdio: ["ignore", "pipe", "pipe"],
393
517
  windowsHide: true
394
518
  });
395
- child.stdout.on("data", (chunk) => process.stderr.write(chunk));
396
- child.stderr.on("data", (chunk) => process.stderr.write(chunk));
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
+ });
397
527
  child.on("error", (error) => reject(
398
528
  new CliError(`Could not start the skill installer: ${error.message}`, EXIT.api)
399
529
  ));
400
530
  child.on("close", (code) => {
401
- if (code === 0) {
531
+ if (code === 0 && !skillInstallerReportedFailures(installerOutput)) {
402
532
  resolve();
403
533
  return;
404
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
+ }
405
542
  reject(new CliError(
406
543
  `Skill installation failed with exit code ${code ?? "unknown"}.`,
407
544
  EXIT.api
@@ -409,15 +546,16 @@ function runCommand(command, args) {
409
546
  });
410
547
  });
411
548
  }
412
- async function installSkill(runner = runCommand) {
413
- const invocation = skillInstallInvocation();
549
+ async function installSkill(runner = runCommand, detector = determineAgent) {
550
+ const agent = await detectSkillInstallerAgent(detector);
551
+ const invocation = skillInstallInvocation(agent);
414
552
  await runner(invocation.command, invocation.args);
415
553
  }
416
554
  function defaultDependencies(apiUrl) {
417
555
  const client2 = new ApiClient(apiUrl);
418
556
  return {
419
557
  hasApiKey: () => Boolean(process.env.SCRAPPYCOCO_API_KEY),
420
- clearRefreshToken,
558
+ clearRefreshToken: clearRefreshToken2,
421
559
  loadRefreshToken,
422
560
  login,
423
561
  installSkill,
@@ -437,9 +575,9 @@ async function performSetup(options, dependencies = defaultDependencies(options.
437
575
  authentication = "oauth";
438
576
  credentialStorage = authenticated.storage;
439
577
  }
440
- let catalog;
578
+ let catalog2;
441
579
  try {
442
- catalog = await dependencies.listAvailableScrapers();
580
+ catalog2 = await dependencies.listAvailableScrapers();
443
581
  } catch (error) {
444
582
  if (usingApiKey || !hadStoredToken || !(error instanceof CliError) || error.exitCode !== EXIT.auth) {
445
583
  throw error;
@@ -450,7 +588,7 @@ async function performSetup(options, dependencies = defaultDependencies(options.
450
588
  noBrowser: options.noBrowser
451
589
  });
452
590
  credentialStorage = authenticated.storage;
453
- catalog = await dependencies.listAvailableScrapers();
591
+ catalog2 = await dependencies.listAvailableScrapers();
454
592
  }
455
593
  await dependencies.installSkill();
456
594
  return {
@@ -463,9 +601,9 @@ async function performSetup(options, dependencies = defaultDependencies(options.
463
601
  },
464
602
  verification: {
465
603
  catalog_reachable: true,
466
- available_capabilities: catalog.length
604
+ available_capabilities: catalog2.length
467
605
  },
468
- next_step: "Reload or restart your agent, then ask it to list available Scrappycoco scrapers."
606
+ next_step: "Reload or restart your agent, then ask it to inspect the Scrappycoco catalog."
469
607
  };
470
608
  }
471
609
 
@@ -474,7 +612,7 @@ var packageMetadata = JSON.parse(
474
612
  readFileSync(new URL("../package.json", import.meta.url), "utf8")
475
613
  );
476
614
  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();
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();
478
616
  function globals(command) {
479
617
  return command.optsWithGlobals();
480
618
  }
@@ -509,6 +647,8 @@ async function requestPayload(options, scraperId) {
509
647
  capability,
510
648
  input,
511
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) } : {},
512
652
  ...options.limit !== void 0 ? { limit: Number(options.limit) } : {}
513
653
  };
514
654
  }
@@ -543,10 +683,10 @@ auth.command("status").action(async (_options, command) => {
543
683
  );
544
684
  });
545
685
  auth.command("logout").action(async (_options, command) => {
546
- await clearRefreshToken();
686
+ await clearRefreshToken2();
547
687
  await emit({ authenticated: false }, globals(command).json || false);
548
688
  });
549
- var scrapers = program.command("scrapers").description("Inspect and execute scraper capabilities");
689
+ var scrapers = program.command("scrapers", { hidden: true }).description("Legacy scraper commands");
550
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) => {
551
691
  const query = new URLSearchParams();
552
692
  if (options.source) query.set("source", options.source);
@@ -564,8 +704,28 @@ scrapers.command("inspect <scraper-id>").action(async (scraperId, _options, comm
564
704
  globals(command).json || false
565
705
  );
566
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
+ });
567
727
  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) => {
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) => {
569
729
  const payload = await requestPayload(options, scraperId);
570
730
  if (payload.limit === void 0) payload.limit = 10;
571
731
  const response = await client(command).postJob(
@@ -578,6 +738,49 @@ function executionCommand(name) {
578
738
  }
579
739
  executionCommand("run");
580
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
+ });
581
784
  var jobs = program.command("jobs").description("Inspect durable queued jobs");
582
785
  jobs.command("get <job-id>").action(async (jobId, _options, command) => {
583
786
  await emit(
@@ -585,17 +788,17 @@ jobs.command("get <job-id>").action(async (jobId, _options, command) => {
585
788
  globals(command).json || false
586
789
  );
587
790
  });
588
- var providers = program.command("providers").description("Inspect integrated scraper providers");
791
+ var providers = program.command("providers", { hidden: true }).description("Legacy provider commands");
589
792
  providers.command("list").option("--available", "only include available provider-capability routes").action(async (options, command) => {
590
793
  await emit(
591
794
  await client(command).get(`/providers${options.available ? "?available_only=true" : ""}`),
592
795
  globals(command).json || false
593
796
  );
594
797
  });
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) => {
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) => {
597
800
  await emit(
598
- await client(command).post("/discoveries", { goal: options.goal, priority: options.priority }),
801
+ await client(command).post("/discoveries", await readJsonFile(options.file)),
599
802
  globals(command).json || false
600
803
  );
601
804
  });
@@ -637,6 +840,50 @@ discoveries.command("delete <discovery-id>").requiredOption("--yes", "confirm pe
637
840
  await client(command).delete(`/discoveries/${encodeURIComponent(discoveryId)}`);
638
841
  await emit({ deleted: true, discovery_id: discoveryId }, globals(command).json || false);
639
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
+ });
640
887
  program.configureOutput({ writeErr: (text) => process.stderr.write(text) });
641
888
  program.parseAsync(process.argv).catch(async (error) => {
642
889
  if (error instanceof CommanderError) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scrappycoco/cli",
3
- "version": "0.3.0",
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"