@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.
- package/README.md +41 -20
- package/dist/index.js +403 -24
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
# Scrappycoco CLI
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
|
38
|
-
npx --yes @scrappycoco/cli catalog
|
|
39
|
-
npx --yes @scrappycoco/cli
|
|
40
|
-
npx --yes @scrappycoco/cli
|
|
41
|
-
npx --yes @scrappycoco/cli discover --
|
|
42
|
-
npx --yes @scrappycoco/cli discover --id DISCOVERY_ID --
|
|
43
|
-
npx --yes @scrappycoco/cli
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
`
|
|
69
|
-
|
|
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
|
|
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
|
-
|
|
558
|
-
|
|
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
|
|
968
|
-
|
|
969
|
-
|
|
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 ||
|
|
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;
|
|
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
|
-
|
|
1372
|
+
await executeQueuedJob(
|
|
1001
1373
|
`/runs/${encodeURIComponent(options.retryFailed)}/retry-failed`,
|
|
1002
1374
|
{},
|
|
1003
|
-
options
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1403
|
+
await executeQueuedJob(
|
|
1032
1404
|
"/scrapers/jobs",
|
|
1033
1405
|
payload,
|
|
1034
|
-
options
|
|
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 ||
|
|
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", "
|
|
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 ||
|
|
1516
|
+
options.idempotencyKey || randomUUID4()
|
|
1138
1517
|
);
|
|
1139
1518
|
await emit(response, globals(command).json || false);
|
|
1140
1519
|
});
|