@scrappycoco/cli 0.8.2 → 0.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +12 -4
  2. package/dist/index.js +140 -93
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -17,10 +17,15 @@ the live catalog, and tells you when to reload or restart your agent. Browser
17
17
  authorization is not complete until the terminal confirms that the credential
18
18
  was saved and the catalog check succeeded.
19
19
 
20
- Ordinary commands never mutate installed agent instructions. Setup installs
21
- the matching skill release. Run `scrappycoco skill status` to inspect the
22
- active target and `scrappycoco skill update` to explicitly install a newer
23
- hash-verified release, then reload or restart the agent.
20
+ Setup installs the matching skill release. On the first ordinary CLI command
21
+ after the 24-hour cooldown, Scrappycoco compares the installed release with the
22
+ hash-verified public feed and installs a changed skill automatically. The data
23
+ command continues when the check or installer is unavailable. Reload or restart
24
+ the agent after an update notice so it reads the new instructions. Run
25
+ `scrappycoco skill status` to inspect the active target and update state, or
26
+ `scrappycoco skill update` to check immediately. Set
27
+ `SCRAPPYCOCO_SKILL_AUTO_UPDATE=0` to keep the installed copy pinned and use only
28
+ explicit updates.
24
29
 
25
30
  When the CLI runs on a remote or headless host whose `127.0.0.1` is not the
26
31
  browser's localhost, keep the command running and use the manual callback
@@ -61,6 +66,9 @@ agent, general workflow, or scheduler commands. For repeated checks, callers
61
66
  schedule ordinary Runs and pass each returned cursor into the next Run.
62
67
 
63
68
  Node.js 20 or newer is required. Interactive use authenticates with Clerk OAuth Authorization Code + PKCE. CI can set `SCRAPPYCOCO_API_KEY`.
69
+ API keys have no refresh-token rotation step and are preferred for unattended
70
+ concurrent application workloads. OAuth refresh is serialized across CLI
71
+ processes and recovers if another process rotates the stored token first.
64
72
 
65
73
  OAuth and API requests have bounded timeouts. Set
66
74
  `SCRAPPYCOCO_AUTH_TIMEOUT_MS` or `SCRAPPYCOCO_API_TIMEOUT_MS` only when a slow
package/dist/index.js CHANGED
@@ -60,17 +60,23 @@ function wait(milliseconds) {
60
60
  }
61
61
  async function withCredentialRefreshLock(operation) {
62
62
  const path = credentialRefreshLockPath();
63
+ const owner = randomUUID();
63
64
  await mkdir(dirname(path), { recursive: true, mode: 448 });
64
65
  const deadline = Date.now() + LOCK_TIMEOUT_MS;
65
66
  while (true) {
66
67
  try {
67
68
  const handle = await open(path, "wx", 384);
68
69
  try {
69
- await handle.writeFile(JSON.stringify({ pid: process.pid, created_at: Date.now() }));
70
+ await handle.writeFile(JSON.stringify({ owner, pid: process.pid, created_at: Date.now() }));
70
71
  return await operation();
71
72
  } finally {
72
73
  await handle.close();
73
- await rm(path, { force: true });
74
+ try {
75
+ const current = JSON.parse(await readFile(path, "utf8"));
76
+ if (current.owner === owner) await rm(path, { force: true });
77
+ } catch (error) {
78
+ if (error.code !== "ENOENT") throw error;
79
+ }
74
80
  }
75
81
  } catch (error) {
76
82
  const code = error.code;
@@ -199,6 +205,8 @@ async function clearRefreshToken() {
199
205
  var cachedAccessToken;
200
206
  var refreshesInFlight = /* @__PURE__ */ new Map();
201
207
  var DEFAULT_AUTH_HTTP_TIMEOUT_MS = 15e3;
208
+ var ROTATED_TOKEN_RELOAD_ATTEMPTS = 10;
209
+ var ROTATED_TOKEN_RELOAD_DELAY_MS = 100;
202
210
  function positiveInteger(value, fallback) {
203
211
  if (!value) return fallback;
204
212
  const parsed = Number(value);
@@ -213,6 +221,12 @@ function networkMessage(error) {
213
221
  }
214
222
  return "failed to reach the authentication service";
215
223
  }
224
+ function isInvalidGrant(error) {
225
+ return error instanceof CliError && error.exitCode === EXIT.auth && typeof error.details === "object" && error.details !== null && "oauth_error" in error.details && error.details.oauth_error === "invalid_grant";
226
+ }
227
+ function wait2(milliseconds) {
228
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
229
+ }
216
230
  async function oauthFetch(url, init, phase, exitCode) {
217
231
  try {
218
232
  return await fetch(url, {
@@ -436,7 +450,9 @@ async function accessToken(apiUrl) {
436
450
  let tokens;
437
451
  let acceptedRefreshToken;
438
452
  let lastAuthError;
453
+ const attemptedRefreshTokens = /* @__PURE__ */ new Set();
439
454
  for (const candidate of candidates) {
455
+ attemptedRefreshTokens.add(candidate.value);
440
456
  try {
441
457
  tokens = await tokenRequest(config.issuer, new URLSearchParams({
442
458
  grant_type: "refresh_token",
@@ -452,6 +468,28 @@ async function accessToken(apiUrl) {
452
468
  lastAuthError = error;
453
469
  }
454
470
  }
471
+ if (!tokens && isInvalidGrant(lastAuthError)) {
472
+ for (let attempt = 0; attempt < ROTATED_TOKEN_RELOAD_ATTEMPTS && !tokens; attempt += 1) {
473
+ await wait2(ROTATED_TOKEN_RELOAD_DELAY_MS);
474
+ const rotatedCandidates = await loadRefreshTokenCandidates();
475
+ const rotated = rotatedCandidates.find(
476
+ (candidate) => !attemptedRefreshTokens.has(candidate.value)
477
+ );
478
+ if (!rotated) continue;
479
+ attemptedRefreshTokens.add(rotated.value);
480
+ try {
481
+ tokens = await tokenRequest(config.issuer, new URLSearchParams({
482
+ grant_type: "refresh_token",
483
+ client_id: config.client_id,
484
+ refresh_token: rotated.value
485
+ }), "token refresh");
486
+ acceptedRefreshToken = rotated.value;
487
+ } catch (error) {
488
+ if (!isInvalidGrant(error)) throw error;
489
+ lastAuthError = error;
490
+ }
491
+ }
492
+ }
455
493
  if (!tokens || !acceptedRefreshToken) throw lastAuthError || new CliError("Stored OAuth login is no longer valid.", EXIT.auth);
456
494
  await saveRefreshToken(tokens.refresh_token || acceptedRefreshToken);
457
495
  cachedAccessToken = {
@@ -861,7 +899,10 @@ var SKILL_NAME = "scrappycoco";
861
899
  var SKILL_INDEX_URL = `${SKILL_SOURCE}/.well-known/agent-skills/index.json`;
862
900
  var SKILL_UPDATE_TIMEOUT_MS = 3e3;
863
901
  var SKILL_UPDATE_LOCK_STALE_MS = 5 * 6e4;
902
+ var SKILL_AUTO_UPDATE_INTERVAL_MS = 24 * 60 * 6e4;
864
903
  var SHA256_DIGEST = /^sha256:[a-f0-9]{64}$/i;
904
+ var DISABLED_AUTO_UPDATE_VALUES = /* @__PURE__ */ new Set(["0", "false", "no", "off"]);
905
+ var ENABLED_AUTO_UPDATE_VALUES = /* @__PURE__ */ new Set(["1", "true", "yes", "on"]);
865
906
  function skillReleaseStatePath() {
866
907
  return join2(dirname2(fallbackCredentialPath()), "skill-release.json");
867
908
  }
@@ -919,9 +960,21 @@ async function loadInstalledDigest() {
919
960
  const entry = (await loadSkillReleaseState()).targets[target];
920
961
  return entry?.digest || null;
921
962
  }
963
+ async function loadLastCheckedAt() {
964
+ const target = await activeSkillTarget();
965
+ const entry = (await loadSkillReleaseState()).targets[target];
966
+ return entry?.checked_at || entry?.updated_at || null;
967
+ }
968
+ function skillAutoUpdateEnabled(environment = process.env) {
969
+ const configured = environment.SCRAPPYCOCO_SKILL_AUTO_UPDATE?.trim().toLowerCase();
970
+ if (configured && ENABLED_AUTO_UPDATE_VALUES.has(configured)) return true;
971
+ if (configured && DISABLED_AUTO_UPDATE_VALUES.has(configured)) return false;
972
+ return environment.NODE_ENV !== "test" && !environment.VITEST && !ENABLED_AUTO_UPDATE_VALUES.has(environment.CI?.trim().toLowerCase() || "");
973
+ }
922
974
  async function installedSkillDiagnostics() {
923
975
  const target = await activeSkillTarget();
924
976
  const state = await loadSkillReleaseState();
977
+ const activeRelease = state.targets[target];
925
978
  const installedTargets = (await Promise.all(
926
979
  installedSkillCandidates().map(async (path) => ({
927
980
  path,
@@ -930,8 +983,13 @@ async function installedSkillDiagnostics() {
930
983
  )).filter((item) => item.installed);
931
984
  return {
932
985
  installed: installedTargets.some((item) => item.path === target),
933
- digest: state.targets[target]?.digest || null,
986
+ digest: activeRelease?.digest || null,
934
987
  target,
988
+ auto_update: {
989
+ enabled: skillAutoUpdateEnabled(),
990
+ interval_hours: SKILL_AUTO_UPDATE_INTERVAL_MS / (60 * 6e4),
991
+ last_checked_at: activeRelease?.checked_at || activeRelease?.updated_at || null
992
+ },
935
993
  targets: installedTargets.map(({ path }) => ({
936
994
  path,
937
995
  digest: state.targets[path]?.digest || null
@@ -964,11 +1022,38 @@ function recordTargetDigest(state, target, digest, updatedAt = (/* @__PURE__ */
964
1022
  ...state.targets,
965
1023
  [target]: {
966
1024
  digest,
967
- updated_at: updatedAt
1025
+ updated_at: updatedAt,
1026
+ checked_at: updatedAt
1027
+ }
1028
+ }
1029
+ };
1030
+ }
1031
+ function recordTargetCheck(state, target, checkedAt = (/* @__PURE__ */ new Date()).toISOString()) {
1032
+ const release = state.targets[target];
1033
+ if (!release) return state;
1034
+ return {
1035
+ targets: {
1036
+ ...state.targets,
1037
+ [target]: {
1038
+ ...release,
1039
+ checked_at: checkedAt
968
1040
  }
969
1041
  }
970
1042
  };
971
1043
  }
1044
+ async function saveLastCheckedAt(checkedAt) {
1045
+ const target = await activeSkillTarget();
1046
+ const state = await loadSkillReleaseState();
1047
+ await writePrivateJson2(
1048
+ skillReleaseStatePath(),
1049
+ recordTargetCheck(state, target, checkedAt)
1050
+ );
1051
+ }
1052
+ function automaticSkillUpdateDue(lastCheckedAt, now = /* @__PURE__ */ new Date(), intervalMs = SKILL_AUTO_UPDATE_INTERVAL_MS) {
1053
+ if (!lastCheckedAt) return true;
1054
+ const elapsed = now.getTime() - Date.parse(lastCheckedAt);
1055
+ return !Number.isFinite(elapsed) || elapsed < 0 || elapsed >= intervalMs;
1056
+ }
972
1057
  async function fetchPublishedSkillDigest(fetcher = fetch) {
973
1058
  const response = await fetcher(SKILL_INDEX_URL, {
974
1059
  headers: { accept: "application/json" },
@@ -1071,8 +1156,46 @@ async function updateInstalledSkill() {
1071
1156
  `
1072
1157
  );
1073
1158
  }
1159
+ if (result.status === "current" || result.status === "updated" && result.state_saved) {
1160
+ try {
1161
+ await saveLastCheckedAt((/* @__PURE__ */ new Date()).toISOString());
1162
+ } catch {
1163
+ }
1164
+ }
1074
1165
  return result;
1075
1166
  }
1167
+ function defaultAutomaticDependencies() {
1168
+ return {
1169
+ enabled: skillAutoUpdateEnabled,
1170
+ now: () => /* @__PURE__ */ new Date(),
1171
+ loadLastCheckedAt,
1172
+ update: updateInstalledSkill,
1173
+ saveLastCheckedAt
1174
+ };
1175
+ }
1176
+ async function maybeUpdateInstalledSkill(dependencies = defaultAutomaticDependencies(), intervalMs = SKILL_AUTO_UPDATE_INTERVAL_MS) {
1177
+ if (!dependencies.enabled()) return { status: "disabled" };
1178
+ try {
1179
+ const now = dependencies.now();
1180
+ if (!automaticSkillUpdateDue(
1181
+ await dependencies.loadLastCheckedAt(),
1182
+ now,
1183
+ intervalMs
1184
+ )) {
1185
+ return { status: "throttled" };
1186
+ }
1187
+ const result = await dependencies.update();
1188
+ if (result.status === "current" || result.status === "updated") {
1189
+ try {
1190
+ await dependencies.saveLastCheckedAt(now.toISOString());
1191
+ } catch {
1192
+ }
1193
+ }
1194
+ return result;
1195
+ } catch {
1196
+ return { status: "unavailable" };
1197
+ }
1198
+ }
1076
1199
  async function rememberInstalledSkillRelease() {
1077
1200
  try {
1078
1201
  await saveInstalledDigest(await fetchPublishedSkillDigest());
@@ -1140,7 +1263,7 @@ async function requestPayload(options, scraperId) {
1140
1263
  };
1141
1264
  }
1142
1265
  async function emitExecution(response, options, command) {
1143
- const records = "records" in response && Array.isArray(response.records) ? response.records : response.items;
1266
+ const records = response.records;
1144
1267
  const jsonMode = globals(command).json || false;
1145
1268
  if (options.output) {
1146
1269
  await emit(response, jsonMode, options.output, formatRecords(records, options.format));
@@ -1153,15 +1276,16 @@ async function emitExecution(response, options, command) {
1153
1276
  await emit(summary, jsonMode);
1154
1277
  process.stderr.write(`Saved ${records.length} records to ${options.output}
1155
1278
  `);
1279
+ if (response.status === "failed") process.exitCode = EXIT.api;
1156
1280
  return;
1157
1281
  }
1158
1282
  await emit(response, jsonMode);
1283
+ if (response.status === "failed") process.exitCode = EXIT.api;
1159
1284
  }
1160
1285
  function executionSummary(response) {
1161
- const records = "records" in response && Array.isArray(response.records) ? response.records : response.items;
1286
+ const records = response.records;
1162
1287
  const summary = { ...response };
1163
1288
  delete summary.records;
1164
- delete summary.items;
1165
1289
  delete summary.normalized_schema;
1166
1290
  summary.truncated_count = records.filter((record) => {
1167
1291
  const metadata = record.metadata;
@@ -1182,6 +1306,7 @@ function executionSummary(response) {
1182
1306
  "result_count",
1183
1307
  "latency_ms",
1184
1308
  "estimated_cost_usd",
1309
+ "provider_http_status",
1185
1310
  "error"
1186
1311
  ]));
1187
1312
  }
@@ -1196,7 +1321,7 @@ function executionSummary(response) {
1196
1321
  if (Object.keys(counts).length) summary.provider_result_counts = counts;
1197
1322
  delete summary.provider_results;
1198
1323
  }
1199
- if (Array.isArray(response.items)) summary.item_count = response.items.length;
1324
+ summary.item_count = records.length;
1200
1325
  if (Array.isArray(summary.routes)) {
1201
1326
  summary.routes = summary.routes.map((value) => {
1202
1327
  if (!value || typeof value !== "object" || Array.isArray(value)) return value;
@@ -1219,6 +1344,7 @@ function executionSummary(response) {
1219
1344
  "result_count",
1220
1345
  "latency_ms",
1221
1346
  "estimated_cost_usd",
1347
+ "provider_http_status",
1222
1348
  "error"
1223
1349
  ]));
1224
1350
  }
@@ -1340,7 +1466,7 @@ auth.command("logout").action(async (_options, command) => {
1340
1466
  await clearRefreshToken2();
1341
1467
  await emit({ authenticated: false }, globals(command).json || false);
1342
1468
  });
1343
- var skill = program.command("skill").description("Inspect or explicitly update the installed skill");
1469
+ var skill = program.command("skill").description("Inspect or update the installed skill");
1344
1470
  skill.command("status").action(async (_options, command) => {
1345
1471
  await emit(await installedSkillDiagnostics(), globals(command).json || false);
1346
1472
  });
@@ -1396,24 +1522,6 @@ program.command("doctor").description("Check the CLI, authentication, installed
1396
1522
  );
1397
1523
  if (!ok) process.exitCode = authentication.configured ? EXIT.api : EXIT.auth;
1398
1524
  });
1399
- var scrapers = program.command("scrapers", { hidden: true }).description("Legacy scraper commands");
1400
- scrapers.command("list").option("--source <source>", "filter by web, x, reddit, instagram, tiktok, or filings").option("--provider <provider>", "filter by provider implementation").option("--available", "only include available providers").action(async (options, command) => {
1401
- const query = new URLSearchParams();
1402
- if (options.source) query.set("source", options.source);
1403
- if (options.provider) query.set("provider", options.provider);
1404
- if (options.available) query.set("available_only", "true");
1405
- const suffix = query.size ? `?${query}` : "";
1406
- await emit(await client(command).get(`/scrapers${suffix}`), globals(command).json || false);
1407
- });
1408
- scrapers.command("inspect <scraper-id>").action(async (scraperId, _options, command) => {
1409
- const { source, capability } = splitScraperId(scraperId);
1410
- await emit(
1411
- await client(command).get(
1412
- `/scrapers/${encodeURIComponent(source)}/${encodeURIComponent(capability)}`
1413
- ),
1414
- globals(command).json || false
1415
- );
1416
- });
1417
1525
  var catalog = program.command("catalog").description("Inspect capabilities, provider schemas, options, and pricing");
1418
1526
  catalog.command("list").option("--source <source>", "filter by web, x, reddit, instagram, tiktok, or filings").option("--provider <provider>", "filter by provider implementation").option("--available", "only include configured providers; this is not a live health check").option("--full", "include input/output and provider option schemas in JSON output").action(async (options, command) => {
1419
1527
  const query = new URLSearchParams();
@@ -1433,20 +1541,6 @@ catalog.command("inspect <capability-id>").action(async (capabilityId, _options,
1433
1541
  globals(command).json || false
1434
1542
  );
1435
1543
  });
1436
- function executionCommand(name) {
1437
- 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) => {
1438
- const payload = await requestPayload(options, scraperId);
1439
- if (payload.limit === void 0) payload.limit = 10;
1440
- const response = await client(command).postJob(
1441
- name === "run" ? "/scrapers/jobs" : "/scrapers/compare/jobs",
1442
- payload,
1443
- options.idempotencyKey || randomUUID4()
1444
- );
1445
- await emitExecution(response, options, command);
1446
- });
1447
- }
1448
- executionCommand("run");
1449
- executionCommand("compare");
1450
1544
  program.command("run [capability-id]").description("Run a capability directly; use Discover only when configuration is uncertain").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").option("--detach", "queue the job and return immediately").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) => {
1451
1545
  if (options.retryFailed) {
1452
1546
  if (capabilityId || options.config) {
@@ -1513,58 +1607,6 @@ jobs.command("cancel <job-id>").description("Cancel a pending or running queued
1513
1607
  globals(command).json || false
1514
1608
  );
1515
1609
  });
1516
- var providers = program.command("providers", { hidden: true }).description("Legacy provider commands");
1517
- providers.command("list").option("--available", "only include available provider-capability routes").action(async (options, command) => {
1518
- await emit(
1519
- await client(command).get(`/providers${options.available ? "?available_only=true" : ""}`),
1520
- globals(command).json || false
1521
- );
1522
- });
1523
- var discoveries = program.command("discoveries", { hidden: true }).description("Legacy discovery commands");
1524
- discoveries.command("create").requiredOption("-f, --file <path>", "agent-authored discovery JSON with goal and configuration").action(async (options, command) => {
1525
- await emit(
1526
- await client(command).post("/discoveries", await readJsonFile(options.file)),
1527
- globals(command).json || false
1528
- );
1529
- });
1530
- discoveries.command("list").action(async (_options, command) => {
1531
- await emit(await client(command).get("/discoveries"), globals(command).json || false);
1532
- });
1533
- discoveries.command("get <discovery-id>").action(async (discoveryId, _options, command) => {
1534
- await emit(
1535
- await client(command).get(`/discoveries/${encodeURIComponent(discoveryId)}`),
1536
- globals(command).json || false
1537
- );
1538
- });
1539
- discoveries.command("update <discovery-id>").option("-f, --file <path>", "update JSON containing name, priority, or configuration").option("--name <name>", "saved discovery name").option("--priority <priority>", "balanced, quality, coverage, cost, or speed").action(async (discoveryId, options, command) => {
1540
- const payload = options.file ? await readJsonFile(options.file) : {};
1541
- if (options.name) payload.name = options.name;
1542
- if (options.priority) payload.priority = options.priority;
1543
- if (!Object.keys(payload).length) throw new CliError("Provide --file, --name, or --priority.", EXIT.usage);
1544
- await emit(
1545
- await client(command).patch(`/discoveries/${encodeURIComponent(discoveryId)}`, payload),
1546
- globals(command).json || false
1547
- );
1548
- });
1549
- discoveries.command("run <discovery-id>").option("-f, --file <path>", "request JSON containing input and optional limit").option("--input <json>", "runtime parameter JSON").option("--limit <number>", "maximum records per route").option("--idempotency-key <key>", "stable retry key").addOption(new Option("--format <format>", "output record format").choices(["json", "jsonl", "csv"]).default("json")).option("-o, --output <path>", "write records to a file").action(async (discoveryId, options, command) => {
1550
- const fromFile = options.file ? await readJsonFile(options.file) : {};
1551
- const input = options.input ? parseJsonObject(options.input, "input JSON") : fromFile.input;
1552
- const payload = {
1553
- ...fromFile,
1554
- input: input || {},
1555
- limit: Number(options.limit ?? fromFile.limit ?? 25)
1556
- };
1557
- const response = await client(command).postJob(
1558
- `/discoveries/${encodeURIComponent(discoveryId)}/jobs`,
1559
- payload,
1560
- options.idempotencyKey || randomUUID4()
1561
- );
1562
- await emitExecution(response, options, command);
1563
- });
1564
- discoveries.command("delete <discovery-id>").requiredOption("--yes", "confirm permanent deletion").action(async (discoveryId, _options, command) => {
1565
- await client(command).delete(`/discoveries/${encodeURIComponent(discoveryId)}`);
1566
- await emit({ deleted: true, discovery_id: discoveryId }, globals(command).json || false);
1567
- });
1568
1610
  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", "run a representative provider 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").option("-o, --output <path>", "write complete sample-test evidence to a JSON file").action(async (options, command) => {
1569
1611
  const selected = Number(Boolean(options.file)) + Number(Boolean(options.test)) + Number(Boolean(options.update)) + Number(Boolean(options.finalize));
1570
1612
  if (selected !== 1) {
@@ -1632,6 +1674,11 @@ program.command("discover").description("Save, sample-test, or finalize an agent
1632
1674
  await emit(response, globals(command).json || false);
1633
1675
  });
1634
1676
  program.configureOutput({ writeErr: (text) => process.stderr.write(text) });
1677
+ program.hook("preAction", async (_command, actionCommand) => {
1678
+ const parentName = actionCommand.parent?.name();
1679
+ if (actionCommand.name() === "setup" || parentName === "skill") return;
1680
+ await maybeUpdateInstalledSkill();
1681
+ });
1635
1682
  program.parseAsync(process.argv).catch(async (error) => {
1636
1683
  if (error instanceof CommanderError) {
1637
1684
  process.exitCode = error.exitCode === 0 ? EXIT.ok : EXIT.usage;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scrappycoco/cli",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
4
4
  "description": "CLI for Scrappycoco scraper discovery and execution",
5
5
  "type": "module",
6
6
  "bin": {