@scrappycoco/cli 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -14
- package/dist/index.js +284 -37
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# Scrappycoco CLI
|
|
2
2
|
|
|
3
|
-
Discover
|
|
3
|
+
Discover configurations and run scraper capabilities from a terminal or
|
|
4
|
+
automation environment. The calling AI agent owns all provider and result
|
|
5
|
+
judgment.
|
|
4
6
|
|
|
5
7
|
Run the published package directly with `npx`:
|
|
6
8
|
|
|
@@ -16,28 +18,30 @@ Individual commands remain available:
|
|
|
16
18
|
|
|
17
19
|
```sh
|
|
18
20
|
npx --yes @scrappycoco/cli auth login
|
|
19
|
-
npx --yes @scrappycoco/cli
|
|
20
|
-
npx --yes @scrappycoco/cli
|
|
21
|
-
npx --yes @scrappycoco/cli
|
|
22
|
-
npx --yes @scrappycoco/cli
|
|
21
|
+
npx --yes @scrappycoco/cli catalog list --available --json
|
|
22
|
+
npx --yes @scrappycoco/cli catalog inspect web.extract_content --json
|
|
23
|
+
npx --yes @scrappycoco/cli run web.extract_content --file request.json --json
|
|
24
|
+
npx --yes @scrappycoco/cli discover --file discovery.json --json
|
|
25
|
+
npx --yes @scrappycoco/cli discover --id DISCOVERY_ID --finalize --json
|
|
26
|
+
npx --yes @scrappycoco/cli run --config DISCOVERY_ID --input '{}' --json
|
|
23
27
|
```
|
|
24
28
|
|
|
25
29
|
From a repository checkout, use `npm ci`, `npm run build`, and
|
|
26
30
|
`node dist/index.js <command>` instead.
|
|
27
31
|
|
|
28
|
-
Use
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
schedule commands.
|
|
32
|
+
Discover is optional. Use Run directly when the current capability, provider,
|
|
33
|
+
and native options are already clear. The CLI does not expose legacy
|
|
34
|
+
assistant, agent, workflow, or schedule commands.
|
|
32
35
|
|
|
33
36
|
Node.js 20 or newer is required. Interactive use authenticates with Clerk OAuth Authorization Code + PKCE. CI can set `SCRAPPYCOCO_API_KEY`.
|
|
34
37
|
|
|
35
38
|
Use `--json` for machine-readable responses. Execution commands support
|
|
36
|
-
`--format json|jsonl|csv` with `--output`,
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
`--format json|jsonl|csv` with `--output`, provider-native
|
|
40
|
+
`--provider-options`, batch `--concurrency`, and an explicit
|
|
41
|
+
`--idempotency-key` for safe identical retries. They submit durable jobs and
|
|
42
|
+
poll for completion; set `SCRAPPYCOCO_JOB_TIMEOUT_MS` to change the 20-minute
|
|
43
|
+
local wait. If a command times out while its job continues, inspect it with
|
|
44
|
+
`scrappycoco jobs get <job-id>`.
|
|
41
45
|
|
|
42
46
|
Use `scrappycoco --help` for the complete command reference. See the
|
|
43
47
|
[Scrappycoco API documentation](https://scrappycoco.ai/docs) for the public
|
package/dist/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import { Command, CommanderError, Option } from "commander";
|
|
|
8
8
|
// src/auth.ts
|
|
9
9
|
import { createHash, randomBytes, timingSafeEqual } from "crypto";
|
|
10
10
|
import { createServer } from "http";
|
|
11
|
-
import
|
|
11
|
+
import open2 from "open";
|
|
12
12
|
|
|
13
13
|
// src/errors.ts
|
|
14
14
|
var EXIT = {
|
|
@@ -32,7 +32,7 @@ var CliError = class extends Error {
|
|
|
32
32
|
};
|
|
33
33
|
|
|
34
34
|
// src/storage.ts
|
|
35
|
-
import { chmod, mkdir, readFile, rm, writeFile } from "fs/promises";
|
|
35
|
+
import { chmod, mkdir, open, readFile, rm, stat, writeFile } from "fs/promises";
|
|
36
36
|
import { homedir } from "os";
|
|
37
37
|
import { dirname, join } from "path";
|
|
38
38
|
import { deletePassword, getPassword, setPassword } from "cross-keychain";
|
|
@@ -42,6 +42,49 @@ function fallbackCredentialPath() {
|
|
|
42
42
|
const base = process.platform === "win32" ? process.env.APPDATA || join(homedir(), "AppData", "Roaming") : process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
43
43
|
return join(base, "scrappycoco", "credentials.json");
|
|
44
44
|
}
|
|
45
|
+
function credentialRefreshLockPath() {
|
|
46
|
+
return `${fallbackCredentialPath()}.refresh.lock`;
|
|
47
|
+
}
|
|
48
|
+
var LOCK_RETRY_MS = 100;
|
|
49
|
+
var LOCK_TIMEOUT_MS = 3e4;
|
|
50
|
+
var LOCK_STALE_MS = 6e4;
|
|
51
|
+
function wait(milliseconds) {
|
|
52
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
53
|
+
}
|
|
54
|
+
async function withCredentialRefreshLock(operation) {
|
|
55
|
+
const path = credentialRefreshLockPath();
|
|
56
|
+
await mkdir(dirname(path), { recursive: true, mode: 448 });
|
|
57
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
58
|
+
while (true) {
|
|
59
|
+
try {
|
|
60
|
+
const handle = await open(path, "wx", 384);
|
|
61
|
+
try {
|
|
62
|
+
await handle.writeFile(JSON.stringify({ pid: process.pid, created_at: Date.now() }));
|
|
63
|
+
return await operation();
|
|
64
|
+
} finally {
|
|
65
|
+
await handle.close();
|
|
66
|
+
await rm(path, { force: true });
|
|
67
|
+
}
|
|
68
|
+
} catch (error) {
|
|
69
|
+
const code = error.code;
|
|
70
|
+
if (code !== "EEXIST") throw error;
|
|
71
|
+
try {
|
|
72
|
+
const lock = await stat(path);
|
|
73
|
+
if (Date.now() - lock.mtimeMs > LOCK_STALE_MS) {
|
|
74
|
+
await rm(path, { force: true });
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
} catch (statError) {
|
|
78
|
+
if (statError.code === "ENOENT") continue;
|
|
79
|
+
throw statError;
|
|
80
|
+
}
|
|
81
|
+
if (Date.now() >= deadline) {
|
|
82
|
+
throw new Error("Timed out waiting for the Scrappycoco credential refresh lock.");
|
|
83
|
+
}
|
|
84
|
+
await wait(LOCK_RETRY_MS);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
45
88
|
async function readFallback() {
|
|
46
89
|
try {
|
|
47
90
|
const value = JSON.parse(await readFile(fallbackCredentialPath(), "utf8"));
|
|
@@ -79,6 +122,8 @@ async function clearRefreshToken() {
|
|
|
79
122
|
}
|
|
80
123
|
|
|
81
124
|
// src/auth.ts
|
|
125
|
+
var cachedAccessToken;
|
|
126
|
+
var refreshesInFlight = /* @__PURE__ */ new Map();
|
|
82
127
|
async function oauthConfig(apiUrl) {
|
|
83
128
|
const clientId = process.env.SCRAPPYCOCO_OAUTH_CLIENT_ID;
|
|
84
129
|
if (clientId) {
|
|
@@ -168,7 +213,7 @@ async function login(options) {
|
|
|
168
213
|
process.stderr.write(`Open this URL to authenticate:
|
|
169
214
|
${authorize.toString()}
|
|
170
215
|
`);
|
|
171
|
-
if (!options.noBrowser) await
|
|
216
|
+
if (!options.noBrowser) await open2(authorize.toString());
|
|
172
217
|
});
|
|
173
218
|
timeout = setTimeout(() => {
|
|
174
219
|
server.close();
|
|
@@ -184,20 +229,53 @@ ${authorize.toString()}
|
|
|
184
229
|
redirect_uri: redirectUri
|
|
185
230
|
}));
|
|
186
231
|
if (!tokens.refresh_token) throw new CliError("OAuth response did not include a refresh token.", EXIT.auth);
|
|
232
|
+
invalidateAccessToken();
|
|
187
233
|
return { storage: await saveRefreshToken(tokens.refresh_token) };
|
|
188
234
|
}
|
|
235
|
+
function invalidateAccessToken() {
|
|
236
|
+
cachedAccessToken = void 0;
|
|
237
|
+
}
|
|
238
|
+
async function clearRefreshToken2() {
|
|
239
|
+
invalidateAccessToken();
|
|
240
|
+
await clearRefreshToken();
|
|
241
|
+
}
|
|
189
242
|
async function accessToken(apiUrl) {
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
243
|
+
const normalizedApiUrl = apiUrl.replace(/\/$/, "");
|
|
244
|
+
if (cachedAccessToken && cachedAccessToken.apiUrl === normalizedApiUrl && cachedAccessToken.expiresAt - 3e4 > Date.now()) {
|
|
245
|
+
return cachedAccessToken.value;
|
|
246
|
+
}
|
|
247
|
+
const existingRefresh = refreshesInFlight.get(normalizedApiUrl);
|
|
248
|
+
if (existingRefresh) return existingRefresh;
|
|
249
|
+
const refresh = withCredentialRefreshLock(async () => {
|
|
250
|
+
if (cachedAccessToken && cachedAccessToken.apiUrl === normalizedApiUrl && cachedAccessToken.expiresAt - 3e4 > Date.now()) {
|
|
251
|
+
return cachedAccessToken.value;
|
|
252
|
+
}
|
|
253
|
+
const config = await oauthConfig(normalizedApiUrl);
|
|
254
|
+
const refreshToken = await loadRefreshToken();
|
|
255
|
+
if (!refreshToken) throw new CliError("Not logged in. Run `scrappycoco auth login`.", EXIT.auth);
|
|
256
|
+
const tokens = await tokenRequest(config.issuer, new URLSearchParams({
|
|
257
|
+
grant_type: "refresh_token",
|
|
258
|
+
client_id: config.client_id,
|
|
259
|
+
refresh_token: refreshToken
|
|
260
|
+
}));
|
|
261
|
+
if (tokens.refresh_token && tokens.refresh_token !== refreshToken) {
|
|
262
|
+
await saveRefreshToken(tokens.refresh_token);
|
|
263
|
+
}
|
|
264
|
+
cachedAccessToken = {
|
|
265
|
+
apiUrl: normalizedApiUrl,
|
|
266
|
+
value: tokens.access_token,
|
|
267
|
+
expiresAt: Date.now() + Math.max(tokens.expires_in ?? 300, 60) * 1e3
|
|
268
|
+
};
|
|
269
|
+
return tokens.access_token;
|
|
270
|
+
});
|
|
271
|
+
refreshesInFlight.set(normalizedApiUrl, refresh);
|
|
272
|
+
try {
|
|
273
|
+
return await refresh;
|
|
274
|
+
} finally {
|
|
275
|
+
if (refreshesInFlight.get(normalizedApiUrl) === refresh) {
|
|
276
|
+
refreshesInFlight.delete(normalizedApiUrl);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
201
279
|
}
|
|
202
280
|
|
|
203
281
|
// src/client.ts
|
|
@@ -235,12 +313,19 @@ var ApiClient = class {
|
|
|
235
313
|
};
|
|
236
314
|
}
|
|
237
315
|
async request(method, path, body, headers, signal) {
|
|
238
|
-
const
|
|
316
|
+
const url = `${this.baseUrl.replace(/\/$/, "")}/api/v1${path}`;
|
|
317
|
+
const requestBody = body === void 0 ? void 0 : JSON.stringify(body);
|
|
318
|
+
const send = async () => fetch(url, {
|
|
239
319
|
method,
|
|
240
320
|
headers: await this.headers(headers),
|
|
241
|
-
body:
|
|
321
|
+
body: requestBody,
|
|
242
322
|
signal
|
|
243
323
|
});
|
|
324
|
+
let response = await send();
|
|
325
|
+
if (response.status === 401 && !process.env.SCRAPPYCOCO_API_KEY) {
|
|
326
|
+
invalidateAccessToken();
|
|
327
|
+
response = await send();
|
|
328
|
+
}
|
|
244
329
|
if (response.status === 204) return void 0;
|
|
245
330
|
const payload = await response.json().catch(() => ({}));
|
|
246
331
|
if (!response.ok) {
|
|
@@ -369,6 +454,7 @@ function errorPayload(error) {
|
|
|
369
454
|
|
|
370
455
|
// src/setup.ts
|
|
371
456
|
import { spawn } from "child_process";
|
|
457
|
+
import { determineAgent } from "@vercel/detect-agent";
|
|
372
458
|
var SKILL_SOURCE = "https://scrappycoco.ai";
|
|
373
459
|
var SKILL_INSTALL_ARGS = [
|
|
374
460
|
"--yes",
|
|
@@ -380,28 +466,79 @@ var SKILL_INSTALL_ARGS = [
|
|
|
380
466
|
"-g",
|
|
381
467
|
"-y"
|
|
382
468
|
];
|
|
469
|
+
var SKILL_INSTALLER_AGENTS = {
|
|
470
|
+
antigravity: "antigravity",
|
|
471
|
+
"augment-cli": "augment",
|
|
472
|
+
claude: "claude-code",
|
|
473
|
+
codex: "codex",
|
|
474
|
+
cowork: "claude-code",
|
|
475
|
+
cursor: "cursor",
|
|
476
|
+
"cursor-cli": "cursor",
|
|
477
|
+
devin: "universal",
|
|
478
|
+
gemini: "gemini-cli",
|
|
479
|
+
"github-copilot": "github-copilot",
|
|
480
|
+
opencode: "opencode",
|
|
481
|
+
replit: "replit"
|
|
482
|
+
};
|
|
483
|
+
var MAX_INSTALLER_OUTPUT_LENGTH = 512 * 1024;
|
|
484
|
+
var ANSI_ESCAPE = /\u001B\[[0-?]*[ -/]*[@-~]/g;
|
|
383
485
|
function npxCommand() {
|
|
384
486
|
return process.platform === "win32" ? "npx.cmd" : "npx";
|
|
385
487
|
}
|
|
386
|
-
function
|
|
387
|
-
|
|
488
|
+
async function detectSkillInstallerAgent(detector = determineAgent) {
|
|
489
|
+
const detected = await detector();
|
|
490
|
+
if (!detected.isAgent || !detected.agent) return "universal";
|
|
491
|
+
return SKILL_INSTALLER_AGENTS[detected.agent.name] || "universal";
|
|
492
|
+
}
|
|
493
|
+
function skillInstallInvocation(agent = "universal") {
|
|
494
|
+
return {
|
|
495
|
+
command: npxCommand(),
|
|
496
|
+
args: [...SKILL_INSTALL_ARGS, "--agent", agent]
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
function skillInstallerReportedFailures(output) {
|
|
500
|
+
const plainOutput = output.replace(ANSI_ESCAPE, "");
|
|
501
|
+
if (/Failed to install\s+1/i.test(plainOutput) && /PromptScript does not support global installation/i.test(plainOutput) && !/Failed to install\s+[2-9]\d*/i.test(plainOutput)) {
|
|
502
|
+
return false;
|
|
503
|
+
}
|
|
504
|
+
return /Failed to install\s+[1-9]\d*/i.test(plainOutput);
|
|
388
505
|
}
|
|
389
506
|
function runCommand(command, args) {
|
|
390
507
|
return new Promise((resolve, reject) => {
|
|
508
|
+
let installerOutput = "";
|
|
509
|
+
const capture = (chunk) => {
|
|
510
|
+
installerOutput += chunk.toString();
|
|
511
|
+
if (installerOutput.length > MAX_INSTALLER_OUTPUT_LENGTH) {
|
|
512
|
+
installerOutput = installerOutput.slice(-MAX_INSTALLER_OUTPUT_LENGTH);
|
|
513
|
+
}
|
|
514
|
+
};
|
|
391
515
|
const child = spawn(command, [...args], {
|
|
392
516
|
stdio: ["ignore", "pipe", "pipe"],
|
|
393
517
|
windowsHide: true
|
|
394
518
|
});
|
|
395
|
-
child.stdout.on("data", (chunk) =>
|
|
396
|
-
|
|
519
|
+
child.stdout.on("data", (chunk) => {
|
|
520
|
+
capture(chunk);
|
|
521
|
+
process.stderr.write(chunk);
|
|
522
|
+
});
|
|
523
|
+
child.stderr.on("data", (chunk) => {
|
|
524
|
+
capture(chunk);
|
|
525
|
+
process.stderr.write(chunk);
|
|
526
|
+
});
|
|
397
527
|
child.on("error", (error) => reject(
|
|
398
528
|
new CliError(`Could not start the skill installer: ${error.message}`, EXIT.api)
|
|
399
529
|
));
|
|
400
530
|
child.on("close", (code) => {
|
|
401
|
-
if (code === 0) {
|
|
531
|
+
if (code === 0 && !skillInstallerReportedFailures(installerOutput)) {
|
|
402
532
|
resolve();
|
|
403
533
|
return;
|
|
404
534
|
}
|
|
535
|
+
if (code === 0) {
|
|
536
|
+
reject(new CliError(
|
|
537
|
+
"Skill installer reported one or more failed targets.",
|
|
538
|
+
EXIT.api
|
|
539
|
+
));
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
405
542
|
reject(new CliError(
|
|
406
543
|
`Skill installation failed with exit code ${code ?? "unknown"}.`,
|
|
407
544
|
EXIT.api
|
|
@@ -409,15 +546,16 @@ function runCommand(command, args) {
|
|
|
409
546
|
});
|
|
410
547
|
});
|
|
411
548
|
}
|
|
412
|
-
async function installSkill(runner = runCommand) {
|
|
413
|
-
const
|
|
549
|
+
async function installSkill(runner = runCommand, detector = determineAgent) {
|
|
550
|
+
const agent = await detectSkillInstallerAgent(detector);
|
|
551
|
+
const invocation = skillInstallInvocation(agent);
|
|
414
552
|
await runner(invocation.command, invocation.args);
|
|
415
553
|
}
|
|
416
554
|
function defaultDependencies(apiUrl) {
|
|
417
555
|
const client2 = new ApiClient(apiUrl);
|
|
418
556
|
return {
|
|
419
557
|
hasApiKey: () => Boolean(process.env.SCRAPPYCOCO_API_KEY),
|
|
420
|
-
clearRefreshToken,
|
|
558
|
+
clearRefreshToken: clearRefreshToken2,
|
|
421
559
|
loadRefreshToken,
|
|
422
560
|
login,
|
|
423
561
|
installSkill,
|
|
@@ -437,9 +575,9 @@ async function performSetup(options, dependencies = defaultDependencies(options.
|
|
|
437
575
|
authentication = "oauth";
|
|
438
576
|
credentialStorage = authenticated.storage;
|
|
439
577
|
}
|
|
440
|
-
let
|
|
578
|
+
let catalog2;
|
|
441
579
|
try {
|
|
442
|
-
|
|
580
|
+
catalog2 = await dependencies.listAvailableScrapers();
|
|
443
581
|
} catch (error) {
|
|
444
582
|
if (usingApiKey || !hadStoredToken || !(error instanceof CliError) || error.exitCode !== EXIT.auth) {
|
|
445
583
|
throw error;
|
|
@@ -450,7 +588,7 @@ async function performSetup(options, dependencies = defaultDependencies(options.
|
|
|
450
588
|
noBrowser: options.noBrowser
|
|
451
589
|
});
|
|
452
590
|
credentialStorage = authenticated.storage;
|
|
453
|
-
|
|
591
|
+
catalog2 = await dependencies.listAvailableScrapers();
|
|
454
592
|
}
|
|
455
593
|
await dependencies.installSkill();
|
|
456
594
|
return {
|
|
@@ -463,9 +601,9 @@ async function performSetup(options, dependencies = defaultDependencies(options.
|
|
|
463
601
|
},
|
|
464
602
|
verification: {
|
|
465
603
|
catalog_reachable: true,
|
|
466
|
-
available_capabilities:
|
|
604
|
+
available_capabilities: catalog2.length
|
|
467
605
|
},
|
|
468
|
-
next_step: "Reload or restart your agent, then ask it to
|
|
606
|
+
next_step: "Reload or restart your agent, then ask it to inspect the Scrappycoco catalog."
|
|
469
607
|
};
|
|
470
608
|
}
|
|
471
609
|
|
|
@@ -474,7 +612,7 @@ var packageMetadata = JSON.parse(
|
|
|
474
612
|
readFileSync(new URL("../package.json", import.meta.url), "utf8")
|
|
475
613
|
);
|
|
476
614
|
var program = new Command();
|
|
477
|
-
program.name("scrappycoco").description("Discover
|
|
615
|
+
program.name("scrappycoco").description("Discover configurations and run external-data capabilities through one deterministic API").version(packageMetadata.version).option("--json", "emit machine-readable JSON to stdout").option("--api-url <url>", "API base URL", process.env.SCRAPPYCOCO_API_URL || "https://api.scrappycoco.ai").showHelpAfterError().exitOverride();
|
|
478
616
|
function globals(command) {
|
|
479
617
|
return command.optsWithGlobals();
|
|
480
618
|
}
|
|
@@ -509,6 +647,8 @@ async function requestPayload(options, scraperId) {
|
|
|
509
647
|
capability,
|
|
510
648
|
input,
|
|
511
649
|
...options.provider?.length ? { providers: options.provider } : {},
|
|
650
|
+
...options.providerOptions ? { provider_options: parseJsonObject(options.providerOptions, "provider options JSON") } : {},
|
|
651
|
+
...options.concurrency !== void 0 ? { concurrency: Number(options.concurrency) } : {},
|
|
512
652
|
...options.limit !== void 0 ? { limit: Number(options.limit) } : {}
|
|
513
653
|
};
|
|
514
654
|
}
|
|
@@ -543,10 +683,10 @@ auth.command("status").action(async (_options, command) => {
|
|
|
543
683
|
);
|
|
544
684
|
});
|
|
545
685
|
auth.command("logout").action(async (_options, command) => {
|
|
546
|
-
await
|
|
686
|
+
await clearRefreshToken2();
|
|
547
687
|
await emit({ authenticated: false }, globals(command).json || false);
|
|
548
688
|
});
|
|
549
|
-
var scrapers = program.command("scrapers").description("
|
|
689
|
+
var scrapers = program.command("scrapers", { hidden: true }).description("Legacy scraper commands");
|
|
550
690
|
scrapers.command("list").option("--source <source>", "filter by web, x, reddit, or filings").option("--provider <provider>", "filter by provider implementation").option("--available", "only include available providers").action(async (options, command) => {
|
|
551
691
|
const query = new URLSearchParams();
|
|
552
692
|
if (options.source) query.set("source", options.source);
|
|
@@ -564,8 +704,28 @@ scrapers.command("inspect <scraper-id>").action(async (scraperId, _options, comm
|
|
|
564
704
|
globals(command).json || false
|
|
565
705
|
);
|
|
566
706
|
});
|
|
707
|
+
var catalog = program.command("catalog").description("Inspect capabilities, provider schemas, options, and pricing");
|
|
708
|
+
catalog.command("list").option("--source <source>", "filter by web, x, reddit, or filings").option("--provider <provider>", "filter by provider implementation").option("--available", "only include available providers").action(async (options, command) => {
|
|
709
|
+
const query = new URLSearchParams();
|
|
710
|
+
if (options.source) query.set("source", options.source);
|
|
711
|
+
if (options.provider) query.set("provider", options.provider);
|
|
712
|
+
if (options.available) query.set("available_only", "true");
|
|
713
|
+
await emit(
|
|
714
|
+
await client(command).get(`/scrapers${query.size ? `?${query}` : ""}`),
|
|
715
|
+
globals(command).json || false
|
|
716
|
+
);
|
|
717
|
+
});
|
|
718
|
+
catalog.command("inspect <capability-id>").action(async (capabilityId, _options, command) => {
|
|
719
|
+
const { source, capability } = splitScraperId(capabilityId);
|
|
720
|
+
await emit(
|
|
721
|
+
await client(command).get(
|
|
722
|
+
`/scrapers/${encodeURIComponent(source)}/${encodeURIComponent(capability)}`
|
|
723
|
+
),
|
|
724
|
+
globals(command).json || false
|
|
725
|
+
);
|
|
726
|
+
});
|
|
567
727
|
function executionCommand(name) {
|
|
568
|
-
return scrapers.command(`${name} <scraper-id>`).description(name === "run" ? "Run one capability through a provider waterfall" : "Compare providers for one capability").option("-f, --file <path>", "canonical request JSON file").option("--input <json>", "capability input JSON").option("--provider <id>", "provider ID; repeat to choose and order providers", collect, []).option("--limit <number>", "maximum records").option("--idempotency-key <key>", "stable retry key").addOption(new Option("--format <format>", "output record format").choices(["json", "jsonl", "csv"]).default("json")).option("-o, --output <path>", "write records to a file").action(async (scraperId, options, command) => {
|
|
728
|
+
return scrapers.command(`${name} <scraper-id>`).description(name === "run" ? "Run one capability through a provider waterfall" : "Compare providers for one capability").option("-f, --file <path>", "canonical request JSON file").option("--input <json>", "capability input JSON").option("--provider <id>", "provider ID; repeat to choose and order providers", collect, []).option("--provider-options <json>", "provider-native options keyed by provider ID").option("--concurrency <number>", "batch concurrency (default 3, maximum 10)").option("--limit <number>", "maximum records").option("--idempotency-key <key>", "stable retry key").addOption(new Option("--format <format>", "output record format").choices(["json", "jsonl", "csv"]).default("json")).option("-o, --output <path>", "write records to a file").action(async (scraperId, options, command) => {
|
|
569
729
|
const payload = await requestPayload(options, scraperId);
|
|
570
730
|
if (payload.limit === void 0) payload.limit = 10;
|
|
571
731
|
const response = await client(command).postJob(
|
|
@@ -578,6 +738,49 @@ function executionCommand(name) {
|
|
|
578
738
|
}
|
|
579
739
|
executionCommand("run");
|
|
580
740
|
executionCommand("compare");
|
|
741
|
+
program.command("run [capability-id]").description("Run a capability directly; discovery is optional").option("--config <discovery-id>", "run a finalized multi-step configuration").option("-f, --file <path>", "canonical request JSON file").option("--input <json>", "capability input JSON; use url or urls for web.extract_content").option("--provider <id>", "one provider selected by the calling agent", collect, []).option("--provider-options <json>", "provider-native options keyed by provider ID").option("--concurrency <number>", "batch concurrency (default 3, maximum 10)").option("--limit <number>", "maximum records").option("--idempotency-key <key>", "stable retry key").option("--retry-failed <run-id>", "retry only failed URLs from a partial batch run").addOption(new Option("--format <format>", "output record format").choices(["json", "jsonl", "csv"]).default("json")).option("-o, --output <path>", "write records to a file").action(async (capabilityId, options, command) => {
|
|
742
|
+
if (options.retryFailed) {
|
|
743
|
+
if (capabilityId || options.config) {
|
|
744
|
+
throw new CliError("Do not combine --retry-failed with a capability ID or --config.", EXIT.usage);
|
|
745
|
+
}
|
|
746
|
+
const response2 = await client(command).postJob(
|
|
747
|
+
`/runs/${encodeURIComponent(options.retryFailed)}/retry-failed`,
|
|
748
|
+
{},
|
|
749
|
+
options.idempotencyKey || randomUUID2()
|
|
750
|
+
);
|
|
751
|
+
await emitExecution(response2, options, command);
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
if (options.config) {
|
|
755
|
+
if (capabilityId) {
|
|
756
|
+
throw new CliError("Choose either a capability ID or --config.", EXIT.usage);
|
|
757
|
+
}
|
|
758
|
+
const fromFile = options.file ? await readJsonFile(options.file) : {};
|
|
759
|
+
const input = options.input ? parseJsonObject(options.input, "runtime input JSON") : fromFile.input || {};
|
|
760
|
+
const response2 = await client(command).postJob(
|
|
761
|
+
`/discoveries/${encodeURIComponent(options.config)}/jobs`,
|
|
762
|
+
{
|
|
763
|
+
...fromFile,
|
|
764
|
+
input,
|
|
765
|
+
limit: Number(options.limit ?? fromFile.limit ?? 25)
|
|
766
|
+
},
|
|
767
|
+
options.idempotencyKey || randomUUID2()
|
|
768
|
+
);
|
|
769
|
+
await emitExecution(response2, options, command);
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
if (!capabilityId) {
|
|
773
|
+
throw new CliError("Provide a capability ID or --retry-failed <run-id>.", EXIT.usage);
|
|
774
|
+
}
|
|
775
|
+
const payload = await requestPayload(options, capabilityId);
|
|
776
|
+
if (payload.limit === void 0) payload.limit = 10;
|
|
777
|
+
const response = await client(command).postJob(
|
|
778
|
+
"/scrapers/jobs",
|
|
779
|
+
payload,
|
|
780
|
+
options.idempotencyKey || randomUUID2()
|
|
781
|
+
);
|
|
782
|
+
await emitExecution(response, options, command);
|
|
783
|
+
});
|
|
581
784
|
var jobs = program.command("jobs").description("Inspect durable queued jobs");
|
|
582
785
|
jobs.command("get <job-id>").action(async (jobId, _options, command) => {
|
|
583
786
|
await emit(
|
|
@@ -585,17 +788,17 @@ jobs.command("get <job-id>").action(async (jobId, _options, command) => {
|
|
|
585
788
|
globals(command).json || false
|
|
586
789
|
);
|
|
587
790
|
});
|
|
588
|
-
var providers = program.command("providers").description("
|
|
791
|
+
var providers = program.command("providers", { hidden: true }).description("Legacy provider commands");
|
|
589
792
|
providers.command("list").option("--available", "only include available provider-capability routes").action(async (options, command) => {
|
|
590
793
|
await emit(
|
|
591
794
|
await client(command).get(`/providers${options.available ? "?available_only=true" : ""}`),
|
|
592
795
|
globals(command).json || false
|
|
593
796
|
);
|
|
594
797
|
});
|
|
595
|
-
var discoveries = program.command("discoveries").description("
|
|
596
|
-
discoveries.command("create").requiredOption("--
|
|
798
|
+
var discoveries = program.command("discoveries", { hidden: true }).description("Legacy discovery commands");
|
|
799
|
+
discoveries.command("create").requiredOption("-f, --file <path>", "agent-authored discovery JSON with goal and configuration").action(async (options, command) => {
|
|
597
800
|
await emit(
|
|
598
|
-
await client(command).post("/discoveries",
|
|
801
|
+
await client(command).post("/discoveries", await readJsonFile(options.file)),
|
|
599
802
|
globals(command).json || false
|
|
600
803
|
);
|
|
601
804
|
});
|
|
@@ -637,6 +840,50 @@ discoveries.command("delete <discovery-id>").requiredOption("--yes", "confirm pe
|
|
|
637
840
|
await client(command).delete(`/discoveries/${encodeURIComponent(discoveryId)}`);
|
|
638
841
|
await emit({ deleted: true, discovery_id: discoveryId }, globals(command).json || false);
|
|
639
842
|
});
|
|
843
|
+
program.command("discover").description("Save, sample-test, or finalize an agent-authored configuration").option("-f, --file <path>", "create from agent-authored discovery JSON").option("--id <discovery-id>", "existing discovery ID").option("--test", "approve and run a paid sample test").option("--input <json>", "sample runtime input JSON").option("--update <path>", "replace fields or configuration from agent-authored JSON").option("--finalize", "mark the current explicit configuration finalized").option("--idempotency-key <key>", "stable sample retry key").action(async (options, command) => {
|
|
844
|
+
const selected = Number(Boolean(options.file)) + Number(Boolean(options.test)) + Number(Boolean(options.update)) + Number(Boolean(options.finalize));
|
|
845
|
+
if (selected !== 1) {
|
|
846
|
+
throw new CliError(
|
|
847
|
+
"Choose exactly one action: --file, --id --update, --id --test, or --id --finalize.",
|
|
848
|
+
EXIT.usage
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
if (options.file) {
|
|
852
|
+
await emit(
|
|
853
|
+
await client(command).post("/discoveries", await readJsonFile(options.file)),
|
|
854
|
+
globals(command).json || false
|
|
855
|
+
);
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
if (!options.id) throw new CliError("--id is required for --update, --test, and --finalize.", EXIT.usage);
|
|
859
|
+
if (options.update) {
|
|
860
|
+
await emit(
|
|
861
|
+
await client(command).patch(
|
|
862
|
+
`/discoveries/${encodeURIComponent(options.id)}`,
|
|
863
|
+
await readJsonFile(options.update)
|
|
864
|
+
),
|
|
865
|
+
globals(command).json || false
|
|
866
|
+
);
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
if (options.finalize) {
|
|
870
|
+
await emit(
|
|
871
|
+
await client(command).post(
|
|
872
|
+
`/discoveries/${encodeURIComponent(options.id)}/finalize`,
|
|
873
|
+
{}
|
|
874
|
+
),
|
|
875
|
+
globals(command).json || false
|
|
876
|
+
);
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
const input = options.input ? parseJsonObject(options.input, "sample input JSON") : {};
|
|
880
|
+
const response = await client(command).postJob(
|
|
881
|
+
`/discoveries/${encodeURIComponent(options.id)}/jobs`,
|
|
882
|
+
{ input, limit: 25 },
|
|
883
|
+
options.idempotencyKey || randomUUID2()
|
|
884
|
+
);
|
|
885
|
+
await emit(response, globals(command).json || false);
|
|
886
|
+
});
|
|
640
887
|
program.configureOutput({ writeErr: (text) => process.stderr.write(text) });
|
|
641
888
|
program.parseAsync(process.argv).catch(async (error) => {
|
|
642
889
|
if (error instanceof CommanderError) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scrappycoco/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "CLI for Scrappycoco scraper discovery, execution, and provider comparison",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"prepack": "npm run build"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
+
"@vercel/detect-agent": "^1.2.3",
|
|
25
26
|
"commander": "^14.0.0",
|
|
26
27
|
"cross-keychain": "^1.1.0",
|
|
27
28
|
"open": "^10.2.0"
|