@scrappycoco/cli 0.4.1 → 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 +41 -20
  2. package/dist/index.js +403 -24
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,13 +1,14 @@
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
 
9
10
  ```sh
10
- npx --yes @scrappycoco/cli setup
11
+ npx --yes @scrappycoco/cli@latest setup
11
12
  ```
12
13
 
13
14
  `setup` opens OAuth in the browser. New users can create an account in that
@@ -16,12 +17,19 @@ 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
 
20
+ Before every ordinary command, the CLI compares the installed Scrappycoco skill
21
+ with the digest in the hash-verified public feed. When the digest changes, it
22
+ automatically refreshes the skill and prints a reload/restart notice to stderr.
23
+ Release-check or installer failures do not block the requested CLI command.
24
+ CLI-only users without an installed skill are not modified. Set
25
+ `SCRAPPYCOCO_DISABLE_SKILL_AUTO_UPDATE=1` to opt out.
26
+
19
27
  When the CLI runs on a remote or headless host whose `127.0.0.1` is not the
20
28
  browser's localhost, keep the command running and use the manual callback
21
29
  flow:
22
30
 
23
31
  ```sh
24
- npx --yes @scrappycoco/cli setup --no-browser --manual-callback
32
+ npx --yes @scrappycoco/cli@latest setup --no-browser --manual-callback
25
33
  ```
26
34
 
27
35
  Open the printed authorization URL, then paste the browser's final callback
@@ -33,14 +41,15 @@ URL.
33
41
  Individual commands remain available:
34
42
 
35
43
  ```sh
36
- npx --yes @scrappycoco/cli auth login
37
- npx --yes @scrappycoco/cli catalog list --available --json
38
- npx --yes @scrappycoco/cli catalog inspect web.extract_content --json
39
- npx --yes @scrappycoco/cli run web.extract_content --file request.json --json
40
- npx --yes @scrappycoco/cli discover --file discovery.json --json
41
- npx --yes @scrappycoco/cli discover --id DISCOVERY_ID --test --input '{"url":"https://example.com"}' --json
42
- npx --yes @scrappycoco/cli discover --id DISCOVERY_ID --finalize --json
43
- npx --yes @scrappycoco/cli run --config DISCOVERY_ID --input '{}' --json
44
+ npx --yes @scrappycoco/cli@latest auth login
45
+ npx --yes @scrappycoco/cli@latest doctor --json
46
+ npx --yes @scrappycoco/cli@latest catalog list --available --json
47
+ npx --yes @scrappycoco/cli@latest catalog inspect web.extract_content --json
48
+ npx --yes @scrappycoco/cli@latest run web.extract_content --file request.json --output results.json --json
49
+ npx --yes @scrappycoco/cli@latest discover --file discovery.json --json
50
+ npx --yes @scrappycoco/cli@latest discover --id DISCOVERY_ID --test --input '{"url":"https://example.com"}' --json
51
+ npx --yes @scrappycoco/cli@latest discover --id DISCOVERY_ID --finalize --json
52
+ npx --yes @scrappycoco/cli@latest run --config DISCOVERY_ID --input '{}' --json
44
53
  ```
45
54
 
46
55
  From a repository checkout, use `npm ci`, `npm run build`, and
@@ -60,16 +69,28 @@ Use `--json` for machine-readable responses. Execution commands support
60
69
  `--format json|jsonl|csv` with `--output`, provider-native
61
70
  `--provider-options`, batch `--concurrency`, and an explicit
62
71
  `--idempotency-key` for safe identical retries. They submit durable jobs and
63
- poll for completion; set `SCRAPPYCOCO_JOB_TIMEOUT_MS` to change the 20-minute
64
- local wait. If a command times out while its job continues, inspect it with
65
- `scrappycoco jobs get <job-id>`.
66
-
67
- When provider or configuration choice is uncertain, define 2 to 5 named
68
- `candidates` in a Discover route. Each candidate has its own `id`, `provider`,
69
- and `options`, so one discovery can test both `zyte-http` with
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
70
88
  `{"browser_html":false}` and `zyte-browser` with
71
89
  `{"browser_html":true}` against the same input.
72
90
 
91
+ The distributed skill includes a Discovery workbook with complete direct-Run,
92
+ comparison, update, finalization, fallback, and audit-handoff examples.
93
+
73
94
  Use `scrappycoco --help` for the complete command reference. See the
74
95
  [Scrappycoco API documentation](https://scrappycoco.ai/docs) for the public
75
96
  contract.
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { randomUUID as randomUUID3 } from "crypto";
4
+ import { randomUUID as randomUUID4 } from "crypto";
5
5
  import { readFileSync } from "fs";
6
6
  import { Command, CommanderError, Option } from "commander";
7
7
 
@@ -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);
@@ -671,7 +677,7 @@ import { determineAgent } from "@vercel/detect-agent";
671
677
  var SKILL_SOURCE = "https://scrappycoco.ai";
672
678
  var SKILL_INSTALL_ARGS = [
673
679
  "--yes",
674
- "skills",
680
+ "skills@latest",
675
681
  "add",
676
682
  SKILL_SOURCE,
677
683
  "--skill",
@@ -710,7 +716,7 @@ function skillInstallInvocation(agent = "universal", npmExecPath = process.env.n
710
716
  args: [
711
717
  npmExecPath,
712
718
  "dlx",
713
- "skills",
719
+ "skills@latest",
714
720
  ...SKILL_INSTALL_ARGS.slice(2),
715
721
  "--agent",
716
722
  agent
@@ -839,6 +845,191 @@ async function performSetup(options, dependencies = defaultDependencies(options.
839
845
  };
840
846
  }
841
847
 
848
+ // src/skill-update.ts
849
+ import { randomUUID as randomUUID3 } from "crypto";
850
+ import { access, chmod as chmod2, mkdir as mkdir2, open as open3, readFile as readFile3, rename as rename2, rm as rm2, stat as stat2, writeFile as writeFile3 } from "fs/promises";
851
+ import { homedir as homedir2 } from "os";
852
+ import { dirname as dirname2, join as join2 } from "path";
853
+ var SKILL_NAME = "scrappycoco";
854
+ var SKILL_INDEX_URL = `${SKILL_SOURCE}/.well-known/agent-skills/index.json`;
855
+ var SKILL_UPDATE_TIMEOUT_MS = 3e3;
856
+ var SKILL_UPDATE_LOCK_STALE_MS = 5 * 6e4;
857
+ var SHA256_DIGEST = /^sha256:[a-f0-9]{64}$/i;
858
+ function skillReleaseStatePath() {
859
+ return join2(dirname2(fallbackCredentialPath()), "skill-release.json");
860
+ }
861
+ function skillUpdateLockPath() {
862
+ return `${skillReleaseStatePath()}.lock`;
863
+ }
864
+ function installedSkillCandidates() {
865
+ return [
866
+ join2(homedir2(), ".agents", "skills", SKILL_NAME, "SKILL.md"),
867
+ join2(homedir2(), ".codex", "skills", SKILL_NAME, "SKILL.md"),
868
+ join2(homedir2(), ".claude", "skills", SKILL_NAME, "SKILL.md"),
869
+ join2(homedir2(), ".cursor", "skills", SKILL_NAME, "SKILL.md")
870
+ ];
871
+ }
872
+ async function isSkillInstalled() {
873
+ for (const path of installedSkillCandidates()) {
874
+ try {
875
+ await access(path);
876
+ return true;
877
+ } catch {
878
+ }
879
+ }
880
+ return false;
881
+ }
882
+ async function loadInstalledDigest() {
883
+ try {
884
+ const state = JSON.parse(
885
+ await readFile3(skillReleaseStatePath(), "utf8")
886
+ );
887
+ return typeof state.digest === "string" && SHA256_DIGEST.test(state.digest) ? state.digest : null;
888
+ } catch {
889
+ return null;
890
+ }
891
+ }
892
+ async function installedSkillDiagnostics() {
893
+ return {
894
+ installed: await isSkillInstalled(),
895
+ digest: await loadInstalledDigest()
896
+ };
897
+ }
898
+ async function writePrivateJson2(path, value) {
899
+ await mkdir2(dirname2(path), { recursive: true, mode: 448 });
900
+ const temporary = `${path}.${process.pid}.${randomUUID3()}.tmp`;
901
+ try {
902
+ await writeFile3(temporary, JSON.stringify(value), { mode: 384 });
903
+ if (process.platform !== "win32") await chmod2(temporary, 384);
904
+ await rename2(temporary, path);
905
+ if (process.platform !== "win32") await chmod2(path, 384);
906
+ } finally {
907
+ await rm2(temporary, { force: true });
908
+ }
909
+ }
910
+ async function saveInstalledDigest(digest) {
911
+ await writePrivateJson2(skillReleaseStatePath(), {
912
+ digest,
913
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
914
+ });
915
+ }
916
+ async function fetchPublishedSkillDigest(fetcher = fetch) {
917
+ const response = await fetcher(SKILL_INDEX_URL, {
918
+ headers: { accept: "application/json" },
919
+ signal: AbortSignal.timeout(SKILL_UPDATE_TIMEOUT_MS)
920
+ });
921
+ if (!response.ok) {
922
+ throw new Error(`Skill release check returned HTTP ${response.status}.`);
923
+ }
924
+ const index = await response.json();
925
+ const entry = index.skills?.find((skill) => skill.name === SKILL_NAME);
926
+ if (!entry || typeof entry.digest !== "string" || !SHA256_DIGEST.test(entry.digest)) {
927
+ throw new Error("Skill release index did not contain a valid Scrappycoco digest.");
928
+ }
929
+ return entry.digest.toLowerCase();
930
+ }
931
+ function defaultDependencies2() {
932
+ return {
933
+ isInstalled: isSkillInstalled,
934
+ loadInstalledDigest,
935
+ fetchPublishedDigest: fetchPublishedSkillDigest,
936
+ install: installSkill,
937
+ saveInstalledDigest
938
+ };
939
+ }
940
+ async function checkAndInstallSkillUpdate(dependencies = defaultDependencies2()) {
941
+ if (!await dependencies.isInstalled()) return { status: "not_installed" };
942
+ let publishedDigest;
943
+ try {
944
+ publishedDigest = await dependencies.fetchPublishedDigest();
945
+ } catch {
946
+ return { status: "unavailable" };
947
+ }
948
+ if (await dependencies.loadInstalledDigest() === publishedDigest) {
949
+ return { status: "current" };
950
+ }
951
+ try {
952
+ await dependencies.install();
953
+ } catch (error) {
954
+ return {
955
+ status: "failed",
956
+ error: error instanceof Error ? error : new Error(String(error))
957
+ };
958
+ }
959
+ let stateSaved = true;
960
+ try {
961
+ await dependencies.saveInstalledDigest(publishedDigest);
962
+ } catch {
963
+ stateSaved = false;
964
+ }
965
+ return { status: "updated", digest: publishedDigest, state_saved: stateSaved };
966
+ }
967
+ async function withSkillUpdateLock(operation) {
968
+ const path = skillUpdateLockPath();
969
+ await mkdir2(dirname2(path), { recursive: true, mode: 448 });
970
+ let handle;
971
+ try {
972
+ handle = await open3(path, "wx", 384);
973
+ } catch (error) {
974
+ if (error.code !== "EEXIST") {
975
+ return { status: "unavailable" };
976
+ }
977
+ try {
978
+ const lock = await stat2(path);
979
+ if (Date.now() - lock.mtimeMs <= SKILL_UPDATE_LOCK_STALE_MS) {
980
+ return { status: "busy" };
981
+ }
982
+ await rm2(path, { force: true });
983
+ handle = await open3(path, "wx", 384);
984
+ } catch {
985
+ return { status: "busy" };
986
+ }
987
+ }
988
+ try {
989
+ await handle.writeFile(JSON.stringify({ pid: process.pid, created_at: Date.now() }));
990
+ return await operation();
991
+ } finally {
992
+ await handle.close();
993
+ await rm2(path, { force: true });
994
+ }
995
+ }
996
+ function autoUpdateDisabled() {
997
+ return ["1", "true", "yes"].includes(
998
+ (process.env.SCRAPPYCOCO_DISABLE_SKILL_AUTO_UPDATE || "").toLowerCase()
999
+ );
1000
+ }
1001
+ async function autoUpdateInstalledSkill() {
1002
+ if (autoUpdateDisabled()) return { status: "disabled" };
1003
+ let result;
1004
+ try {
1005
+ result = await withSkillUpdateLock(() => checkAndInstallSkillUpdate());
1006
+ } catch {
1007
+ return { status: "unavailable" };
1008
+ }
1009
+ if (result.status === "updated") {
1010
+ process.stderr.write(
1011
+ "Updated the Scrappycoco skill. Reload or restart the agent to use the new instructions.\n"
1012
+ );
1013
+ if (!result.state_saved) {
1014
+ process.stderr.write(
1015
+ "Warning: the skill update succeeded, but its release state could not be saved.\n"
1016
+ );
1017
+ }
1018
+ } else if (result.status === "failed") {
1019
+ process.stderr.write(
1020
+ `Warning: could not update the Scrappycoco skill; continuing with the installed copy: ${result.error.message}
1021
+ `
1022
+ );
1023
+ }
1024
+ return result;
1025
+ }
1026
+ async function rememberInstalledSkillRelease() {
1027
+ try {
1028
+ await saveInstalledDigest(await fetchPublishedSkillDigest());
1029
+ } catch {
1030
+ }
1031
+ }
1032
+
842
1033
  // src/index.ts
843
1034
  var packageMetadata = JSON.parse(
844
1035
  readFileSync(new URL("../package.json", import.meta.url), "utf8")
@@ -854,14 +1045,25 @@ function client(command) {
854
1045
  function collect(value, previous) {
855
1046
  return [...previous, value];
856
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
+ }
857
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) => {
858
1055
  const result = await performSetup({
859
1056
  apiUrl: globals(command).apiUrl,
860
1057
  noBrowser: options.browser === false,
861
1058
  manualCallback: options.manualCallback
862
1059
  });
1060
+ await rememberInstalledSkillRelease();
863
1061
  await emit(result, globals(command).json || false);
864
1062
  });
1063
+ program.hook("preAction", async (_thisCommand, actionCommand) => {
1064
+ if (actionCommand.name() === "setup") return;
1065
+ await autoUpdateInstalledSkill();
1066
+ });
865
1067
  function splitScraperId(value) {
866
1068
  const separator = value.indexOf(".");
867
1069
  if (separator <= 0 || separator === value.length - 1) {
@@ -890,12 +1092,141 @@ async function emitExecution(response, options, command) {
890
1092
  const jsonMode = globals(command).json || false;
891
1093
  if (options.output) {
892
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);
893
1175
  process.stderr.write(`Saved ${records.length} records to ${options.output}
894
1176
  `);
895
1177
  return;
896
1178
  }
897
1179
  await emit(response, jsonMode);
898
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
+ }
899
1230
  var auth = program.command("auth").description("Manage Clerk OAuth credentials");
900
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) => {
901
1232
  const result = await login({
@@ -940,6 +1271,48 @@ auth.command("logout").action(async (_options, command) => {
940
1271
  await clearRefreshToken2();
941
1272
  await emit({ authenticated: false }, globals(command).json || false);
942
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
+ });
943
1316
  var scrapers = program.command("scrapers", { hidden: true }).description("Legacy scraper commands");
944
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) => {
945
1318
  const query = new URLSearchParams();
@@ -964,10 +1337,9 @@ catalog.command("list").option("--source <source>", "filter by web, x, reddit, o
964
1337
  if (options.source) query.set("source", options.source);
965
1338
  if (options.provider) query.set("provider", options.provider);
966
1339
  if (options.available) query.set("available_only", "true");
967
- await emit(
968
- await client(command).get(`/scrapers${query.size ? `?${query}` : ""}`),
969
- globals(command).json || false
970
- );
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));
971
1343
  });
972
1344
  catalog.command("inspect <capability-id>").action(async (capabilityId, _options, command) => {
973
1345
  const { source, capability } = splitScraperId(capabilityId);
@@ -985,24 +1357,24 @@ function executionCommand(name) {
985
1357
  const response = await client(command).postJob(
986
1358
  name === "run" ? "/scrapers/jobs" : "/scrapers/compare/jobs",
987
1359
  payload,
988
- options.idempotencyKey || randomUUID3()
1360
+ options.idempotencyKey || randomUUID4()
989
1361
  );
990
1362
  await emitExecution(response, options, command);
991
1363
  });
992
1364
  }
993
1365
  executionCommand("run");
994
1366
  executionCommand("compare");
995
- program.command("run [capability-id]").description("Run a capability directly; discovery is optional").option("--config <discovery-id>", "run a finalized multi-step configuration").option("-f, --file <path>", "canonical request JSON file").option("--input <json>", "capability input JSON; use url or urls for web.extract_content").option("--provider <id>", "provider ID; repeat for an ordered fallback waterfall", collect, []).option("--provider-options <json>", "provider-native options keyed by provider ID").option("--concurrency <number>", "batch concurrency (default 3, maximum 10)").option("--limit <number>", "maximum records").option("--idempotency-key <key>", "stable retry key").option("--retry-failed <run-id>", "retry only failed URLs from a partial batch run").addOption(new Option("--format <format>", "output record format").choices(["json", "jsonl", "csv"]).default("json")).option("-o, --output <path>", "write records to a file").action(async (capabilityId, options, command) => {
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) => {
996
1368
  if (options.retryFailed) {
997
1369
  if (capabilityId || options.config) {
998
1370
  throw new CliError("Do not combine --retry-failed with a capability ID or --config.", EXIT.usage);
999
1371
  }
1000
- const response2 = await client(command).postJob(
1372
+ await executeQueuedJob(
1001
1373
  `/runs/${encodeURIComponent(options.retryFailed)}/retry-failed`,
1002
1374
  {},
1003
- options.idempotencyKey || randomUUID3()
1375
+ options,
1376
+ command
1004
1377
  );
1005
- await emitExecution(response2, options, command);
1006
1378
  return;
1007
1379
  }
1008
1380
  if (options.config) {
@@ -1011,16 +1383,16 @@ program.command("run [capability-id]").description("Run a capability directly; d
1011
1383
  }
1012
1384
  const fromFile = options.file ? await readJsonFile(options.file) : {};
1013
1385
  const input = options.input ? parseJsonObject(options.input, "runtime input JSON") : fromFile.input || {};
1014
- const response2 = await client(command).postJob(
1386
+ await executeQueuedJob(
1015
1387
  `/discoveries/${encodeURIComponent(options.config)}/jobs`,
1016
1388
  {
1017
1389
  ...fromFile,
1018
1390
  input,
1019
1391
  limit: Number(options.limit ?? fromFile.limit ?? 25)
1020
1392
  },
1021
- options.idempotencyKey || randomUUID3()
1393
+ options,
1394
+ command
1022
1395
  );
1023
- await emitExecution(response2, options, command);
1024
1396
  return;
1025
1397
  }
1026
1398
  if (!capabilityId) {
@@ -1028,12 +1400,12 @@ program.command("run [capability-id]").description("Run a capability directly; d
1028
1400
  }
1029
1401
  const payload = await requestPayload(options, capabilityId);
1030
1402
  if (payload.limit === void 0) payload.limit = 10;
1031
- const response = await client(command).postJob(
1403
+ await executeQueuedJob(
1032
1404
  "/scrapers/jobs",
1033
1405
  payload,
1034
- options.idempotencyKey || randomUUID3()
1406
+ options,
1407
+ command
1035
1408
  );
1036
- await emitExecution(response, options, command);
1037
1409
  });
1038
1410
  var jobs = program.command("jobs").description("Inspect durable queued jobs");
1039
1411
  jobs.command("get <job-id>").action(async (jobId, _options, command) => {
@@ -1042,6 +1414,13 @@ jobs.command("get <job-id>").action(async (jobId, _options, command) => {
1042
1414
  globals(command).json || false
1043
1415
  );
1044
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
+ });
1045
1424
  var providers = program.command("providers", { hidden: true }).description("Legacy provider commands");
1046
1425
  providers.command("list").option("--available", "only include available provider-capability routes").action(async (options, command) => {
1047
1426
  await emit(
@@ -1086,7 +1465,7 @@ discoveries.command("run <discovery-id>").option("-f, --file <path>", "request J
1086
1465
  const response = await client(command).postJob(
1087
1466
  `/discoveries/${encodeURIComponent(discoveryId)}/jobs`,
1088
1467
  payload,
1089
- options.idempotencyKey || randomUUID3()
1468
+ options.idempotencyKey || randomUUID4()
1090
1469
  );
1091
1470
  await emitExecution(response, options, command);
1092
1471
  });
@@ -1094,7 +1473,7 @@ discoveries.command("delete <discovery-id>").requiredOption("--yes", "confirm pe
1094
1473
  await client(command).delete(`/discoveries/${encodeURIComponent(discoveryId)}`);
1095
1474
  await emit({ deleted: true, discovery_id: discoveryId }, globals(command).json || false);
1096
1475
  });
1097
- program.command("discover").description("Save, sample-test, or finalize an agent-authored configuration").option("-f, --file <path>", "create from agent-authored discovery JSON").option("--id <discovery-id>", "existing discovery ID").option("--test", "approve and run a paid sample test").option("--input <json>", "sample runtime input JSON").option("--update <path>", "replace fields or configuration from agent-authored JSON").option("--finalize", "mark the current explicit configuration finalized").option("--idempotency-key <key>", "stable sample retry key").action(async (options, command) => {
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) => {
1098
1477
  const selected = Number(Boolean(options.file)) + Number(Boolean(options.test)) + Number(Boolean(options.update)) + Number(Boolean(options.finalize));
1099
1478
  if (selected !== 1) {
1100
1479
  throw new CliError(
@@ -1134,7 +1513,7 @@ program.command("discover").description("Save, sample-test, or finalize an agent
1134
1513
  const response = await client(command).postJob(
1135
1514
  `/discoveries/${encodeURIComponent(options.id)}/jobs`,
1136
1515
  { input, limit: 25 },
1137
- options.idempotencyKey || randomUUID3()
1516
+ options.idempotencyKey || randomUUID4()
1138
1517
  );
1139
1518
  await emit(response, globals(command).json || false);
1140
1519
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scrappycoco/cli",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "CLI for Scrappycoco scraper discovery and execution",
5
5
  "type": "module",
6
6
  "bin": {