@scrappycoco/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +32 -0
  3. package/dist/index.js +476 -0
  4. package/package.json +43 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ScrappyCoco
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # Scrappycoco CLI
2
+
3
+ Discover, run, and compare scraper capabilities from a terminal or automation environment.
4
+
5
+ The npm package must be published before using `npx`. Verify availability with
6
+ `npm view @scrappycoco/cli version`, then run:
7
+
8
+ ```sh
9
+ npx @scrappycoco/cli auth login
10
+ npx @scrappycoco/cli scrapers list --available --json
11
+ npx @scrappycoco/cli scrapers inspect web.extract_content --json
12
+ npx @scrappycoco/cli scrapers run web.extract_content --file request.json --json
13
+ npx @scrappycoco/cli scrapers compare web.extract_content --file request.json --json
14
+ ```
15
+
16
+ From a repository checkout, use `npm ci`, `npm run build`, and
17
+ `node dist/index.js <command>` instead.
18
+
19
+ Use `providers list` to inspect routes and
20
+ `discoveries create|list|get|update|run|delete` for saved multi-capability
21
+ configurations. The CLI does not expose legacy assistant, agent, workflow, or
22
+ schedule commands.
23
+
24
+ Node.js 20 or newer is required. Interactive use authenticates with Clerk OAuth Authorization Code + PKCE. CI can set `SCRAPPYCOCO_API_KEY`.
25
+
26
+ Use `--json` for machine-readable responses. Execution commands support
27
+ `--format json|jsonl|csv` with `--output`, repeatable `--provider` flags, and an
28
+ explicit `--idempotency-key` for safe identical retries.
29
+
30
+ Use `scrappycoco --help` for the complete command reference. See the
31
+ [Scrappycoco API documentation](https://scrappycoco.readme.io) for the public
32
+ contract.
package/dist/index.js ADDED
@@ -0,0 +1,476 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { randomUUID as randomUUID2 } from "crypto";
5
+ import { Command, CommanderError, Option } from "commander";
6
+
7
+ // src/auth.ts
8
+ import { createHash, randomBytes, timingSafeEqual } from "crypto";
9
+ import { createServer } from "http";
10
+ import open from "open";
11
+
12
+ // src/errors.ts
13
+ var EXIT = {
14
+ ok: 0,
15
+ usage: 2,
16
+ auth: 3,
17
+ api: 4,
18
+ billing: 5,
19
+ conflict: 6,
20
+ cancelled: 7,
21
+ timeout: 8
22
+ };
23
+ var CliError = class extends Error {
24
+ constructor(message, exitCode, details) {
25
+ super(message);
26
+ this.exitCode = exitCode;
27
+ this.details = details;
28
+ }
29
+ exitCode;
30
+ details;
31
+ };
32
+
33
+ // src/storage.ts
34
+ import { chmod, mkdir, readFile, rm, writeFile } from "fs/promises";
35
+ import { homedir } from "os";
36
+ import { dirname, join } from "path";
37
+ import { deletePassword, getPassword, setPassword } from "cross-keychain";
38
+ var SERVICE = "scrappycoco-cli";
39
+ var ACCOUNT = "refresh-token";
40
+ function fallbackCredentialPath() {
41
+ const base = process.platform === "win32" ? process.env.APPDATA || join(homedir(), "AppData", "Roaming") : process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
42
+ return join(base, "scrappycoco", "credentials.json");
43
+ }
44
+ async function readFallback() {
45
+ try {
46
+ const value = JSON.parse(await readFile(fallbackCredentialPath(), "utf8"));
47
+ return value.refresh_token || null;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+ async function loadRefreshToken() {
53
+ try {
54
+ const value = await getPassword(SERVICE, ACCOUNT);
55
+ if (value) return value;
56
+ } catch {
57
+ }
58
+ return readFallback();
59
+ }
60
+ async function saveRefreshToken(refreshToken) {
61
+ try {
62
+ await setPassword(SERVICE, ACCOUNT, refreshToken);
63
+ return "keychain";
64
+ } catch {
65
+ const path = fallbackCredentialPath();
66
+ await mkdir(dirname(path), { recursive: true, mode: 448 });
67
+ await writeFile(path, JSON.stringify({ refresh_token: refreshToken }), { mode: 384 });
68
+ if (process.platform !== "win32") await chmod(path, 384);
69
+ return "file";
70
+ }
71
+ }
72
+ async function clearRefreshToken() {
73
+ try {
74
+ await deletePassword(SERVICE, ACCOUNT);
75
+ } catch {
76
+ }
77
+ await rm(fallbackCredentialPath(), { force: true });
78
+ }
79
+
80
+ // src/auth.ts
81
+ async function oauthConfig(apiUrl) {
82
+ const clientId = process.env.SCRAPPYCOCO_OAUTH_CLIENT_ID;
83
+ if (clientId) {
84
+ return {
85
+ issuer: process.env.SCRAPPYCOCO_OAUTH_ISSUER || "https://clerk.scrappycoco.ai",
86
+ client_id: clientId,
87
+ scopes: ["openid", "profile", "email"]
88
+ };
89
+ }
90
+ const response = await fetch(`${apiUrl.replace(/\/$/, "")}/api/v1/oauth/cli-config`);
91
+ const payload = await response.json().catch(() => ({}));
92
+ if (!response.ok || !payload.issuer || !payload.client_id || !Array.isArray(payload.scopes)) {
93
+ throw new CliError(payload.detail || "Scrappycoco CLI OAuth is not configured.", EXIT.auth);
94
+ }
95
+ return payload;
96
+ }
97
+ function base64url(value) {
98
+ return value.toString("base64url");
99
+ }
100
+ function createPkce() {
101
+ const verifier = base64url(randomBytes(48));
102
+ return {
103
+ verifier,
104
+ challenge: base64url(createHash("sha256").update(verifier).digest()),
105
+ state: base64url(randomBytes(24))
106
+ };
107
+ }
108
+ function statesMatch(expected, received) {
109
+ const left = Buffer.from(expected);
110
+ const right = Buffer.from(received);
111
+ return left.length === right.length && timingSafeEqual(left, right);
112
+ }
113
+ async function tokenRequest(issuer, body) {
114
+ const response = await fetch(`${issuer.replace(/\/$/, "")}/oauth/token`, {
115
+ method: "POST",
116
+ headers: { "content-type": "application/x-www-form-urlencoded" },
117
+ body
118
+ });
119
+ const payload = await response.json().catch(() => ({}));
120
+ if (!response.ok || typeof payload.access_token !== "string") {
121
+ throw new CliError(String(payload.error_description || payload.error || "OAuth token exchange failed."), EXIT.auth);
122
+ }
123
+ return payload;
124
+ }
125
+ async function login(options) {
126
+ const config = await oauthConfig(options.apiUrl);
127
+ const clientId = config.client_id;
128
+ const pkce = createPkce();
129
+ let timeout;
130
+ const callback = new Promise((resolve, reject) => {
131
+ const server = createServer((request, response) => {
132
+ const current = new URL(request.url || "/", "http://127.0.0.1");
133
+ if (current.pathname !== "/callback") {
134
+ response.writeHead(404).end("Not found");
135
+ return;
136
+ }
137
+ const state = current.searchParams.get("state") || "";
138
+ const code2 = current.searchParams.get("code") || "";
139
+ if (!statesMatch(pkce.state, state) || !code2) {
140
+ response.writeHead(400, { "content-type": "text/plain" }).end("Invalid OAuth callback. You may close this window.");
141
+ clearTimeout(timeout);
142
+ server.close();
143
+ reject(new CliError("OAuth state validation failed.", EXIT.auth));
144
+ return;
145
+ }
146
+ const address = server.address();
147
+ if (!address || typeof address === "string") return reject(new CliError("OAuth callback failed.", EXIT.auth));
148
+ response.writeHead(200, { "content-type": "text/plain" }).end("Scrappycoco login complete. You may close this window.");
149
+ clearTimeout(timeout);
150
+ server.close();
151
+ resolve({ code: code2, redirectUri: `http://127.0.0.1:${address.port}/callback` });
152
+ });
153
+ server.listen(0, "127.0.0.1", async () => {
154
+ const address = server.address();
155
+ if (!address || typeof address === "string") return reject(new CliError("Could not start OAuth callback.", EXIT.auth));
156
+ const redirectUri2 = `http://127.0.0.1:${address.port}/callback`;
157
+ const authorize = new URL(`${config.issuer.replace(/\/$/, "")}/oauth/authorize`);
158
+ authorize.search = new URLSearchParams({
159
+ client_id: clientId,
160
+ redirect_uri: redirectUri2,
161
+ response_type: "code",
162
+ scope: config.scopes.join(" "),
163
+ state: pkce.state,
164
+ code_challenge: pkce.challenge,
165
+ code_challenge_method: "S256"
166
+ }).toString();
167
+ process.stderr.write(`Open this URL to authenticate:
168
+ ${authorize.toString()}
169
+ `);
170
+ if (!options.noBrowser) await open(authorize.toString());
171
+ });
172
+ timeout = setTimeout(() => {
173
+ server.close();
174
+ reject(new CliError("OAuth login timed out.", EXIT.timeout));
175
+ }, 5 * 6e4);
176
+ });
177
+ const { code, redirectUri } = await callback;
178
+ const tokens = await tokenRequest(config.issuer, new URLSearchParams({
179
+ grant_type: "authorization_code",
180
+ client_id: clientId,
181
+ code,
182
+ code_verifier: pkce.verifier,
183
+ redirect_uri: redirectUri
184
+ }));
185
+ if (!tokens.refresh_token) throw new CliError("OAuth response did not include a refresh token.", EXIT.auth);
186
+ return { storage: await saveRefreshToken(tokens.refresh_token) };
187
+ }
188
+ async function accessToken(apiUrl) {
189
+ const config = await oauthConfig(apiUrl);
190
+ const clientId = config.client_id;
191
+ const refreshToken = await loadRefreshToken();
192
+ if (!refreshToken) throw new CliError("Not logged in. Run `scrappycoco auth login`.", EXIT.auth);
193
+ const tokens = await tokenRequest(config.issuer, new URLSearchParams({
194
+ grant_type: "refresh_token",
195
+ client_id: clientId,
196
+ refresh_token: refreshToken
197
+ }));
198
+ if (tokens.refresh_token && tokens.refresh_token !== refreshToken) await saveRefreshToken(tokens.refresh_token);
199
+ return tokens.access_token;
200
+ }
201
+
202
+ // src/client.ts
203
+ import { randomUUID } from "crypto";
204
+ var DEFAULT_API_URL = process.env.SCRAPPYCOCO_API_URL || "https://api.scrappycoco.ai";
205
+ var ApiClient = class {
206
+ constructor(baseUrl = DEFAULT_API_URL) {
207
+ this.baseUrl = baseUrl;
208
+ }
209
+ baseUrl;
210
+ async headers(extra = {}) {
211
+ const apiKey = process.env.SCRAPPYCOCO_API_KEY;
212
+ const authentication = apiKey ? { "X-API-Key": apiKey } : { Authorization: `Bearer ${await accessToken(this.baseUrl)}` };
213
+ return {
214
+ accept: "application/json",
215
+ "content-type": "application/json",
216
+ "X-Scrappycoco-Channel": "cli",
217
+ ...authentication,
218
+ ...extra
219
+ };
220
+ }
221
+ async request(method, path, body, headers) {
222
+ const response = await fetch(`${this.baseUrl.replace(/\/$/, "")}/api/v1${path}`, {
223
+ method,
224
+ headers: await this.headers(headers),
225
+ body: body === void 0 ? void 0 : JSON.stringify(body)
226
+ });
227
+ if (response.status === 204) return void 0;
228
+ const payload = await response.json().catch(() => ({}));
229
+ if (!response.ok) {
230
+ const message = typeof payload.detail === "string" ? payload.detail : `Scrappycoco API returned HTTP ${response.status}.`;
231
+ const exitCode = response.status === 401 || response.status === 403 ? EXIT.auth : response.status === 402 ? EXIT.billing : response.status === 409 ? EXIT.conflict : EXIT.api;
232
+ throw new CliError(message, exitCode, payload);
233
+ }
234
+ return payload;
235
+ }
236
+ get(path) {
237
+ return this.request("GET", path);
238
+ }
239
+ post(path, body, key = randomUUID()) {
240
+ return this.request("POST", path, body, { "Idempotency-Key": key });
241
+ }
242
+ patch(path, body) {
243
+ return this.request("PATCH", path, body);
244
+ }
245
+ delete(path) {
246
+ return this.request("DELETE", path);
247
+ }
248
+ };
249
+
250
+ // src/input.ts
251
+ import { readFile as readFile2 } from "fs/promises";
252
+ async function readJsonFile(path) {
253
+ try {
254
+ const value = JSON.parse(await readFile2(path, "utf8"));
255
+ if (!value || Array.isArray(value) || typeof value !== "object") throw new Error("expected a JSON object");
256
+ return value;
257
+ } catch (error) {
258
+ throw new CliError(`Could not read request JSON from ${path}: ${error.message}`, EXIT.usage);
259
+ }
260
+ }
261
+ function parseJsonObject(value, label) {
262
+ try {
263
+ const parsed = JSON.parse(value);
264
+ if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") throw new Error("expected a JSON object");
265
+ return parsed;
266
+ } catch (error) {
267
+ throw new CliError(`Invalid ${label}: ${error.message}`, EXIT.usage);
268
+ }
269
+ }
270
+
271
+ // src/output.ts
272
+ import { writeFile as writeFile2 } from "fs/promises";
273
+ function csvEscape(value) {
274
+ const text = value === null || value === void 0 ? "" : typeof value === "object" ? JSON.stringify(value) : String(value);
275
+ return /[",\n\r]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
276
+ }
277
+ function toCsv(records) {
278
+ const flattened = records.map((record) => {
279
+ const { data, ...topLevel } = record;
280
+ if (!data || typeof data !== "object" || Array.isArray(data)) return record;
281
+ const nested = Object.fromEntries(
282
+ Object.entries(data).filter(([key]) => !(key in topLevel))
283
+ );
284
+ return { ...topLevel, ...nested };
285
+ });
286
+ const headers = [...new Set(flattened.flatMap((record) => Object.keys(record)))];
287
+ return [
288
+ headers.join(","),
289
+ ...flattened.map((record) => headers.map((header) => csvEscape(record[header])).join(","))
290
+ ].join("\n") + "\n";
291
+ }
292
+ function formatRecords(records, format) {
293
+ if (format === "jsonl") {
294
+ return records.map((record) => JSON.stringify(record)).join("\n") + (records.length ? "\n" : "");
295
+ }
296
+ if (format === "csv") return toCsv(records);
297
+ return JSON.stringify(records, null, 2) + "\n";
298
+ }
299
+ async function emit(value, json, outputPath, raw) {
300
+ const rendered = raw ?? (json ? JSON.stringify(value) : JSON.stringify(value, null, 2)) + "\n";
301
+ if (outputPath) {
302
+ await writeFile2(outputPath, rendered, { encoding: "utf8", mode: 384 });
303
+ return;
304
+ }
305
+ process.stdout.write(rendered);
306
+ }
307
+ function errorPayload(error) {
308
+ return { error: error.message, details: error.details ?? null };
309
+ }
310
+
311
+ // src/index.ts
312
+ var program = new Command();
313
+ program.name("scrappycoco").description("Discover, run, and compare scraper capabilities through one API").version("0.1.0").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();
314
+ function globals(command) {
315
+ return command.optsWithGlobals();
316
+ }
317
+ function client(command) {
318
+ return new ApiClient(globals(command).apiUrl);
319
+ }
320
+ function collect(value, previous) {
321
+ return [...previous, value];
322
+ }
323
+ function splitScraperId(value) {
324
+ const separator = value.indexOf(".");
325
+ if (separator <= 0 || separator === value.length - 1) {
326
+ throw new CliError("Scraper IDs use source.capability, for example web.extract_content.", EXIT.usage);
327
+ }
328
+ return { source: value.slice(0, separator), capability: value.slice(separator + 1) };
329
+ }
330
+ async function requestPayload(options, scraperId) {
331
+ const fromFile = options.file ? await readJsonFile(options.file) : {};
332
+ const { source, capability } = splitScraperId(scraperId);
333
+ const input = options.input ? parseJsonObject(options.input, "input JSON") : fromFile.input;
334
+ if (!input) throw new CliError("Provide input through --file or --input.", EXIT.usage);
335
+ return {
336
+ ...fromFile,
337
+ source,
338
+ capability,
339
+ input,
340
+ ...options.provider?.length ? { providers: options.provider } : {},
341
+ ...options.limit !== void 0 ? { limit: Number(options.limit) } : {}
342
+ };
343
+ }
344
+ async function emitExecution(response, options, command) {
345
+ const records = "records" in response && Array.isArray(response.records) ? response.records : response.items;
346
+ const jsonMode = globals(command).json || false;
347
+ if (options.output) {
348
+ await emit(response, jsonMode, options.output, formatRecords(records, options.format));
349
+ process.stderr.write(`Saved ${records.length} records to ${options.output}
350
+ `);
351
+ return;
352
+ }
353
+ await emit(response, jsonMode);
354
+ }
355
+ var auth = program.command("auth").description("Manage Clerk OAuth credentials");
356
+ auth.command("login").option("--no-browser", "print the authorization URL without opening it").action(async (options, command) => {
357
+ const result = await login({ noBrowser: options.browser === false, apiUrl: globals(command).apiUrl });
358
+ await emit({ authenticated: true, storage: result.storage }, globals(command).json || false);
359
+ });
360
+ auth.command("status").action(async (_options, command) => {
361
+ const usingApiKey = Boolean(process.env.SCRAPPYCOCO_API_KEY);
362
+ const stored = usingApiKey || Boolean(await loadRefreshToken());
363
+ if (!stored) {
364
+ await emit({ authenticated: false }, globals(command).json || false);
365
+ process.exitCode = EXIT.auth;
366
+ return;
367
+ }
368
+ const me = await client(command).get("/me");
369
+ await emit(
370
+ { authenticated: true, method: usingApiKey ? "api_key" : "oauth", account: me },
371
+ globals(command).json || false
372
+ );
373
+ });
374
+ auth.command("logout").action(async (_options, command) => {
375
+ await clearRefreshToken();
376
+ await emit({ authenticated: false }, globals(command).json || false);
377
+ });
378
+ var scrapers = program.command("scrapers").description("Inspect and execute scraper capabilities");
379
+ 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) => {
380
+ const query = new URLSearchParams();
381
+ if (options.source) query.set("source", options.source);
382
+ if (options.provider) query.set("provider", options.provider);
383
+ if (options.available) query.set("available_only", "true");
384
+ const suffix = query.size ? `?${query}` : "";
385
+ await emit(await client(command).get(`/scrapers${suffix}`), globals(command).json || false);
386
+ });
387
+ scrapers.command("inspect <scraper-id>").action(async (scraperId, _options, command) => {
388
+ const { source, capability } = splitScraperId(scraperId);
389
+ await emit(
390
+ await client(command).get(
391
+ `/scrapers/${encodeURIComponent(source)}/${encodeURIComponent(capability)}`
392
+ ),
393
+ globals(command).json || false
394
+ );
395
+ });
396
+ function executionCommand(name) {
397
+ 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) => {
398
+ const payload = await requestPayload(options, scraperId);
399
+ if (payload.limit === void 0) payload.limit = 10;
400
+ const response = await client(command).post(
401
+ `/scrapers/${name === "run" ? "execute" : "compare"}`,
402
+ payload,
403
+ options.idempotencyKey || randomUUID2()
404
+ );
405
+ await emitExecution(response, options, command);
406
+ });
407
+ }
408
+ executionCommand("run");
409
+ executionCommand("compare");
410
+ var providers = program.command("providers").description("Inspect integrated scraper providers");
411
+ providers.command("list").option("--available", "only include available provider-capability routes").action(async (options, command) => {
412
+ await emit(
413
+ await client(command).get(`/providers${options.available ? "?available_only=true" : ""}`),
414
+ globals(command).json || false
415
+ );
416
+ });
417
+ var discoveries = program.command("discoveries").description("Design and run saved multi-capability scraper configurations");
418
+ discoveries.command("create").requiredOption("--goal <text>", "natural-language data goal").addOption(new Option("--priority <priority>", "comparison priority").choices(["balanced", "quality", "coverage", "cost", "speed"]).default("balanced")).action(async (options, command) => {
419
+ await emit(
420
+ await client(command).post("/discoveries", { goal: options.goal, priority: options.priority }),
421
+ globals(command).json || false
422
+ );
423
+ });
424
+ discoveries.command("list").action(async (_options, command) => {
425
+ await emit(await client(command).get("/discoveries"), globals(command).json || false);
426
+ });
427
+ discoveries.command("get <discovery-id>").action(async (discoveryId, _options, command) => {
428
+ await emit(
429
+ await client(command).get(`/discoveries/${encodeURIComponent(discoveryId)}`),
430
+ globals(command).json || false
431
+ );
432
+ });
433
+ discoveries.command("update <discovery-id>").option("-f, --file <path>", "update JSON containing name, priority, or configuration").option("--name <name>", "saved discovery name").option("--priority <priority>", "balanced, quality, coverage, cost, or speed").action(async (discoveryId, options, command) => {
434
+ const payload = options.file ? await readJsonFile(options.file) : {};
435
+ if (options.name) payload.name = options.name;
436
+ if (options.priority) payload.priority = options.priority;
437
+ if (!Object.keys(payload).length) throw new CliError("Provide --file, --name, or --priority.", EXIT.usage);
438
+ await emit(
439
+ await client(command).patch(`/discoveries/${encodeURIComponent(discoveryId)}`, payload),
440
+ globals(command).json || false
441
+ );
442
+ });
443
+ discoveries.command("run <discovery-id>").option("-f, --file <path>", "request JSON containing input and optional limit").option("--input <json>", "runtime parameter JSON").option("--limit <number>", "maximum records per route").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 (discoveryId, options, command) => {
444
+ const fromFile = options.file ? await readJsonFile(options.file) : {};
445
+ const input = options.input ? parseJsonObject(options.input, "input JSON") : fromFile.input;
446
+ const payload = {
447
+ ...fromFile,
448
+ input: input || {},
449
+ limit: Number(options.limit ?? fromFile.limit ?? 25)
450
+ };
451
+ const response = await client(command).post(
452
+ `/discoveries/${encodeURIComponent(discoveryId)}/run`,
453
+ payload,
454
+ options.idempotencyKey || randomUUID2()
455
+ );
456
+ await emitExecution(response, options, command);
457
+ });
458
+ discoveries.command("delete <discovery-id>").requiredOption("--yes", "confirm permanent deletion").action(async (discoveryId, _options, command) => {
459
+ await client(command).delete(`/discoveries/${encodeURIComponent(discoveryId)}`);
460
+ await emit({ deleted: true, discovery_id: discoveryId }, globals(command).json || false);
461
+ });
462
+ program.configureOutput({ writeErr: (text) => process.stderr.write(text) });
463
+ program.parseAsync(process.argv).catch(async (error) => {
464
+ if (error instanceof CommanderError) {
465
+ process.exitCode = error.exitCode === 0 ? EXIT.ok : EXIT.usage;
466
+ return;
467
+ }
468
+ const normalized = error instanceof Error ? error : new Error(String(error));
469
+ const exitCode = normalized instanceof CliError ? normalized.exitCode : EXIT.api;
470
+ const json = Boolean(program.opts().json);
471
+ if (json) process.stdout.write(`${JSON.stringify(errorPayload(normalized))}
472
+ `);
473
+ else process.stderr.write(`Error: ${normalized.message}
474
+ `);
475
+ process.exitCode = exitCode;
476
+ });
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@scrappycoco/cli",
3
+ "version": "0.1.0",
4
+ "description": "CLI for Scrappycoco scraper discovery, execution, and provider comparison",
5
+ "type": "module",
6
+ "bin": {
7
+ "scrappycoco": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "engines": {
15
+ "node": ">=20"
16
+ },
17
+ "scripts": {
18
+ "build": "tsup src/index.ts --format esm --platform node --target node20 --clean && node scripts/make-executable.mjs",
19
+ "test": "npm run build && vitest run",
20
+ "typecheck": "tsc --noEmit",
21
+ "prepack": "npm run build"
22
+ },
23
+ "dependencies": {
24
+ "commander": "^14.0.0",
25
+ "cross-keychain": "^1.1.0",
26
+ "open": "^10.2.0"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^24.0.0",
30
+ "tsup": "^8.5.0",
31
+ "typescript": "^5.9.0",
32
+ "vitest": "^3.2.0"
33
+ },
34
+ "license": "MIT",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "https://github.com/Albert-Tam/ai-scraper-project.git",
38
+ "directory": "cli"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ }
43
+ }