@witchpot/gmc 0.8.0 → 0.9.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.
@@ -4,6 +4,7 @@ import test from "node:test";
4
4
  import { parseArgs } from "./args.js";
5
5
  import { buildRequest } from "./requests.js";
6
6
  import { printHelp } from "./help.js";
7
+ import { PROVISIONAL_BETA_COMMANDS } from "./constants.js";
7
8
 
8
9
  function buildRequestFromArgv(argv) {
9
10
  const { command, options, positional } = parseArgs(argv);
@@ -402,6 +403,117 @@ test("games more-like-this forwards an explicit country, normalized", () => {
402
403
  assert.deepEqual(request.query, { country: "jp" });
403
404
  });
404
405
 
406
+ // --- gmc games ai-disclosure (NAK-804) ------------------------------------
407
+ //
408
+ // A different route shape from every other `games` sub-path tested above:
409
+ // there is no --source flag (the endpoint is keyed on the plain numeric
410
+ // appid, not a --source-qualified externalId) and no query parameters at
411
+ // all, the review-history sibling's shape.
412
+
413
+ test("games ai-disclosure targets the canonical route", () => {
414
+ const request = buildRequestFromArgv(["games", "ai-disclosure", "730", "--json"]);
415
+
416
+ assert.equal(request.path, "/api/v1/games/730/ai-disclosure");
417
+ assert.deepEqual(request.query, {});
418
+ });
419
+
420
+ test("games ai-disclosure rejects a missing appid", () => {
421
+ const { output, exitCode } = captureUsageFailure(() =>
422
+ buildRequestFromArgv(["games", "ai-disclosure", "--json"])
423
+ );
424
+
425
+ assert.equal(exitCode, 2);
426
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
427
+ assert.match(output.error.message, /gmc games ai-disclosure <appid> --json/);
428
+ });
429
+
430
+ /**
431
+ * The appid is a plain positive decimal integer, matching the strict
432
+ * path-parameter check the app applies to every `/api/v1/.../games/{appid}`
433
+ * route. Each rejected spelling is asserted individually: a string like
434
+ * "7e2" or "0x2da" round-trips through `Number()` into a valid-looking
435
+ * integer, so a looser check here would let a value through that the API
436
+ * answers 400 for.
437
+ */
438
+ test("games ai-disclosure rejects non-numeric and malformed appid spellings", () => {
439
+ for (const badAppid of ["not-an-appid", "7e2", "0x2da", "730.5", "-730", "0", " 730", "730 "]) {
440
+ const { output, exitCode } = captureUsageFailure(() =>
441
+ buildRequestFromArgv(["games", "ai-disclosure", badAppid, "--json"])
442
+ );
443
+
444
+ assert.equal(exitCode, 2, `expected "${badAppid}" to be rejected`);
445
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
446
+ assert.match(output.error.message, /Missing or invalid appid/);
447
+ }
448
+ });
449
+
450
+ test("games ai-disclosure rejects request-shaping flags the endpoint does not have", () => {
451
+ for (const [flag, value] of [
452
+ ["--source", "steam"],
453
+ ["--locale", "ja"],
454
+ ["--limit", "50"]
455
+ ]) {
456
+ const { output, exitCode } = captureUsageFailure(() =>
457
+ buildRequestFromArgv(["games", "ai-disclosure", "730", flag, value, "--json"])
458
+ );
459
+
460
+ assert.equal(exitCode, 2, `expected ${flag} to be rejected`);
461
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
462
+ assert.match(
463
+ output.error.message,
464
+ new RegExp(`\\${flag} is not supported by gmc games ai-disclosure`)
465
+ );
466
+ }
467
+ });
468
+
469
+ /**
470
+ * Output shaping never reaches the wire, so it must not trip the allowlist.
471
+ */
472
+ test("games ai-disclosure still accepts the global output flags", () => {
473
+ const request = buildRequestFromArgv(["games", "ai-disclosure", "730", "--fields", "state", "--json"]);
474
+
475
+ assert.equal(request.path, "/api/v1/games/730/ai-disclosure");
476
+ assert.deepEqual(request.query, {});
477
+ });
478
+
479
+ test("games ai-disclosure rejects an extra positional argument", () => {
480
+ const { output, exitCode } = captureUsageFailure(() =>
481
+ buildRequestFromArgv(["games", "ai-disclosure", "730", "440", "--json"])
482
+ );
483
+
484
+ assert.equal(exitCode, 2);
485
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
486
+ assert.match(output.error.message, /Unexpected extra argument "440"/);
487
+ });
488
+
489
+ test("help documents gmc games ai-disclosure (NAK-804)", () => {
490
+ const chunks = [];
491
+ const original = process.stdout.write;
492
+ process.stdout.write = (chunk) => {
493
+ chunks.push(String(chunk));
494
+ return true;
495
+ };
496
+ try {
497
+ printHelp();
498
+ } finally {
499
+ process.stdout.write = original;
500
+ }
501
+ const help = chunks.join("");
502
+
503
+ assert.ok(help.includes("gmc games ai-disclosure <appid> --json"));
504
+ assert.ok(help.includes("never that the title\n uses no generative AI"));
505
+ });
506
+
507
+ test("games ai-disclosure is listed as Provisional (NAK-804)", () => {
508
+ // CHANGELOG, api.md and cli-use-cases.md all state that this command is
509
+ // Provisional in the beta command set. The help-output substring check
510
+ // above cannot pin this: "gmc games ai-disclosure <appid> --json" also
511
+ // appears verbatim in the Usage section on its own, so deleting this entry
512
+ // from PROVISIONAL_BETA_COMMANDS would leave every existing test green
513
+ // while the docs and reality diverge silently.
514
+ assert.ok(PROVISIONAL_BETA_COMMANDS.includes("gmc games ai-disclosure <appid> --json"));
515
+ });
516
+
405
517
  // --- gmc games language-topics (NAK-455) ---------------------------------
406
518
 
407
519
  test("games language-topics targets the canonical route and sends no invented parameters", () => {
@@ -480,7 +592,7 @@ test("games language-topics rejects an out-of-range min_reviews before any reque
480
592
  /**
481
593
  * `Number("1e2")` and `Number("0x64")` are both 100, so a `Number`-based bounds
482
594
  * check would forward these spellings to an endpoint that rejects them:
483
- * `parseIntParam` (src/server/gmc/api/validation.ts) tests `/^-?\d+$/` and
595
+ * `parseIntParam` (in the app's API validation module) tests `/^-?\d+$/` and
484
596
  * answers 400 `min_reviews must be an integer`. The CLI must not turn a string
485
597
  * the API refuses into a successful request.
486
598
  */
@@ -921,8 +1033,8 @@ test("(NAK-587) an omitted --catalog-as-of leaves the query untouched", () => {
921
1033
  * NAK-773. `--ai-disclosure` reaches games search/count/aggregate through the
922
1034
  * SAME shared query builder as every other filter, and is forwarded verbatim
923
1035
  * (kebab-case flag to camelCase query param) as the three literal values the
924
- * server matches case-sensitively (assertAiDisclosureFilterValue,
925
- * src/server/gmc/repositories/ai-disclosure.ts) -- the CLI does not
1036
+ * server matches case-sensitively (assertAiDisclosureFilterValue, in the
1037
+ * app's AI-disclosure repository module) -- the CLI does not
926
1038
  * lower-case or trim a typo into a value the caller did not send.
927
1039
  */
928
1040
  test("(NAK-773) games search/count/aggregate forward --ai-disclosure as the aiDisclosure query param", () => {
@@ -1022,6 +1134,89 @@ test("(NAK-773) --ai-disclosure composes with the release_month,ai_disclosure cr
1022
1134
  assert.equal(request.query.aiDisclosure, "unconfirmed");
1023
1135
  });
1024
1136
 
1137
+ /**
1138
+ * NAK-803. `--release-status` closes the CLI/REST divergence the
1139
+ * AI-disclosure chart-ready recipe documented: `releaseStatus` was reachable
1140
+ * on the REST endpoint but had no CLI mapping, so the CLI variant of a
1141
+ * "released games only" question had to rely on the release window alone.
1142
+ * The three values are the closed set the server enforces
1143
+ * (RELEASE_STATUS_FILTER_VALUES in the app's games repository module, and
1144
+ * the same literal triple the market RPCs re-check), so the CLI rejects
1145
+ * anything outside it locally rather than normalizing a typo into a value
1146
+ * the caller did not send.
1147
+ */
1148
+ test("(NAK-803) games search/count/aggregate forward --release-status as the releaseStatus query param", () => {
1149
+ const search = buildRequestFromArgv([
1150
+ "games", "search", "--source", "steam", "--release-status", "released", "--json"
1151
+ ]);
1152
+ assert.equal(search.query.releaseStatus, "released");
1153
+
1154
+ const count = buildRequestFromArgv([
1155
+ "games", "count", "--source", "steam", "--release-status", "upcoming", "--json"
1156
+ ]);
1157
+ assert.equal(count.query.releaseStatus, "upcoming");
1158
+
1159
+ const aggregate = buildRequestFromArgv([
1160
+ "games", "aggregate", "--source", "steam", "--metric", "count",
1161
+ "--release-status", "tba", "--json"
1162
+ ]);
1163
+ assert.equal(aggregate.query.releaseStatus, "tba");
1164
+ });
1165
+
1166
+ test("(NAK-803) --release-status accepts each of the three enum values", () => {
1167
+ for (const value of ["released", "upcoming", "tba"]) {
1168
+ const request = buildRequestFromArgv(["games", "count", "--source", "steam", "--release-status", value, "--json"]);
1169
+ assert.equal(request.query.releaseStatus, value);
1170
+ }
1171
+ });
1172
+
1173
+ test("(NAK-803) --release-status composes with the other filters instead of replacing them", () => {
1174
+ const request = buildRequestFromArgv([
1175
+ "games", "count", "--source", "steam",
1176
+ "--release-status", "released",
1177
+ "--ai-disclosure", "disclosed",
1178
+ "--tags", "roguelite",
1179
+ "--rating-min", "80",
1180
+ "--json"
1181
+ ]);
1182
+ assert.equal(request.query.releaseStatus, "released");
1183
+ assert.equal(request.query.aiDisclosure, "disclosed");
1184
+ assert.equal(request.query.tags, "roguelite");
1185
+ assert.equal(request.query.ratingMin, "80");
1186
+ });
1187
+
1188
+ test("(NAK-803) an invalid --release-status value is a usage error, not a silently unfiltered request", () => {
1189
+ const { output, exitCode } = captureUsageFailure(() =>
1190
+ buildRequestFromArgv(["games", "count", "--source", "steam", "--release-status", "launched", "--json"])
1191
+ );
1192
+ assert.equal(exitCode, 2);
1193
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
1194
+ assert.match(output.error.message, /--release-status must be one of: released, upcoming, tba/);
1195
+ });
1196
+
1197
+ test("(NAK-803) --release-status is case-sensitive: a differently-cased value is rejected, not normalized", () => {
1198
+ const { output, exitCode } = captureUsageFailure(() =>
1199
+ buildRequestFromArgv(["games", "count", "--source", "steam", "--release-status", "Released", "--json"])
1200
+ );
1201
+ assert.equal(exitCode, 2);
1202
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
1203
+ assert.match(output.error.message, /--release-status must be one of: released, upcoming, tba/);
1204
+ });
1205
+
1206
+ test("(NAK-803) a blank --release-status is a usage error, not a silently unfiltered request", () => {
1207
+ const { output, exitCode } = captureUsageFailure(() =>
1208
+ buildRequestFromArgv(["games", "count", "--source", "steam", "--release-status", " ", "--json"])
1209
+ );
1210
+ assert.equal(exitCode, 2);
1211
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
1212
+ assert.match(output.error.message, /--release-status must be one of: released, upcoming, tba/);
1213
+ });
1214
+
1215
+ test("(NAK-803) an omitted --release-status leaves the query untouched", () => {
1216
+ const request = buildRequestFromArgv(["games", "count", "--source", "steam", "--json"]);
1217
+ assert.equal("releaseStatus" in request.query, false);
1218
+ });
1219
+
1025
1220
  /**
1026
1221
  * NAK-706: `gmc steam tag-trends`.
1027
1222
  *
@@ -1,177 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import { spawnSync } from "node:child_process";
4
- import { existsSync, readFileSync } from "node:fs";
5
- import path from "node:path";
6
-
7
- const packageDir = path.resolve(new URL("..", import.meta.url).pathname);
8
- const packageJsonPath = path.join(packageDir, "package.json");
9
- const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
10
-
11
- const requiredFiles = [
12
- "bin/gmc.mjs",
13
- "src/index.js",
14
- "src/main.js",
15
- "src/requests.js",
16
- "src/workflows.js",
17
- "skill/gmc-analysis/SKILL.md",
18
- "skill/gmc-analysis/references/recipes.md",
19
- "skill/gmc-analysis/references/pitfalls.md",
20
- "skill/gmc-analysis/references/validity.md",
21
- "skill/gmc-analysis/references/quota.md",
22
- "skill/gmc-analysis/references/charts.md",
23
- "skill/gmc-analysis/CHANGELOG.md",
24
- "README.md",
25
- "CHANGELOG.md"
26
- ];
27
-
28
- const requiredFileGlobs = ["bin", "src", "scripts", "skill", "README.md", "CHANGELOG.md"];
29
- const forbiddenPatterns = [
30
- { pattern: /@supabase\//, message: "CLI package must not import Supabase SDKs." },
31
- { pattern: /@clerk\//, message: "CLI package must not import Clerk SDKs." },
32
- { pattern: /src\/server|server\/gmc|server-only/, message: "CLI package must not import server-only app code." },
33
- { pattern: /SUPABASE_[A-Z0-9_]*|CLERK_[A-Z0-9_]*|STRIPE_[A-Z0-9_]*/, message: "CLI package must not reference server secret environment variables." }
34
- ];
35
-
36
- const sourceFiles = [
37
- "bin/gmc.mjs",
38
- "src/index.js",
39
- "src/args.js",
40
- "src/browser-login.js",
41
- "src/config.js",
42
- "src/constants.js",
43
- "src/help.js",
44
- "src/main.js",
45
- "src/output.js",
46
- "src/projection.js",
47
- "src/requests.js",
48
- "src/workflows.js"
49
- ];
50
-
51
- const failures = [];
52
-
53
- if (packageJson.private === true) {
54
- failures.push("packages/gmc-cli/package.json must not have private:true for beta publishing.");
55
- }
56
-
57
- if (packageJson.name !== "@witchpot/gmc") {
58
- failures.push(`Unexpected package name: ${packageJson.name}`);
59
- }
60
-
61
- if (packageJson.publishConfig?.access !== "public") {
62
- failures.push("publishConfig.access must be public for npm beta-tag publishing.");
63
- }
64
-
65
- for (const binName of ["gmc", "gmcp", "game-market-copilot"]) {
66
- if (packageJson.bin?.[binName] !== "bin/gmc.mjs") {
67
- failures.push(`bin.${binName} must point to bin/gmc.mjs.`);
68
- }
69
- }
70
-
71
- if (packageJson.main !== "./src/index.js") {
72
- failures.push("main must point to ./src/index.js.");
73
- }
74
-
75
- if (packageJson.exports?.["."] !== "./src/index.js") {
76
- failures.push("exports['.'] must point to ./src/index.js.");
77
- }
78
-
79
- for (const required of requiredFileGlobs) {
80
- if (!packageJson.files?.includes(required)) {
81
- failures.push(`package.json files must include ${required}.`);
82
- }
83
- }
84
-
85
- for (const file of requiredFiles) {
86
- if (!existsSync(path.join(packageDir, file))) {
87
- failures.push(`Missing required package file: ${file}`);
88
- }
89
- }
90
-
91
- const constantsPath = path.join(packageDir, "src/constants.js");
92
- const constantsSource = readFileSync(constantsPath, "utf8");
93
- const versionMatch = constantsSource.match(/CLI_VERSION\s*=\s*"([^"]+)"/);
94
- if (!versionMatch) {
95
- failures.push("src/constants.js must export a string CLI_VERSION.");
96
- } else if (versionMatch[1] !== packageJson.version) {
97
- failures.push(`CLI_VERSION (${versionMatch[1]}) must match package version (${packageJson.version}).`);
98
- }
99
-
100
- const changelog = readFileSync(path.join(packageDir, "CHANGELOG.md"), "utf8");
101
- if (!changelog.includes(`## ${packageJson.version} -`)) {
102
- failures.push(`CHANGELOG.md must include a section for ${packageJson.version}.`);
103
- }
104
-
105
- const skillSource = readFileSync(path.join(packageDir, "skill/gmc-analysis/SKILL.md"), "utf8");
106
- const skillVersionMatch = skillSource.match(/^\s*version:\s*"?([0-9][0-9A-Za-z.\-]*)"?\s*$/m);
107
- if (!skillVersionMatch) {
108
- failures.push("skill/gmc-analysis/SKILL.md must declare metadata.version in its frontmatter.");
109
- } else {
110
- const skillChangelog = readFileSync(path.join(packageDir, "skill/gmc-analysis/CHANGELOG.md"), "utf8");
111
- if (!skillChangelog.includes(`## ${skillVersionMatch[1]} -`)) {
112
- failures.push(`skill CHANGELOG.md must include a section for skill version ${skillVersionMatch[1]}.`);
113
- }
114
- }
115
-
116
- for (const file of sourceFiles) {
117
- const source = readFileSync(path.join(packageDir, file), "utf8");
118
- for (const forbidden of forbiddenPatterns) {
119
- if (forbidden.pattern.test(source)) {
120
- failures.push(`${file}: ${forbidden.message}`);
121
- }
122
- }
123
- }
124
-
125
- // The skill content-hash manifest (NAK-190) is also enforced by
126
- // src/skill-manifest.test.js, but release:check/prepublishOnly run WITHOUT
127
- // the test suite — recheck here so a stale manifest can never reach npm.
128
- const { buildSkillManifest, MANIFEST_PATH } = await import("./gen-skill-manifest.mjs");
129
- if (!existsSync(MANIFEST_PATH)) {
130
- failures.push("skill/gmc-analysis.manifest.json is missing — run npm run skill:manifest.");
131
- } else {
132
- const committedManifest = JSON.parse(readFileSync(MANIFEST_PATH, "utf8"));
133
- const currentManifest = buildSkillManifest();
134
- if (
135
- committedManifest.contentHash !== currentManifest.contentHash ||
136
- committedManifest.version !== currentManifest.version
137
- ) {
138
- failures.push(
139
- "skill/gmc-analysis.manifest.json is stale: bump metadata.version in SKILL.md, run npm run skill:manifest, and re-sync the gmc-agent-skills distribution repo."
140
- );
141
- }
142
- }
143
-
144
- // The public distribution repo (github.com/witchpot-studio/gmc-agent-skills)
145
- // mirrors skill/gmc-analysis. When it is checked out as a workspace sibling,
146
- // a drift between the two is a release blocker — MCP-only users install the
147
- // skill from that repo and must get the same content the npm package ships.
148
- const explicitAgentSkillsDir = process.env.GMC_AGENT_SKILLS_DIR;
149
- const agentSkillsRepo =
150
- explicitAgentSkillsDir ?? path.join(packageDir, "..", "..", "..", "gmc-agent-skills");
151
- const agentSkillsSyncScript = path.join(agentSkillsRepo, "scripts", "sync-from-cli.mjs");
152
- if (explicitAgentSkillsDir && !existsSync(agentSkillsSyncScript)) {
153
- failures.push(
154
- `GMC_AGENT_SKILLS_DIR is set but ${agentSkillsSyncScript} does not exist — fix the path or unset the variable.`
155
- );
156
- } else if (existsSync(agentSkillsSyncScript)) {
157
- const drift = spawnSync(process.execPath, ["scripts/sync-from-cli.mjs", "--check"], {
158
- cwd: agentSkillsRepo,
159
- encoding: "utf8",
160
- // Pin the canonical source to THIS checkout — the sync script's own
161
- // default (../GMC relative to the skills repo) could silently compare
162
- // against a different GMC checkout.
163
- env: { ...process.env, GMC_REPO_DIR: path.join(packageDir, "..", "..") }
164
- });
165
- if (drift.status !== 0) {
166
- failures.push(
167
- `gmc-agent-skills repo has drifted from skill/gmc-analysis — run node scripts/sync-from-cli.mjs there.\n${(drift.stderr || "").trim()}`
168
- );
169
- }
170
- }
171
-
172
- if (failures.length > 0) {
173
- process.stderr.write(`GMC CLI release readiness failed:\n${failures.map((failure) => `- ${failure}`).join("\n")}\n`);
174
- process.exit(1);
175
- }
176
-
177
- process.stdout.write(`GMC CLI release readiness ok: ${packageJson.name}@${packageJson.version}\n`);
@@ -1,151 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- // Regenerates skill/gmc-analysis.manifest.json — a content-hash fingerprint
4
- // of the packaged gmc-analysis skill (SKILL.md + references/ + CHANGELOG.md).
5
- //
6
- // Maintainer rule: any time skill/gmc-analysis/** content changes, bump
7
- // metadata.version in SKILL.md, then run `npm run skill:manifest` (in
8
- // packages/gmc-cli) to regenerate this manifest, and re-sync the
9
- // gmc-agent-skills distribution repo (node scripts/sync-from-cli.mjs there).
10
- // src/skill-manifest.test.js recomputes the same hash on every
11
- // `npm run test:cli` / `npm run verify` and fails loudly if it no longer
12
- // matches the committed manifest — this is the drift guard that would have
13
- // caught the unversioned charts.md changes in PR #18 / PR #43 (NAK-190).
14
- //
15
- // The manifest is written OUTSIDE skill/gmc-analysis/ on purpose: that
16
- // directory is mirrored byte-for-byte into the public gmc-agent-skills
17
- // distribution repo, and the manifest must never be part of (or affect the
18
- // hash of) the content it describes.
19
-
20
- import { spawnSync } from "node:child_process";
21
- import { createHash } from "node:crypto";
22
- import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
23
- import path from "node:path";
24
- import { fileURLToPath } from "node:url";
25
-
26
- const packageDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
27
- export const SKILL_DIR = path.join(packageDir, "skill", "gmc-analysis");
28
- export const MANIFEST_PATH = path.join(packageDir, "skill", "gmc-analysis.manifest.json");
29
-
30
- /** List every file under `dir`, as sorted relative POSIX paths. */
31
- export function listSkillFiles(dir = SKILL_DIR) {
32
- const results = [];
33
- walk(dir, dir, results);
34
- return results.sort();
35
- }
36
-
37
- function walk(root, current, results) {
38
- const entries = readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
39
- for (const entry of entries) {
40
- const abs = path.join(current, entry.name);
41
- if (entry.isDirectory()) {
42
- walk(root, abs, results);
43
- } else if (entry.isFile()) {
44
- results.push(path.relative(root, abs).split(path.sep).join("/"));
45
- }
46
- }
47
- }
48
-
49
- function sha256Hex(buffer) {
50
- return createHash("sha256").update(buffer).digest("hex");
51
- }
52
-
53
- /**
54
- * Hash every file under `dir` and fold the per-file hashes into one
55
- * top-level sha256 over `relpath\nfilehash\n` lines (sorted, deterministic).
56
- */
57
- export function computeSkillContentHash(dir = SKILL_DIR) {
58
- const files = listSkillFiles(dir);
59
- const digest = createHash("sha256");
60
- for (const relPath of files) {
61
- const fileHash = sha256Hex(readFileSync(path.join(dir, relPath)));
62
- digest.update(`${relPath}\n${fileHash}\n`);
63
- }
64
- return { contentHash: digest.digest("hex"), files: files.length };
65
- }
66
-
67
- /** Parse metadata.version out of SKILL.md source text; undefined when absent. */
68
- export function parseSkillVersion(source) {
69
- const frontmatter = source.match(/^---\n([\s\S]*?)\n---/);
70
- return frontmatter?.[1].match(/^\s*version:\s*"?([0-9][0-9A-Za-z.\-]*)"?\s*$/m)?.[1];
71
- }
72
-
73
- /** Read metadata.version from SKILL.md's YAML frontmatter. */
74
- export function readSkillManifestVersion(dir = SKILL_DIR) {
75
- const version = parseSkillVersion(readFileSync(path.join(dir, "SKILL.md"), "utf8"));
76
- if (!version) {
77
- throw new Error(`${path.join(dir, "SKILL.md")} must declare metadata.version in its frontmatter.`);
78
- }
79
- return version;
80
- }
81
-
82
- /** Build the manifest object (version + contentHash + files) for `dir`. */
83
- export function buildSkillManifest(dir = SKILL_DIR) {
84
- const { contentHash, files } = computeSkillContentHash(dir);
85
- return { version: readSkillManifestVersion(dir), contentHash, files };
86
- }
87
-
88
- /**
89
- * Baseline manifest for the version-bump rule. The authoritative baseline is
90
- * the manifest COMMITTED ON origin/main (read via `git show`, so deleting or
91
- * hand-editing the local file cannot dodge the rule); the local file is only
92
- * a fallback for environments where the git baseline is unreadable.
93
- */
94
- function baselineSkillManifest() {
95
- const result = spawnSync("git", ["show", "origin/main:packages/gmc-cli/skill/gmc-analysis.manifest.json"], {
96
- cwd: packageDir,
97
- encoding: "utf8"
98
- });
99
- if (result.status === 0 && typeof result.stdout === "string") {
100
- try {
101
- return JSON.parse(result.stdout);
102
- } catch {
103
- // fall through to the local file
104
- }
105
- }
106
- if (existsSync(MANIFEST_PATH)) {
107
- try {
108
- return JSON.parse(readFileSync(MANIFEST_PATH, "utf8"));
109
- } catch {
110
- return undefined;
111
- }
112
- }
113
- return undefined;
114
- }
115
-
116
- /**
117
- * Pure regen-permission rule (unit-tested in src/skill-manifest.test.js).
118
- * Content that differs from the baseline while keeping the baseline's
119
- * version is refused: once a version's content is on origin/main, changing
120
- * content under that version always requires a bump. A differing version
121
- * (the in-flight bump case, iterated freely within one branch) and a
122
- * missing baseline (true first generation) are allowed. There is
123
- * deliberately NO force escape hatch: an override flag would let
124
- * unversioned content changes reach npm and the public skills repo.
125
- */
126
- export function assertRegenAllowed({ baseline, next }) {
127
- if (!baseline) return;
128
- if (baseline.contentHash === next.contentHash) return;
129
- if (baseline.version !== next.version) return;
130
- throw new Error(
131
- `skill content changed but metadata.version is still ${next.version}, whose content is already fixed by the baseline manifest (origin/main) — bump the version in SKILL.md, then rerun npm run skill:manifest.`
132
- );
133
- }
134
-
135
- function main() {
136
- const manifest = buildSkillManifest();
137
- try {
138
- assertRegenAllowed({ baseline: baselineSkillManifest(), next: manifest });
139
- } catch (error) {
140
- console.error(error.message);
141
- process.exit(1);
142
- }
143
- writeFileSync(MANIFEST_PATH, `${JSON.stringify(manifest, null, 2)}\n`);
144
- process.stdout.write(
145
- `Wrote ${path.relative(packageDir, MANIFEST_PATH)}: version ${manifest.version}, ${manifest.files} files, contentHash ${manifest.contentHash}\n`
146
- );
147
- }
148
-
149
- if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
150
- main();
151
- }