@witchpot/gmc 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/requests.js CHANGED
@@ -410,6 +410,117 @@ export function buildRequest(command, options, positional) {
410
410
  };
411
411
  }
412
412
 
413
+ // NAK-934. Same route shape as `gmc games ai-disclosure`: a plain numeric
414
+ // appid, no --source. The window flags mirror the server grammar exactly
415
+ // (follower-history's `range` XOR `from`/`to`), and are validated here so a
416
+ // malformed window is a usage error rather than a charged 400. Omitted flags
417
+ // are omitted from the query so the server's `all` default stays its own.
418
+ if (matches(command, ["games", "price-history"])) {
419
+ const usage = "gmc games price-history <appid> [--range 7d|1m|1y|all] [--from YYYY-MM-DD] [--to YYYY-MM-DD] --json";
420
+ assertSupportedOptions(options, {
421
+ command: "gmc games price-history",
422
+ allowed: ["range", "from", "to"]
423
+ });
424
+ const appid = assertAppid(positional[0], usage);
425
+ assertNoExtraPositionals(positional, { expected: 1, usage });
426
+ if (options.range != null && (options.from != null || options.to != null)) {
427
+ writeJsonError({
428
+ code: "CLI_USAGE_ERROR",
429
+ message: "--range cannot be combined with --from or --to."
430
+ });
431
+ process.exit(EXIT_USAGE);
432
+ }
433
+ const range = options.range == null
434
+ ? undefined
435
+ : assertEnumQueryOption(options.range, "--range", HISTORY_RANGES);
436
+ if (options.from != null) assertCalendarDate(options.from, "--from");
437
+ if (options.to != null) assertCalendarDate(options.to, "--to");
438
+ // Both are zero-padded YYYY-MM-DD here, so string order is date order.
439
+ // Only an explicit pair is checked: an omitted --to is the SERVER's today,
440
+ // which the local clock must not second-guess.
441
+ if (options.from != null && options.to != null && String(options.from) > String(options.to)) {
442
+ writeJsonError({
443
+ code: "CLI_USAGE_ERROR",
444
+ message: "--from must be on or before --to."
445
+ });
446
+ process.exit(EXIT_USAGE);
447
+ }
448
+ return {
449
+ path: `/api/v1/steam/games/${encodeURIComponent(String(appid))}/price-history`,
450
+ query: {
451
+ ...(range ? { range } : {}),
452
+ ...(options.from != null ? { from: String(options.from) } : {}),
453
+ ...(options.to != null ? { to: String(options.to) } : {})
454
+ }
455
+ };
456
+ }
457
+
458
+ // NAK-934 specimen sheet. The follower-history route owns the window grammar
459
+ // price-history borrowed, so the flags and their local checks are identical.
460
+ if (matches(command, ["games", "follower-history"])) {
461
+ const usage = "gmc games follower-history <appid> [--range 7d|1m|1y|all] [--from YYYY-MM-DD] [--to YYYY-MM-DD] --json";
462
+ assertSupportedOptions(options, { command: "gmc games follower-history", allowed: ["range", "from", "to"] });
463
+ const appid = assertAppid(positional[0], usage);
464
+ assertNoExtraPositionals(positional, { expected: 1, usage });
465
+ if (options.range != null && (options.from != null || options.to != null)) {
466
+ writeJsonError({ code: "CLI_USAGE_ERROR", message: "--range cannot be combined with --from or --to." });
467
+ process.exit(EXIT_USAGE);
468
+ }
469
+ const range = options.range == null ? undefined : assertEnumQueryOption(options.range, "--range", HISTORY_RANGES);
470
+ if (options.from != null) assertCalendarDate(options.from, "--from");
471
+ if (options.to != null) assertCalendarDate(options.to, "--to");
472
+ if (options.from != null && options.to != null && String(options.from) > String(options.to)) {
473
+ writeJsonError({ code: "CLI_USAGE_ERROR", message: "--from must be on or before --to." });
474
+ process.exit(EXIT_USAGE);
475
+ }
476
+ return {
477
+ path: `/api/v1/steam/games/${encodeURIComponent(String(appid))}/follower-history`,
478
+ query: {
479
+ ...(range ? { range } : {}),
480
+ ...(options.from != null ? { from: String(options.from) } : {}),
481
+ ...(options.to != null ? { to: String(options.to) } : {})
482
+ }
483
+ };
484
+ }
485
+
486
+ // NAK-934 specimen sheet. include is the route's opt-in list; its only value
487
+ // is rank_series, checked locally so a typo is a usage error.
488
+ if (matches(command, ["games", "events"])) {
489
+ const usage = "gmc games events <appid> [--include rank_series] --json";
490
+ assertSupportedOptions(options, { command: "gmc games events", allowed: ["include"] });
491
+ if (options.repeatedOptions?.has("include")) {
492
+ writeJsonError({
493
+ code: "CLI_USAGE_ERROR",
494
+ message: "--include may be given once; its only value is rank_series",
495
+ usage
496
+ });
497
+ process.exit(EXIT_USAGE);
498
+ }
499
+ const appid = assertAppid(positional[0], usage);
500
+ assertNoExtraPositionals(positional, { expected: 1, usage });
501
+ const include = options.include == null ? undefined : assertEnumQueryOption(options.include, "--include", STEAM_EVENTS_INCLUDE);
502
+ return {
503
+ path: `/api/v1/steam/games/${encodeURIComponent(String(appid))}/events`,
504
+ query: include ? { include } : {}
505
+ };
506
+ }
507
+
508
+ // NAK-934. No query parameters at all (the ai-disclosure shape), so any
509
+ // flag is a usage error rather than a silently ignored narrowing.
510
+ if (matches(command, ["games", "screenshot-labels"])) {
511
+ const usage = "gmc games screenshot-labels <appid> --json";
512
+ assertSupportedOptions(options, {
513
+ command: "gmc games screenshot-labels",
514
+ allowed: []
515
+ });
516
+ const appid = assertAppid(positional[0], usage);
517
+ assertNoExtraPositionals(positional, { expected: 1, usage });
518
+ return {
519
+ path: `/api/v1/steam/games/${encodeURIComponent(String(appid))}/screenshot-labels`,
520
+ query: {}
521
+ };
522
+ }
523
+
413
524
  if (matches(command, ["reviews", "patterns"])) {
414
525
  assertSource(options.source);
415
526
  const isCompare = positional[0] === "compare";
@@ -650,6 +761,215 @@ export function buildRequest(command, options, positional) {
650
761
  };
651
762
  }
652
763
 
764
+ // NAK-715. Thin pass-throughs to the Roblox read endpoints, on the same rule
765
+ // the tag-trends branch above documents: the CLI forwards the flags it was
766
+ // given and the SERVER owns the contract — the per-endpoint allowlists, the
767
+ // enum members, the limit ceilings, the window bounds and the cursor
768
+ // binding. `assertSupportedOptions` still runs for the narrower thing it is
769
+ // for: a flag the endpoint has no parameter for would otherwise be dropped
770
+ // silently and answer the unnarrowed question with exit 0.
771
+ //
772
+ // A Roblox universe id is NOT a Steam appid and is never sent as a number.
773
+ // It travels as the decimal string the user typed, because these ids exceed
774
+ // the range a JS number represents exactly.
775
+ if (matches(command, ["roblox", "charts"])) {
776
+ assertSupportedOptions(options, {
777
+ command: "gmc roblox charts",
778
+ allowed: ["country", "device", "sort", "as-of", "limit", "offset"]
779
+ });
780
+ assertNoExtraPositionals(positional, {
781
+ expected: 0,
782
+ usage: "gmc roblox charts --country all --sort top-playing-now --json"
783
+ });
784
+ return {
785
+ path: "/api/v1/roblox/charts",
786
+ // Every value is omitted when unset so the server owns each default and
787
+ // no default can drift into a copy kept here.
788
+ query: pickQuery(options, {
789
+ country: "country",
790
+ device: "device",
791
+ sort: "sort",
792
+ "as-of": "as_of",
793
+ limit: "limit",
794
+ offset: "offset"
795
+ })
796
+ };
797
+ }
798
+
799
+ if (matches(command, ["roblox", "experiences"])) {
800
+ assertSupportedOptions(options, {
801
+ command: "gmc roblox experiences",
802
+ allowed: [
803
+ "country",
804
+ "device",
805
+ "sort",
806
+ "as-of",
807
+ "eligibility-days",
808
+ "min-player-count",
809
+ "max-rank",
810
+ "genre",
811
+ "name",
812
+ "creator-type",
813
+ "detail",
814
+ "order-by",
815
+ "order",
816
+ "limit",
817
+ "cursor"
818
+ ]
819
+ });
820
+ assertNoExtraPositionals(positional, {
821
+ expected: 0,
822
+ usage: "gmc roblox experiences --country all --limit 50 --json"
823
+ });
824
+ return {
825
+ path: "/api/v1/roblox/experiences",
826
+ query: pickQuery(options, {
827
+ country: "country",
828
+ device: "device",
829
+ sort: "sort",
830
+ "as-of": "as_of",
831
+ "eligibility-days": "eligibility_days",
832
+ "min-player-count": "min_player_count",
833
+ "max-rank": "max_rank",
834
+ genre: "genre",
835
+ name: "name",
836
+ "creator-type": "creator_type",
837
+ detail: "detail",
838
+ "order-by": "order_by",
839
+ order: "order",
840
+ limit: "limit",
841
+ cursor: "cursor"
842
+ })
843
+ };
844
+ }
845
+
846
+ if (matches(command, ["roblox", "experience"])) {
847
+ assertSupportedOptions(options, { command: "gmc roblox experience", allowed: ["as-of"] });
848
+ const universeId = assertUniverseId(
849
+ positional[0],
850
+ "gmc roblox experience <universeId> --json"
851
+ );
852
+ assertNoExtraPositionals(positional, {
853
+ expected: 1,
854
+ usage: "gmc roblox experience <universeId> --json"
855
+ });
856
+ return {
857
+ path: `/api/v1/roblox/experiences/${encodeURIComponent(universeId)}`,
858
+ query: pickQuery(options, { "as-of": "as_of" })
859
+ };
860
+ }
861
+
862
+ if (matches(command, ["roblox", "history"])) {
863
+ assertSupportedOptions(options, {
864
+ command: "gmc roblox history",
865
+ allowed: ["granularity", "from", "to", "as-of"]
866
+ });
867
+ const universeId = assertUniverseId(
868
+ positional[0],
869
+ "gmc roblox history <universeId> --granularity daily --json"
870
+ );
871
+ assertNoExtraPositionals(positional, {
872
+ expected: 1,
873
+ usage: "gmc roblox history <universeId> --granularity daily --json"
874
+ });
875
+ return {
876
+ path: `/api/v1/roblox/experiences/${encodeURIComponent(universeId)}/history`,
877
+ query: pickQuery(options, {
878
+ granularity: "granularity",
879
+ from: "from",
880
+ to: "to",
881
+ "as-of": "as_of"
882
+ })
883
+ };
884
+ }
885
+
886
+ if (matches(command, ["roblox", "coverage"])) {
887
+ assertSupportedOptions(options, { command: "gmc roblox coverage", allowed: ["as-of"] });
888
+ assertNoExtraPositionals(positional, { expected: 0, usage: "gmc roblox coverage --json" });
889
+ return {
890
+ path: "/api/v1/roblox/coverage",
891
+ query: pickQuery(options, { "as-of": "as_of" })
892
+ };
893
+ }
894
+
895
+ // NAK-723. The saved-experiences surface: the only WORKSPACE-SCOPED Roblox
896
+ // commands. The five reads above are anonymous and shared-cacheable; these
897
+ // three carry one workspace's own data and resolve through the same unified
898
+ // auth `gmc lists` uses, so they are modelled on that family rather than on
899
+ // the Roblox read family beside them.
900
+ //
901
+ // THE SCOPE FLAGS BELONG TO `saved` ALONE, and the two writes take none. A
902
+ // `--country` on a save is refused here rather than dropped, matching both
903
+ // the REST allowlist (`SAVED_MUTATION_PARAMS` is empty) and the MCP tool's
904
+ // per-action allowlist: a save that quietly ignored a scope flag would look
905
+ // correct while answering a question the caller did not ask.
906
+ //
907
+ // SAVED MEMBERSHIP IS SCOPE-INDEPENDENT. The flags on `saved` decide which
908
+ // of `in_scope` / `out_of_scope` / `not_tracked` each saved experience is
909
+ // in, never which experiences are listed — so no flag here can add or drop
910
+ // a row, and an `out_of_scope` row is saved and tracked, not missing.
911
+ if (matches(command, ["roblox", "saved"])) {
912
+ assertSupportedOptions(options, {
913
+ command: "gmc roblox saved",
914
+ allowed: ["country", "device", "sort", "as-of", "eligibility-days"]
915
+ });
916
+ assertNoExtraPositionals(positional, { expected: 0, usage: "gmc roblox saved --json" });
917
+ return {
918
+ path: "/api/v1/roblox/saved",
919
+ // Deliberately no --limit and no --cursor: the saved set is capped per
920
+ // workspace and one response always holds all of it. Accepting either
921
+ // would imply the list can hide part of itself.
922
+ query: pickQuery(options, {
923
+ country: "country",
924
+ device: "device",
925
+ sort: "sort",
926
+ "as-of": "as_of",
927
+ "eligibility-days": "eligibility_days"
928
+ })
929
+ };
930
+ }
931
+
932
+ // Both writes are IDEMPOTENT and report the state that now holds. A repeat
933
+ // answers `changed: false` with the same body and is success, not a failure
934
+ // to retry — nothing here turns that into a non-zero exit.
935
+ //
936
+ // The id reaches the wire as the decimal STRING `assertUniverseId` returned.
937
+ // Coercing it to a number would both lose precision above 2^53 and trip the
938
+ // server's own `requireSavedUniverseId`, which refuses a JSON number outright
939
+ // because a number is exactly what a caller sends when it has routed a Steam
940
+ // appid into this path.
941
+ if (matches(command, ["roblox", "save"])) {
942
+ assertSupportedOptions(options, { command: "gmc roblox save", allowed: [] });
943
+ const universeId = assertUniverseId(positional[0], "gmc roblox save <universeId> --json");
944
+ assertNoExtraPositionals(positional, {
945
+ expected: 1,
946
+ usage: "gmc roblox save <universeId> --json"
947
+ });
948
+ return {
949
+ path: "/api/v1/roblox/saved",
950
+ method: "POST",
951
+ query: {},
952
+ body: { universeId }
953
+ };
954
+ }
955
+
956
+ // Removes the id from THIS WORKSPACE's saved set. It does not delete an
957
+ // experience, does not touch collection, and removing something that is not
958
+ // saved is `changed: false` rather than a 404.
959
+ if (matches(command, ["roblox", "remove"])) {
960
+ assertSupportedOptions(options, { command: "gmc roblox remove", allowed: [] });
961
+ const universeId = assertUniverseId(positional[0], "gmc roblox remove <universeId> --json");
962
+ assertNoExtraPositionals(positional, {
963
+ expected: 1,
964
+ usage: "gmc roblox remove <universeId> --json"
965
+ });
966
+ return {
967
+ path: `/api/v1/roblox/saved/${encodeURIComponent(universeId)}`,
968
+ method: "DELETE",
969
+ query: {}
970
+ };
971
+ }
972
+
653
973
  return null;
654
974
  }
655
975
 
@@ -1010,6 +1330,11 @@ function assertSource(source = "steam") {
1010
1330
  }
1011
1331
  }
1012
1332
 
1333
+ // Mirrors GAME_FOLLOWER_HISTORY_RANGE_PRESETS on the server, which the
1334
+ // price-history endpoint reuses (NAK-934). The server remains the authority.
1335
+ const HISTORY_RANGES = ["7d", "1m", "1y", "all"];
1336
+ const STEAM_EVENTS_INCLUDE = ["rank_series"];
1337
+
1013
1338
  function assertDate(value, optionName) {
1014
1339
  if (!/^\d{4}-\d{2}-\d{2}$/.test(String(value))) {
1015
1340
  writeJsonError({
@@ -1020,6 +1345,30 @@ function assertDate(value, optionName) {
1020
1345
  }
1021
1346
  }
1022
1347
 
1348
+ // `assertDate` plus a real-calendar check, mirroring the server's
1349
+ // `parseCalendarDateParam` (follower-history.ts) so `2026-02-30` is a usage
1350
+ // error here instead of a request. Kept separate from `assertDate`, whose
1351
+ // other caller (--deadline-before) is not in scope for this rule.
1352
+ function assertCalendarDate(value, optionName) {
1353
+ assertDate(value, optionName);
1354
+ const [year, month, day] = String(value).split("-").map(Number);
1355
+ const probe = new Date(Date.UTC(year, month - 1, day));
1356
+ // Date.UTC maps years 0-99 into the 1900s; restore the year so the check
1357
+ // is about the DAY being real, as on the server.
1358
+ probe.setUTCFullYear(year);
1359
+ if (
1360
+ probe.getUTCFullYear() !== year ||
1361
+ probe.getUTCMonth() !== month - 1 ||
1362
+ probe.getUTCDate() !== day
1363
+ ) {
1364
+ writeJsonError({
1365
+ code: "CLI_USAGE_ERROR",
1366
+ message: `${optionName} must be a real calendar date`
1367
+ });
1368
+ process.exit(EXIT_USAGE);
1369
+ }
1370
+ }
1371
+
1023
1372
  function assertPositiveIntegerOption(value, optionName) {
1024
1373
  const number = Number(value);
1025
1374
  if (!Number.isInteger(number) || number < 1) {
@@ -1184,10 +1533,17 @@ const GLOBAL_CLI_OPTIONS = new Set(["json", "help", "version", "fields", "preset
1184
1533
  function assertSupportedOptions(options, { command, allowed }) {
1185
1534
  for (const key of Object.keys(options)) {
1186
1535
  if (GLOBAL_CLI_OPTIONS.has(key) || allowed.includes(key)) continue;
1187
- const supported = allowed.map((name) => `--${name}`).join(", ");
1536
+ // NAK-723: an empty allowlist rendered as "Supported options: ." — which
1537
+ // reads like a message with its list missing rather than like a command
1538
+ // that takes no options. `gmc roblox save`/`remove` are the second and
1539
+ // third such command (after `gmc games ai-disclosure`), so it is spelled
1540
+ // out here once, in the MCP tool's own wording for the same case.
1541
+ const supported = allowed.length
1542
+ ? `Supported options: ${allowed.map((name) => `--${name}`).join(", ")}.`
1543
+ : "This command takes no options.";
1188
1544
  writeJsonError({
1189
1545
  code: "CLI_USAGE_ERROR",
1190
- message: `--${key} is not supported by ${command}. Supported options: ${supported}. The endpoint does not accept it, so it would be ignored rather than applied.`
1546
+ message: `--${key} is not supported by ${command}. ${supported} The endpoint does not accept it, so it would be ignored rather than applied.`
1191
1547
  });
1192
1548
  process.exit(EXIT_USAGE);
1193
1549
  }
@@ -1243,6 +1599,46 @@ function assertAppid(value, usage) {
1243
1599
  return appid;
1244
1600
  }
1245
1601
 
1602
+ /**
1603
+ * NAK-715. Validates the universeId positional for the Roblox per-experience
1604
+ * commands, mirroring the path-parameter check the app applies. NAK-723 shares
1605
+ * it with `roblox save` / `roblox remove`, where the same string is also the
1606
+ * request BODY's `universeId` — the regex below is character-for-character the
1607
+ * server's own `parseRobloxId`, so the two surfaces accept and reject the same
1608
+ * spellings.
1609
+ *
1610
+ * It returns a STRING and never a number, which is the point: a Roblox
1611
+ * universe id is a 64-bit value, and `Number.parseInt` would round anything
1612
+ * above 2^53 into a different experience's id. The lexical test is
1613
+ * `/^[1-9][0-9]*$/` for the same reason `assertAppid` above uses a lexical
1614
+ * test — `Number` also accepts the exponent, hex and decimal-point spellings
1615
+ * the app's validator rejects, and leading zeros would make one experience
1616
+ * reachable under two spellings. This check is advisory; the server remains
1617
+ * the enforcing authority.
1618
+ *
1619
+ * A Roblox universe id is NOT a Steam appid: the two id spaces are unrelated
1620
+ * and this value never reaches a Steam route.
1621
+ *
1622
+ * What this CANNOT catch, deliberately: a bare Steam appid like `730` is a
1623
+ * lexically valid universe id, on this side and on the server's. It is
1624
+ * forwarded, and the server's tracked-cohort gate answers NOT_FOUND naming
1625
+ * exactly that — "not in the tracked cohort, so saving it would create a row
1626
+ * nothing could explain". Guessing here from the id's magnitude instead would
1627
+ * invent a rule the server does not have and would reject real small universe
1628
+ * ids, which is a worse failure than the precise 404.
1629
+ */
1630
+ function assertUniverseId(value, usage) {
1631
+ const raw = value == null ? "" : String(value).trim();
1632
+ if (!/^[1-9][0-9]{0,18}$/.test(raw)) {
1633
+ writeJsonError({
1634
+ code: "CLI_USAGE_ERROR",
1635
+ message: `Missing or invalid universeId (must be a positive decimal Roblox universe id, not a Steam appid). Usage: ${usage}`
1636
+ });
1637
+ process.exit(EXIT_USAGE);
1638
+ }
1639
+ return raw;
1640
+ }
1641
+
1246
1642
  function assertResolveQuery(value) {
1247
1643
  if (value == null || String(value).trim() === "") {
1248
1644
  writeJsonError({