@scrappycoco/cli 0.2.1 → 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 +29 -15
- package/dist/index.js +380 -24
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,33 +1,47 @@
|
|
|
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
|
|
|
7
9
|
```sh
|
|
8
|
-
npx @scrappycoco/cli
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
npx --yes @scrappycoco/cli setup
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
`setup` opens OAuth in the browser. New users can create an account in that
|
|
14
|
+
flow. After authentication, it installs the public Scrappycoco skill, verifies
|
|
15
|
+
the live catalog, and tells you when to reload or restart your agent.
|
|
16
|
+
|
|
17
|
+
Individual commands remain available:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
npx --yes @scrappycoco/cli auth login
|
|
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
|
|
13
27
|
```
|
|
14
28
|
|
|
15
29
|
From a repository checkout, use `npm ci`, `npm run build`, and
|
|
16
30
|
`node dist/index.js <command>` instead.
|
|
17
31
|
|
|
18
|
-
Use
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
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.
|
|
22
35
|
|
|
23
36
|
Node.js 20 or newer is required. Interactive use authenticates with Clerk OAuth Authorization Code + PKCE. CI can set `SCRAPPYCOCO_API_KEY`.
|
|
24
37
|
|
|
25
38
|
Use `--json` for machine-readable responses. Execution commands support
|
|
26
|
-
`--format json|jsonl|csv` with `--output`,
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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>`.
|
|
31
45
|
|
|
32
46
|
Use `scrappycoco --help` for the complete command reference. See the
|
|
33
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) {
|
|
@@ -367,12 +452,167 @@ function errorPayload(error) {
|
|
|
367
452
|
return { error: error.message, details: error.details ?? null };
|
|
368
453
|
}
|
|
369
454
|
|
|
455
|
+
// src/setup.ts
|
|
456
|
+
import { spawn } from "child_process";
|
|
457
|
+
import { determineAgent } from "@vercel/detect-agent";
|
|
458
|
+
var SKILL_SOURCE = "https://scrappycoco.ai";
|
|
459
|
+
var SKILL_INSTALL_ARGS = [
|
|
460
|
+
"--yes",
|
|
461
|
+
"skills",
|
|
462
|
+
"add",
|
|
463
|
+
SKILL_SOURCE,
|
|
464
|
+
"--skill",
|
|
465
|
+
"scrappycoco",
|
|
466
|
+
"-g",
|
|
467
|
+
"-y"
|
|
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;
|
|
485
|
+
function npxCommand() {
|
|
486
|
+
return process.platform === "win32" ? "npx.cmd" : "npx";
|
|
487
|
+
}
|
|
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);
|
|
505
|
+
}
|
|
506
|
+
function runCommand(command, args) {
|
|
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
|
+
};
|
|
515
|
+
const child = spawn(command, [...args], {
|
|
516
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
517
|
+
windowsHide: true
|
|
518
|
+
});
|
|
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
|
+
});
|
|
527
|
+
child.on("error", (error) => reject(
|
|
528
|
+
new CliError(`Could not start the skill installer: ${error.message}`, EXIT.api)
|
|
529
|
+
));
|
|
530
|
+
child.on("close", (code) => {
|
|
531
|
+
if (code === 0 && !skillInstallerReportedFailures(installerOutput)) {
|
|
532
|
+
resolve();
|
|
533
|
+
return;
|
|
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
|
+
}
|
|
542
|
+
reject(new CliError(
|
|
543
|
+
`Skill installation failed with exit code ${code ?? "unknown"}.`,
|
|
544
|
+
EXIT.api
|
|
545
|
+
));
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
async function installSkill(runner = runCommand, detector = determineAgent) {
|
|
550
|
+
const agent = await detectSkillInstallerAgent(detector);
|
|
551
|
+
const invocation = skillInstallInvocation(agent);
|
|
552
|
+
await runner(invocation.command, invocation.args);
|
|
553
|
+
}
|
|
554
|
+
function defaultDependencies(apiUrl) {
|
|
555
|
+
const client2 = new ApiClient(apiUrl);
|
|
556
|
+
return {
|
|
557
|
+
hasApiKey: () => Boolean(process.env.SCRAPPYCOCO_API_KEY),
|
|
558
|
+
clearRefreshToken: clearRefreshToken2,
|
|
559
|
+
loadRefreshToken,
|
|
560
|
+
login,
|
|
561
|
+
installSkill,
|
|
562
|
+
listAvailableScrapers: () => client2.get("/scrapers?available_only=true")
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
async function performSetup(options, dependencies = defaultDependencies(options.apiUrl)) {
|
|
566
|
+
const usingApiKey = dependencies.hasApiKey();
|
|
567
|
+
let authentication = usingApiKey ? "api_key" : "oauth";
|
|
568
|
+
let credentialStorage = usingApiKey ? "environment" : "existing";
|
|
569
|
+
const hadStoredToken = !usingApiKey && Boolean(await dependencies.loadRefreshToken());
|
|
570
|
+
if (!usingApiKey && !hadStoredToken) {
|
|
571
|
+
const authenticated = await dependencies.login({
|
|
572
|
+
apiUrl: options.apiUrl,
|
|
573
|
+
noBrowser: options.noBrowser
|
|
574
|
+
});
|
|
575
|
+
authentication = "oauth";
|
|
576
|
+
credentialStorage = authenticated.storage;
|
|
577
|
+
}
|
|
578
|
+
let catalog2;
|
|
579
|
+
try {
|
|
580
|
+
catalog2 = await dependencies.listAvailableScrapers();
|
|
581
|
+
} catch (error) {
|
|
582
|
+
if (usingApiKey || !hadStoredToken || !(error instanceof CliError) || error.exitCode !== EXIT.auth) {
|
|
583
|
+
throw error;
|
|
584
|
+
}
|
|
585
|
+
await dependencies.clearRefreshToken();
|
|
586
|
+
const authenticated = await dependencies.login({
|
|
587
|
+
apiUrl: options.apiUrl,
|
|
588
|
+
noBrowser: options.noBrowser
|
|
589
|
+
});
|
|
590
|
+
credentialStorage = authenticated.storage;
|
|
591
|
+
catalog2 = await dependencies.listAvailableScrapers();
|
|
592
|
+
}
|
|
593
|
+
await dependencies.installSkill();
|
|
594
|
+
return {
|
|
595
|
+
authenticated: true,
|
|
596
|
+
authentication,
|
|
597
|
+
credential_storage: credentialStorage,
|
|
598
|
+
skill: {
|
|
599
|
+
installed: true,
|
|
600
|
+
source: SKILL_SOURCE
|
|
601
|
+
},
|
|
602
|
+
verification: {
|
|
603
|
+
catalog_reachable: true,
|
|
604
|
+
available_capabilities: catalog2.length
|
|
605
|
+
},
|
|
606
|
+
next_step: "Reload or restart your agent, then ask it to inspect the Scrappycoco catalog."
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
|
|
370
610
|
// src/index.ts
|
|
371
611
|
var packageMetadata = JSON.parse(
|
|
372
612
|
readFileSync(new URL("../package.json", import.meta.url), "utf8")
|
|
373
613
|
);
|
|
374
614
|
var program = new Command();
|
|
375
|
-
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();
|
|
376
616
|
function globals(command) {
|
|
377
617
|
return command.optsWithGlobals();
|
|
378
618
|
}
|
|
@@ -382,6 +622,13 @@ function client(command) {
|
|
|
382
622
|
function collect(value, previous) {
|
|
383
623
|
return [...previous, value];
|
|
384
624
|
}
|
|
625
|
+
program.command("setup").description("Authenticate, install the Scrappycoco skill, and verify the connection").option("--no-browser", "print the authorization URL without opening it").action(async (options, command) => {
|
|
626
|
+
const result = await performSetup({
|
|
627
|
+
apiUrl: globals(command).apiUrl,
|
|
628
|
+
noBrowser: options.browser === false
|
|
629
|
+
});
|
|
630
|
+
await emit(result, globals(command).json || false);
|
|
631
|
+
});
|
|
385
632
|
function splitScraperId(value) {
|
|
386
633
|
const separator = value.indexOf(".");
|
|
387
634
|
if (separator <= 0 || separator === value.length - 1) {
|
|
@@ -400,6 +647,8 @@ async function requestPayload(options, scraperId) {
|
|
|
400
647
|
capability,
|
|
401
648
|
input,
|
|
402
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) } : {},
|
|
403
652
|
...options.limit !== void 0 ? { limit: Number(options.limit) } : {}
|
|
404
653
|
};
|
|
405
654
|
}
|
|
@@ -434,10 +683,10 @@ auth.command("status").action(async (_options, command) => {
|
|
|
434
683
|
);
|
|
435
684
|
});
|
|
436
685
|
auth.command("logout").action(async (_options, command) => {
|
|
437
|
-
await
|
|
686
|
+
await clearRefreshToken2();
|
|
438
687
|
await emit({ authenticated: false }, globals(command).json || false);
|
|
439
688
|
});
|
|
440
|
-
var scrapers = program.command("scrapers").description("
|
|
689
|
+
var scrapers = program.command("scrapers", { hidden: true }).description("Legacy scraper commands");
|
|
441
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) => {
|
|
442
691
|
const query = new URLSearchParams();
|
|
443
692
|
if (options.source) query.set("source", options.source);
|
|
@@ -455,8 +704,28 @@ scrapers.command("inspect <scraper-id>").action(async (scraperId, _options, comm
|
|
|
455
704
|
globals(command).json || false
|
|
456
705
|
);
|
|
457
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
|
+
});
|
|
458
727
|
function executionCommand(name) {
|
|
459
|
-
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) => {
|
|
460
729
|
const payload = await requestPayload(options, scraperId);
|
|
461
730
|
if (payload.limit === void 0) payload.limit = 10;
|
|
462
731
|
const response = await client(command).postJob(
|
|
@@ -469,6 +738,49 @@ function executionCommand(name) {
|
|
|
469
738
|
}
|
|
470
739
|
executionCommand("run");
|
|
471
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
|
+
});
|
|
472
784
|
var jobs = program.command("jobs").description("Inspect durable queued jobs");
|
|
473
785
|
jobs.command("get <job-id>").action(async (jobId, _options, command) => {
|
|
474
786
|
await emit(
|
|
@@ -476,17 +788,17 @@ jobs.command("get <job-id>").action(async (jobId, _options, command) => {
|
|
|
476
788
|
globals(command).json || false
|
|
477
789
|
);
|
|
478
790
|
});
|
|
479
|
-
var providers = program.command("providers").description("
|
|
791
|
+
var providers = program.command("providers", { hidden: true }).description("Legacy provider commands");
|
|
480
792
|
providers.command("list").option("--available", "only include available provider-capability routes").action(async (options, command) => {
|
|
481
793
|
await emit(
|
|
482
794
|
await client(command).get(`/providers${options.available ? "?available_only=true" : ""}`),
|
|
483
795
|
globals(command).json || false
|
|
484
796
|
);
|
|
485
797
|
});
|
|
486
|
-
var discoveries = program.command("discoveries").description("
|
|
487
|
-
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) => {
|
|
488
800
|
await emit(
|
|
489
|
-
await client(command).post("/discoveries",
|
|
801
|
+
await client(command).post("/discoveries", await readJsonFile(options.file)),
|
|
490
802
|
globals(command).json || false
|
|
491
803
|
);
|
|
492
804
|
});
|
|
@@ -528,6 +840,50 @@ discoveries.command("delete <discovery-id>").requiredOption("--yes", "confirm pe
|
|
|
528
840
|
await client(command).delete(`/discoveries/${encodeURIComponent(discoveryId)}`);
|
|
529
841
|
await emit({ deleted: true, discovery_id: discoveryId }, globals(command).json || false);
|
|
530
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
|
+
});
|
|
531
887
|
program.configureOutput({ writeErr: (text) => process.stderr.write(text) });
|
|
532
888
|
program.parseAsync(process.argv).catch(async (error) => {
|
|
533
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"
|