@scrappycoco/cli 0.3.0 → 0.4.1
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 +46 -15
- package/dist/index.js +595 -94
- package/package.json +3 -2
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
|
|
|
@@ -10,34 +12,63 @@ npx --yes @scrappycoco/cli setup
|
|
|
10
12
|
|
|
11
13
|
`setup` opens OAuth in the browser. New users can create an account in that
|
|
12
14
|
flow. After authentication, it installs the public Scrappycoco skill, verifies
|
|
13
|
-
the live catalog, and tells you when to reload or restart your agent.
|
|
15
|
+
the live catalog, and tells you when to reload or restart your agent. Browser
|
|
16
|
+
authorization is not complete until the terminal confirms that the credential
|
|
17
|
+
was saved and the catalog check succeeded.
|
|
18
|
+
|
|
19
|
+
When the CLI runs on a remote or headless host whose `127.0.0.1` is not the
|
|
20
|
+
browser's localhost, keep the command running and use the manual callback
|
|
21
|
+
flow:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
npx --yes @scrappycoco/cli setup --no-browser --manual-callback
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Open the printed authorization URL, then paste the browser's final callback
|
|
28
|
+
URL into that same terminal. The callback contains a short-lived
|
|
29
|
+
authorization code: never paste it into chat, logs, or a saved file. A timed
|
|
30
|
+
out or exited login cannot be resumed; start a new command and use its new
|
|
31
|
+
URL.
|
|
14
32
|
|
|
15
33
|
Individual commands remain available:
|
|
16
34
|
|
|
17
35
|
```sh
|
|
18
36
|
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
|
|
37
|
+
npx --yes @scrappycoco/cli catalog list --available --json
|
|
38
|
+
npx --yes @scrappycoco/cli catalog inspect web.extract_content --json
|
|
39
|
+
npx --yes @scrappycoco/cli run web.extract_content --file request.json --json
|
|
40
|
+
npx --yes @scrappycoco/cli discover --file discovery.json --json
|
|
41
|
+
npx --yes @scrappycoco/cli discover --id DISCOVERY_ID --test --input '{"url":"https://example.com"}' --json
|
|
42
|
+
npx --yes @scrappycoco/cli discover --id DISCOVERY_ID --finalize --json
|
|
43
|
+
npx --yes @scrappycoco/cli run --config DISCOVERY_ID --input '{}' --json
|
|
23
44
|
```
|
|
24
45
|
|
|
25
46
|
From a repository checkout, use `npm ci`, `npm run build`, and
|
|
26
47
|
`node dist/index.js <command>` instead.
|
|
27
48
|
|
|
28
|
-
Use
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
schedule commands.
|
|
49
|
+
Discover is optional. Use Run directly when the current capability, provider,
|
|
50
|
+
and native options are already clear. The CLI does not expose legacy
|
|
51
|
+
assistant, agent, workflow, or schedule commands.
|
|
32
52
|
|
|
33
53
|
Node.js 20 or newer is required. Interactive use authenticates with Clerk OAuth Authorization Code + PKCE. CI can set `SCRAPPYCOCO_API_KEY`.
|
|
34
54
|
|
|
55
|
+
OAuth and API requests have bounded timeouts. Set
|
|
56
|
+
`SCRAPPYCOCO_AUTH_TIMEOUT_MS` or `SCRAPPYCOCO_API_TIMEOUT_MS` only when a slow
|
|
57
|
+
network requires a longer window.
|
|
58
|
+
|
|
35
59
|
Use `--json` for machine-readable responses. Execution commands support
|
|
36
|
-
`--format json|jsonl|csv` with `--output`,
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
60
|
+
`--format json|jsonl|csv` with `--output`, provider-native
|
|
61
|
+
`--provider-options`, batch `--concurrency`, and an explicit
|
|
62
|
+
`--idempotency-key` for safe identical retries. They submit durable jobs and
|
|
63
|
+
poll for completion; set `SCRAPPYCOCO_JOB_TIMEOUT_MS` to change the 20-minute
|
|
64
|
+
local wait. If a command times out while its job continues, inspect it with
|
|
65
|
+
`scrappycoco jobs get <job-id>`.
|
|
66
|
+
|
|
67
|
+
When provider or configuration choice is uncertain, define 2 to 5 named
|
|
68
|
+
`candidates` in a Discover route. Each candidate has its own `id`, `provider`,
|
|
69
|
+
and `options`, so one discovery can test both `zyte-http` with
|
|
70
|
+
`{"browser_html":false}` and `zyte-browser` with
|
|
71
|
+
`{"browser_html":true}` against the same input.
|
|
41
72
|
|
|
42
73
|
Use `scrappycoco --help` for the complete command reference. See the
|
|
43
74
|
[Scrappycoco API documentation](https://scrappycoco.ai/docs) for the public
|
package/dist/index.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { randomUUID as
|
|
4
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
5
5
|
import { readFileSync } from "fs";
|
|
6
6
|
import { Command, CommanderError, Option } from "commander";
|
|
7
7
|
|
|
8
8
|
// src/auth.ts
|
|
9
9
|
import { createHash, randomBytes, timingSafeEqual } from "crypto";
|
|
10
10
|
import { createServer } from "http";
|
|
11
|
-
import
|
|
11
|
+
import { createInterface } from "readline/promises";
|
|
12
|
+
import open2 from "open";
|
|
12
13
|
|
|
13
14
|
// src/errors.ts
|
|
14
15
|
var EXIT = {
|
|
@@ -32,16 +33,65 @@ var CliError = class extends Error {
|
|
|
32
33
|
};
|
|
33
34
|
|
|
34
35
|
// src/storage.ts
|
|
35
|
-
import {
|
|
36
|
+
import { randomUUID } from "crypto";
|
|
37
|
+
import { chmod, mkdir, open, readFile, rename, rm, stat, writeFile } from "fs/promises";
|
|
36
38
|
import { homedir } from "os";
|
|
37
39
|
import { dirname, join } from "path";
|
|
38
40
|
import { deletePassword, getPassword, setPassword } from "cross-keychain";
|
|
39
41
|
var SERVICE = "scrappycoco-cli";
|
|
40
42
|
var ACCOUNT = "refresh-token";
|
|
41
43
|
function fallbackCredentialPath() {
|
|
44
|
+
const configured = process.env.SCRAPPYCOCO_CONFIG_HOME;
|
|
45
|
+
if (configured) return join(configured, "credentials.json");
|
|
42
46
|
const base = process.platform === "win32" ? process.env.APPDATA || join(homedir(), "AppData", "Roaming") : process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
43
47
|
return join(base, "scrappycoco", "credentials.json");
|
|
44
48
|
}
|
|
49
|
+
function credentialRefreshLockPath() {
|
|
50
|
+
return `${fallbackCredentialPath()}.refresh.lock`;
|
|
51
|
+
}
|
|
52
|
+
function credentialStatePath() {
|
|
53
|
+
return `${fallbackCredentialPath()}.state`;
|
|
54
|
+
}
|
|
55
|
+
var LOCK_RETRY_MS = 100;
|
|
56
|
+
var LOCK_TIMEOUT_MS = 3e4;
|
|
57
|
+
var LOCK_STALE_MS = 6e4;
|
|
58
|
+
function wait(milliseconds) {
|
|
59
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
60
|
+
}
|
|
61
|
+
async function withCredentialRefreshLock(operation) {
|
|
62
|
+
const path = credentialRefreshLockPath();
|
|
63
|
+
await mkdir(dirname(path), { recursive: true, mode: 448 });
|
|
64
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
65
|
+
while (true) {
|
|
66
|
+
try {
|
|
67
|
+
const handle = await open(path, "wx", 384);
|
|
68
|
+
try {
|
|
69
|
+
await handle.writeFile(JSON.stringify({ pid: process.pid, created_at: Date.now() }));
|
|
70
|
+
return await operation();
|
|
71
|
+
} finally {
|
|
72
|
+
await handle.close();
|
|
73
|
+
await rm(path, { force: true });
|
|
74
|
+
}
|
|
75
|
+
} catch (error) {
|
|
76
|
+
const code = error.code;
|
|
77
|
+
if (code !== "EEXIST") throw error;
|
|
78
|
+
try {
|
|
79
|
+
const lock = await stat(path);
|
|
80
|
+
if (Date.now() - lock.mtimeMs > LOCK_STALE_MS) {
|
|
81
|
+
await rm(path, { force: true });
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
} catch (statError) {
|
|
85
|
+
if (statError.code === "ENOENT") continue;
|
|
86
|
+
throw statError;
|
|
87
|
+
}
|
|
88
|
+
if (Date.now() >= deadline) {
|
|
89
|
+
throw new Error("Timed out waiting for the Scrappycoco credential refresh lock.");
|
|
90
|
+
}
|
|
91
|
+
await wait(LOCK_RETRY_MS);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
45
95
|
async function readFallback() {
|
|
46
96
|
try {
|
|
47
97
|
const value = JSON.parse(await readFile(fallbackCredentialPath(), "utf8"));
|
|
@@ -50,23 +100,89 @@ async function readFallback() {
|
|
|
50
100
|
return null;
|
|
51
101
|
}
|
|
52
102
|
}
|
|
53
|
-
async function
|
|
103
|
+
async function readKeychain() {
|
|
54
104
|
try {
|
|
55
105
|
const value = await getPassword(SERVICE, ACCOUNT);
|
|
56
|
-
|
|
106
|
+
return value || null;
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async function readCredentialState() {
|
|
112
|
+
try {
|
|
113
|
+
const value = JSON.parse(await readFile(credentialStatePath(), "utf8"));
|
|
114
|
+
return value.storage === "keychain" || value.storage === "file" || value.storage === "none" ? value.storage : null;
|
|
57
115
|
} catch {
|
|
116
|
+
return null;
|
|
58
117
|
}
|
|
59
|
-
|
|
118
|
+
}
|
|
119
|
+
async function writePrivateJson(path, value) {
|
|
120
|
+
await mkdir(dirname(path), { recursive: true, mode: 448 });
|
|
121
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
122
|
+
try {
|
|
123
|
+
await writeFile(temporary, JSON.stringify(value), { mode: 384 });
|
|
124
|
+
if (process.platform !== "win32") await chmod(temporary, 384);
|
|
125
|
+
await rename(temporary, path);
|
|
126
|
+
if (process.platform !== "win32") await chmod(path, 384);
|
|
127
|
+
} finally {
|
|
128
|
+
await rm(temporary, { force: true });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
async function writeCredentialState(storage) {
|
|
132
|
+
await writePrivateJson(credentialStatePath(), { storage });
|
|
133
|
+
}
|
|
134
|
+
async function loadRefreshTokenCandidates() {
|
|
135
|
+
const state = await readCredentialState();
|
|
136
|
+
if (state === "none") return [];
|
|
137
|
+
if (state === "file") {
|
|
138
|
+
const value = await readFallback();
|
|
139
|
+
return value ? [{ value, storage: "file" }] : [];
|
|
140
|
+
}
|
|
141
|
+
if (state === "keychain") {
|
|
142
|
+
const value = await readKeychain();
|
|
143
|
+
return value ? [{ value, storage: "keychain" }] : [];
|
|
144
|
+
}
|
|
145
|
+
const [keychain, file] = await Promise.all([readKeychain(), readFallback()]);
|
|
146
|
+
const candidates = [];
|
|
147
|
+
if (keychain) candidates.push({ value: keychain, storage: "keychain" });
|
|
148
|
+
if (file && file !== keychain) candidates.push({ value: file, storage: "file" });
|
|
149
|
+
return candidates;
|
|
150
|
+
}
|
|
151
|
+
async function prepareCredentialStorage() {
|
|
152
|
+
const state = await readCredentialState();
|
|
153
|
+
if (state === "file") {
|
|
154
|
+
await assertFallbackWritable();
|
|
155
|
+
return "file";
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
await getPassword(SERVICE, ACCOUNT);
|
|
159
|
+
return "keychain";
|
|
160
|
+
} catch {
|
|
161
|
+
await assertFallbackWritable();
|
|
162
|
+
return "file";
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
async function assertFallbackWritable() {
|
|
166
|
+
const path = fallbackCredentialPath();
|
|
167
|
+
await mkdir(dirname(path), { recursive: true, mode: 448 });
|
|
168
|
+
const probe = `${path}.write-test-${process.pid}-${randomUUID()}`;
|
|
169
|
+
const handle = await open(probe, "w", 384);
|
|
170
|
+
await handle.close();
|
|
171
|
+
await rm(probe, { force: true });
|
|
60
172
|
}
|
|
61
173
|
async function saveRefreshToken(refreshToken) {
|
|
62
174
|
try {
|
|
63
175
|
await setPassword(SERVICE, ACCOUNT, refreshToken);
|
|
176
|
+
await rm(fallbackCredentialPath(), { force: true });
|
|
177
|
+
await writeCredentialState("keychain");
|
|
64
178
|
return "keychain";
|
|
65
179
|
} catch {
|
|
66
|
-
|
|
67
|
-
await
|
|
68
|
-
|
|
69
|
-
|
|
180
|
+
await writePrivateJson(fallbackCredentialPath(), { refresh_token: refreshToken });
|
|
181
|
+
await writeCredentialState("file");
|
|
182
|
+
try {
|
|
183
|
+
await deletePassword(SERVICE, ACCOUNT);
|
|
184
|
+
} catch {
|
|
185
|
+
}
|
|
70
186
|
return "file";
|
|
71
187
|
}
|
|
72
188
|
}
|
|
@@ -76,9 +192,42 @@ async function clearRefreshToken() {
|
|
|
76
192
|
} catch {
|
|
77
193
|
}
|
|
78
194
|
await rm(fallbackCredentialPath(), { force: true });
|
|
195
|
+
await writeCredentialState("none");
|
|
79
196
|
}
|
|
80
197
|
|
|
81
198
|
// src/auth.ts
|
|
199
|
+
var cachedAccessToken;
|
|
200
|
+
var refreshesInFlight = /* @__PURE__ */ new Map();
|
|
201
|
+
var DEFAULT_AUTH_HTTP_TIMEOUT_MS = 15e3;
|
|
202
|
+
function positiveInteger(value, fallback) {
|
|
203
|
+
if (!value) return fallback;
|
|
204
|
+
const parsed = Number(value);
|
|
205
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
206
|
+
}
|
|
207
|
+
function authHttpTimeoutMs() {
|
|
208
|
+
return positiveInteger(process.env.SCRAPPYCOCO_AUTH_TIMEOUT_MS, DEFAULT_AUTH_HTTP_TIMEOUT_MS);
|
|
209
|
+
}
|
|
210
|
+
function networkMessage(error) {
|
|
211
|
+
if (error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError")) {
|
|
212
|
+
return `timed out after ${Math.ceil(authHttpTimeoutMs() / 1e3)} seconds`;
|
|
213
|
+
}
|
|
214
|
+
return "failed to reach the authentication service";
|
|
215
|
+
}
|
|
216
|
+
async function oauthFetch(url, init, phase, exitCode) {
|
|
217
|
+
try {
|
|
218
|
+
return await fetch(url, {
|
|
219
|
+
...init,
|
|
220
|
+
signal: AbortSignal.timeout(authHttpTimeoutMs())
|
|
221
|
+
});
|
|
222
|
+
} catch (error) {
|
|
223
|
+
const timeout = error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError");
|
|
224
|
+
throw new CliError(
|
|
225
|
+
`OAuth ${phase} ${networkMessage(error)}. ${phase === "authorization-code exchange" ? "Authorization was received but was not saved; run `scrappycoco auth login` again." : "Check the connection and try again."}`,
|
|
226
|
+
timeout ? EXIT.timeout : exitCode,
|
|
227
|
+
{ phase, network_error: true }
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
82
231
|
async function oauthConfig(apiUrl) {
|
|
83
232
|
const clientId = process.env.SCRAPPYCOCO_OAUTH_CLIENT_ID;
|
|
84
233
|
if (clientId) {
|
|
@@ -88,7 +237,12 @@ async function oauthConfig(apiUrl) {
|
|
|
88
237
|
scopes: ["openid", "profile", "email"]
|
|
89
238
|
};
|
|
90
239
|
}
|
|
91
|
-
const response = await
|
|
240
|
+
const response = await oauthFetch(
|
|
241
|
+
`${apiUrl.replace(/\/$/, "")}/api/v1/oauth/cli-config`,
|
|
242
|
+
{},
|
|
243
|
+
"configuration request",
|
|
244
|
+
EXIT.api
|
|
245
|
+
);
|
|
92
246
|
const payload = await response.json().catch(() => ({}));
|
|
93
247
|
if (!response.ok || !payload.issuer || !payload.client_id || !Array.isArray(payload.scopes)) {
|
|
94
248
|
throw new CliError(payload.detail || "Scrappycoco CLI OAuth is not configured.", EXIT.auth);
|
|
@@ -111,49 +265,99 @@ function statesMatch(expected, received) {
|
|
|
111
265
|
const right = Buffer.from(received);
|
|
112
266
|
return left.length === right.length && timingSafeEqual(left, right);
|
|
113
267
|
}
|
|
114
|
-
async function tokenRequest(issuer, body) {
|
|
115
|
-
const response = await
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
268
|
+
async function tokenRequest(issuer, body, phase) {
|
|
269
|
+
const response = await oauthFetch(
|
|
270
|
+
`${issuer.replace(/\/$/, "")}/oauth/token`,
|
|
271
|
+
{
|
|
272
|
+
method: "POST",
|
|
273
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
274
|
+
body
|
|
275
|
+
},
|
|
276
|
+
phase,
|
|
277
|
+
EXIT.auth
|
|
278
|
+
);
|
|
120
279
|
const payload = await response.json().catch(() => ({}));
|
|
121
280
|
if (!response.ok || typeof payload.access_token !== "string") {
|
|
122
|
-
throw new CliError(
|
|
281
|
+
throw new CliError(
|
|
282
|
+
String(payload.error_description || payload.error || `OAuth ${phase} failed.`),
|
|
283
|
+
EXIT.auth,
|
|
284
|
+
{
|
|
285
|
+
phase,
|
|
286
|
+
status: response.status,
|
|
287
|
+
...typeof payload.error === "string" ? { oauth_error: payload.error } : {}
|
|
288
|
+
}
|
|
289
|
+
);
|
|
123
290
|
}
|
|
124
291
|
return payload;
|
|
125
292
|
}
|
|
293
|
+
function callbackResult(rawUrl, expectedState, redirectUri) {
|
|
294
|
+
const current = new URL(rawUrl, redirectUri);
|
|
295
|
+
const expected = new URL(redirectUri);
|
|
296
|
+
if (current.protocol !== expected.protocol || current.hostname !== expected.hostname || current.port !== expected.port || current.pathname !== expected.pathname) {
|
|
297
|
+
throw new CliError("OAuth callback URL did not match this login attempt.", EXIT.auth);
|
|
298
|
+
}
|
|
299
|
+
const state = current.searchParams.get("state") || "";
|
|
300
|
+
const providerError = current.searchParams.get("error");
|
|
301
|
+
if (!statesMatch(expectedState, state)) {
|
|
302
|
+
throw new CliError("OAuth state validation failed.", EXIT.auth);
|
|
303
|
+
}
|
|
304
|
+
if (providerError) {
|
|
305
|
+
const description = current.searchParams.get("error_description") || providerError;
|
|
306
|
+
throw new CliError(`OAuth authorization was denied: ${description}`, EXIT.auth);
|
|
307
|
+
}
|
|
308
|
+
const code = current.searchParams.get("code") || "";
|
|
309
|
+
if (!code) throw new CliError("OAuth callback did not include an authorization code.", EXIT.auth);
|
|
310
|
+
return { code, redirectUri };
|
|
311
|
+
}
|
|
126
312
|
async function login(options) {
|
|
127
313
|
const config = await oauthConfig(options.apiUrl);
|
|
128
314
|
const clientId = config.client_id;
|
|
129
315
|
const pkce = createPkce();
|
|
130
316
|
let timeout;
|
|
317
|
+
let manualReader;
|
|
131
318
|
const callback = new Promise((resolve, reject) => {
|
|
319
|
+
let settled = false;
|
|
320
|
+
const finish = (result, error) => {
|
|
321
|
+
if (settled) return;
|
|
322
|
+
settled = true;
|
|
323
|
+
if (timeout) clearTimeout(timeout);
|
|
324
|
+
manualReader?.close();
|
|
325
|
+
server.close();
|
|
326
|
+
if (error) reject(error);
|
|
327
|
+
else if (result) resolve(result);
|
|
328
|
+
};
|
|
132
329
|
const server = createServer((request, response) => {
|
|
133
|
-
const
|
|
134
|
-
if (
|
|
135
|
-
response.writeHead(
|
|
330
|
+
const address = server.address();
|
|
331
|
+
if (!address || typeof address === "string") {
|
|
332
|
+
response.writeHead(500, { "content-type": "text/plain" }).end("OAuth callback failed.");
|
|
333
|
+
finish(void 0, new CliError("OAuth callback failed.", EXIT.auth));
|
|
136
334
|
return;
|
|
137
335
|
}
|
|
138
|
-
const
|
|
139
|
-
const
|
|
140
|
-
if (
|
|
141
|
-
response.writeHead(
|
|
142
|
-
clearTimeout(timeout);
|
|
143
|
-
server.close();
|
|
144
|
-
reject(new CliError("OAuth state validation failed.", EXIT.auth));
|
|
336
|
+
const redirectUri2 = `http://127.0.0.1:${address.port}/callback`;
|
|
337
|
+
const current = new URL(request.url || "/", redirectUri2);
|
|
338
|
+
if (current.pathname !== "/callback") {
|
|
339
|
+
response.writeHead(404, { "content-type": "text/plain" }).end("Not found");
|
|
145
340
|
return;
|
|
146
341
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
342
|
+
try {
|
|
343
|
+
const result = callbackResult(request.url || "/", pkce.state, redirectUri2);
|
|
344
|
+
response.writeHead(200, { "content-type": "text/plain" }).end(
|
|
345
|
+
"Scrappycoco authorization received. Keep the terminal open while it securely saves the login; the terminal will report when it is complete."
|
|
346
|
+
);
|
|
347
|
+
finish(result);
|
|
348
|
+
} catch (error) {
|
|
349
|
+
response.writeHead(400, { "content-type": "text/plain" }).end(
|
|
350
|
+
error instanceof Error ? error.message : "Invalid OAuth callback."
|
|
351
|
+
);
|
|
352
|
+
finish(void 0, error);
|
|
353
|
+
}
|
|
153
354
|
});
|
|
154
355
|
server.listen(0, "127.0.0.1", async () => {
|
|
155
356
|
const address = server.address();
|
|
156
|
-
if (!address || typeof address === "string")
|
|
357
|
+
if (!address || typeof address === "string") {
|
|
358
|
+
finish(void 0, new CliError("Could not start OAuth callback.", EXIT.auth));
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
157
361
|
const redirectUri2 = `http://127.0.0.1:${address.port}/callback`;
|
|
158
362
|
const authorize = new URL(`${config.issuer.replace(/\/$/, "")}/oauth/authorize`);
|
|
159
363
|
authorize.search = new URLSearchParams({
|
|
@@ -168,11 +372,32 @@ async function login(options) {
|
|
|
168
372
|
process.stderr.write(`Open this URL to authenticate:
|
|
169
373
|
${authorize.toString()}
|
|
170
374
|
`);
|
|
171
|
-
if (
|
|
375
|
+
if (options.manualCallback) {
|
|
376
|
+
process.stderr.write(
|
|
377
|
+
"After signing in, paste the final callback URL into this terminal. Never paste it into chat.\n"
|
|
378
|
+
);
|
|
379
|
+
manualReader = createInterface({ input: process.stdin, output: process.stderr });
|
|
380
|
+
void manualReader.question("Callback URL: ").then((rawUrl) => {
|
|
381
|
+
try {
|
|
382
|
+
finish(callbackResult(rawUrl.trim(), pkce.state, redirectUri2));
|
|
383
|
+
} catch (error) {
|
|
384
|
+
finish(void 0, error);
|
|
385
|
+
}
|
|
386
|
+
}).catch((error) => finish(void 0, error));
|
|
387
|
+
}
|
|
388
|
+
if (!options.noBrowser) {
|
|
389
|
+
try {
|
|
390
|
+
await open2(authorize.toString());
|
|
391
|
+
} catch {
|
|
392
|
+
process.stderr.write("Could not open a browser automatically; use the URL above.\n");
|
|
393
|
+
}
|
|
394
|
+
}
|
|
172
395
|
});
|
|
173
396
|
timeout = setTimeout(() => {
|
|
174
|
-
|
|
175
|
-
|
|
397
|
+
finish(void 0, new CliError(
|
|
398
|
+
"OAuth login timed out. The printed URL and callback port are no longer valid; start a new login.",
|
|
399
|
+
EXIT.timeout
|
|
400
|
+
));
|
|
176
401
|
}, 5 * 6e4);
|
|
177
402
|
});
|
|
178
403
|
const { code, redirectUri } = await callback;
|
|
@@ -182,31 +407,81 @@ ${authorize.toString()}
|
|
|
182
407
|
code,
|
|
183
408
|
code_verifier: pkce.verifier,
|
|
184
409
|
redirect_uri: redirectUri
|
|
185
|
-
}));
|
|
410
|
+
}), "authorization-code exchange");
|
|
186
411
|
if (!tokens.refresh_token) throw new CliError("OAuth response did not include a refresh token.", EXIT.auth);
|
|
412
|
+
invalidateAccessToken();
|
|
187
413
|
return { storage: await saveRefreshToken(tokens.refresh_token) };
|
|
188
414
|
}
|
|
415
|
+
function invalidateAccessToken() {
|
|
416
|
+
cachedAccessToken = void 0;
|
|
417
|
+
}
|
|
418
|
+
async function clearRefreshToken2() {
|
|
419
|
+
invalidateAccessToken();
|
|
420
|
+
await clearRefreshToken();
|
|
421
|
+
}
|
|
189
422
|
async function accessToken(apiUrl) {
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
423
|
+
const normalizedApiUrl = apiUrl.replace(/\/$/, "");
|
|
424
|
+
if (cachedAccessToken && cachedAccessToken.apiUrl === normalizedApiUrl && cachedAccessToken.expiresAt - 3e4 > Date.now()) {
|
|
425
|
+
return cachedAccessToken.value;
|
|
426
|
+
}
|
|
427
|
+
const existingRefresh = refreshesInFlight.get(normalizedApiUrl);
|
|
428
|
+
if (existingRefresh) return existingRefresh;
|
|
429
|
+
const refresh = withCredentialRefreshLock(async () => {
|
|
430
|
+
if (cachedAccessToken && cachedAccessToken.apiUrl === normalizedApiUrl && cachedAccessToken.expiresAt - 3e4 > Date.now()) {
|
|
431
|
+
return cachedAccessToken.value;
|
|
432
|
+
}
|
|
433
|
+
const config = await oauthConfig(normalizedApiUrl);
|
|
434
|
+
const candidates = await loadRefreshTokenCandidates();
|
|
435
|
+
if (!candidates.length) throw new CliError("Not logged in. Run `scrappycoco auth login`.", EXIT.auth);
|
|
436
|
+
let tokens;
|
|
437
|
+
let acceptedRefreshToken;
|
|
438
|
+
let lastAuthError;
|
|
439
|
+
for (const candidate of candidates) {
|
|
440
|
+
try {
|
|
441
|
+
tokens = await tokenRequest(config.issuer, new URLSearchParams({
|
|
442
|
+
grant_type: "refresh_token",
|
|
443
|
+
client_id: config.client_id,
|
|
444
|
+
refresh_token: candidate.value
|
|
445
|
+
}), "token refresh");
|
|
446
|
+
acceptedRefreshToken = candidate.value;
|
|
447
|
+
break;
|
|
448
|
+
} catch (error) {
|
|
449
|
+
if (!(error instanceof CliError) || error.exitCode !== EXIT.auth || typeof error.details === "object" && error.details !== null && "network_error" in error.details) {
|
|
450
|
+
throw error;
|
|
451
|
+
}
|
|
452
|
+
lastAuthError = error;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (!tokens || !acceptedRefreshToken) throw lastAuthError || new CliError("Stored OAuth login is no longer valid.", EXIT.auth);
|
|
456
|
+
await saveRefreshToken(tokens.refresh_token || acceptedRefreshToken);
|
|
457
|
+
cachedAccessToken = {
|
|
458
|
+
apiUrl: normalizedApiUrl,
|
|
459
|
+
value: tokens.access_token,
|
|
460
|
+
expiresAt: Date.now() + Math.max(tokens.expires_in ?? 300, 60) * 1e3
|
|
461
|
+
};
|
|
462
|
+
return tokens.access_token;
|
|
463
|
+
});
|
|
464
|
+
refreshesInFlight.set(normalizedApiUrl, refresh);
|
|
465
|
+
try {
|
|
466
|
+
return await refresh;
|
|
467
|
+
} finally {
|
|
468
|
+
if (refreshesInFlight.get(normalizedApiUrl) === refresh) {
|
|
469
|
+
refreshesInFlight.delete(normalizedApiUrl);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
async function loadRefreshToken() {
|
|
474
|
+
return (await loadRefreshTokenCandidates())[0]?.value || null;
|
|
201
475
|
}
|
|
202
476
|
|
|
203
477
|
// src/client.ts
|
|
204
|
-
import { randomUUID } from "crypto";
|
|
478
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
205
479
|
var DEFAULT_API_URL = process.env.SCRAPPYCOCO_API_URL || "https://api.scrappycoco.ai";
|
|
206
480
|
var DEFAULT_JOB_TIMEOUT_MS = 20 * 60 * 1e3;
|
|
207
481
|
var DEFAULT_JOB_POLL_INITIAL_MS = 250;
|
|
208
482
|
var DEFAULT_JOB_POLL_MAX_MS = 5e3;
|
|
209
|
-
|
|
483
|
+
var DEFAULT_API_REQUEST_TIMEOUT_MS = 3e4;
|
|
484
|
+
function positiveInteger2(value, fallback) {
|
|
210
485
|
if (!value) return fallback;
|
|
211
486
|
const parsed = Number(value);
|
|
212
487
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
@@ -235,12 +510,35 @@ var ApiClient = class {
|
|
|
235
510
|
};
|
|
236
511
|
}
|
|
237
512
|
async request(method, path, body, headers, signal) {
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
513
|
+
const url = `${this.baseUrl.replace(/\/$/, "")}/api/v1${path}`;
|
|
514
|
+
const requestBody = body === void 0 ? void 0 : JSON.stringify(body);
|
|
515
|
+
const send = async () => {
|
|
516
|
+
const timeoutSignal = AbortSignal.timeout(
|
|
517
|
+
positiveInteger2(process.env.SCRAPPYCOCO_API_TIMEOUT_MS, DEFAULT_API_REQUEST_TIMEOUT_MS)
|
|
518
|
+
);
|
|
519
|
+
const requestSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
|
520
|
+
const requestHeaders = await this.headers(headers);
|
|
521
|
+
try {
|
|
522
|
+
return await fetch(url, {
|
|
523
|
+
method,
|
|
524
|
+
headers: requestHeaders,
|
|
525
|
+
body: requestBody,
|
|
526
|
+
signal: requestSignal
|
|
527
|
+
});
|
|
528
|
+
} catch (error) {
|
|
529
|
+
const timeout = error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError");
|
|
530
|
+
throw new CliError(
|
|
531
|
+
timeout ? `Scrappycoco API request timed out: ${method} ${path}.` : `Could not reach the Scrappycoco API: ${method} ${path}.`,
|
|
532
|
+
timeout ? EXIT.timeout : EXIT.api,
|
|
533
|
+
{ method, path }
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
let response = await send();
|
|
538
|
+
if (response.status === 401 && !process.env.SCRAPPYCOCO_API_KEY) {
|
|
539
|
+
invalidateAccessToken();
|
|
540
|
+
response = await send();
|
|
541
|
+
}
|
|
244
542
|
if (response.status === 204) return void 0;
|
|
245
543
|
const payload = await response.json().catch(() => ({}));
|
|
246
544
|
if (!response.ok) {
|
|
@@ -253,17 +551,17 @@ var ApiClient = class {
|
|
|
253
551
|
get(path, signal) {
|
|
254
552
|
return this.request("GET", path, void 0, void 0, signal);
|
|
255
553
|
}
|
|
256
|
-
post(path, body, key =
|
|
554
|
+
post(path, body, key = randomUUID2()) {
|
|
257
555
|
return this.request("POST", path, body, { "Idempotency-Key": key });
|
|
258
556
|
}
|
|
259
|
-
async postJob(path, body, key =
|
|
557
|
+
async postJob(path, body, key = randomUUID2()) {
|
|
260
558
|
const submitted = await this.post(path, body, key);
|
|
261
|
-
const timeoutMs =
|
|
262
|
-
const initialDelayMs =
|
|
559
|
+
const timeoutMs = positiveInteger2(process.env.SCRAPPYCOCO_JOB_TIMEOUT_MS, DEFAULT_JOB_TIMEOUT_MS);
|
|
560
|
+
const initialDelayMs = positiveInteger2(
|
|
263
561
|
process.env.SCRAPPYCOCO_JOB_POLL_INITIAL_MS,
|
|
264
562
|
DEFAULT_JOB_POLL_INITIAL_MS
|
|
265
563
|
);
|
|
266
|
-
const maxDelayMs =
|
|
564
|
+
const maxDelayMs = positiveInteger2(process.env.SCRAPPYCOCO_JOB_POLL_MAX_MS, DEFAULT_JOB_POLL_MAX_MS);
|
|
267
565
|
const deadline = Date.now() + timeoutMs;
|
|
268
566
|
let delayMs = Math.min(initialDelayMs, maxDelayMs);
|
|
269
567
|
let job = submitted;
|
|
@@ -369,6 +667,7 @@ function errorPayload(error) {
|
|
|
369
667
|
|
|
370
668
|
// src/setup.ts
|
|
371
669
|
import { spawn } from "child_process";
|
|
670
|
+
import { determineAgent } from "@vercel/detect-agent";
|
|
372
671
|
var SKILL_SOURCE = "https://scrappycoco.ai";
|
|
373
672
|
var SKILL_INSTALL_ARGS = [
|
|
374
673
|
"--yes",
|
|
@@ -380,28 +679,92 @@ var SKILL_INSTALL_ARGS = [
|
|
|
380
679
|
"-g",
|
|
381
680
|
"-y"
|
|
382
681
|
];
|
|
682
|
+
var SKILL_INSTALLER_AGENTS = {
|
|
683
|
+
antigravity: "antigravity",
|
|
684
|
+
"augment-cli": "augment",
|
|
685
|
+
claude: "claude-code",
|
|
686
|
+
codex: "codex",
|
|
687
|
+
cowork: "claude-code",
|
|
688
|
+
cursor: "cursor",
|
|
689
|
+
"cursor-cli": "cursor",
|
|
690
|
+
devin: "universal",
|
|
691
|
+
gemini: "gemini-cli",
|
|
692
|
+
"github-copilot": "github-copilot",
|
|
693
|
+
opencode: "opencode",
|
|
694
|
+
replit: "replit"
|
|
695
|
+
};
|
|
696
|
+
var MAX_INSTALLER_OUTPUT_LENGTH = 512 * 1024;
|
|
697
|
+
var ANSI_ESCAPE = /\u001B\[[0-?]*[ -/]*[@-~]/g;
|
|
383
698
|
function npxCommand() {
|
|
384
699
|
return process.platform === "win32" ? "npx.cmd" : "npx";
|
|
385
700
|
}
|
|
386
|
-
function
|
|
387
|
-
|
|
701
|
+
async function detectSkillInstallerAgent(detector = determineAgent) {
|
|
702
|
+
const detected = await detector();
|
|
703
|
+
if (!detected.isAgent || !detected.agent) return "universal";
|
|
704
|
+
return SKILL_INSTALLER_AGENTS[detected.agent.name] || "universal";
|
|
705
|
+
}
|
|
706
|
+
function skillInstallInvocation(agent = "universal", npmExecPath = process.env.npm_execpath) {
|
|
707
|
+
if (npmExecPath && /(?:^|[/\\])pnpm(?:\.c?js)?$/i.test(npmExecPath)) {
|
|
708
|
+
return {
|
|
709
|
+
command: process.execPath,
|
|
710
|
+
args: [
|
|
711
|
+
npmExecPath,
|
|
712
|
+
"dlx",
|
|
713
|
+
"skills",
|
|
714
|
+
...SKILL_INSTALL_ARGS.slice(2),
|
|
715
|
+
"--agent",
|
|
716
|
+
agent
|
|
717
|
+
]
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
return {
|
|
721
|
+
command: npxCommand(),
|
|
722
|
+
args: [...SKILL_INSTALL_ARGS, "--agent", agent]
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
function skillInstallerReportedFailures(output) {
|
|
726
|
+
const plainOutput = output.replace(ANSI_ESCAPE, "");
|
|
727
|
+
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)) {
|
|
728
|
+
return false;
|
|
729
|
+
}
|
|
730
|
+
return /Failed to install\s+[1-9]\d*/i.test(plainOutput);
|
|
388
731
|
}
|
|
389
732
|
function runCommand(command, args) {
|
|
390
733
|
return new Promise((resolve, reject) => {
|
|
734
|
+
let installerOutput = "";
|
|
735
|
+
const capture = (chunk) => {
|
|
736
|
+
installerOutput += chunk.toString();
|
|
737
|
+
if (installerOutput.length > MAX_INSTALLER_OUTPUT_LENGTH) {
|
|
738
|
+
installerOutput = installerOutput.slice(-MAX_INSTALLER_OUTPUT_LENGTH);
|
|
739
|
+
}
|
|
740
|
+
};
|
|
391
741
|
const child = spawn(command, [...args], {
|
|
392
742
|
stdio: ["ignore", "pipe", "pipe"],
|
|
393
743
|
windowsHide: true
|
|
394
744
|
});
|
|
395
|
-
child.stdout.on("data", (chunk) =>
|
|
396
|
-
|
|
745
|
+
child.stdout.on("data", (chunk) => {
|
|
746
|
+
capture(chunk);
|
|
747
|
+
process.stderr.write(chunk);
|
|
748
|
+
});
|
|
749
|
+
child.stderr.on("data", (chunk) => {
|
|
750
|
+
capture(chunk);
|
|
751
|
+
process.stderr.write(chunk);
|
|
752
|
+
});
|
|
397
753
|
child.on("error", (error) => reject(
|
|
398
754
|
new CliError(`Could not start the skill installer: ${error.message}`, EXIT.api)
|
|
399
755
|
));
|
|
400
756
|
child.on("close", (code) => {
|
|
401
|
-
if (code === 0) {
|
|
757
|
+
if (code === 0 && !skillInstallerReportedFailures(installerOutput)) {
|
|
402
758
|
resolve();
|
|
403
759
|
return;
|
|
404
760
|
}
|
|
761
|
+
if (code === 0) {
|
|
762
|
+
reject(new CliError(
|
|
763
|
+
"Skill installer reported one or more failed targets.",
|
|
764
|
+
EXIT.api
|
|
765
|
+
));
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
405
768
|
reject(new CliError(
|
|
406
769
|
`Skill installation failed with exit code ${code ?? "unknown"}.`,
|
|
407
770
|
EXIT.api
|
|
@@ -409,17 +772,19 @@ function runCommand(command, args) {
|
|
|
409
772
|
});
|
|
410
773
|
});
|
|
411
774
|
}
|
|
412
|
-
async function installSkill(runner = runCommand) {
|
|
413
|
-
const
|
|
775
|
+
async function installSkill(runner = runCommand, detector = determineAgent) {
|
|
776
|
+
const agent = await detectSkillInstallerAgent(detector);
|
|
777
|
+
const invocation = skillInstallInvocation(agent);
|
|
414
778
|
await runner(invocation.command, invocation.args);
|
|
415
779
|
}
|
|
416
780
|
function defaultDependencies(apiUrl) {
|
|
417
781
|
const client2 = new ApiClient(apiUrl);
|
|
418
782
|
return {
|
|
419
783
|
hasApiKey: () => Boolean(process.env.SCRAPPYCOCO_API_KEY),
|
|
420
|
-
clearRefreshToken,
|
|
784
|
+
clearRefreshToken: clearRefreshToken2,
|
|
421
785
|
loadRefreshToken,
|
|
422
786
|
login,
|
|
787
|
+
prepareCredentialStorage,
|
|
423
788
|
installSkill,
|
|
424
789
|
listAvailableScrapers: () => client2.get("/scrapers?available_only=true")
|
|
425
790
|
};
|
|
@@ -429,17 +794,22 @@ async function performSetup(options, dependencies = defaultDependencies(options.
|
|
|
429
794
|
let authentication = usingApiKey ? "api_key" : "oauth";
|
|
430
795
|
let credentialStorage = usingApiKey ? "environment" : "existing";
|
|
431
796
|
const hadStoredToken = !usingApiKey && Boolean(await dependencies.loadRefreshToken());
|
|
797
|
+
if (!usingApiKey && !hadStoredToken) {
|
|
798
|
+
await dependencies.prepareCredentialStorage();
|
|
799
|
+
}
|
|
800
|
+
await dependencies.installSkill();
|
|
432
801
|
if (!usingApiKey && !hadStoredToken) {
|
|
433
802
|
const authenticated = await dependencies.login({
|
|
434
803
|
apiUrl: options.apiUrl,
|
|
435
|
-
noBrowser: options.noBrowser
|
|
804
|
+
noBrowser: options.noBrowser,
|
|
805
|
+
manualCallback: options.manualCallback
|
|
436
806
|
});
|
|
437
807
|
authentication = "oauth";
|
|
438
808
|
credentialStorage = authenticated.storage;
|
|
439
809
|
}
|
|
440
|
-
let
|
|
810
|
+
let catalog2;
|
|
441
811
|
try {
|
|
442
|
-
|
|
812
|
+
catalog2 = await dependencies.listAvailableScrapers();
|
|
443
813
|
} catch (error) {
|
|
444
814
|
if (usingApiKey || !hadStoredToken || !(error instanceof CliError) || error.exitCode !== EXIT.auth) {
|
|
445
815
|
throw error;
|
|
@@ -447,12 +817,12 @@ async function performSetup(options, dependencies = defaultDependencies(options.
|
|
|
447
817
|
await dependencies.clearRefreshToken();
|
|
448
818
|
const authenticated = await dependencies.login({
|
|
449
819
|
apiUrl: options.apiUrl,
|
|
450
|
-
noBrowser: options.noBrowser
|
|
820
|
+
noBrowser: options.noBrowser,
|
|
821
|
+
manualCallback: options.manualCallback
|
|
451
822
|
});
|
|
452
823
|
credentialStorage = authenticated.storage;
|
|
453
|
-
|
|
824
|
+
catalog2 = await dependencies.listAvailableScrapers();
|
|
454
825
|
}
|
|
455
|
-
await dependencies.installSkill();
|
|
456
826
|
return {
|
|
457
827
|
authenticated: true,
|
|
458
828
|
authentication,
|
|
@@ -463,9 +833,9 @@ async function performSetup(options, dependencies = defaultDependencies(options.
|
|
|
463
833
|
},
|
|
464
834
|
verification: {
|
|
465
835
|
catalog_reachable: true,
|
|
466
|
-
available_capabilities:
|
|
836
|
+
available_capabilities: catalog2.length
|
|
467
837
|
},
|
|
468
|
-
next_step: "Reload or restart your agent, then ask it to
|
|
838
|
+
next_step: "Reload or restart your agent, then ask it to inspect the Scrappycoco catalog."
|
|
469
839
|
};
|
|
470
840
|
}
|
|
471
841
|
|
|
@@ -474,7 +844,7 @@ var packageMetadata = JSON.parse(
|
|
|
474
844
|
readFileSync(new URL("../package.json", import.meta.url), "utf8")
|
|
475
845
|
);
|
|
476
846
|
var program = new Command();
|
|
477
|
-
program.name("scrappycoco").description("Discover
|
|
847
|
+
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
848
|
function globals(command) {
|
|
479
849
|
return command.optsWithGlobals();
|
|
480
850
|
}
|
|
@@ -484,10 +854,11 @@ function client(command) {
|
|
|
484
854
|
function collect(value, previous) {
|
|
485
855
|
return [...previous, value];
|
|
486
856
|
}
|
|
487
|
-
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) => {
|
|
857
|
+
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) => {
|
|
488
858
|
const result = await performSetup({
|
|
489
859
|
apiUrl: globals(command).apiUrl,
|
|
490
|
-
noBrowser: options.browser === false
|
|
860
|
+
noBrowser: options.browser === false,
|
|
861
|
+
manualCallback: options.manualCallback
|
|
491
862
|
});
|
|
492
863
|
await emit(result, globals(command).json || false);
|
|
493
864
|
});
|
|
@@ -509,6 +880,8 @@ async function requestPayload(options, scraperId) {
|
|
|
509
880
|
capability,
|
|
510
881
|
input,
|
|
511
882
|
...options.provider?.length ? { providers: options.provider } : {},
|
|
883
|
+
...options.providerOptions ? { provider_options: parseJsonObject(options.providerOptions, "provider options JSON") } : {},
|
|
884
|
+
...options.concurrency !== void 0 ? { concurrency: Number(options.concurrency) } : {},
|
|
512
885
|
...options.limit !== void 0 ? { limit: Number(options.limit) } : {}
|
|
513
886
|
};
|
|
514
887
|
}
|
|
@@ -524,8 +897,12 @@ async function emitExecution(response, options, command) {
|
|
|
524
897
|
await emit(response, jsonMode);
|
|
525
898
|
}
|
|
526
899
|
var auth = program.command("auth").description("Manage Clerk OAuth credentials");
|
|
527
|
-
auth.command("login").option("--no-browser", "print the authorization URL without opening it").action(async (options, command) => {
|
|
528
|
-
const result = await login({
|
|
900
|
+
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
|
+
const result = await login({
|
|
902
|
+
noBrowser: options.browser === false,
|
|
903
|
+
manualCallback: options.manualCallback,
|
|
904
|
+
apiUrl: globals(command).apiUrl
|
|
905
|
+
});
|
|
529
906
|
await emit({ authenticated: true, storage: result.storage }, globals(command).json || false);
|
|
530
907
|
});
|
|
531
908
|
auth.command("status").action(async (_options, command) => {
|
|
@@ -536,17 +913,34 @@ auth.command("status").action(async (_options, command) => {
|
|
|
536
913
|
process.exitCode = EXIT.auth;
|
|
537
914
|
return;
|
|
538
915
|
}
|
|
539
|
-
|
|
916
|
+
let me;
|
|
917
|
+
try {
|
|
918
|
+
me = await client(command).get("/me");
|
|
919
|
+
} catch (error) {
|
|
920
|
+
if (error instanceof CliError && error.exitCode === EXIT.auth) {
|
|
921
|
+
await emit(
|
|
922
|
+
{
|
|
923
|
+
authenticated: false,
|
|
924
|
+
reason: error.message,
|
|
925
|
+
next_action: usingApiKey ? "Verify or replace the existing SCRAPPYCOCO_API_KEY environment variable." : "Run `scrappycoco auth login` to replace the stored OAuth login."
|
|
926
|
+
},
|
|
927
|
+
globals(command).json || false
|
|
928
|
+
);
|
|
929
|
+
process.exitCode = EXIT.auth;
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
throw error;
|
|
933
|
+
}
|
|
540
934
|
await emit(
|
|
541
935
|
{ authenticated: true, method: usingApiKey ? "api_key" : "oauth", account: me },
|
|
542
936
|
globals(command).json || false
|
|
543
937
|
);
|
|
544
938
|
});
|
|
545
939
|
auth.command("logout").action(async (_options, command) => {
|
|
546
|
-
await
|
|
940
|
+
await clearRefreshToken2();
|
|
547
941
|
await emit({ authenticated: false }, globals(command).json || false);
|
|
548
942
|
});
|
|
549
|
-
var scrapers = program.command("scrapers").description("
|
|
943
|
+
var scrapers = program.command("scrapers", { hidden: true }).description("Legacy scraper commands");
|
|
550
944
|
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
945
|
const query = new URLSearchParams();
|
|
552
946
|
if (options.source) query.set("source", options.source);
|
|
@@ -564,20 +958,83 @@ scrapers.command("inspect <scraper-id>").action(async (scraperId, _options, comm
|
|
|
564
958
|
globals(command).json || false
|
|
565
959
|
);
|
|
566
960
|
});
|
|
961
|
+
var catalog = program.command("catalog").description("Inspect capabilities, provider schemas, options, and pricing");
|
|
962
|
+
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) => {
|
|
963
|
+
const query = new URLSearchParams();
|
|
964
|
+
if (options.source) query.set("source", options.source);
|
|
965
|
+
if (options.provider) query.set("provider", options.provider);
|
|
966
|
+
if (options.available) query.set("available_only", "true");
|
|
967
|
+
await emit(
|
|
968
|
+
await client(command).get(`/scrapers${query.size ? `?${query}` : ""}`),
|
|
969
|
+
globals(command).json || false
|
|
970
|
+
);
|
|
971
|
+
});
|
|
972
|
+
catalog.command("inspect <capability-id>").action(async (capabilityId, _options, command) => {
|
|
973
|
+
const { source, capability } = splitScraperId(capabilityId);
|
|
974
|
+
await emit(
|
|
975
|
+
await client(command).get(
|
|
976
|
+
`/scrapers/${encodeURIComponent(source)}/${encodeURIComponent(capability)}`
|
|
977
|
+
),
|
|
978
|
+
globals(command).json || false
|
|
979
|
+
);
|
|
980
|
+
});
|
|
567
981
|
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) => {
|
|
982
|
+
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
983
|
const payload = await requestPayload(options, scraperId);
|
|
570
984
|
if (payload.limit === void 0) payload.limit = 10;
|
|
571
985
|
const response = await client(command).postJob(
|
|
572
986
|
name === "run" ? "/scrapers/jobs" : "/scrapers/compare/jobs",
|
|
573
987
|
payload,
|
|
574
|
-
options.idempotencyKey ||
|
|
988
|
+
options.idempotencyKey || randomUUID3()
|
|
575
989
|
);
|
|
576
990
|
await emitExecution(response, options, command);
|
|
577
991
|
});
|
|
578
992
|
}
|
|
579
993
|
executionCommand("run");
|
|
580
994
|
executionCommand("compare");
|
|
995
|
+
program.command("run [capability-id]").description("Run a capability directly; discovery is optional").option("--config <discovery-id>", "run a finalized multi-step configuration").option("-f, --file <path>", "canonical request JSON file").option("--input <json>", "capability input JSON; use url or urls for web.extract_content").option("--provider <id>", "provider ID; repeat for an ordered fallback waterfall", collect, []).option("--provider-options <json>", "provider-native options keyed by provider ID").option("--concurrency <number>", "batch concurrency (default 3, maximum 10)").option("--limit <number>", "maximum records").option("--idempotency-key <key>", "stable retry key").option("--retry-failed <run-id>", "retry only failed URLs from a partial batch run").addOption(new Option("--format <format>", "output record format").choices(["json", "jsonl", "csv"]).default("json")).option("-o, --output <path>", "write records to a file").action(async (capabilityId, options, command) => {
|
|
996
|
+
if (options.retryFailed) {
|
|
997
|
+
if (capabilityId || options.config) {
|
|
998
|
+
throw new CliError("Do not combine --retry-failed with a capability ID or --config.", EXIT.usage);
|
|
999
|
+
}
|
|
1000
|
+
const response2 = await client(command).postJob(
|
|
1001
|
+
`/runs/${encodeURIComponent(options.retryFailed)}/retry-failed`,
|
|
1002
|
+
{},
|
|
1003
|
+
options.idempotencyKey || randomUUID3()
|
|
1004
|
+
);
|
|
1005
|
+
await emitExecution(response2, options, command);
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
if (options.config) {
|
|
1009
|
+
if (capabilityId) {
|
|
1010
|
+
throw new CliError("Choose either a capability ID or --config.", EXIT.usage);
|
|
1011
|
+
}
|
|
1012
|
+
const fromFile = options.file ? await readJsonFile(options.file) : {};
|
|
1013
|
+
const input = options.input ? parseJsonObject(options.input, "runtime input JSON") : fromFile.input || {};
|
|
1014
|
+
const response2 = await client(command).postJob(
|
|
1015
|
+
`/discoveries/${encodeURIComponent(options.config)}/jobs`,
|
|
1016
|
+
{
|
|
1017
|
+
...fromFile,
|
|
1018
|
+
input,
|
|
1019
|
+
limit: Number(options.limit ?? fromFile.limit ?? 25)
|
|
1020
|
+
},
|
|
1021
|
+
options.idempotencyKey || randomUUID3()
|
|
1022
|
+
);
|
|
1023
|
+
await emitExecution(response2, options, command);
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
if (!capabilityId) {
|
|
1027
|
+
throw new CliError("Provide a capability ID or --retry-failed <run-id>.", EXIT.usage);
|
|
1028
|
+
}
|
|
1029
|
+
const payload = await requestPayload(options, capabilityId);
|
|
1030
|
+
if (payload.limit === void 0) payload.limit = 10;
|
|
1031
|
+
const response = await client(command).postJob(
|
|
1032
|
+
"/scrapers/jobs",
|
|
1033
|
+
payload,
|
|
1034
|
+
options.idempotencyKey || randomUUID3()
|
|
1035
|
+
);
|
|
1036
|
+
await emitExecution(response, options, command);
|
|
1037
|
+
});
|
|
581
1038
|
var jobs = program.command("jobs").description("Inspect durable queued jobs");
|
|
582
1039
|
jobs.command("get <job-id>").action(async (jobId, _options, command) => {
|
|
583
1040
|
await emit(
|
|
@@ -585,17 +1042,17 @@ jobs.command("get <job-id>").action(async (jobId, _options, command) => {
|
|
|
585
1042
|
globals(command).json || false
|
|
586
1043
|
);
|
|
587
1044
|
});
|
|
588
|
-
var providers = program.command("providers").description("
|
|
1045
|
+
var providers = program.command("providers", { hidden: true }).description("Legacy provider commands");
|
|
589
1046
|
providers.command("list").option("--available", "only include available provider-capability routes").action(async (options, command) => {
|
|
590
1047
|
await emit(
|
|
591
1048
|
await client(command).get(`/providers${options.available ? "?available_only=true" : ""}`),
|
|
592
1049
|
globals(command).json || false
|
|
593
1050
|
);
|
|
594
1051
|
});
|
|
595
|
-
var discoveries = program.command("discoveries").description("
|
|
596
|
-
discoveries.command("create").requiredOption("--
|
|
1052
|
+
var discoveries = program.command("discoveries", { hidden: true }).description("Legacy discovery commands");
|
|
1053
|
+
discoveries.command("create").requiredOption("-f, --file <path>", "agent-authored discovery JSON with goal and configuration").action(async (options, command) => {
|
|
597
1054
|
await emit(
|
|
598
|
-
await client(command).post("/discoveries",
|
|
1055
|
+
await client(command).post("/discoveries", await readJsonFile(options.file)),
|
|
599
1056
|
globals(command).json || false
|
|
600
1057
|
);
|
|
601
1058
|
});
|
|
@@ -629,7 +1086,7 @@ discoveries.command("run <discovery-id>").option("-f, --file <path>", "request J
|
|
|
629
1086
|
const response = await client(command).postJob(
|
|
630
1087
|
`/discoveries/${encodeURIComponent(discoveryId)}/jobs`,
|
|
631
1088
|
payload,
|
|
632
|
-
options.idempotencyKey ||
|
|
1089
|
+
options.idempotencyKey || randomUUID3()
|
|
633
1090
|
);
|
|
634
1091
|
await emitExecution(response, options, command);
|
|
635
1092
|
});
|
|
@@ -637,6 +1094,50 @@ discoveries.command("delete <discovery-id>").requiredOption("--yes", "confirm pe
|
|
|
637
1094
|
await client(command).delete(`/discoveries/${encodeURIComponent(discoveryId)}`);
|
|
638
1095
|
await emit({ deleted: true, discovery_id: discoveryId }, globals(command).json || false);
|
|
639
1096
|
});
|
|
1097
|
+
program.command("discover").description("Save, sample-test, or finalize an agent-authored configuration").option("-f, --file <path>", "create from agent-authored discovery JSON").option("--id <discovery-id>", "existing discovery ID").option("--test", "approve and run a paid sample test").option("--input <json>", "sample runtime input JSON").option("--update <path>", "replace fields or configuration from agent-authored JSON").option("--finalize", "mark the current explicit configuration finalized").option("--idempotency-key <key>", "stable sample retry key").action(async (options, command) => {
|
|
1098
|
+
const selected = Number(Boolean(options.file)) + Number(Boolean(options.test)) + Number(Boolean(options.update)) + Number(Boolean(options.finalize));
|
|
1099
|
+
if (selected !== 1) {
|
|
1100
|
+
throw new CliError(
|
|
1101
|
+
"Choose exactly one action: --file, --id --update, --id --test, or --id --finalize.",
|
|
1102
|
+
EXIT.usage
|
|
1103
|
+
);
|
|
1104
|
+
}
|
|
1105
|
+
if (options.file) {
|
|
1106
|
+
await emit(
|
|
1107
|
+
await client(command).post("/discoveries", await readJsonFile(options.file)),
|
|
1108
|
+
globals(command).json || false
|
|
1109
|
+
);
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
if (!options.id) throw new CliError("--id is required for --update, --test, and --finalize.", EXIT.usage);
|
|
1113
|
+
if (options.update) {
|
|
1114
|
+
await emit(
|
|
1115
|
+
await client(command).patch(
|
|
1116
|
+
`/discoveries/${encodeURIComponent(options.id)}`,
|
|
1117
|
+
await readJsonFile(options.update)
|
|
1118
|
+
),
|
|
1119
|
+
globals(command).json || false
|
|
1120
|
+
);
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
if (options.finalize) {
|
|
1124
|
+
await emit(
|
|
1125
|
+
await client(command).post(
|
|
1126
|
+
`/discoveries/${encodeURIComponent(options.id)}/finalize`,
|
|
1127
|
+
{}
|
|
1128
|
+
),
|
|
1129
|
+
globals(command).json || false
|
|
1130
|
+
);
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
const input = options.input ? parseJsonObject(options.input, "sample input JSON") : {};
|
|
1134
|
+
const response = await client(command).postJob(
|
|
1135
|
+
`/discoveries/${encodeURIComponent(options.id)}/jobs`,
|
|
1136
|
+
{ input, limit: 25 },
|
|
1137
|
+
options.idempotencyKey || randomUUID3()
|
|
1138
|
+
);
|
|
1139
|
+
await emit(response, globals(command).json || false);
|
|
1140
|
+
});
|
|
640
1141
|
program.configureOutput({ writeErr: (text) => process.stderr.write(text) });
|
|
641
1142
|
program.parseAsync(process.argv).catch(async (error) => {
|
|
642
1143
|
if (error instanceof CommanderError) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scrappycoco/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "CLI for Scrappycoco scraper discovery
|
|
3
|
+
"version": "0.4.1",
|
|
4
|
+
"description": "CLI for Scrappycoco scraper discovery and execution",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"scrappycoco": "dist/index.js"
|
|
@@ -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"
|