@witchpot/gmc 0.10.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/CHANGELOG.md CHANGED
@@ -2,6 +2,66 @@
2
2
 
3
3
  All notable changes to the Game Market Copilot CLI will be recorded here.
4
4
 
5
+ ## 0.11.0 - 2026-10-27
6
+
7
+ - `gmc games follower-history <appid>`: observed Steam follower series (same window flags as `price-history`). MCP: `game_follower_history`.
8
+ - `gmc games events <appid> [--include rank_series]`: Steam event participations with observed ranks and shelves. MCP: `game_steam_events`.
9
+ - The bundled `gmc-analysis` skill 0.17.0 covers both commands, and the
10
+ optional `positiveReviews` field on `gmc games review-history` points (state
11
+ a rate as `positiveReviews / totalReviews` when it is present).
12
+
13
+ - New `gmc games price-history <appid>` and `gmc games screenshot-labels
14
+ <appid>` commands (NAK-934, Provisional), the same plain-appid shape as
15
+ `gmc games ai-disclosure`. `price-history` returns the observed Steam
16
+ storefront price on every observed day (major currency units, with
17
+ discount, currency and store country) and takes `--range 7d|1m|1y|all`
18
+ (default `all`) or `--from`/`--to` (`YYYY-MM-DD`, real calendar dates,
19
+ `--from` on or before `--to`), never both; a bad window is a usage error
20
+ before any request. Days without an observation
21
+ are absent, never filled, and a price never implies sales.
22
+ `screenshot-labels` returns one set of visual labels per Steam store
23
+ screenshot and takes no flags. The labels are model output, not Steam
24
+ data. `docs/cli-use-cases.md` and `docs/agent-usage.md` cover both.
25
+
26
+ - Not shipped here: the bundled `gmc-analysis` skill does not mention the
27
+ two new commands yet. It is a separately versioned artifact with its own
28
+ manifest gate, so its coverage follows in its own change.
29
+
30
+ - New `gmc roblox saved | save | remove` commands (NAK-723, Provisional),
31
+ mapping the saved-experiences surface 0.10.0 recorded as "not yet
32
+ mapped". `gmc roblox saved --json` lists the workspace's saved
33
+ experiences resolved for one chart, country and cutoff (`--country`,
34
+ `--device`, `--sort`, `--as-of`, `--eligibility-days`); `gmc roblox save
35
+ <universeId>` and `gmc roblox remove <universeId>` change the set and
36
+ take no flags at all, refusing a scope flag rather than ignoring it.
37
+
38
+ Unlike the five read commands these are workspace-scoped: they need an
39
+ API key or CLI credential and answer 401 without one, which is not an
40
+ empty saved list. They charge no credits — saving is workspace metadata,
41
+ not analytics — and the paid-plan gate still applies.
42
+
43
+ Saved membership is scope-independent: the flags on `saved` decide which
44
+ of `in_scope` / `out_of_scope` / `not_tracked` each row is in, never
45
+ which rows are listed. Both writes are idempotent and report the state
46
+ that now holds, so `changed: false` is success rather than a write to
47
+ retry. Universe ids travel as decimal strings into the request body as
48
+ well as the path, and the CLI rejects a spelling the server would reject
49
+ before any request is sent.
50
+
51
+ `docs/cli-use-cases.md` (new case 70) and `docs/agent-usage.md` now cover
52
+ the saved commands alongside the read family.
53
+
54
+ - The 0.10.0 entry below is left exactly as published. Its "not yet
55
+ mapped" line was true of 0.10.0 and is corrected by this entry rather
56
+ than edited in place, matching how 0.8.0 recorded the 0.7.0 registry gap:
57
+ a published entry describes the release users installed, so amending one
58
+ would make the repo disagree with the tarball already on npm.
59
+
60
+ - Not shipped here: the bundled `gmc-analysis` skill still documents the
61
+ Roblox read family only. The skill is a separately versioned artifact
62
+ with its own manifest gate, so its Roblox-saved coverage and version bump
63
+ follow in their own change.
64
+
5
65
  ## 0.10.0 - 2026-09-22
6
66
 
7
67
  - New `gmc roblox` command family (NAK-715, Provisional): `charts` (one
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@witchpot/gmc",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "private": false,
5
5
  "description": "Command line tools for Game Market Copilot market research.",
6
6
  "license": "UNLICENSED",
@@ -1,5 +1,27 @@
1
1
  # gmc-analysis skill changelog
2
2
 
3
+ ## 0.17.0 - 2026-10-08
4
+
5
+ - Review-count history (NAK-934): each `series[]` point can carry the
6
+ optional `positiveReviews`, the positive-review count observed on the same
7
+ row, beside the optional `ratingPercent`. Both are absent, never zero, when
8
+ the row carried no usable value. New guardrail: state a rate as
9
+ `positiveReviews / totalReviews` when the count is present, because
10
+ `ratingPercent` is rounded to one decimal (1,999 of 2,000 reads 100.0);
11
+ without the count, a `100.0` on 2,000 or more reviews is possibly rounded
12
+ and is never reported as "no negative reviews".
13
+ - New "Steam follower history" section: `gmc games follower-history <appid>`
14
+ (MCP: `game_follower_history`), its `--range` or `--from`/`--to` window
15
+ (never both), and the guardrails: observed days only, followers are not
16
+ wishlists or sales, `not_collected` is a collection state, truncation
17
+ drops the oldest points.
18
+ - New "Steam event participation" section: `gmc games events <appid>
19
+ [--include rank_series]` (MCP: `game_steam_events`), with the guardrails:
20
+ ranks and shelves are observed event-page state, not an official ranking;
21
+ an absent block or an empty list is a collection state; the rank series is
22
+ raw and never resampled.
23
+ - MCP-only mapping updated with both tools.
24
+
3
25
  ## 0.16.0 - 2026-09-16
4
26
 
5
27
  - New "Disclosure text and change history" subsection under "AI disclosure
@@ -8,7 +8,7 @@ description: >-
8
8
  or campaigns that GMC can answer — including turning results into
9
9
  publish-ready branded chart cards.
10
10
  metadata:
11
- version: 0.16.0
11
+ version: 0.17.0
12
12
  ---
13
13
 
14
14
  # GMC Analysis
@@ -59,7 +59,9 @@ applies identically. Mechanics map as follows:
59
59
  its lifecycle/announcement/discount context (`games review-history`) ->
60
60
  `game_review_history`; per-title AI-disclosure text and change history
61
61
  (`games ai-disclosure`) -> `game_profile` with `ai_disclosure` in
62
- `sections`.
62
+ `sections`; Steam follower history (`games follower-history`) ->
63
+ `game_follower_history`; Steam event participation (`games events`) ->
64
+ `game_steam_events` (`include: ["rank_series"]` for the rank series).
63
65
  - Per-family collection status ("has GMC actually collected X for this
64
66
  game?") has no CLI command yet and is MCP-only: `game_collection_status`
65
67
  (see the dedicated section below).
@@ -413,6 +415,13 @@ Field paths differ by surface: the CLI and REST wrap the payload, so read
413
415
  the same fields at the top level. The guardrails below name the CLI path;
414
416
  drop the `data.` prefix over MCP.
415
417
 
418
+ Each `data.series[]` point carries `date` and `totalReviews`, and two
419
+ OPTIONAL fields: `ratingPercent` (the positive-review percentage observed on
420
+ that row, 0-100, rounded to one decimal) and `positiveReviews` (the
421
+ positive-review count observed on that row, 0 to `totalReviews`). Absent
422
+ means the row carried no usable value; neither is ever zero-filled, and a
423
+ returned `0` is a real observation.
424
+
416
425
  Interpretation guardrails (honesty rules, not suggestions):
417
426
 
418
427
  - **Scale by date, never by array index.** Collection has real gaps, so two
@@ -439,9 +448,67 @@ Interpretation guardrails (honesty rules, not suggestions):
439
448
  day from it. `endDate: null` means no end has been observed yet;
440
449
  `discountPercent` is the opening observation, not the deepest or final
441
450
  depth.
451
+ - **State a rate from the count when it is there.** `ratingPercent` is
452
+ rounded to one decimal, so 1,999 positive of 2,000 reads `100.0`. When
453
+ `positiveReviews` is present, compute the rate as
454
+ `positiveReviews / totalReviews`. When it is absent and `ratingPercent` is
455
+ `100.0` on `totalReviews` of 2,000 or more, treat the 100 as possibly
456
+ rounded (anything from 99.95% up): say "about 100% (rounded)", never "no
457
+ negative reviews". Below 2,000 reviews a `100.0` is exact, because one
458
+ negative review already reads 99.9 or less.
442
459
  - **Temporal comparison only.** "Reviews rose in the week after the update"
443
460
  is a claim you can make; "the update caused the rise" is not.
444
461
 
462
+ ## Steam follower history
463
+
464
+ `gmc games follower-history <appid> [--range 7d|1m|1y|all] [--from YYYY-MM-DD] [--to YYYY-MM-DD] --json`
465
+ (MCP: `game_follower_history` with `appid` and either `range` or
466
+ `from`/`to`) returns one game's Steam Community group member count on every
467
+ OBSERVED day: `data.series[]` of `date` and `followerCount`, plus
468
+ `data.availability` and `data.seriesMeta`. It takes the appid alone (no
469
+ `--source`). `--range` defaults to `all`, ending today (UTC); `--from` and
470
+ `--to` are inclusive. `--range` cannot be combined with `--from`/`--to`:
471
+ the CLI rejects that as a usage error before any request, so the failure is
472
+ not transient.
473
+
474
+ Interpretation guardrails:
475
+
476
+ - **Observed days only.** Days without an observation are absent and never
477
+ filled or interpolated. Scale by date, never by array index.
478
+ - **Followers are not wishlists.** They are Steam Community game-group
479
+ members, a public proxy for audience interest. Never report them as a
480
+ wishlist count and never as sales.
481
+ - **`not_collected` is a collection state.** `data.availability:
482
+ not_collected` means followers were never collected, never zero followers.
483
+ - **Truncation drops the oldest points.** When `data.seriesMeta.truncated`
484
+ is true the series still ends at the newest observation; say the early
485
+ history is cut.
486
+
487
+ ## Steam event participation
488
+
489
+ `gmc games events <appid> [--include rank_series] --json` (MCP:
490
+ `game_steam_events`, with `include: ["rank_series"]`) returns the
491
+ Steam-run events the game took part in (Next Fest and others), newest
492
+ first: each with its observed best rank, shelf exposure, demo review
493
+ series, store follower series and social account deltas. `--include
494
+ rank_series` adds `rankSeries[]` of `{ snapshotUtc, rank, sourceKey }` per
495
+ event, bounded per event and per response (`rankSeriesTruncated` marks an
496
+ event a cap cut).
497
+
498
+ Interpretation guardrails:
499
+
500
+ - **Observed state, not an official ranking.** Ranks and shelf values are
501
+ event-page state captured by the collector. Report them as observed
502
+ positions, never as Steam's own ranking.
503
+ - **Absence is a collection state.** An absent block means that signal was
504
+ not collected for the event. An empty `data.events` means no observed
505
+ participation, not proof the game never took part.
506
+ - **The rank series is raw.** `rankSeries` holds the observations themselves
507
+ (about every 30 minutes during a festival), never resampled; a gap is a
508
+ real collection gap.
509
+ - **Temporal comparison only.** A follower or review change during an event
510
+ is something you observed beside it, never something the event caused.
511
+
445
512
  ## Per-family collection status
446
513
 
447
514
  `game_collection_status` (MCP-only, no CLI command) returns per-signal-family
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.16.0",
3
- "contentHash": "a751df9637a24adb16604c91f77618ba1af16ce191843d85d3d8f2e6ae4df363",
2
+ "version": "0.17.0",
3
+ "contentHash": "64d3e4ed54842f921cc532ac76c936fbd99619ceed13dc6fc9317fe56552df2d",
4
4
  "files": 7
5
5
  }
package/src/args.js CHANGED
@@ -21,6 +21,12 @@ export function parseArgs(args) {
21
21
  const command = [];
22
22
  const positional = [];
23
23
  const options = {};
24
+ // Option names given more than once. Later values still win in `options`
25
+ // (every command's parsing is unchanged); a command that cannot take a list
26
+ // reads this to reject the repetition. Non-enumerable so it never shows up
27
+ // as an option.
28
+ const repeated = new Set();
29
+ Object.defineProperty(options, "repeatedOptions", { value: repeated, enumerable: false });
24
30
 
25
31
  for (let index = 0; index < args.length; index += 1) {
26
32
  const arg = args[index];
@@ -57,6 +63,7 @@ export function parseArgs(args) {
57
63
  }
58
64
 
59
65
  if (inlineValue == null) index += 1;
66
+ if (Object.prototype.hasOwnProperty.call(options, key)) repeated.add(key);
60
67
  options[key] = value;
61
68
  continue;
62
69
  }
@@ -114,7 +121,7 @@ function isCommandToken(command, token) {
114
121
  return ["install", "print", "status"].includes(token);
115
122
  }
116
123
  if (command[0] === "games") {
117
- return ["search", "count", "aggregate", "resolve", "benchmark", "detail", "analysis", "compare", "topics", "language-topics", "review-history", "success-report", "more-like-this", "ai-disclosure"].includes(token);
124
+ return ["search", "count", "aggregate", "resolve", "benchmark", "detail", "analysis", "compare", "topics", "language-topics", "review-history", "success-report", "more-like-this", "ai-disclosure", "price-history", "follower-history", "events", "screenshot-labels"].includes(token);
118
125
  }
119
126
  if (command[0] === "reviews") {
120
127
  return token === "patterns";
@@ -144,7 +151,21 @@ function isCommandToken(command, token) {
144
151
  return ["tag-trends", "trend-stories"].includes(token);
145
152
  }
146
153
  if (command[0] === "roblox") {
147
- return ["charts", "experiences", "experience", "history", "coverage"].includes(token);
154
+ // NAK-723 adds saved/save/remove. Same trap the read family documents, and
155
+ // worse for the two writes: a token missing from this list falls through to
156
+ // positional rather than erroring, so `gmc roblox save <universeId>` would
157
+ // parse as the unknown command `roblox` and exit 2 with a message naming
158
+ // the id rather than the subcommand.
159
+ return [
160
+ "charts",
161
+ "experiences",
162
+ "experience",
163
+ "history",
164
+ "coverage",
165
+ "saved",
166
+ "save",
167
+ "remove"
168
+ ].includes(token);
148
169
  }
149
170
  return false;
150
171
  }
package/src/constants.js CHANGED
@@ -7,7 +7,7 @@ export const EXIT_CONFIG = 3;
7
7
  export const EXIT_API = 4;
8
8
 
9
9
  export const CLI_PACKAGE_NAME = "@witchpot/gmc";
10
- export const CLI_VERSION = "0.10.0";
10
+ export const CLI_VERSION = "0.11.0";
11
11
 
12
12
  export const DEFAULT_API_BASE_URL = "https://gamemarketcopilot.com";
13
13
  export const DEFAULT_CONFIG_PATH = path.join(homedir(), ".config", "gmc", "config.json");
@@ -64,6 +64,11 @@ export const PROVISIONAL_BETA_COMMANDS = [
64
64
  // NAK-804. New command; starts Provisional per the closed-beta roadmap's
65
65
  // default for anything not yet through broader real-data smoke testing.
66
66
  "gmc games ai-disclosure <appid> --json",
67
+ // NAK-934. New commands; Provisional per the same closed-beta default.
68
+ "gmc games price-history <appid> --range 7d|1m|1y|all --json",
69
+ "gmc games follower-history <appid> --range 7d|1m|1y|all --json",
70
+ "gmc games events <appid> --include rank_series --json",
71
+ "gmc games screenshot-labels <appid> --json",
67
72
  // NAK-715/NAK-725. Enabled in production on 2026-09-22; stays Provisional
68
73
  // per the closed-beta roadmap's default until the surface has been through
69
74
  // a broader real-data smoke period (the NAK-804 rule), then graduates.
@@ -71,7 +76,15 @@ export const PROVISIONAL_BETA_COMMANDS = [
71
76
  "gmc roblox experiences --country all --limit 50 --json",
72
77
  "gmc roblox experience <universeId> --json",
73
78
  "gmc roblox history <universeId> --granularity daily|hourly --json",
74
- "gmc roblox coverage --json"
79
+ "gmc roblox coverage --json",
80
+ // NAK-723. The workspace's saved Roblox experiences, mapping the REST
81
+ // surface that shipped with the pages. Provisional for the SAME reason the
82
+ // five read commands above are — they are one surface, gated by one flag,
83
+ // and graduating the saved commands ahead of the reads they resolve against
84
+ // would promise a stability the reads have not earned yet.
85
+ "gmc roblox saved --country all --json",
86
+ "gmc roblox save <universeId> --json",
87
+ "gmc roblox remove <universeId> --json"
75
88
  ];
76
89
 
77
90
  export const COMPARE_FOCUSES = ["overview", "market", "reviews", "marketing", "audience", "showcases"];
package/src/help.js CHANGED
@@ -52,6 +52,10 @@ Usage:
52
52
  gmc games review-history --source steam <externalId> --json
53
53
  gmc games more-like-this --source steam <externalId> --country <country> --json
54
54
  gmc games ai-disclosure <appid> --json
55
+ gmc games price-history <appid> --range 1y --json
56
+ gmc games follower-history <appid> --range 1y --json
57
+ gmc games events <appid> --json
58
+ gmc games screenshot-labels <appid> --json
55
59
  gmc reviews patterns --source steam --tags <tag_slug[,tag_slug...]> --reviews-min <number> --json
56
60
  gmc reviews patterns --source steam --tags <tag_slug[,tag_slug...]> --sample-size <number> --preset summary --json
57
61
  gmc marketing patterns --source steam --tags <tag_slug[,tag_slug...]> --reviews-min <number> --json
@@ -75,6 +79,9 @@ Usage:
75
79
  gmc roblox experience <universeId> --json
76
80
  gmc roblox history <universeId> --granularity daily --json
77
81
  gmc roblox coverage --json
82
+ gmc roblox saved --country all --json
83
+ gmc roblox save <universeId> --json
84
+ gmc roblox remove <universeId> --json
78
85
 
79
86
  Tag taxonomy:
80
87
  gmc tags --json List the full tag dictionary (slug, name).
@@ -182,6 +189,77 @@ AI-disclosure text and history:
182
189
  Example:
183
190
  gmc games ai-disclosure 730 --json
184
191
 
192
+ Observed price history:
193
+ gmc games price-history <appid> [--range <preset>] [--from <date>] [--to <date>] --json
194
+ One game's Steam storefront price on
195
+ every OBSERVED day: priceInitial,
196
+ priceFinal (major currency units),
197
+ discountPercent, currency, storeCountry.
198
+ --range <preset> 7d | 1m | 1y | all, ending today (UTC).
199
+ Defaults to all.
200
+ --from <YYYY-MM-DD> Inclusive window start.
201
+ --to <YYYY-MM-DD> Inclusive window end; defaults to today.
202
+ --range cannot be combined with --from
203
+ or --to.
204
+
205
+ Collection is not daily for every game, so days without an observation are
206
+ absent and never filled; scale by date, never by array index. priceFinal 0
207
+ with a null priceInitial is a free game; a null priceFinal means no price
208
+ was shown that day. data.availability not_collected means no snapshot was
209
+ ever collected, never a free game. A price says nothing about units sold or
210
+ revenue.
211
+
212
+ Example:
213
+ gmc games price-history 730 --range 1y --json
214
+
215
+ Steam follower history:
216
+ gmc games follower-history <appid> [--range <preset>] [--from <date>] [--to <date>] --json
217
+ Steam Community game-group members on
218
+ every OBSERVED day: date, followerCount.
219
+ --range <preset> 7d | 1m | 1y | all, ending today (UTC).
220
+ Defaults to all. Cannot be combined with
221
+ --from or --to.
222
+
223
+ Followers are a public proxy for audience interest, never a wishlist count
224
+ and never sales. Days without an observation are absent and never filled.
225
+ data.availability not_collected means followers were never collected.
226
+
227
+ Example:
228
+ gmc games follower-history 730 --range 1y --json
229
+
230
+ Steam event participation:
231
+ gmc games events <appid> [--include rank_series] --json
232
+ Steam-run events the game took part in
233
+ (Next Fest and others), newest first, with
234
+ observed best rank, shelf exposure, demo
235
+ reviews, store followers and social
236
+ deltas.
237
+ --include rank_series Attach the observed ranking series per
238
+ event.
239
+
240
+ Ranks and shelves are observed event-page state, never an official Steam
241
+ ranking. An absent block means the signal was not collected.
242
+
243
+ Example:
244
+ gmc games events 730 --include rank_series --json
245
+
246
+ Screenshot labels:
247
+ gmc games screenshot-labels <appid> --json
248
+ Visual labels for each Steam store
249
+ screenshot of one game (screen type,
250
+ camera, art style, lighting, colour, a
251
+ one-line observation), in store order.
252
+ There are no parameters beyond the appid.
253
+
254
+ The labels are MODEL OUTPUT (data.labels[].labeler), not Steam data and not
255
+ a human review, and they can be wrong. One label set is returned per
256
+ screenshot: the most recently labelled taxonomy version. data.availability
257
+ not_labelled means nothing has been labelled yet, never that the game has
258
+ no screenshots.
259
+
260
+ Example:
261
+ gmc games screenshot-labels 730 --json
262
+
185
263
  Steam tag trends:
186
264
  gmc steam tag-trends --view <view> --json
187
265
  Tag supply trends over one frozen monthly
@@ -248,6 +326,13 @@ Roblox:
248
326
  A bounded window of stored metric
249
327
  observations.
250
328
  gmc roblox coverage --json What the surface has, per dimension.
329
+ gmc roblox saved --json This WORKSPACE's saved experiences,
330
+ resolved for one cohort and cutoff.
331
+ gmc roblox save <universeId> --json
332
+ Save one experience to the workspace.
333
+ gmc roblox remove <universeId> --json
334
+ Remove one from the workspace's saved
335
+ set.
251
336
  --country <country> all | US | JP. Selects which CHART was
252
337
  read, never a regional metric scope.
253
338
  --device <device> computer.
@@ -255,8 +340,8 @@ Roblox:
255
340
  top-revisited | fun-with-friends.
256
341
  --as-of <instant> ISO 8601 cutoff; defaults to now. No
257
342
  observation after it is considered.
258
- --eligibility-days <number> experiences only, 1-30 (default 7). How
259
- far back a chart appearance still counts.
343
+ --eligibility-days <number> experiences and saved, 1-30 (default 7).
344
+ How far back a chart appearance counts.
260
345
  --min-player-count <number> experiences only, on the resolved row.
261
346
  --max-rank <number> experiences only, on the resolved row.
262
347
  --genre <genre> experiences only, exact match.
@@ -295,11 +380,37 @@ Roblox:
295
380
  ROBLOX_UNAVAILABLE. That is the documented not-available state, not an
296
381
  outage and not worth retrying on a schedule.
297
382
 
383
+ Saved experiences (workspace-scoped, unlike the five reads above):
384
+ saved, save and remove carry one workspace's own data and need a
385
+ credential — a Game Market Copilot API key or a CLI credential. Without
386
+ one they answer 401, which is not an empty saved list.
387
+
388
+ Saved membership is scope-independent. --country, --device, --sort,
389
+ --as-of and --eligibility-days apply to "saved" alone and decide which of
390
+ three states each row is in, never which rows are listed: in_scope
391
+ carries the observation that scope recorded, out_of_scope is saved and
392
+ tracked with NO reading there (never back-filled from another cohort or
393
+ from after the cutoff, and never reportable as zero or as removed), and
394
+ not_tracked has nothing recorded at all. The two writes take no flags.
395
+
396
+ Both writes are idempotent and report the state that NOW holds, so
397
+ changed: false is success and must not be retried as a failure. Saving
398
+ records interest in a universe id — it never triggers collection, however
399
+ often it is called, and it refuses an id the tracked cohort has never
400
+ recorded (404 NOT_FOUND, a different fact from ROBLOX_UNAVAILABLE).
401
+ "remove" removes the id from this workspace's saved set only; it deletes
402
+ no data and removing something not saved is changed: false, not a 404.
403
+ The set is capped per workspace (savedLimit in every response); past it,
404
+ saving answers 409 CONFLICT.
405
+
298
406
  Examples:
299
407
  gmc roblox coverage --json
300
408
  gmc roblox charts --country JP --sort top-trending --json
301
409
  gmc roblox experiences --min-player-count 1000 --order-by player_count --json
302
410
  gmc roblox history <universeId> --granularity hourly --json
411
+ gmc roblox saved --country JP --sort top-trending --json
412
+ gmc roblox save <universeId> --json
413
+ gmc roblox remove <universeId> --json
303
414
 
304
415
  Binary aliases:
305
416
  gmcp and game-market-copilot run the same CLI. Use them when a shell
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";
@@ -781,6 +892,84 @@ export function buildRequest(command, options, positional) {
781
892
  };
782
893
  }
783
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
+
784
973
  return null;
785
974
  }
786
975
 
@@ -1141,6 +1330,11 @@ function assertSource(source = "steam") {
1141
1330
  }
1142
1331
  }
1143
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
+
1144
1338
  function assertDate(value, optionName) {
1145
1339
  if (!/^\d{4}-\d{2}-\d{2}$/.test(String(value))) {
1146
1340
  writeJsonError({
@@ -1151,6 +1345,30 @@ function assertDate(value, optionName) {
1151
1345
  }
1152
1346
  }
1153
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
+
1154
1372
  function assertPositiveIntegerOption(value, optionName) {
1155
1373
  const number = Number(value);
1156
1374
  if (!Number.isInteger(number) || number < 1) {
@@ -1315,10 +1533,17 @@ const GLOBAL_CLI_OPTIONS = new Set(["json", "help", "version", "fields", "preset
1315
1533
  function assertSupportedOptions(options, { command, allowed }) {
1316
1534
  for (const key of Object.keys(options)) {
1317
1535
  if (GLOBAL_CLI_OPTIONS.has(key) || allowed.includes(key)) continue;
1318
- 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.";
1319
1544
  writeJsonError({
1320
1545
  code: "CLI_USAGE_ERROR",
1321
- 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.`
1322
1547
  });
1323
1548
  process.exit(EXIT_USAGE);
1324
1549
  }
@@ -1376,7 +1601,11 @@ function assertAppid(value, usage) {
1376
1601
 
1377
1602
  /**
1378
1603
  * NAK-715. Validates the universeId positional for the Roblox per-experience
1379
- * commands, mirroring the path-parameter check the app applies.
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.
1380
1609
  *
1381
1610
  * It returns a STRING and never a number, which is the point: a Roblox
1382
1611
  * universe id is a 64-bit value, and `Number.parseInt` would round anything
@@ -1389,6 +1618,14 @@ function assertAppid(value, usage) {
1389
1618
  *
1390
1619
  * A Roblox universe id is NOT a Steam appid: the two id spaces are unrelated
1391
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.
1392
1629
  */
1393
1630
  function assertUniverseId(value, usage) {
1394
1631
  const raw = value == null ? "" : String(value).trim();
@@ -514,6 +514,111 @@ test("games ai-disclosure is listed as Provisional (NAK-804)", () => {
514
514
  assert.ok(PROVISIONAL_BETA_COMMANDS.includes("gmc games ai-disclosure <appid> --json"));
515
515
  });
516
516
 
517
+ // --- gmc games price-history / screenshot-labels (NAK-934) ----------------
518
+ //
519
+ // The ai-disclosure route shape: a plain numeric appid, no --source.
520
+
521
+ test("games price-history targets the steam route and leaves the window to the server by default", () => {
522
+ const request = buildRequestFromArgv(["games", "price-history", "730", "--json"]);
523
+
524
+ assert.equal(request.path, "/api/v1/steam/games/730/price-history");
525
+ assert.deepEqual(request.query, {});
526
+ });
527
+
528
+ test("games price-history forwards --range, or --from/--to, verbatim", () => {
529
+ assert.deepEqual(
530
+ buildRequestFromArgv(["games", "price-history", "730", "--range", "1y", "--json"]).query,
531
+ { range: "1y" }
532
+ );
533
+ assert.deepEqual(
534
+ buildRequestFromArgv([
535
+ "games", "price-history", "730", "--from", "2026-01-01", "--to", "2026-03-31", "--json"
536
+ ]).query,
537
+ { from: "2026-01-01", to: "2026-03-31" }
538
+ );
539
+ });
540
+
541
+ test("games price-history rejects a bad window before any request", () => {
542
+ for (const [argv, message] of [
543
+ [["--range", "90d"], /--range must be one of: 7d, 1m, 1y, all/],
544
+ [["--range", "1y", "--from", "2026-01-01"], /--range cannot be combined with --from or --to/],
545
+ [["--from", "2026/01/01"], /--from must use YYYY-MM-DD/],
546
+ [["--to", "yesterday"], /--to must use YYYY-MM-DD/],
547
+ // Real calendar dates only, as on the server (NAK-934 review).
548
+ [["--from", "2026-02-30"], /--from must be a real calendar date/],
549
+ [["--to", "2025-02-29"], /--to must be a real calendar date/],
550
+ [["--from", "2026-13-01"], /--from must be a real calendar date/],
551
+ [["--to", "2026-04-00"], /--to must be a real calendar date/],
552
+ // An explicit pair must be ordered.
553
+ [["--from", "2026-05-01", "--to", "2026-04-01"], /--from must be on or before --to/],
554
+ [["--country", "jp"], /--country is not supported by gmc games price-history/],
555
+ [["--source", "steam"], /--source is not supported by gmc games price-history/]
556
+ ]) {
557
+ const { output, exitCode } = captureUsageFailure(() =>
558
+ buildRequestFromArgv(["games", "price-history", "730", ...argv, "--json"])
559
+ );
560
+ assert.equal(exitCode, 2, argv.join(" "));
561
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
562
+ assert.match(output.error.message, message);
563
+ }
564
+ });
565
+
566
+ test("games price-history accepts a leap day, a single-day window and a lone --from", () => {
567
+ assert.deepEqual(
568
+ buildRequestFromArgv(["games", "price-history", "730", "--from", "2024-02-29", "--json"]).query,
569
+ { from: "2024-02-29" }
570
+ );
571
+ assert.deepEqual(
572
+ buildRequestFromArgv([
573
+ "games", "price-history", "730", "--from", "2026-04-01", "--to", "2026-04-01", "--json"
574
+ ]).query,
575
+ { from: "2026-04-01", to: "2026-04-01" }
576
+ );
577
+ });
578
+
579
+ test("games price-history rejects a malformed appid and an extra positional", () => {
580
+ for (const argv of [["0x2da"], ["730", "440"], []]) {
581
+ const { output, exitCode } = captureUsageFailure(() =>
582
+ buildRequestFromArgv(["games", "price-history", ...argv, "--json"])
583
+ );
584
+ assert.equal(exitCode, 2, argv.join(" "));
585
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
586
+ }
587
+ });
588
+
589
+ test("games screenshot-labels targets the steam route and takes no options", () => {
590
+ const request = buildRequestFromArgv(["games", "screenshot-labels", "730", "--fields", "labels", "--json"]);
591
+ assert.equal(request.path, "/api/v1/steam/games/730/screenshot-labels");
592
+ assert.deepEqual(request.query, {});
593
+
594
+ const { output, exitCode } = captureUsageFailure(() =>
595
+ buildRequestFromArgv(["games", "screenshot-labels", "730", "--limit", "5", "--json"])
596
+ );
597
+ assert.equal(exitCode, 2);
598
+ assert.match(output.error.message, /--limit is not supported by gmc games screenshot-labels/);
599
+ });
600
+
601
+ test("help documents the NAK-934 commands and lists them as Provisional", () => {
602
+ const chunks = [];
603
+ const original = process.stdout.write;
604
+ process.stdout.write = (chunk) => {
605
+ chunks.push(String(chunk));
606
+ return true;
607
+ };
608
+ try {
609
+ printHelp();
610
+ } finally {
611
+ process.stdout.write = original;
612
+ }
613
+ const help = chunks.join("");
614
+
615
+ assert.ok(help.includes("gmc games price-history <appid> [--range <preset>]"));
616
+ assert.ok(help.includes("gmc games screenshot-labels <appid> --json"));
617
+ assert.ok(help.includes("MODEL OUTPUT"));
618
+ assert.ok(PROVISIONAL_BETA_COMMANDS.includes("gmc games price-history <appid> --range 7d|1m|1y|all --json"));
619
+ assert.ok(PROVISIONAL_BETA_COMMANDS.includes("gmc games screenshot-labels <appid> --json"));
620
+ });
621
+
517
622
  // --- gmc games language-topics (NAK-455) ---------------------------------
518
623
 
519
624
  test("games language-topics targets the canonical route and sends no invented parameters", () => {
@@ -1468,3 +1573,272 @@ test("(NAK-715) the roblox commands are published as provisional in --help", ()
1468
1573
  );
1469
1574
  }
1470
1575
  });
1576
+
1577
+ /**
1578
+ * NAK-723: `gmc roblox saved | save | remove`.
1579
+ *
1580
+ * The saved surface is workspace-scoped and MUTATING, so these tests pin what
1581
+ * the read tests above do not have to: which flags belong to the list and not
1582
+ * to the writes, that the two writes carry the right HTTP method and path,
1583
+ * and that the id reaches the BODY as a decimal string rather than a number.
1584
+ */
1585
+ test("(NAK-723) roblox saved reads the bare endpoint and lets the server pick the scope", () => {
1586
+ assert.deepEqual(buildRequestFromArgv(["roblox", "saved", "--json"]), {
1587
+ path: "/api/v1/roblox/saved",
1588
+ // Empty, not {country: "all"}: a default copied here could drift from the
1589
+ // server's, exactly as on the read family.
1590
+ query: {}
1591
+ });
1592
+ });
1593
+
1594
+ test("(NAK-723) roblox saved maps every scope flag to its query parameter", () => {
1595
+ const request = buildRequestFromArgv([
1596
+ "roblox", "saved",
1597
+ "--country", "JP",
1598
+ "--device", "computer",
1599
+ "--sort", "top-trending",
1600
+ "--as-of", "2026-09-20T00:00:00.000Z",
1601
+ "--eligibility-days", "30",
1602
+ "--json"
1603
+ ]);
1604
+
1605
+ assert.equal(request.path, "/api/v1/roblox/saved");
1606
+ assert.equal(request.method, undefined);
1607
+ assert.deepEqual(request.query, {
1608
+ country: "JP",
1609
+ device: "computer",
1610
+ sort: "top-trending",
1611
+ as_of: "2026-09-20T00:00:00.000Z",
1612
+ eligibility_days: "30"
1613
+ });
1614
+ });
1615
+
1616
+ /**
1617
+ * The saved set is capped per workspace and one response always holds all of
1618
+ * it. Accepting a pager would imply the list can hide part of itself, and the
1619
+ * REST allowlist rejects both.
1620
+ */
1621
+ test("(NAK-723) roblox saved is not paginated and refuses a pager rather than dropping it", () => {
1622
+ for (const flag of ["--limit", "--cursor", "--offset"]) {
1623
+ const { output, exitCode } = captureUsageFailure(() =>
1624
+ buildRequestFromArgv(["roblox", "saved", flag, "10", "--json"])
1625
+ );
1626
+ assert.equal(exitCode, 2, `expected ${flag} to be refused`);
1627
+ assert.match(output.error.message, /is not supported by gmc roblox saved/);
1628
+ }
1629
+ });
1630
+
1631
+ test("(NAK-723) roblox save posts the id as a decimal string body", () => {
1632
+ const request = buildRequestFromArgv(["roblox", "save", "4924922222", "--json"]);
1633
+
1634
+ assert.equal(request.path, "/api/v1/roblox/saved");
1635
+ assert.equal(request.method, "POST");
1636
+ assert.deepEqual(request.query, {});
1637
+ assert.deepEqual(request.body, { universeId: "4924922222" });
1638
+ // The server refuses a JSON number outright, because a number is what a
1639
+ // caller sends when it has routed a Steam appid into this path.
1640
+ assert.equal(typeof request.body.universeId, "string");
1641
+ });
1642
+
1643
+ /**
1644
+ * Above 2^53 a number would round into a DIFFERENT experience's id, and the
1645
+ * body is where that would silently succeed.
1646
+ */
1647
+ test("(NAK-723) a large universe id keeps every digit through the save body", () => {
1648
+ const request = buildRequestFromArgv(["roblox", "save", "9007199254740993", "--json"]);
1649
+ assert.deepEqual(request.body, { universeId: "9007199254740993" });
1650
+ assert.notEqual(String(Number("9007199254740993")), request.body.universeId);
1651
+ });
1652
+
1653
+ test("(NAK-723) roblox remove deletes the per-id path", () => {
1654
+ const request = buildRequestFromArgv(["roblox", "remove", "4924922222", "--json"]);
1655
+
1656
+ assert.equal(request.path, "/api/v1/roblox/saved/4924922222");
1657
+ assert.equal(request.method, "DELETE");
1658
+ assert.deepEqual(request.query, {});
1659
+ assert.equal(request.body, undefined);
1660
+ });
1661
+
1662
+ /**
1663
+ * The scope flags belong to `saved` alone. A save that quietly ignored
1664
+ * --country would look correct while answering a question nobody asked, and
1665
+ * the identical REST request is a 400.
1666
+ */
1667
+ test("(NAK-723) the writes take no flags, so a scope flag is a usage error", () => {
1668
+ for (const subcommand of ["save", "remove"]) {
1669
+ for (const [flag, value] of [["--country", "JP"], ["--as-of", "2026-09-20T00:00:00.000Z"]]) {
1670
+ const { output, exitCode } = captureUsageFailure(() =>
1671
+ buildRequestFromArgv(["roblox", subcommand, "4924922222", flag, value, "--json"])
1672
+ );
1673
+ assert.equal(exitCode, 2, `expected ${subcommand} ${flag} to be refused`);
1674
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
1675
+ assert.match(output.error.message, new RegExp(`is not supported by gmc roblox ${subcommand}`));
1676
+ // A command with an empty allowlist says so, rather than rendering
1677
+ // "Supported options: ." and reading like a truncated message.
1678
+ assert.match(output.error.message, /This command takes no options\./);
1679
+ }
1680
+ }
1681
+ });
1682
+
1683
+ /**
1684
+ * The same lexical contract the read commands enforce, applied before any
1685
+ * request is built: a spelling the server's `parseRobloxId` rejects must not
1686
+ * reach the wire looking fine, and the message must name the Steam-appid
1687
+ * confusion rather than only "invalid id".
1688
+ */
1689
+ test("(NAK-723) a malformed universe id fails fast on both writes", () => {
1690
+ for (const subcommand of ["save", "remove"]) {
1691
+ for (const bad of ["730.0", "0x2da", "7e2", "-1", "0", "007", "abc"]) {
1692
+ const { output, exitCode } = captureUsageFailure(() =>
1693
+ buildRequestFromArgv(["roblox", subcommand, bad, "--json"])
1694
+ );
1695
+ assert.equal(exitCode, 2, `expected ${subcommand} ${bad} to be refused`);
1696
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
1697
+ assert.match(output.error.message, /not a Steam appid/);
1698
+ assert.match(output.error.message, new RegExp(`gmc roblox ${subcommand} <universeId>`));
1699
+ }
1700
+
1701
+ const missing = captureUsageFailure(() => buildRequestFromArgv(["roblox", subcommand, "--json"]));
1702
+ assert.equal(missing.exitCode, 2, `expected a missing id on ${subcommand} to be refused`);
1703
+ }
1704
+ });
1705
+
1706
+ /**
1707
+ * A bare Steam appid is a LEXICALLY valid universe id on both sides, so it is
1708
+ * forwarded and the server's tracked-cohort gate answers NOT_FOUND naming that
1709
+ * fact. Guessing from the id's magnitude here would invent a rule the server
1710
+ * does not have and would reject real small universe ids.
1711
+ */
1712
+ test("(NAK-723) a lexically valid small id is forwarded for the server's cohort gate to judge", () => {
1713
+ assert.deepEqual(buildRequestFromArgv(["roblox", "save", "730", "--json"]).body, {
1714
+ universeId: "730"
1715
+ });
1716
+ });
1717
+
1718
+ test("(NAK-723) a stray positional on a write is a usage error, not a second id", () => {
1719
+ for (const subcommand of ["save", "remove"]) {
1720
+ const { exitCode } = captureUsageFailure(() =>
1721
+ buildRequestFromArgv(["roblox", subcommand, "4924922222", "4924922223", "--json"])
1722
+ );
1723
+ assert.equal(exitCode, 2, `expected a second id on ${subcommand} to be refused`);
1724
+ }
1725
+ });
1726
+
1727
+ /**
1728
+ * A token missing from the args.js whitelist falls through to positional, so
1729
+ * `gmc roblox save <id>` would parse as the unknown command `roblox` — the same
1730
+ * trap `lists materialize` documents, and worse here because both are writes.
1731
+ */
1732
+ test("(NAK-723) saved, save and remove are real command tokens, not positionals", () => {
1733
+ for (const subcommand of ["saved", "save", "remove"]) {
1734
+ const { command, positional } = parseArgs(["roblox", subcommand, "4924922222", "--json"]);
1735
+ assert.deepEqual(command, ["roblox", subcommand]);
1736
+ // The id is the command's own positional, so the branch above receives it
1737
+ // rather than the subcommand name landing here as a stray argument.
1738
+ assert.deepEqual(positional, ["4924922222"]);
1739
+ }
1740
+
1741
+ // And `saved` takes none, so the same token there is an extra argument
1742
+ // rather than a silently ignored one.
1743
+ assert.equal(
1744
+ captureUsageFailure(() => buildRequestFromArgv(["roblox", "saved", "4924922222", "--json"])).exitCode,
1745
+ 2
1746
+ );
1747
+ });
1748
+
1749
+ test("(NAK-723) the saved commands are published as provisional in --help", () => {
1750
+ for (const command of [
1751
+ "gmc roblox saved --country all --json",
1752
+ "gmc roblox save <universeId> --json",
1753
+ "gmc roblox remove <universeId> --json"
1754
+ ]) {
1755
+ assert.ok(
1756
+ PROVISIONAL_BETA_COMMANDS.includes(command),
1757
+ `${command} must stay listed as provisional`
1758
+ );
1759
+ }
1760
+
1761
+ const chunks = [];
1762
+ const original = process.stdout.write;
1763
+ process.stdout.write = (chunk) => {
1764
+ chunks.push(String(chunk));
1765
+ return true;
1766
+ };
1767
+ try {
1768
+ printHelp();
1769
+ } finally {
1770
+ process.stdout.write = original;
1771
+ }
1772
+ const help = chunks.join("");
1773
+
1774
+ assert.ok(help.includes("gmc roblox saved --country all --json"));
1775
+ assert.ok(help.includes("gmc roblox save <universeId> --json"));
1776
+ assert.ok(help.includes("gmc roblox remove <universeId> --json"));
1777
+ // The two facts an agent most needs from the help text, and the two most
1778
+ // expensive to get wrong: a repeat write is success, and an out-of-scope row
1779
+ // is still saved.
1780
+ assert.match(help, /changed: false is success/);
1781
+ assert.match(help, /out_of_scope is saved and\s+tracked/);
1782
+ });
1783
+
1784
+ // --- gmc games follower-history / events (NAK-934) -------------------------
1785
+
1786
+ test("games follower-history targets the steam route and leaves the window to the server by default", () => {
1787
+ const request = buildRequestFromArgv(["games", "follower-history", "2436940", "--json"]);
1788
+ assert.equal(request.path, "/api/v1/steam/games/2436940/follower-history");
1789
+ assert.deepEqual(request.query, {});
1790
+ });
1791
+
1792
+ test("games follower-history passes a preset or an explicit window", () => {
1793
+ assert.deepEqual(buildRequestFromArgv(["games", "follower-history", "730", "--range", "1y", "--json"]).query, { range: "1y" });
1794
+ assert.deepEqual(
1795
+ buildRequestFromArgv(["games", "follower-history", "730", "--from", "2026-01-01", "--to", "2026-06-30", "--json"]).query,
1796
+ { from: "2026-01-01", to: "2026-06-30" }
1797
+ );
1798
+ });
1799
+
1800
+ test("games follower-history rejects a bad window before any request", () => {
1801
+ for (const [argv, message] of [
1802
+ [["--range", "1y", "--from", "2026-01-01"], /--range cannot be combined with --from or --to/],
1803
+ [["--range", "90d"], /--range must be one of: 7d, 1m, 1y, all/],
1804
+ [["--from", "2026/01/01"], /--from must use YYYY-MM-DD/],
1805
+ [["--from", "2026-02-30"], /--from must be a real calendar date/],
1806
+ [["--from", "2026-05-01", "--to", "2026-04-01"], /--from must be on or before --to/],
1807
+ [["--country", "jp"], /--country is not supported by gmc games follower-history/]
1808
+ ]) {
1809
+ const { output, exitCode } = captureUsageFailure(() =>
1810
+ buildRequestFromArgv(["games", "follower-history", "730", ...argv, "--json"])
1811
+ );
1812
+ assert.equal(exitCode, 2, argv.join(" "));
1813
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
1814
+ assert.match(output.error.message, message);
1815
+ }
1816
+ });
1817
+
1818
+ test("games events targets the steam route, with rank_series opt-in", () => {
1819
+ assert.equal(buildRequestFromArgv(["games", "events", "3364220", "--json"]).path, "/api/v1/steam/games/3364220/events");
1820
+ assert.deepEqual(buildRequestFromArgv(["games", "events", "3364220", "--json"]).query, {});
1821
+ assert.deepEqual(buildRequestFromArgv(["games", "events", "3364220", "--include", "rank_series", "--json"]).query, { include: "rank_series" });
1822
+ });
1823
+
1824
+ test("games events rejects an unknown include value and unsupported flags", () => {
1825
+ for (const [argv, message] of [
1826
+ [["--include", "ranks"], /--include must be one of: rank_series/],
1827
+ [["--include", "bad", "--include", "rank_series"], /--include may be given once; its only value is rank_series/],
1828
+ [["--include", "rank_series", "--include=rank_series"], /--include may be given once/],
1829
+ [["--range", "1y"], /--range is not supported by gmc games events/]
1830
+ ]) {
1831
+ const { output, exitCode } = captureUsageFailure(() =>
1832
+ buildRequestFromArgv(["games", "events", "730", ...argv, "--json"])
1833
+ );
1834
+ assert.equal(exitCode, 2, argv.join(" "));
1835
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
1836
+ assert.match(output.error.message, message);
1837
+ }
1838
+ });
1839
+
1840
+ test("the new commands are listed and documented", async () => {
1841
+ const { PROVISIONAL_BETA_COMMANDS } = await import("./constants.js");
1842
+ assert.ok(PROVISIONAL_BETA_COMMANDS.some((command) => command.startsWith("gmc games follower-history ")));
1843
+ assert.ok(PROVISIONAL_BETA_COMMANDS.some((command) => command.startsWith("gmc games events ")));
1844
+ });