@scrappycoco/cli 0.4.2 → 0.6.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 -17
  2. package/dist/index.js +306 -45
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,8 +1,9 @@
1
1
  # Scrappycoco CLI
2
2
 
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.
3
+ Run external-data capabilities from a terminal or automation environment.
4
+ Scrappycoco is deterministic infrastructure, not an AI agent: all LLM
5
+ reasoning, provider decisions, and result judgment stay in the user's agentic
6
+ client.
6
7
 
7
8
  Run the published package directly with `npx`:
8
9
 
@@ -16,12 +17,10 @@ the live catalog, and tells you when to reload or restart your agent. Browser
16
17
  authorization is not complete until the terminal confirms that the credential
17
18
  was saved and the catalog check succeeded.
18
19
 
19
- Before every ordinary command, the CLI compares the installed Scrappycoco skill
20
- with the digest in the hash-verified public feed. When the digest changes, it
21
- automatically refreshes the skill and prints a reload/restart notice to stderr.
22
- Release-check or installer failures do not block the requested CLI command.
23
- CLI-only users without an installed skill are not modified. Set
24
- `SCRAPPYCOCO_DISABLE_SKILL_AUTO_UPDATE=1` to opt out.
20
+ Ordinary commands never mutate installed agent instructions. Setup installs
21
+ the matching skill release. Run `scrappycoco skill status` to inspect the
22
+ active target and `scrappycoco skill update` to explicitly install a newer
23
+ hash-verified release, then reload or restart the agent.
25
24
 
26
25
  When the CLI runs on a remote or headless host whose `127.0.0.1` is not the
27
26
  browser's localhost, keep the command running and use the manual callback
@@ -41,9 +40,12 @@ Individual commands remain available:
41
40
 
42
41
  ```sh
43
42
  npx --yes @scrappycoco/cli@latest auth login
43
+ npx --yes @scrappycoco/cli@latest skill status
44
+ npx --yes @scrappycoco/cli@latest skill update
45
+ npx --yes @scrappycoco/cli@latest doctor --json
44
46
  npx --yes @scrappycoco/cli@latest catalog list --available --json
45
47
  npx --yes @scrappycoco/cli@latest catalog inspect web.extract_content --json
46
- npx --yes @scrappycoco/cli@latest run web.extract_content --file request.json --json
48
+ npx --yes @scrappycoco/cli@latest run web.extract_content --file request.json --output results.json --json
47
49
  npx --yes @scrappycoco/cli@latest discover --file discovery.json --json
48
50
  npx --yes @scrappycoco/cli@latest discover --id DISCOVERY_ID --test --input '{"url":"https://example.com"}' --json
49
51
  npx --yes @scrappycoco/cli@latest discover --id DISCOVERY_ID --finalize --json
@@ -67,13 +69,23 @@ Use `--json` for machine-readable responses. Execution commands support
67
69
  `--format json|jsonl|csv` with `--output`, provider-native
68
70
  `--provider-options`, batch `--concurrency`, and an explicit
69
71
  `--idempotency-key` for safe identical retries. They submit durable jobs and
70
- poll for completion; set `SCRAPPYCOCO_JOB_TIMEOUT_MS` to change the 20-minute
71
- local wait. If a command times out while its job continues, inspect it with
72
- `scrappycoco jobs get <job-id>`.
73
-
74
- When provider or configuration choice is uncertain, define 2 to 5 named
75
- `candidates` in a Discover route. Each candidate has its own `id`, `provider`,
76
- and `options`, so one discovery can test both `zyte-http` with
72
+ poll for completion. When `--output` is used, records go to the file and a
73
+ compact execution summary remains on stdout.
74
+
75
+ For a long run, add `--detach` to return a job ID immediately, then finish with
76
+ `scrappycoco jobs wait <job-id> --output results.json`. Use
77
+ `scrappycoco jobs get <job-id>` for a single status check or
78
+ `scrappycoco jobs cancel <job-id>` to stop it. Set
79
+ `SCRAPPYCOCO_JOB_TIMEOUT_MS` to change the default 20-minute foreground wait.
80
+ Run `scrappycoco doctor --json` only when authentication, connectivity, or
81
+ installation is unclear.
82
+
83
+ When provider or configuration choice is uncertain, define at least one named
84
+ candidate for each special provider configuration under `candidates` in a
85
+ Discover route. Scrappycoco automatically adds one default candidate for every
86
+ available provider omitted from the draft, so the comparison remains
87
+ exhaustive. Each candidate has its own `id`, `provider`, and `options`, so one
88
+ discovery can test both `zyte-http` with
77
89
  `{"browser_html":false}` and `zyte-browser` with
78
90
  `{"browser_html":true}` against the same input.
79
91
 
package/dist/index.js CHANGED
@@ -554,8 +554,11 @@ var ApiClient = class {
554
554
  post(path, body, key = randomUUID2()) {
555
555
  return this.request("POST", path, body, { "Idempotency-Key": key });
556
556
  }
557
- async postJob(path, body, key = randomUUID2()) {
558
- const submitted = await this.post(path, body, key);
557
+ submitJob(path, body, key = randomUUID2()) {
558
+ return this.post(path, body, key);
559
+ }
560
+ async waitForJob(submittedOrJobId) {
561
+ const submitted = typeof submittedOrJobId === "string" ? await this.get(`/jobs/${encodeURIComponent(submittedOrJobId)}`) : submittedOrJobId;
559
562
  const timeoutMs = positiveInteger2(process.env.SCRAPPYCOCO_JOB_TIMEOUT_MS, DEFAULT_JOB_TIMEOUT_MS);
560
563
  const initialDelayMs = positiveInteger2(
561
564
  process.env.SCRAPPYCOCO_JOB_POLL_INITIAL_MS,
@@ -573,6 +576,13 @@ var ApiClient = class {
573
576
  job
574
577
  );
575
578
  }
579
+ if (job.status === "cancelled") {
580
+ throw new CliError(
581
+ `Job ${job.job_id} was cancelled.`,
582
+ EXIT.cancelled,
583
+ job
584
+ );
585
+ }
576
586
  const remainingBeforeDelay = deadline - Date.now();
577
587
  if (remainingBeforeDelay <= 0) throw jobTimeoutError(job, timeoutMs);
578
588
  await new Promise((resolve) => setTimeout(resolve, Math.min(delayMs, remainingBeforeDelay)));
@@ -594,7 +604,10 @@ var ApiClient = class {
594
604
  if (!job.result || typeof job.result !== "object" || Array.isArray(job.result)) {
595
605
  throw new CliError(`Job ${job.job_id} completed without a result.`, EXIT.api, job);
596
606
  }
597
- return job.result;
607
+ return { ...job.result, job_id: job.job_id };
608
+ }
609
+ async postJob(path, body, key = randomUUID2()) {
610
+ return this.waitForJob(await this.submitJob(path, body, key));
598
611
  }
599
612
  patch(path, body) {
600
613
  return this.request("PATCH", path, body);
@@ -863,26 +876,68 @@ function installedSkillCandidates() {
863
876
  join2(homedir2(), ".cursor", "skills", SKILL_NAME, "SKILL.md")
864
877
  ];
865
878
  }
866
- async function isSkillInstalled() {
867
- for (const path of installedSkillCandidates()) {
868
- try {
869
- await access(path);
870
- return true;
871
- } catch {
872
- }
879
+ function configuredTargetPath(agent) {
880
+ const directory = agent === "codex" ? ".codex" : agent === "claude-code" ? ".claude" : agent === "cursor" ? ".cursor" : ".agents";
881
+ return join2(homedir2(), directory, "skills", SKILL_NAME, "SKILL.md");
882
+ }
883
+ async function pathExists(path) {
884
+ try {
885
+ await access(path);
886
+ return true;
887
+ } catch {
888
+ return false;
873
889
  }
874
- return false;
875
890
  }
876
- async function loadInstalledDigest() {
891
+ async function activeSkillTarget() {
892
+ const configured = configuredTargetPath(await detectSkillInstallerAgent());
893
+ if (await pathExists(configured)) return configured;
894
+ const shared = configuredTargetPath("universal");
895
+ if (await pathExists(shared)) return shared;
896
+ return configured;
897
+ }
898
+ async function isSkillInstalled() {
899
+ return pathExists(await activeSkillTarget());
900
+ }
901
+ async function loadSkillReleaseState() {
877
902
  try {
878
903
  const state = JSON.parse(
879
904
  await readFile3(skillReleaseStatePath(), "utf8")
880
905
  );
881
- return typeof state.digest === "string" && SHA256_DIGEST.test(state.digest) ? state.digest : null;
906
+ return {
907
+ targets: Object.fromEntries(
908
+ Object.entries(state.targets || {}).filter(
909
+ ([path, value]) => Boolean(path) && typeof value?.digest === "string" && SHA256_DIGEST.test(value.digest)
910
+ )
911
+ )
912
+ };
882
913
  } catch {
883
- return null;
914
+ return { targets: {} };
884
915
  }
885
916
  }
917
+ async function loadInstalledDigest() {
918
+ const target = await activeSkillTarget();
919
+ const entry = (await loadSkillReleaseState()).targets[target];
920
+ return entry?.digest || null;
921
+ }
922
+ async function installedSkillDiagnostics() {
923
+ const target = await activeSkillTarget();
924
+ const state = await loadSkillReleaseState();
925
+ const installedTargets = (await Promise.all(
926
+ installedSkillCandidates().map(async (path) => ({
927
+ path,
928
+ installed: await pathExists(path)
929
+ }))
930
+ )).filter((item) => item.installed);
931
+ return {
932
+ installed: installedTargets.some((item) => item.path === target),
933
+ digest: state.targets[target]?.digest || null,
934
+ target,
935
+ targets: installedTargets.map(({ path }) => ({
936
+ path,
937
+ digest: state.targets[path]?.digest || null
938
+ }))
939
+ };
940
+ }
886
941
  async function writePrivateJson2(path, value) {
887
942
  await mkdir2(dirname2(path), { recursive: true, mode: 448 });
888
943
  const temporary = `${path}.${process.pid}.${randomUUID3()}.tmp`;
@@ -896,10 +951,23 @@ async function writePrivateJson2(path, value) {
896
951
  }
897
952
  }
898
953
  async function saveInstalledDigest(digest) {
899
- await writePrivateJson2(skillReleaseStatePath(), {
900
- digest,
901
- updated_at: (/* @__PURE__ */ new Date()).toISOString()
902
- });
954
+ const target = await activeSkillTarget();
955
+ const state = await loadSkillReleaseState();
956
+ await writePrivateJson2(
957
+ skillReleaseStatePath(),
958
+ recordTargetDigest(state, target, digest)
959
+ );
960
+ }
961
+ function recordTargetDigest(state, target, digest, updatedAt = (/* @__PURE__ */ new Date()).toISOString()) {
962
+ return {
963
+ targets: {
964
+ ...state.targets,
965
+ [target]: {
966
+ digest,
967
+ updated_at: updatedAt
968
+ }
969
+ }
970
+ };
903
971
  }
904
972
  async function fetchPublishedSkillDigest(fetcher = fetch) {
905
973
  const response = await fetcher(SKILL_INDEX_URL, {
@@ -910,7 +978,7 @@ async function fetchPublishedSkillDigest(fetcher = fetch) {
910
978
  throw new Error(`Skill release check returned HTTP ${response.status}.`);
911
979
  }
912
980
  const index = await response.json();
913
- const entry = index.skills?.find((skill) => skill.name === SKILL_NAME);
981
+ const entry = index.skills?.find((skill2) => skill2.name === SKILL_NAME);
914
982
  if (!entry || typeof entry.digest !== "string" || !SHA256_DIGEST.test(entry.digest)) {
915
983
  throw new Error("Skill release index did not contain a valid Scrappycoco digest.");
916
984
  }
@@ -981,13 +1049,7 @@ async function withSkillUpdateLock(operation) {
981
1049
  await rm2(path, { force: true });
982
1050
  }
983
1051
  }
984
- function autoUpdateDisabled() {
985
- return ["1", "true", "yes"].includes(
986
- (process.env.SCRAPPYCOCO_DISABLE_SKILL_AUTO_UPDATE || "").toLowerCase()
987
- );
988
- }
989
- async function autoUpdateInstalledSkill() {
990
- if (autoUpdateDisabled()) return { status: "disabled" };
1052
+ async function updateInstalledSkill() {
991
1053
  let result;
992
1054
  try {
993
1055
  result = await withSkillUpdateLock(() => checkAndInstallSkillUpdate());
@@ -1033,6 +1095,12 @@ function client(command) {
1033
1095
  function collect(value, previous) {
1034
1096
  return [...previous, value];
1035
1097
  }
1098
+ function selectFields(value, fields) {
1099
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
1100
+ return Object.fromEntries(
1101
+ fields.filter((field) => field in value).map((field) => [field, value[field]])
1102
+ );
1103
+ }
1036
1104
  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) => {
1037
1105
  const result = await performSetup({
1038
1106
  apiUrl: globals(command).apiUrl,
@@ -1042,10 +1110,6 @@ program.command("setup").description("Authenticate, install the Scrappycoco skil
1042
1110
  await rememberInstalledSkillRelease();
1043
1111
  await emit(result, globals(command).json || false);
1044
1112
  });
1045
- program.hook("preAction", async (_thisCommand, actionCommand) => {
1046
- if (actionCommand.name() === "setup") return;
1047
- await autoUpdateInstalledSkill();
1048
- });
1049
1113
  function splitScraperId(value) {
1050
1114
  const separator = value.indexOf(".");
1051
1115
  if (separator <= 0 || separator === value.length - 1) {
@@ -1074,12 +1138,138 @@ async function emitExecution(response, options, command) {
1074
1138
  const jsonMode = globals(command).json || false;
1075
1139
  if (options.output) {
1076
1140
  await emit(response, jsonMode, options.output, formatRecords(records, options.format));
1141
+ const summary = { ...response };
1142
+ delete summary.records;
1143
+ delete summary.items;
1144
+ delete summary.normalized_schema;
1145
+ summary.truncated_count = records.filter((record) => {
1146
+ const metadata = record.metadata;
1147
+ return metadata && typeof metadata === "object" && !Array.isArray(metadata) && metadata.truncated === true;
1148
+ }).length;
1149
+ if (summary.usage) {
1150
+ summary.usage = selectFields(summary.usage, [
1151
+ "billing_status",
1152
+ "payg_charge_usd_exact",
1153
+ "provider_cost_usd_exact",
1154
+ "unresolved_cost_count"
1155
+ ]);
1156
+ }
1157
+ if (Array.isArray(summary.attempts)) {
1158
+ summary.attempts = summary.attempts.map((attempt) => selectFields(attempt, [
1159
+ "provider",
1160
+ "status",
1161
+ "result_count",
1162
+ "latency_ms",
1163
+ "estimated_cost_usd",
1164
+ "error"
1165
+ ]));
1166
+ }
1167
+ const providerResults = summary.provider_results;
1168
+ if (providerResults && typeof providerResults === "object" && !Array.isArray(providerResults)) {
1169
+ const counts = Object.fromEntries(
1170
+ Object.entries(providerResults).map(([provider, values]) => [
1171
+ provider,
1172
+ Array.isArray(values) ? values.length : 0
1173
+ ])
1174
+ );
1175
+ if (Object.keys(counts).length) summary.provider_result_counts = counts;
1176
+ delete summary.provider_results;
1177
+ }
1178
+ if (Array.isArray(response.items)) {
1179
+ summary.item_count = response.items.length;
1180
+ }
1181
+ if (Array.isArray(summary.routes)) {
1182
+ summary.routes = summary.routes.map((value) => {
1183
+ if (!value || typeof value !== "object" || Array.isArray(value)) return value;
1184
+ const route = { ...value };
1185
+ const routeResults = route.provider_results;
1186
+ if (routeResults && typeof routeResults === "object" && !Array.isArray(routeResults)) {
1187
+ const counts = Object.fromEntries(
1188
+ Object.entries(routeResults).map(([provider, values]) => [
1189
+ provider,
1190
+ Array.isArray(values) ? values.length : 0
1191
+ ])
1192
+ );
1193
+ if (Object.keys(counts).length) route.provider_result_counts = counts;
1194
+ delete route.provider_results;
1195
+ }
1196
+ if (Array.isArray(route.attempts)) {
1197
+ route.attempts = route.attempts.map((attempt) => selectFields(attempt, [
1198
+ "provider",
1199
+ "status",
1200
+ "result_count",
1201
+ "latency_ms",
1202
+ "estimated_cost_usd",
1203
+ "error"
1204
+ ]));
1205
+ }
1206
+ return route;
1207
+ });
1208
+ }
1209
+ for (const field of ["cursor", "monitor"]) {
1210
+ if (summary[field] === null) delete summary[field];
1211
+ }
1212
+ summary.output = {
1213
+ path: options.output,
1214
+ format: options.format,
1215
+ record_count: records.length
1216
+ };
1217
+ await emit(summary, jsonMode);
1077
1218
  process.stderr.write(`Saved ${records.length} records to ${options.output}
1078
1219
  `);
1079
1220
  return;
1080
1221
  }
1081
1222
  await emit(response, jsonMode);
1082
1223
  }
1224
+ async function executeQueuedJob(path, payload, options, command) {
1225
+ if (options.detach && options.output) {
1226
+ throw new CliError(
1227
+ "Do not combine --detach with --output. Use `scrappycoco jobs wait JOB_ID --output PATH`.",
1228
+ EXIT.usage
1229
+ );
1230
+ }
1231
+ const apiClient = client(command);
1232
+ const idempotencyKey = options.idempotencyKey || randomUUID4();
1233
+ if (options.detach) {
1234
+ const job = await apiClient.submitJob(path, payload, idempotencyKey);
1235
+ await emit(
1236
+ {
1237
+ ...job,
1238
+ next_command: `scrappycoco jobs wait ${job.job_id}`
1239
+ },
1240
+ globals(command).json || false
1241
+ );
1242
+ return;
1243
+ }
1244
+ await emitExecution(
1245
+ await apiClient.postJob(
1246
+ path,
1247
+ payload,
1248
+ idempotencyKey
1249
+ ),
1250
+ options,
1251
+ command
1252
+ );
1253
+ }
1254
+ function formatCatalog(items) {
1255
+ const grouped = /* @__PURE__ */ new Map();
1256
+ for (const item of items) {
1257
+ const source = String(item.source || item.id.split(".", 1)[0] || "other");
1258
+ grouped.set(source, [...grouped.get(source) || [], item]);
1259
+ }
1260
+ const lines = [];
1261
+ for (const [source, capabilities] of grouped) {
1262
+ lines.push(`${source.toUpperCase()} (${capabilities.length})`);
1263
+ for (const capability of capabilities) {
1264
+ const label = typeof capability.label === "string" ? capability.label : capability.id;
1265
+ const description = typeof capability.description === "string" ? ` \u2014 ${capability.description}` : "";
1266
+ lines.push(` ${capability.id} ${label}${description}`);
1267
+ }
1268
+ lines.push("");
1269
+ }
1270
+ return `${lines.join("\n").trimEnd()}
1271
+ `;
1272
+ }
1083
1273
  var auth = program.command("auth").description("Manage Clerk OAuth credentials");
1084
1274
  auth.command("login").option("--no-browser", "print the authorization URL without opening it").option("--manual-callback", "paste the final callback URL in this terminal for remote/headless login").action(async (options, command) => {
1085
1275
  const result = await login({
@@ -1124,6 +1314,62 @@ auth.command("logout").action(async (_options, command) => {
1124
1314
  await clearRefreshToken2();
1125
1315
  await emit({ authenticated: false }, globals(command).json || false);
1126
1316
  });
1317
+ var skill = program.command("skill").description("Inspect or explicitly update the installed skill");
1318
+ skill.command("status").action(async (_options, command) => {
1319
+ await emit(await installedSkillDiagnostics(), globals(command).json || false);
1320
+ });
1321
+ skill.command("update").action(async (_options, command) => {
1322
+ const result = await updateInstalledSkill();
1323
+ await emit(
1324
+ result.status === "failed" ? { status: result.status, error: result.error.message } : result,
1325
+ globals(command).json || false
1326
+ );
1327
+ if (result.status === "failed" || result.status === "unavailable") {
1328
+ process.exitCode = EXIT.api;
1329
+ }
1330
+ });
1331
+ program.command("doctor").description("Check the CLI, authentication, installed skill, API, and live catalog").action(async (_options, command) => {
1332
+ const usingApiKey = Boolean(process.env.SCRAPPYCOCO_API_KEY);
1333
+ const usingOAuth = !usingApiKey && Boolean(await loadRefreshToken());
1334
+ const authentication = {
1335
+ configured: usingApiKey || usingOAuth,
1336
+ method: usingApiKey ? "api_key" : usingOAuth ? "oauth" : null
1337
+ };
1338
+ let catalog2 = [];
1339
+ let catalogError = null;
1340
+ if (authentication.configured) {
1341
+ try {
1342
+ catalog2 = await client(command).get("/scrapers?available_only=true");
1343
+ } catch (error) {
1344
+ catalogError = error instanceof Error ? error.message : String(error);
1345
+ }
1346
+ }
1347
+ const skill2 = await installedSkillDiagnostics();
1348
+ const ok = authentication.configured && catalogError === null;
1349
+ await emit(
1350
+ {
1351
+ ok,
1352
+ cli: {
1353
+ version: packageMetadata.version,
1354
+ node: process.version
1355
+ },
1356
+ authentication,
1357
+ api: {
1358
+ url: globals(command).apiUrl,
1359
+ reachable: authentication.configured && catalogError === null
1360
+ },
1361
+ catalog: {
1362
+ reachable: authentication.configured && catalogError === null,
1363
+ available_capabilities: catalog2.length,
1364
+ error: catalogError
1365
+ },
1366
+ skill: skill2,
1367
+ next_action: ok ? "Scrappycoco is ready." : authentication.configured ? "Check the API connection, then run doctor again." : "Run `scrappycoco setup`."
1368
+ },
1369
+ globals(command).json || false
1370
+ );
1371
+ if (!ok) process.exitCode = authentication.configured ? EXIT.api : EXIT.auth;
1372
+ });
1127
1373
  var scrapers = program.command("scrapers", { hidden: true }).description("Legacy scraper commands");
1128
1374
  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) => {
1129
1375
  const query = new URLSearchParams();
@@ -1148,10 +1394,9 @@ catalog.command("list").option("--source <source>", "filter by web, x, reddit, o
1148
1394
  if (options.source) query.set("source", options.source);
1149
1395
  if (options.provider) query.set("provider", options.provider);
1150
1396
  if (options.available) query.set("available_only", "true");
1151
- await emit(
1152
- await client(command).get(`/scrapers${query.size ? `?${query}` : ""}`),
1153
- globals(command).json || false
1154
- );
1397
+ const items = await client(command).get(`/scrapers${query.size ? `?${query}` : ""}`);
1398
+ if (globals(command).json) await emit(items, true);
1399
+ else process.stdout.write(formatCatalog(items));
1155
1400
  });
1156
1401
  catalog.command("inspect <capability-id>").action(async (capabilityId, _options, command) => {
1157
1402
  const { source, capability } = splitScraperId(capabilityId);
@@ -1176,17 +1421,17 @@ function executionCommand(name) {
1176
1421
  }
1177
1422
  executionCommand("run");
1178
1423
  executionCommand("compare");
1179
- program.command("run [capability-id]").description("Run a capability directly; discovery is optional").option("--config <discovery-id>", "run a finalized multi-step configuration").option("-f, --file <path>", "canonical request JSON file").option("--input <json>", "capability input JSON; use url or urls for web.extract_content").option("--provider <id>", "provider ID; repeat for an ordered fallback waterfall", collect, []).option("--provider-options <json>", "provider-native options keyed by provider ID").option("--concurrency <number>", "batch concurrency (default 3, maximum 10)").option("--limit <number>", "maximum records").option("--idempotency-key <key>", "stable retry key").option("--retry-failed <run-id>", "retry only failed URLs from a partial batch run").addOption(new Option("--format <format>", "output record format").choices(["json", "jsonl", "csv"]).default("json")).option("-o, --output <path>", "write records to a file").action(async (capabilityId, options, command) => {
1424
+ program.command("run [capability-id]").description("Run a capability directly; use Discover only when configuration is uncertain").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").option("--detach", "queue the job and return immediately").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) => {
1180
1425
  if (options.retryFailed) {
1181
1426
  if (capabilityId || options.config) {
1182
1427
  throw new CliError("Do not combine --retry-failed with a capability ID or --config.", EXIT.usage);
1183
1428
  }
1184
- const response2 = await client(command).postJob(
1429
+ await executeQueuedJob(
1185
1430
  `/runs/${encodeURIComponent(options.retryFailed)}/retry-failed`,
1186
1431
  {},
1187
- options.idempotencyKey || randomUUID4()
1432
+ options,
1433
+ command
1188
1434
  );
1189
- await emitExecution(response2, options, command);
1190
1435
  return;
1191
1436
  }
1192
1437
  if (options.config) {
@@ -1195,16 +1440,16 @@ program.command("run [capability-id]").description("Run a capability directly; d
1195
1440
  }
1196
1441
  const fromFile = options.file ? await readJsonFile(options.file) : {};
1197
1442
  const input = options.input ? parseJsonObject(options.input, "runtime input JSON") : fromFile.input || {};
1198
- const response2 = await client(command).postJob(
1443
+ await executeQueuedJob(
1199
1444
  `/discoveries/${encodeURIComponent(options.config)}/jobs`,
1200
1445
  {
1201
1446
  ...fromFile,
1202
1447
  input,
1203
1448
  limit: Number(options.limit ?? fromFile.limit ?? 25)
1204
1449
  },
1205
- options.idempotencyKey || randomUUID4()
1450
+ options,
1451
+ command
1206
1452
  );
1207
- await emitExecution(response2, options, command);
1208
1453
  return;
1209
1454
  }
1210
1455
  if (!capabilityId) {
@@ -1212,12 +1457,12 @@ program.command("run [capability-id]").description("Run a capability directly; d
1212
1457
  }
1213
1458
  const payload = await requestPayload(options, capabilityId);
1214
1459
  if (payload.limit === void 0) payload.limit = 10;
1215
- const response = await client(command).postJob(
1460
+ await executeQueuedJob(
1216
1461
  "/scrapers/jobs",
1217
1462
  payload,
1218
- options.idempotencyKey || randomUUID4()
1463
+ options,
1464
+ command
1219
1465
  );
1220
- await emitExecution(response, options, command);
1221
1466
  });
1222
1467
  var jobs = program.command("jobs").description("Inspect durable queued jobs");
1223
1468
  jobs.command("get <job-id>").action(async (jobId, _options, command) => {
@@ -1226,6 +1471,22 @@ jobs.command("get <job-id>").action(async (jobId, _options, command) => {
1226
1471
  globals(command).json || false
1227
1472
  );
1228
1473
  });
1474
+ jobs.command("wait <job-id>").description("Wait for a queued job and return its result").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 (jobId, options, command) => {
1475
+ await emitExecution(
1476
+ await client(command).waitForJob(jobId),
1477
+ options,
1478
+ command
1479
+ );
1480
+ });
1481
+ jobs.command("cancel <job-id>").description("Cancel a pending or running queued job").action(async (jobId, _options, command) => {
1482
+ await emit(
1483
+ await client(command).post(
1484
+ `/jobs/${encodeURIComponent(jobId)}/cancel`,
1485
+ {}
1486
+ ),
1487
+ globals(command).json || false
1488
+ );
1489
+ });
1229
1490
  var providers = program.command("providers", { hidden: true }).description("Legacy provider commands");
1230
1491
  providers.command("list").option("--available", "only include available provider-capability routes").action(async (options, command) => {
1231
1492
  await emit(
@@ -1278,7 +1539,7 @@ discoveries.command("delete <discovery-id>").requiredOption("--yes", "confirm pe
1278
1539
  await client(command).delete(`/discoveries/${encodeURIComponent(discoveryId)}`);
1279
1540
  await emit({ deleted: true, discovery_id: discoveryId }, globals(command).json || false);
1280
1541
  });
1281
- 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) => {
1542
+ 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", "run a representative provider 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) => {
1282
1543
  const selected = Number(Boolean(options.file)) + Number(Boolean(options.test)) + Number(Boolean(options.update)) + Number(Boolean(options.finalize));
1283
1544
  if (selected !== 1) {
1284
1545
  throw new CliError(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scrappycoco/cli",
3
- "version": "0.4.2",
3
+ "version": "0.6.0",
4
4
  "description": "CLI for Scrappycoco scraper discovery and execution",
5
5
  "type": "module",
6
6
  "bin": {