@scrappycoco/cli 0.4.2 → 0.5.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 +22 -11
  2. package/dist/index.js +213 -18
  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
 
@@ -41,9 +42,10 @@ Individual commands remain available:
41
42
 
42
43
  ```sh
43
44
  npx --yes @scrappycoco/cli@latest auth login
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,22 @@ 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. Set
78
+ `SCRAPPYCOCO_JOB_TIMEOUT_MS` to change the default 20-minute foreground wait.
79
+ Run `scrappycoco doctor --json` only when authentication, connectivity, or
80
+ installation is unclear.
81
+
82
+ When provider or configuration choice is uncertain, define at least one named
83
+ candidate for each special provider configuration under `candidates` in a
84
+ Discover route. Scrappycoco automatically adds one default candidate for every
85
+ available provider omitted from the draft, so the comparison remains
86
+ exhaustive. Each candidate has its own `id`, `provider`, and `options`, so one
87
+ discovery can test both `zyte-http` with
77
88
  `{"browser_html":false}` and `zyte-browser` with
78
89
  `{"browser_html":true}` against the same input.
79
90
 
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,
@@ -594,7 +597,10 @@ var ApiClient = class {
594
597
  if (!job.result || typeof job.result !== "object" || Array.isArray(job.result)) {
595
598
  throw new CliError(`Job ${job.job_id} completed without a result.`, EXIT.api, job);
596
599
  }
597
- return job.result;
600
+ return { ...job.result, job_id: job.job_id };
601
+ }
602
+ async postJob(path, body, key = randomUUID2()) {
603
+ return this.waitForJob(await this.submitJob(path, body, key));
598
604
  }
599
605
  patch(path, body) {
600
606
  return this.request("PATCH", path, body);
@@ -883,6 +889,12 @@ async function loadInstalledDigest() {
883
889
  return null;
884
890
  }
885
891
  }
892
+ async function installedSkillDiagnostics() {
893
+ return {
894
+ installed: await isSkillInstalled(),
895
+ digest: await loadInstalledDigest()
896
+ };
897
+ }
886
898
  async function writePrivateJson2(path, value) {
887
899
  await mkdir2(dirname2(path), { recursive: true, mode: 448 });
888
900
  const temporary = `${path}.${process.pid}.${randomUUID3()}.tmp`;
@@ -1033,6 +1045,12 @@ function client(command) {
1033
1045
  function collect(value, previous) {
1034
1046
  return [...previous, value];
1035
1047
  }
1048
+ function selectFields(value, fields) {
1049
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
1050
+ return Object.fromEntries(
1051
+ fields.filter((field) => field in value).map((field) => [field, value[field]])
1052
+ );
1053
+ }
1036
1054
  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
1055
  const result = await performSetup({
1038
1056
  apiUrl: globals(command).apiUrl,
@@ -1074,12 +1092,141 @@ async function emitExecution(response, options, command) {
1074
1092
  const jsonMode = globals(command).json || false;
1075
1093
  if (options.output) {
1076
1094
  await emit(response, jsonMode, options.output, formatRecords(records, options.format));
1095
+ const summary = { ...response };
1096
+ delete summary.records;
1097
+ delete summary.items;
1098
+ delete summary.normalized_schema;
1099
+ summary.truncated_count = records.filter((record) => {
1100
+ const metadata = record.metadata;
1101
+ return metadata && typeof metadata === "object" && !Array.isArray(metadata) && metadata.truncated === true;
1102
+ }).length;
1103
+ if (summary.usage) {
1104
+ summary.usage = selectFields(summary.usage, [
1105
+ "billing_status",
1106
+ "payg_charge_usd_exact",
1107
+ "provider_cost_usd_exact",
1108
+ "unresolved_cost_count"
1109
+ ]);
1110
+ }
1111
+ if (Array.isArray(summary.attempts)) {
1112
+ summary.attempts = summary.attempts.map((attempt) => selectFields(attempt, [
1113
+ "provider",
1114
+ "status",
1115
+ "result_count",
1116
+ "latency_ms",
1117
+ "estimated_cost_usd",
1118
+ "error"
1119
+ ]));
1120
+ }
1121
+ const providerResults = summary.provider_results;
1122
+ if (providerResults && typeof providerResults === "object" && !Array.isArray(providerResults)) {
1123
+ const counts = Object.fromEntries(
1124
+ Object.entries(providerResults).map(([provider, values]) => [
1125
+ provider,
1126
+ Array.isArray(values) ? values.length : 0
1127
+ ])
1128
+ );
1129
+ if (Object.keys(counts).length) summary.provider_result_counts = counts;
1130
+ delete summary.provider_results;
1131
+ }
1132
+ if (Array.isArray(response.items)) {
1133
+ summary.item_count = response.items.length;
1134
+ summary.failed_item_count = response.items.filter(
1135
+ (item) => item && typeof item === "object" && item.status === "failed"
1136
+ ).length;
1137
+ }
1138
+ if (Array.isArray(summary.routes)) {
1139
+ summary.routes = summary.routes.map((value) => {
1140
+ if (!value || typeof value !== "object" || Array.isArray(value)) return value;
1141
+ const route = { ...value };
1142
+ const routeResults = route.provider_results;
1143
+ if (routeResults && typeof routeResults === "object" && !Array.isArray(routeResults)) {
1144
+ const counts = Object.fromEntries(
1145
+ Object.entries(routeResults).map(([provider, values]) => [
1146
+ provider,
1147
+ Array.isArray(values) ? values.length : 0
1148
+ ])
1149
+ );
1150
+ if (Object.keys(counts).length) route.provider_result_counts = counts;
1151
+ delete route.provider_results;
1152
+ }
1153
+ if (Array.isArray(route.attempts)) {
1154
+ route.attempts = route.attempts.map((attempt) => selectFields(attempt, [
1155
+ "provider",
1156
+ "status",
1157
+ "result_count",
1158
+ "latency_ms",
1159
+ "estimated_cost_usd",
1160
+ "error"
1161
+ ]));
1162
+ }
1163
+ return route;
1164
+ });
1165
+ }
1166
+ for (const field of ["cursor", "monitor"]) {
1167
+ if (summary[field] === null) delete summary[field];
1168
+ }
1169
+ summary.output = {
1170
+ path: options.output,
1171
+ format: options.format,
1172
+ record_count: records.length
1173
+ };
1174
+ await emit(summary, jsonMode);
1077
1175
  process.stderr.write(`Saved ${records.length} records to ${options.output}
1078
1176
  `);
1079
1177
  return;
1080
1178
  }
1081
1179
  await emit(response, jsonMode);
1082
1180
  }
1181
+ async function executeQueuedJob(path, payload, options, command) {
1182
+ if (options.detach && options.output) {
1183
+ throw new CliError(
1184
+ "Do not combine --detach with --output. Use `scrappycoco jobs wait JOB_ID --output PATH`.",
1185
+ EXIT.usage
1186
+ );
1187
+ }
1188
+ const apiClient = client(command);
1189
+ const idempotencyKey = options.idempotencyKey || randomUUID4();
1190
+ if (options.detach) {
1191
+ const job = await apiClient.submitJob(path, payload, idempotencyKey);
1192
+ await emit(
1193
+ {
1194
+ ...job,
1195
+ next_command: `scrappycoco jobs wait ${job.job_id}`
1196
+ },
1197
+ globals(command).json || false
1198
+ );
1199
+ return;
1200
+ }
1201
+ await emitExecution(
1202
+ await apiClient.postJob(
1203
+ path,
1204
+ payload,
1205
+ idempotencyKey
1206
+ ),
1207
+ options,
1208
+ command
1209
+ );
1210
+ }
1211
+ function formatCatalog(items) {
1212
+ const grouped = /* @__PURE__ */ new Map();
1213
+ for (const item of items) {
1214
+ const source = String(item.source || item.id.split(".", 1)[0] || "other");
1215
+ grouped.set(source, [...grouped.get(source) || [], item]);
1216
+ }
1217
+ const lines = [];
1218
+ for (const [source, capabilities] of grouped) {
1219
+ lines.push(`${source.toUpperCase()} (${capabilities.length})`);
1220
+ for (const capability of capabilities) {
1221
+ const label = typeof capability.label === "string" ? capability.label : capability.id;
1222
+ const description = typeof capability.description === "string" ? ` \u2014 ${capability.description}` : "";
1223
+ lines.push(` ${capability.id} ${label}${description}`);
1224
+ }
1225
+ lines.push("");
1226
+ }
1227
+ return `${lines.join("\n").trimEnd()}
1228
+ `;
1229
+ }
1083
1230
  var auth = program.command("auth").description("Manage Clerk OAuth credentials");
1084
1231
  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
1232
  const result = await login({
@@ -1124,6 +1271,48 @@ auth.command("logout").action(async (_options, command) => {
1124
1271
  await clearRefreshToken2();
1125
1272
  await emit({ authenticated: false }, globals(command).json || false);
1126
1273
  });
1274
+ program.command("doctor").description("Check the CLI, authentication, installed skill, API, and live catalog").action(async (_options, command) => {
1275
+ const usingApiKey = Boolean(process.env.SCRAPPYCOCO_API_KEY);
1276
+ const usingOAuth = !usingApiKey && Boolean(await loadRefreshToken());
1277
+ const authentication = {
1278
+ configured: usingApiKey || usingOAuth,
1279
+ method: usingApiKey ? "api_key" : usingOAuth ? "oauth" : null
1280
+ };
1281
+ let catalog2 = [];
1282
+ let catalogError = null;
1283
+ if (authentication.configured) {
1284
+ try {
1285
+ catalog2 = await client(command).get("/scrapers?available_only=true");
1286
+ } catch (error) {
1287
+ catalogError = error instanceof Error ? error.message : String(error);
1288
+ }
1289
+ }
1290
+ const skill = await installedSkillDiagnostics();
1291
+ const ok = authentication.configured && catalogError === null;
1292
+ await emit(
1293
+ {
1294
+ ok,
1295
+ cli: {
1296
+ version: packageMetadata.version,
1297
+ node: process.version
1298
+ },
1299
+ authentication,
1300
+ api: {
1301
+ url: globals(command).apiUrl,
1302
+ reachable: authentication.configured && catalogError === null
1303
+ },
1304
+ catalog: {
1305
+ reachable: authentication.configured && catalogError === null,
1306
+ available_capabilities: catalog2.length,
1307
+ error: catalogError
1308
+ },
1309
+ skill,
1310
+ next_action: ok ? "Scrappycoco is ready." : authentication.configured ? "Check the API connection, then run doctor again." : "Run `scrappycoco setup`."
1311
+ },
1312
+ globals(command).json || false
1313
+ );
1314
+ if (!ok) process.exitCode = authentication.configured ? EXIT.api : EXIT.auth;
1315
+ });
1127
1316
  var scrapers = program.command("scrapers", { hidden: true }).description("Legacy scraper commands");
1128
1317
  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
1318
  const query = new URLSearchParams();
@@ -1148,10 +1337,9 @@ catalog.command("list").option("--source <source>", "filter by web, x, reddit, o
1148
1337
  if (options.source) query.set("source", options.source);
1149
1338
  if (options.provider) query.set("provider", options.provider);
1150
1339
  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
- );
1340
+ const items = await client(command).get(`/scrapers${query.size ? `?${query}` : ""}`);
1341
+ if (globals(command).json) await emit(items, true);
1342
+ else process.stdout.write(formatCatalog(items));
1155
1343
  });
1156
1344
  catalog.command("inspect <capability-id>").action(async (capabilityId, _options, command) => {
1157
1345
  const { source, capability } = splitScraperId(capabilityId);
@@ -1176,17 +1364,17 @@ function executionCommand(name) {
1176
1364
  }
1177
1365
  executionCommand("run");
1178
1366
  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) => {
1367
+ 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
1368
  if (options.retryFailed) {
1181
1369
  if (capabilityId || options.config) {
1182
1370
  throw new CliError("Do not combine --retry-failed with a capability ID or --config.", EXIT.usage);
1183
1371
  }
1184
- const response2 = await client(command).postJob(
1372
+ await executeQueuedJob(
1185
1373
  `/runs/${encodeURIComponent(options.retryFailed)}/retry-failed`,
1186
1374
  {},
1187
- options.idempotencyKey || randomUUID4()
1375
+ options,
1376
+ command
1188
1377
  );
1189
- await emitExecution(response2, options, command);
1190
1378
  return;
1191
1379
  }
1192
1380
  if (options.config) {
@@ -1195,16 +1383,16 @@ program.command("run [capability-id]").description("Run a capability directly; d
1195
1383
  }
1196
1384
  const fromFile = options.file ? await readJsonFile(options.file) : {};
1197
1385
  const input = options.input ? parseJsonObject(options.input, "runtime input JSON") : fromFile.input || {};
1198
- const response2 = await client(command).postJob(
1386
+ await executeQueuedJob(
1199
1387
  `/discoveries/${encodeURIComponent(options.config)}/jobs`,
1200
1388
  {
1201
1389
  ...fromFile,
1202
1390
  input,
1203
1391
  limit: Number(options.limit ?? fromFile.limit ?? 25)
1204
1392
  },
1205
- options.idempotencyKey || randomUUID4()
1393
+ options,
1394
+ command
1206
1395
  );
1207
- await emitExecution(response2, options, command);
1208
1396
  return;
1209
1397
  }
1210
1398
  if (!capabilityId) {
@@ -1212,12 +1400,12 @@ program.command("run [capability-id]").description("Run a capability directly; d
1212
1400
  }
1213
1401
  const payload = await requestPayload(options, capabilityId);
1214
1402
  if (payload.limit === void 0) payload.limit = 10;
1215
- const response = await client(command).postJob(
1403
+ await executeQueuedJob(
1216
1404
  "/scrapers/jobs",
1217
1405
  payload,
1218
- options.idempotencyKey || randomUUID4()
1406
+ options,
1407
+ command
1219
1408
  );
1220
- await emitExecution(response, options, command);
1221
1409
  });
1222
1410
  var jobs = program.command("jobs").description("Inspect durable queued jobs");
1223
1411
  jobs.command("get <job-id>").action(async (jobId, _options, command) => {
@@ -1226,6 +1414,13 @@ jobs.command("get <job-id>").action(async (jobId, _options, command) => {
1226
1414
  globals(command).json || false
1227
1415
  );
1228
1416
  });
1417
+ 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) => {
1418
+ await emitExecution(
1419
+ await client(command).waitForJob(jobId),
1420
+ options,
1421
+ command
1422
+ );
1423
+ });
1229
1424
  var providers = program.command("providers", { hidden: true }).description("Legacy provider commands");
1230
1425
  providers.command("list").option("--available", "only include available provider-capability routes").action(async (options, command) => {
1231
1426
  await emit(
@@ -1278,7 +1473,7 @@ discoveries.command("delete <discovery-id>").requiredOption("--yes", "confirm pe
1278
1473
  await client(command).delete(`/discoveries/${encodeURIComponent(discoveryId)}`);
1279
1474
  await emit({ deleted: true, discovery_id: discoveryId }, globals(command).json || false);
1280
1475
  });
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) => {
1476
+ 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
1477
  const selected = Number(Boolean(options.file)) + Number(Boolean(options.test)) + Number(Boolean(options.update)) + Number(Boolean(options.finalize));
1283
1478
  if (selected !== 1) {
1284
1479
  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.5.0",
4
4
  "description": "CLI for Scrappycoco scraper discovery and execution",
5
5
  "type": "module",
6
6
  "bin": {