@scrappycoco/cli 0.4.0 → 0.4.2
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 -9
- package/dist/index.js +515 -77
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -7,23 +7,47 @@ judgment.
|
|
|
7
7
|
Run the published package directly with `npx`:
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
|
-
npx --yes @scrappycoco/cli setup
|
|
10
|
+
npx --yes @scrappycoco/cli@latest setup
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
`setup` opens OAuth in the browser. New users can create an account in that
|
|
14
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.
|
|
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
|
+
Before every ordinary command, the CLI compares the installed Scrappycoco skill
|
|
20
|
+
with the digest in the hash-verified public feed. When the digest changes, it
|
|
21
|
+
automatically refreshes the skill and prints a reload/restart notice to stderr.
|
|
22
|
+
Release-check or installer failures do not block the requested CLI command.
|
|
23
|
+
CLI-only users without an installed skill are not modified. Set
|
|
24
|
+
`SCRAPPYCOCO_DISABLE_SKILL_AUTO_UPDATE=1` to opt out.
|
|
25
|
+
|
|
26
|
+
When the CLI runs on a remote or headless host whose `127.0.0.1` is not the
|
|
27
|
+
browser's localhost, keep the command running and use the manual callback
|
|
28
|
+
flow:
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
npx --yes @scrappycoco/cli@latest setup --no-browser --manual-callback
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Open the printed authorization URL, then paste the browser's final callback
|
|
35
|
+
URL into that same terminal. The callback contains a short-lived
|
|
36
|
+
authorization code: never paste it into chat, logs, or a saved file. A timed
|
|
37
|
+
out or exited login cannot be resumed; start a new command and use its new
|
|
38
|
+
URL.
|
|
16
39
|
|
|
17
40
|
Individual commands remain available:
|
|
18
41
|
|
|
19
42
|
```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 --
|
|
26
|
-
npx --yes @scrappycoco/cli
|
|
43
|
+
npx --yes @scrappycoco/cli@latest auth login
|
|
44
|
+
npx --yes @scrappycoco/cli@latest catalog list --available --json
|
|
45
|
+
npx --yes @scrappycoco/cli@latest catalog inspect web.extract_content --json
|
|
46
|
+
npx --yes @scrappycoco/cli@latest run web.extract_content --file request.json --json
|
|
47
|
+
npx --yes @scrappycoco/cli@latest discover --file discovery.json --json
|
|
48
|
+
npx --yes @scrappycoco/cli@latest discover --id DISCOVERY_ID --test --input '{"url":"https://example.com"}' --json
|
|
49
|
+
npx --yes @scrappycoco/cli@latest discover --id DISCOVERY_ID --finalize --json
|
|
50
|
+
npx --yes @scrappycoco/cli@latest run --config DISCOVERY_ID --input '{}' --json
|
|
27
51
|
```
|
|
28
52
|
|
|
29
53
|
From a repository checkout, use `npm ci`, `npm run build`, and
|
|
@@ -35,6 +59,10 @@ assistant, agent, workflow, or schedule commands.
|
|
|
35
59
|
|
|
36
60
|
Node.js 20 or newer is required. Interactive use authenticates with Clerk OAuth Authorization Code + PKCE. CI can set `SCRAPPYCOCO_API_KEY`.
|
|
37
61
|
|
|
62
|
+
OAuth and API requests have bounded timeouts. Set
|
|
63
|
+
`SCRAPPYCOCO_AUTH_TIMEOUT_MS` or `SCRAPPYCOCO_API_TIMEOUT_MS` only when a slow
|
|
64
|
+
network requires a longer window.
|
|
65
|
+
|
|
38
66
|
Use `--json` for machine-readable responses. Execution commands support
|
|
39
67
|
`--format json|jsonl|csv` with `--output`, provider-native
|
|
40
68
|
`--provider-options`, batch `--concurrency`, and an explicit
|
|
@@ -43,6 +71,15 @@ poll for completion; set `SCRAPPYCOCO_JOB_TIMEOUT_MS` to change the 20-minute
|
|
|
43
71
|
local wait. If a command times out while its job continues, inspect it with
|
|
44
72
|
`scrappycoco jobs get <job-id>`.
|
|
45
73
|
|
|
74
|
+
When provider or configuration choice is uncertain, define 2 to 5 named
|
|
75
|
+
`candidates` in a Discover route. Each candidate has its own `id`, `provider`,
|
|
76
|
+
and `options`, so one discovery can test both `zyte-http` with
|
|
77
|
+
`{"browser_html":false}` and `zyte-browser` with
|
|
78
|
+
`{"browser_html":true}` against the same input.
|
|
79
|
+
|
|
80
|
+
The distributed skill includes a Discovery workbook with complete direct-Run,
|
|
81
|
+
comparison, update, finalization, fallback, and audit-handoff examples.
|
|
82
|
+
|
|
46
83
|
Use `scrappycoco --help` for the complete command reference. See the
|
|
47
84
|
[Scrappycoco API documentation](https://scrappycoco.ai/docs) for the public
|
|
48
85
|
contract.
|
package/dist/index.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { randomUUID as
|
|
4
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
5
5
|
import { readFileSync } from "fs";
|
|
6
6
|
import { Command, CommanderError, Option } from "commander";
|
|
7
7
|
|
|
8
8
|
// src/auth.ts
|
|
9
9
|
import { createHash, randomBytes, timingSafeEqual } from "crypto";
|
|
10
10
|
import { createServer } from "http";
|
|
11
|
+
import { createInterface } from "readline/promises";
|
|
11
12
|
import open2 from "open";
|
|
12
13
|
|
|
13
14
|
// src/errors.ts
|
|
@@ -32,19 +33,25 @@ 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
|
}
|
|
45
49
|
function credentialRefreshLockPath() {
|
|
46
50
|
return `${fallbackCredentialPath()}.refresh.lock`;
|
|
47
51
|
}
|
|
52
|
+
function credentialStatePath() {
|
|
53
|
+
return `${fallbackCredentialPath()}.state`;
|
|
54
|
+
}
|
|
48
55
|
var LOCK_RETRY_MS = 100;
|
|
49
56
|
var LOCK_TIMEOUT_MS = 3e4;
|
|
50
57
|
var LOCK_STALE_MS = 6e4;
|
|
@@ -93,23 +100,89 @@ async function readFallback() {
|
|
|
93
100
|
return null;
|
|
94
101
|
}
|
|
95
102
|
}
|
|
96
|
-
async function
|
|
103
|
+
async function readKeychain() {
|
|
97
104
|
try {
|
|
98
105
|
const value = await getPassword(SERVICE, ACCOUNT);
|
|
99
|
-
|
|
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;
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
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";
|
|
100
160
|
} catch {
|
|
161
|
+
await assertFallbackWritable();
|
|
162
|
+
return "file";
|
|
101
163
|
}
|
|
102
|
-
|
|
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 });
|
|
103
172
|
}
|
|
104
173
|
async function saveRefreshToken(refreshToken) {
|
|
105
174
|
try {
|
|
106
175
|
await setPassword(SERVICE, ACCOUNT, refreshToken);
|
|
176
|
+
await rm(fallbackCredentialPath(), { force: true });
|
|
177
|
+
await writeCredentialState("keychain");
|
|
107
178
|
return "keychain";
|
|
108
179
|
} catch {
|
|
109
|
-
|
|
110
|
-
await
|
|
111
|
-
|
|
112
|
-
|
|
180
|
+
await writePrivateJson(fallbackCredentialPath(), { refresh_token: refreshToken });
|
|
181
|
+
await writeCredentialState("file");
|
|
182
|
+
try {
|
|
183
|
+
await deletePassword(SERVICE, ACCOUNT);
|
|
184
|
+
} catch {
|
|
185
|
+
}
|
|
113
186
|
return "file";
|
|
114
187
|
}
|
|
115
188
|
}
|
|
@@ -119,11 +192,42 @@ async function clearRefreshToken() {
|
|
|
119
192
|
} catch {
|
|
120
193
|
}
|
|
121
194
|
await rm(fallbackCredentialPath(), { force: true });
|
|
195
|
+
await writeCredentialState("none");
|
|
122
196
|
}
|
|
123
197
|
|
|
124
198
|
// src/auth.ts
|
|
125
199
|
var cachedAccessToken;
|
|
126
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
|
+
}
|
|
127
231
|
async function oauthConfig(apiUrl) {
|
|
128
232
|
const clientId = process.env.SCRAPPYCOCO_OAUTH_CLIENT_ID;
|
|
129
233
|
if (clientId) {
|
|
@@ -133,7 +237,12 @@ async function oauthConfig(apiUrl) {
|
|
|
133
237
|
scopes: ["openid", "profile", "email"]
|
|
134
238
|
};
|
|
135
239
|
}
|
|
136
|
-
const response = await
|
|
240
|
+
const response = await oauthFetch(
|
|
241
|
+
`${apiUrl.replace(/\/$/, "")}/api/v1/oauth/cli-config`,
|
|
242
|
+
{},
|
|
243
|
+
"configuration request",
|
|
244
|
+
EXIT.api
|
|
245
|
+
);
|
|
137
246
|
const payload = await response.json().catch(() => ({}));
|
|
138
247
|
if (!response.ok || !payload.issuer || !payload.client_id || !Array.isArray(payload.scopes)) {
|
|
139
248
|
throw new CliError(payload.detail || "Scrappycoco CLI OAuth is not configured.", EXIT.auth);
|
|
@@ -156,49 +265,99 @@ function statesMatch(expected, received) {
|
|
|
156
265
|
const right = Buffer.from(received);
|
|
157
266
|
return left.length === right.length && timingSafeEqual(left, right);
|
|
158
267
|
}
|
|
159
|
-
async function tokenRequest(issuer, body) {
|
|
160
|
-
const response = await
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
+
);
|
|
165
279
|
const payload = await response.json().catch(() => ({}));
|
|
166
280
|
if (!response.ok || typeof payload.access_token !== "string") {
|
|
167
|
-
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
|
+
);
|
|
168
290
|
}
|
|
169
291
|
return payload;
|
|
170
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
|
+
}
|
|
171
312
|
async function login(options) {
|
|
172
313
|
const config = await oauthConfig(options.apiUrl);
|
|
173
314
|
const clientId = config.client_id;
|
|
174
315
|
const pkce = createPkce();
|
|
175
316
|
let timeout;
|
|
317
|
+
let manualReader;
|
|
176
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
|
+
};
|
|
177
329
|
const server = createServer((request, response) => {
|
|
178
|
-
const
|
|
179
|
-
if (
|
|
180
|
-
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));
|
|
181
334
|
return;
|
|
182
335
|
}
|
|
183
|
-
const
|
|
184
|
-
const
|
|
185
|
-
if (
|
|
186
|
-
response.writeHead(
|
|
187
|
-
clearTimeout(timeout);
|
|
188
|
-
server.close();
|
|
189
|
-
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");
|
|
190
340
|
return;
|
|
191
341
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
+
}
|
|
198
354
|
});
|
|
199
355
|
server.listen(0, "127.0.0.1", async () => {
|
|
200
356
|
const address = server.address();
|
|
201
|
-
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
|
+
}
|
|
202
361
|
const redirectUri2 = `http://127.0.0.1:${address.port}/callback`;
|
|
203
362
|
const authorize = new URL(`${config.issuer.replace(/\/$/, "")}/oauth/authorize`);
|
|
204
363
|
authorize.search = new URLSearchParams({
|
|
@@ -213,11 +372,32 @@ async function login(options) {
|
|
|
213
372
|
process.stderr.write(`Open this URL to authenticate:
|
|
214
373
|
${authorize.toString()}
|
|
215
374
|
`);
|
|
216
|
-
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
|
+
}
|
|
217
395
|
});
|
|
218
396
|
timeout = setTimeout(() => {
|
|
219
|
-
|
|
220
|
-
|
|
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
|
+
));
|
|
221
401
|
}, 5 * 6e4);
|
|
222
402
|
});
|
|
223
403
|
const { code, redirectUri } = await callback;
|
|
@@ -227,7 +407,7 @@ ${authorize.toString()}
|
|
|
227
407
|
code,
|
|
228
408
|
code_verifier: pkce.verifier,
|
|
229
409
|
redirect_uri: redirectUri
|
|
230
|
-
}));
|
|
410
|
+
}), "authorization-code exchange");
|
|
231
411
|
if (!tokens.refresh_token) throw new CliError("OAuth response did not include a refresh token.", EXIT.auth);
|
|
232
412
|
invalidateAccessToken();
|
|
233
413
|
return { storage: await saveRefreshToken(tokens.refresh_token) };
|
|
@@ -251,16 +431,29 @@ async function accessToken(apiUrl) {
|
|
|
251
431
|
return cachedAccessToken.value;
|
|
252
432
|
}
|
|
253
433
|
const config = await oauthConfig(normalizedApiUrl);
|
|
254
|
-
const
|
|
255
|
-
if (!
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
+
}
|
|
263
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);
|
|
264
457
|
cachedAccessToken = {
|
|
265
458
|
apiUrl: normalizedApiUrl,
|
|
266
459
|
value: tokens.access_token,
|
|
@@ -277,14 +470,18 @@ async function accessToken(apiUrl) {
|
|
|
277
470
|
}
|
|
278
471
|
}
|
|
279
472
|
}
|
|
473
|
+
async function loadRefreshToken() {
|
|
474
|
+
return (await loadRefreshTokenCandidates())[0]?.value || null;
|
|
475
|
+
}
|
|
280
476
|
|
|
281
477
|
// src/client.ts
|
|
282
|
-
import { randomUUID } from "crypto";
|
|
478
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
283
479
|
var DEFAULT_API_URL = process.env.SCRAPPYCOCO_API_URL || "https://api.scrappycoco.ai";
|
|
284
480
|
var DEFAULT_JOB_TIMEOUT_MS = 20 * 60 * 1e3;
|
|
285
481
|
var DEFAULT_JOB_POLL_INITIAL_MS = 250;
|
|
286
482
|
var DEFAULT_JOB_POLL_MAX_MS = 5e3;
|
|
287
|
-
|
|
483
|
+
var DEFAULT_API_REQUEST_TIMEOUT_MS = 3e4;
|
|
484
|
+
function positiveInteger2(value, fallback) {
|
|
288
485
|
if (!value) return fallback;
|
|
289
486
|
const parsed = Number(value);
|
|
290
487
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
@@ -315,12 +512,28 @@ var ApiClient = class {
|
|
|
315
512
|
async request(method, path, body, headers, signal) {
|
|
316
513
|
const url = `${this.baseUrl.replace(/\/$/, "")}/api/v1${path}`;
|
|
317
514
|
const requestBody = body === void 0 ? void 0 : JSON.stringify(body);
|
|
318
|
-
const send = async () =>
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
signal
|
|
323
|
-
|
|
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
|
+
};
|
|
324
537
|
let response = await send();
|
|
325
538
|
if (response.status === 401 && !process.env.SCRAPPYCOCO_API_KEY) {
|
|
326
539
|
invalidateAccessToken();
|
|
@@ -338,17 +551,17 @@ var ApiClient = class {
|
|
|
338
551
|
get(path, signal) {
|
|
339
552
|
return this.request("GET", path, void 0, void 0, signal);
|
|
340
553
|
}
|
|
341
|
-
post(path, body, key =
|
|
554
|
+
post(path, body, key = randomUUID2()) {
|
|
342
555
|
return this.request("POST", path, body, { "Idempotency-Key": key });
|
|
343
556
|
}
|
|
344
|
-
async postJob(path, body, key =
|
|
557
|
+
async postJob(path, body, key = randomUUID2()) {
|
|
345
558
|
const submitted = await this.post(path, body, key);
|
|
346
|
-
const timeoutMs =
|
|
347
|
-
const initialDelayMs =
|
|
559
|
+
const timeoutMs = positiveInteger2(process.env.SCRAPPYCOCO_JOB_TIMEOUT_MS, DEFAULT_JOB_TIMEOUT_MS);
|
|
560
|
+
const initialDelayMs = positiveInteger2(
|
|
348
561
|
process.env.SCRAPPYCOCO_JOB_POLL_INITIAL_MS,
|
|
349
562
|
DEFAULT_JOB_POLL_INITIAL_MS
|
|
350
563
|
);
|
|
351
|
-
const maxDelayMs =
|
|
564
|
+
const maxDelayMs = positiveInteger2(process.env.SCRAPPYCOCO_JOB_POLL_MAX_MS, DEFAULT_JOB_POLL_MAX_MS);
|
|
352
565
|
const deadline = Date.now() + timeoutMs;
|
|
353
566
|
let delayMs = Math.min(initialDelayMs, maxDelayMs);
|
|
354
567
|
let job = submitted;
|
|
@@ -458,7 +671,7 @@ import { determineAgent } from "@vercel/detect-agent";
|
|
|
458
671
|
var SKILL_SOURCE = "https://scrappycoco.ai";
|
|
459
672
|
var SKILL_INSTALL_ARGS = [
|
|
460
673
|
"--yes",
|
|
461
|
-
"skills",
|
|
674
|
+
"skills@latest",
|
|
462
675
|
"add",
|
|
463
676
|
SKILL_SOURCE,
|
|
464
677
|
"--skill",
|
|
@@ -490,7 +703,20 @@ async function detectSkillInstallerAgent(detector = determineAgent) {
|
|
|
490
703
|
if (!detected.isAgent || !detected.agent) return "universal";
|
|
491
704
|
return SKILL_INSTALLER_AGENTS[detected.agent.name] || "universal";
|
|
492
705
|
}
|
|
493
|
-
function skillInstallInvocation(agent = "universal") {
|
|
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@latest",
|
|
714
|
+
...SKILL_INSTALL_ARGS.slice(2),
|
|
715
|
+
"--agent",
|
|
716
|
+
agent
|
|
717
|
+
]
|
|
718
|
+
};
|
|
719
|
+
}
|
|
494
720
|
return {
|
|
495
721
|
command: npxCommand(),
|
|
496
722
|
args: [...SKILL_INSTALL_ARGS, "--agent", agent]
|
|
@@ -558,6 +784,7 @@ function defaultDependencies(apiUrl) {
|
|
|
558
784
|
clearRefreshToken: clearRefreshToken2,
|
|
559
785
|
loadRefreshToken,
|
|
560
786
|
login,
|
|
787
|
+
prepareCredentialStorage,
|
|
561
788
|
installSkill,
|
|
562
789
|
listAvailableScrapers: () => client2.get("/scrapers?available_only=true")
|
|
563
790
|
};
|
|
@@ -567,10 +794,15 @@ async function performSetup(options, dependencies = defaultDependencies(options.
|
|
|
567
794
|
let authentication = usingApiKey ? "api_key" : "oauth";
|
|
568
795
|
let credentialStorage = usingApiKey ? "environment" : "existing";
|
|
569
796
|
const hadStoredToken = !usingApiKey && Boolean(await dependencies.loadRefreshToken());
|
|
797
|
+
if (!usingApiKey && !hadStoredToken) {
|
|
798
|
+
await dependencies.prepareCredentialStorage();
|
|
799
|
+
}
|
|
800
|
+
await dependencies.installSkill();
|
|
570
801
|
if (!usingApiKey && !hadStoredToken) {
|
|
571
802
|
const authenticated = await dependencies.login({
|
|
572
803
|
apiUrl: options.apiUrl,
|
|
573
|
-
noBrowser: options.noBrowser
|
|
804
|
+
noBrowser: options.noBrowser,
|
|
805
|
+
manualCallback: options.manualCallback
|
|
574
806
|
});
|
|
575
807
|
authentication = "oauth";
|
|
576
808
|
credentialStorage = authenticated.storage;
|
|
@@ -585,12 +817,12 @@ async function performSetup(options, dependencies = defaultDependencies(options.
|
|
|
585
817
|
await dependencies.clearRefreshToken();
|
|
586
818
|
const authenticated = await dependencies.login({
|
|
587
819
|
apiUrl: options.apiUrl,
|
|
588
|
-
noBrowser: options.noBrowser
|
|
820
|
+
noBrowser: options.noBrowser,
|
|
821
|
+
manualCallback: options.manualCallback
|
|
589
822
|
});
|
|
590
823
|
credentialStorage = authenticated.storage;
|
|
591
824
|
catalog2 = await dependencies.listAvailableScrapers();
|
|
592
825
|
}
|
|
593
|
-
await dependencies.installSkill();
|
|
594
826
|
return {
|
|
595
827
|
authenticated: true,
|
|
596
828
|
authentication,
|
|
@@ -607,6 +839,185 @@ async function performSetup(options, dependencies = defaultDependencies(options.
|
|
|
607
839
|
};
|
|
608
840
|
}
|
|
609
841
|
|
|
842
|
+
// src/skill-update.ts
|
|
843
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
844
|
+
import { access, chmod as chmod2, mkdir as mkdir2, open as open3, readFile as readFile3, rename as rename2, rm as rm2, stat as stat2, writeFile as writeFile3 } from "fs/promises";
|
|
845
|
+
import { homedir as homedir2 } from "os";
|
|
846
|
+
import { dirname as dirname2, join as join2 } from "path";
|
|
847
|
+
var SKILL_NAME = "scrappycoco";
|
|
848
|
+
var SKILL_INDEX_URL = `${SKILL_SOURCE}/.well-known/agent-skills/index.json`;
|
|
849
|
+
var SKILL_UPDATE_TIMEOUT_MS = 3e3;
|
|
850
|
+
var SKILL_UPDATE_LOCK_STALE_MS = 5 * 6e4;
|
|
851
|
+
var SHA256_DIGEST = /^sha256:[a-f0-9]{64}$/i;
|
|
852
|
+
function skillReleaseStatePath() {
|
|
853
|
+
return join2(dirname2(fallbackCredentialPath()), "skill-release.json");
|
|
854
|
+
}
|
|
855
|
+
function skillUpdateLockPath() {
|
|
856
|
+
return `${skillReleaseStatePath()}.lock`;
|
|
857
|
+
}
|
|
858
|
+
function installedSkillCandidates() {
|
|
859
|
+
return [
|
|
860
|
+
join2(homedir2(), ".agents", "skills", SKILL_NAME, "SKILL.md"),
|
|
861
|
+
join2(homedir2(), ".codex", "skills", SKILL_NAME, "SKILL.md"),
|
|
862
|
+
join2(homedir2(), ".claude", "skills", SKILL_NAME, "SKILL.md"),
|
|
863
|
+
join2(homedir2(), ".cursor", "skills", SKILL_NAME, "SKILL.md")
|
|
864
|
+
];
|
|
865
|
+
}
|
|
866
|
+
async function isSkillInstalled() {
|
|
867
|
+
for (const path of installedSkillCandidates()) {
|
|
868
|
+
try {
|
|
869
|
+
await access(path);
|
|
870
|
+
return true;
|
|
871
|
+
} catch {
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
return false;
|
|
875
|
+
}
|
|
876
|
+
async function loadInstalledDigest() {
|
|
877
|
+
try {
|
|
878
|
+
const state = JSON.parse(
|
|
879
|
+
await readFile3(skillReleaseStatePath(), "utf8")
|
|
880
|
+
);
|
|
881
|
+
return typeof state.digest === "string" && SHA256_DIGEST.test(state.digest) ? state.digest : null;
|
|
882
|
+
} catch {
|
|
883
|
+
return null;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
async function writePrivateJson2(path, value) {
|
|
887
|
+
await mkdir2(dirname2(path), { recursive: true, mode: 448 });
|
|
888
|
+
const temporary = `${path}.${process.pid}.${randomUUID3()}.tmp`;
|
|
889
|
+
try {
|
|
890
|
+
await writeFile3(temporary, JSON.stringify(value), { mode: 384 });
|
|
891
|
+
if (process.platform !== "win32") await chmod2(temporary, 384);
|
|
892
|
+
await rename2(temporary, path);
|
|
893
|
+
if (process.platform !== "win32") await chmod2(path, 384);
|
|
894
|
+
} finally {
|
|
895
|
+
await rm2(temporary, { force: true });
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
async function saveInstalledDigest(digest) {
|
|
899
|
+
await writePrivateJson2(skillReleaseStatePath(), {
|
|
900
|
+
digest,
|
|
901
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
async function fetchPublishedSkillDigest(fetcher = fetch) {
|
|
905
|
+
const response = await fetcher(SKILL_INDEX_URL, {
|
|
906
|
+
headers: { accept: "application/json" },
|
|
907
|
+
signal: AbortSignal.timeout(SKILL_UPDATE_TIMEOUT_MS)
|
|
908
|
+
});
|
|
909
|
+
if (!response.ok) {
|
|
910
|
+
throw new Error(`Skill release check returned HTTP ${response.status}.`);
|
|
911
|
+
}
|
|
912
|
+
const index = await response.json();
|
|
913
|
+
const entry = index.skills?.find((skill) => skill.name === SKILL_NAME);
|
|
914
|
+
if (!entry || typeof entry.digest !== "string" || !SHA256_DIGEST.test(entry.digest)) {
|
|
915
|
+
throw new Error("Skill release index did not contain a valid Scrappycoco digest.");
|
|
916
|
+
}
|
|
917
|
+
return entry.digest.toLowerCase();
|
|
918
|
+
}
|
|
919
|
+
function defaultDependencies2() {
|
|
920
|
+
return {
|
|
921
|
+
isInstalled: isSkillInstalled,
|
|
922
|
+
loadInstalledDigest,
|
|
923
|
+
fetchPublishedDigest: fetchPublishedSkillDigest,
|
|
924
|
+
install: installSkill,
|
|
925
|
+
saveInstalledDigest
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
async function checkAndInstallSkillUpdate(dependencies = defaultDependencies2()) {
|
|
929
|
+
if (!await dependencies.isInstalled()) return { status: "not_installed" };
|
|
930
|
+
let publishedDigest;
|
|
931
|
+
try {
|
|
932
|
+
publishedDigest = await dependencies.fetchPublishedDigest();
|
|
933
|
+
} catch {
|
|
934
|
+
return { status: "unavailable" };
|
|
935
|
+
}
|
|
936
|
+
if (await dependencies.loadInstalledDigest() === publishedDigest) {
|
|
937
|
+
return { status: "current" };
|
|
938
|
+
}
|
|
939
|
+
try {
|
|
940
|
+
await dependencies.install();
|
|
941
|
+
} catch (error) {
|
|
942
|
+
return {
|
|
943
|
+
status: "failed",
|
|
944
|
+
error: error instanceof Error ? error : new Error(String(error))
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
let stateSaved = true;
|
|
948
|
+
try {
|
|
949
|
+
await dependencies.saveInstalledDigest(publishedDigest);
|
|
950
|
+
} catch {
|
|
951
|
+
stateSaved = false;
|
|
952
|
+
}
|
|
953
|
+
return { status: "updated", digest: publishedDigest, state_saved: stateSaved };
|
|
954
|
+
}
|
|
955
|
+
async function withSkillUpdateLock(operation) {
|
|
956
|
+
const path = skillUpdateLockPath();
|
|
957
|
+
await mkdir2(dirname2(path), { recursive: true, mode: 448 });
|
|
958
|
+
let handle;
|
|
959
|
+
try {
|
|
960
|
+
handle = await open3(path, "wx", 384);
|
|
961
|
+
} catch (error) {
|
|
962
|
+
if (error.code !== "EEXIST") {
|
|
963
|
+
return { status: "unavailable" };
|
|
964
|
+
}
|
|
965
|
+
try {
|
|
966
|
+
const lock = await stat2(path);
|
|
967
|
+
if (Date.now() - lock.mtimeMs <= SKILL_UPDATE_LOCK_STALE_MS) {
|
|
968
|
+
return { status: "busy" };
|
|
969
|
+
}
|
|
970
|
+
await rm2(path, { force: true });
|
|
971
|
+
handle = await open3(path, "wx", 384);
|
|
972
|
+
} catch {
|
|
973
|
+
return { status: "busy" };
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
try {
|
|
977
|
+
await handle.writeFile(JSON.stringify({ pid: process.pid, created_at: Date.now() }));
|
|
978
|
+
return await operation();
|
|
979
|
+
} finally {
|
|
980
|
+
await handle.close();
|
|
981
|
+
await rm2(path, { force: true });
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
function autoUpdateDisabled() {
|
|
985
|
+
return ["1", "true", "yes"].includes(
|
|
986
|
+
(process.env.SCRAPPYCOCO_DISABLE_SKILL_AUTO_UPDATE || "").toLowerCase()
|
|
987
|
+
);
|
|
988
|
+
}
|
|
989
|
+
async function autoUpdateInstalledSkill() {
|
|
990
|
+
if (autoUpdateDisabled()) return { status: "disabled" };
|
|
991
|
+
let result;
|
|
992
|
+
try {
|
|
993
|
+
result = await withSkillUpdateLock(() => checkAndInstallSkillUpdate());
|
|
994
|
+
} catch {
|
|
995
|
+
return { status: "unavailable" };
|
|
996
|
+
}
|
|
997
|
+
if (result.status === "updated") {
|
|
998
|
+
process.stderr.write(
|
|
999
|
+
"Updated the Scrappycoco skill. Reload or restart the agent to use the new instructions.\n"
|
|
1000
|
+
);
|
|
1001
|
+
if (!result.state_saved) {
|
|
1002
|
+
process.stderr.write(
|
|
1003
|
+
"Warning: the skill update succeeded, but its release state could not be saved.\n"
|
|
1004
|
+
);
|
|
1005
|
+
}
|
|
1006
|
+
} else if (result.status === "failed") {
|
|
1007
|
+
process.stderr.write(
|
|
1008
|
+
`Warning: could not update the Scrappycoco skill; continuing with the installed copy: ${result.error.message}
|
|
1009
|
+
`
|
|
1010
|
+
);
|
|
1011
|
+
}
|
|
1012
|
+
return result;
|
|
1013
|
+
}
|
|
1014
|
+
async function rememberInstalledSkillRelease() {
|
|
1015
|
+
try {
|
|
1016
|
+
await saveInstalledDigest(await fetchPublishedSkillDigest());
|
|
1017
|
+
} catch {
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
|
|
610
1021
|
// src/index.ts
|
|
611
1022
|
var packageMetadata = JSON.parse(
|
|
612
1023
|
readFileSync(new URL("../package.json", import.meta.url), "utf8")
|
|
@@ -622,13 +1033,19 @@ function client(command) {
|
|
|
622
1033
|
function collect(value, previous) {
|
|
623
1034
|
return [...previous, value];
|
|
624
1035
|
}
|
|
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) => {
|
|
1036
|
+
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) => {
|
|
626
1037
|
const result = await performSetup({
|
|
627
1038
|
apiUrl: globals(command).apiUrl,
|
|
628
|
-
noBrowser: options.browser === false
|
|
1039
|
+
noBrowser: options.browser === false,
|
|
1040
|
+
manualCallback: options.manualCallback
|
|
629
1041
|
});
|
|
1042
|
+
await rememberInstalledSkillRelease();
|
|
630
1043
|
await emit(result, globals(command).json || false);
|
|
631
1044
|
});
|
|
1045
|
+
program.hook("preAction", async (_thisCommand, actionCommand) => {
|
|
1046
|
+
if (actionCommand.name() === "setup") return;
|
|
1047
|
+
await autoUpdateInstalledSkill();
|
|
1048
|
+
});
|
|
632
1049
|
function splitScraperId(value) {
|
|
633
1050
|
const separator = value.indexOf(".");
|
|
634
1051
|
if (separator <= 0 || separator === value.length - 1) {
|
|
@@ -664,8 +1081,12 @@ async function emitExecution(response, options, command) {
|
|
|
664
1081
|
await emit(response, jsonMode);
|
|
665
1082
|
}
|
|
666
1083
|
var auth = program.command("auth").description("Manage Clerk OAuth credentials");
|
|
667
|
-
auth.command("login").option("--no-browser", "print the authorization URL without opening it").action(async (options, command) => {
|
|
668
|
-
const result = await login({
|
|
1084
|
+
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) => {
|
|
1085
|
+
const result = await login({
|
|
1086
|
+
noBrowser: options.browser === false,
|
|
1087
|
+
manualCallback: options.manualCallback,
|
|
1088
|
+
apiUrl: globals(command).apiUrl
|
|
1089
|
+
});
|
|
669
1090
|
await emit({ authenticated: true, storage: result.storage }, globals(command).json || false);
|
|
670
1091
|
});
|
|
671
1092
|
auth.command("status").action(async (_options, command) => {
|
|
@@ -676,7 +1097,24 @@ auth.command("status").action(async (_options, command) => {
|
|
|
676
1097
|
process.exitCode = EXIT.auth;
|
|
677
1098
|
return;
|
|
678
1099
|
}
|
|
679
|
-
|
|
1100
|
+
let me;
|
|
1101
|
+
try {
|
|
1102
|
+
me = await client(command).get("/me");
|
|
1103
|
+
} catch (error) {
|
|
1104
|
+
if (error instanceof CliError && error.exitCode === EXIT.auth) {
|
|
1105
|
+
await emit(
|
|
1106
|
+
{
|
|
1107
|
+
authenticated: false,
|
|
1108
|
+
reason: error.message,
|
|
1109
|
+
next_action: usingApiKey ? "Verify or replace the existing SCRAPPYCOCO_API_KEY environment variable." : "Run `scrappycoco auth login` to replace the stored OAuth login."
|
|
1110
|
+
},
|
|
1111
|
+
globals(command).json || false
|
|
1112
|
+
);
|
|
1113
|
+
process.exitCode = EXIT.auth;
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
throw error;
|
|
1117
|
+
}
|
|
680
1118
|
await emit(
|
|
681
1119
|
{ authenticated: true, method: usingApiKey ? "api_key" : "oauth", account: me },
|
|
682
1120
|
globals(command).json || false
|
|
@@ -731,14 +1169,14 @@ function executionCommand(name) {
|
|
|
731
1169
|
const response = await client(command).postJob(
|
|
732
1170
|
name === "run" ? "/scrapers/jobs" : "/scrapers/compare/jobs",
|
|
733
1171
|
payload,
|
|
734
|
-
options.idempotencyKey ||
|
|
1172
|
+
options.idempotencyKey || randomUUID4()
|
|
735
1173
|
);
|
|
736
1174
|
await emitExecution(response, options, command);
|
|
737
1175
|
});
|
|
738
1176
|
}
|
|
739
1177
|
executionCommand("run");
|
|
740
1178
|
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>", "
|
|
1179
|
+
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) => {
|
|
742
1180
|
if (options.retryFailed) {
|
|
743
1181
|
if (capabilityId || options.config) {
|
|
744
1182
|
throw new CliError("Do not combine --retry-failed with a capability ID or --config.", EXIT.usage);
|
|
@@ -746,7 +1184,7 @@ program.command("run [capability-id]").description("Run a capability directly; d
|
|
|
746
1184
|
const response2 = await client(command).postJob(
|
|
747
1185
|
`/runs/${encodeURIComponent(options.retryFailed)}/retry-failed`,
|
|
748
1186
|
{},
|
|
749
|
-
options.idempotencyKey ||
|
|
1187
|
+
options.idempotencyKey || randomUUID4()
|
|
750
1188
|
);
|
|
751
1189
|
await emitExecution(response2, options, command);
|
|
752
1190
|
return;
|
|
@@ -764,7 +1202,7 @@ program.command("run [capability-id]").description("Run a capability directly; d
|
|
|
764
1202
|
input,
|
|
765
1203
|
limit: Number(options.limit ?? fromFile.limit ?? 25)
|
|
766
1204
|
},
|
|
767
|
-
options.idempotencyKey ||
|
|
1205
|
+
options.idempotencyKey || randomUUID4()
|
|
768
1206
|
);
|
|
769
1207
|
await emitExecution(response2, options, command);
|
|
770
1208
|
return;
|
|
@@ -777,7 +1215,7 @@ program.command("run [capability-id]").description("Run a capability directly; d
|
|
|
777
1215
|
const response = await client(command).postJob(
|
|
778
1216
|
"/scrapers/jobs",
|
|
779
1217
|
payload,
|
|
780
|
-
options.idempotencyKey ||
|
|
1218
|
+
options.idempotencyKey || randomUUID4()
|
|
781
1219
|
);
|
|
782
1220
|
await emitExecution(response, options, command);
|
|
783
1221
|
});
|
|
@@ -832,7 +1270,7 @@ discoveries.command("run <discovery-id>").option("-f, --file <path>", "request J
|
|
|
832
1270
|
const response = await client(command).postJob(
|
|
833
1271
|
`/discoveries/${encodeURIComponent(discoveryId)}/jobs`,
|
|
834
1272
|
payload,
|
|
835
|
-
options.idempotencyKey ||
|
|
1273
|
+
options.idempotencyKey || randomUUID4()
|
|
836
1274
|
);
|
|
837
1275
|
await emitExecution(response, options, command);
|
|
838
1276
|
});
|
|
@@ -880,7 +1318,7 @@ program.command("discover").description("Save, sample-test, or finalize an agent
|
|
|
880
1318
|
const response = await client(command).postJob(
|
|
881
1319
|
`/discoveries/${encodeURIComponent(options.id)}/jobs`,
|
|
882
1320
|
{ input, limit: 25 },
|
|
883
|
-
options.idempotencyKey ||
|
|
1321
|
+
options.idempotencyKey || randomUUID4()
|
|
884
1322
|
);
|
|
885
1323
|
await emit(response, globals(command).json || false);
|
|
886
1324
|
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scrappycoco/cli",
|
|
3
|
-
"version": "0.4.
|
|
4
|
-
"description": "CLI for Scrappycoco scraper discovery
|
|
3
|
+
"version": "0.4.2",
|
|
4
|
+
"description": "CLI for Scrappycoco scraper discovery and execution",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"scrappycoco": "dist/index.js"
|