@scrappycoco/cli 0.4.1 → 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.
Files changed (3) hide show
  1. package/README.md +20 -10
  2. package/dist/index.js +193 -9
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -7,7 +7,7 @@ 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
@@ -16,12 +16,19 @@ the live catalog, and tells you when to reload or restart your agent. Browser
16
16
  authorization is not complete until the terminal confirms that the credential
17
17
  was saved and the catalog check succeeded.
18
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
+
19
26
  When the CLI runs on a remote or headless host whose `127.0.0.1` is not the
20
27
  browser's localhost, keep the command running and use the manual callback
21
28
  flow:
22
29
 
23
30
  ```sh
24
- npx --yes @scrappycoco/cli setup --no-browser --manual-callback
31
+ npx --yes @scrappycoco/cli@latest setup --no-browser --manual-callback
25
32
  ```
26
33
 
27
34
  Open the printed authorization URL, then paste the browser's final callback
@@ -33,14 +40,14 @@ URL.
33
40
  Individual commands remain available:
34
41
 
35
42
  ```sh
36
- npx --yes @scrappycoco/cli auth login
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
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
44
51
  ```
45
52
 
46
53
  From a repository checkout, use `npm ci`, `npm run build`, and
@@ -70,6 +77,9 @@ and `options`, so one discovery can test both `zyte-http` with
70
77
  `{"browser_html":false}` and `zyte-browser` with
71
78
  `{"browser_html":true}` against the same input.
72
79
 
80
+ The distributed skill includes a Discovery workbook with complete direct-Run,
81
+ comparison, update, finalization, fallback, and audit-handoff examples.
82
+
73
83
  Use `scrappycoco --help` for the complete command reference. See the
74
84
  [Scrappycoco API documentation](https://scrappycoco.ai/docs) for the public
75
85
  contract.
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { randomUUID as randomUUID3 } from "crypto";
4
+ import { randomUUID as randomUUID4 } from "crypto";
5
5
  import { readFileSync } from "fs";
6
6
  import { Command, CommanderError, Option } from "commander";
7
7
 
@@ -671,7 +671,7 @@ import { determineAgent } from "@vercel/detect-agent";
671
671
  var SKILL_SOURCE = "https://scrappycoco.ai";
672
672
  var SKILL_INSTALL_ARGS = [
673
673
  "--yes",
674
- "skills",
674
+ "skills@latest",
675
675
  "add",
676
676
  SKILL_SOURCE,
677
677
  "--skill",
@@ -710,7 +710,7 @@ function skillInstallInvocation(agent = "universal", npmExecPath = process.env.n
710
710
  args: [
711
711
  npmExecPath,
712
712
  "dlx",
713
- "skills",
713
+ "skills@latest",
714
714
  ...SKILL_INSTALL_ARGS.slice(2),
715
715
  "--agent",
716
716
  agent
@@ -839,6 +839,185 @@ async function performSetup(options, dependencies = defaultDependencies(options.
839
839
  };
840
840
  }
841
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
+
842
1021
  // src/index.ts
843
1022
  var packageMetadata = JSON.parse(
844
1023
  readFileSync(new URL("../package.json", import.meta.url), "utf8")
@@ -860,8 +1039,13 @@ program.command("setup").description("Authenticate, install the Scrappycoco skil
860
1039
  noBrowser: options.browser === false,
861
1040
  manualCallback: options.manualCallback
862
1041
  });
1042
+ await rememberInstalledSkillRelease();
863
1043
  await emit(result, globals(command).json || false);
864
1044
  });
1045
+ program.hook("preAction", async (_thisCommand, actionCommand) => {
1046
+ if (actionCommand.name() === "setup") return;
1047
+ await autoUpdateInstalledSkill();
1048
+ });
865
1049
  function splitScraperId(value) {
866
1050
  const separator = value.indexOf(".");
867
1051
  if (separator <= 0 || separator === value.length - 1) {
@@ -985,7 +1169,7 @@ function executionCommand(name) {
985
1169
  const response = await client(command).postJob(
986
1170
  name === "run" ? "/scrapers/jobs" : "/scrapers/compare/jobs",
987
1171
  payload,
988
- options.idempotencyKey || randomUUID3()
1172
+ options.idempotencyKey || randomUUID4()
989
1173
  );
990
1174
  await emitExecution(response, options, command);
991
1175
  });
@@ -1000,7 +1184,7 @@ program.command("run [capability-id]").description("Run a capability directly; d
1000
1184
  const response2 = await client(command).postJob(
1001
1185
  `/runs/${encodeURIComponent(options.retryFailed)}/retry-failed`,
1002
1186
  {},
1003
- options.idempotencyKey || randomUUID3()
1187
+ options.idempotencyKey || randomUUID4()
1004
1188
  );
1005
1189
  await emitExecution(response2, options, command);
1006
1190
  return;
@@ -1018,7 +1202,7 @@ program.command("run [capability-id]").description("Run a capability directly; d
1018
1202
  input,
1019
1203
  limit: Number(options.limit ?? fromFile.limit ?? 25)
1020
1204
  },
1021
- options.idempotencyKey || randomUUID3()
1205
+ options.idempotencyKey || randomUUID4()
1022
1206
  );
1023
1207
  await emitExecution(response2, options, command);
1024
1208
  return;
@@ -1031,7 +1215,7 @@ program.command("run [capability-id]").description("Run a capability directly; d
1031
1215
  const response = await client(command).postJob(
1032
1216
  "/scrapers/jobs",
1033
1217
  payload,
1034
- options.idempotencyKey || randomUUID3()
1218
+ options.idempotencyKey || randomUUID4()
1035
1219
  );
1036
1220
  await emitExecution(response, options, command);
1037
1221
  });
@@ -1086,7 +1270,7 @@ discoveries.command("run <discovery-id>").option("-f, --file <path>", "request J
1086
1270
  const response = await client(command).postJob(
1087
1271
  `/discoveries/${encodeURIComponent(discoveryId)}/jobs`,
1088
1272
  payload,
1089
- options.idempotencyKey || randomUUID3()
1273
+ options.idempotencyKey || randomUUID4()
1090
1274
  );
1091
1275
  await emitExecution(response, options, command);
1092
1276
  });
@@ -1134,7 +1318,7 @@ program.command("discover").description("Save, sample-test, or finalize an agent
1134
1318
  const response = await client(command).postJob(
1135
1319
  `/discoveries/${encodeURIComponent(options.id)}/jobs`,
1136
1320
  { input, limit: 25 },
1137
- options.idempotencyKey || randomUUID3()
1321
+ options.idempotencyKey || randomUUID4()
1138
1322
  );
1139
1323
  await emit(response, globals(command).json || false);
1140
1324
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scrappycoco/cli",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "CLI for Scrappycoco scraper discovery and execution",
5
5
  "type": "module",
6
6
  "bin": {