frogoe 0.2.2 → 0.3.1

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/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # frogoe
2
+
3
+ **Write a closure. Ship a game.** A tiny game framework built for agents — one
4
+ `defineGame` closure with four nouns, and a CLI that scaffolds, serves, gates,
5
+ and bundles feed-ready games as single self-contained HTML files.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npx frogoe init my-game
11
+ cd my-game
12
+ ```
13
+
14
+ Or install globally:
15
+
16
+ ```bash
17
+ npm install -g frogoe
18
+ ```
19
+
20
+ ## The 60-second loop
21
+
22
+ ```bash
23
+ frogoe init my-game # runnable folder: living stub game, BRIEF, pinned contract
24
+ cd my-game
25
+ frogoe run # live reload + phone QR (try --tunnel: works on any network)
26
+ frogoe add score-card # themeable HUD block, injected + idempotent
27
+ frogoe lint # fast static contract lint (stable finding codes, --json)
28
+ frogoe check # full gate: lint + headless Chrome — full lifecycle, fps,
29
+ # audio recovery, 4x phone-class throttle, screenshots
30
+ frogoe report # last playtest: fps dips, errors, wall-clock
31
+ frogoe bundle # ONE self-contained HTML — externals dissolved, zero
32
+ # runtime requests (only after check passes)
33
+ frogoe skills check # skill freshness (hash = per-bundle SHA16)
34
+ ```
35
+
36
+ ## Built for agents
37
+
38
+ The real docs are [agent skills](https://github.com/frogoe/engine/tree/main/skills) —
39
+ the contract, creative direction, CLI loop, and HUD registry, written for the
40
+ AI that writes the game:
41
+
42
+ ```bash
43
+ npx skills add frogoe/engine
44
+ ```
45
+
46
+ Playtests under `frogoe run` are telemetered: fps dips, page errors and
47
+ lock-screens print live in your terminal and persist to
48
+ `.frogoe/sessions/*.jsonl`. `frogoe report` replays the last session — dips
49
+ below 30fps with their wall-clock moments.
50
+
51
+ ## The contract
52
+
53
+ A game is one closure. The platform gives four nouns — everything visible is
54
+ yours:
55
+
56
+ ```js
57
+ import { defineGame } from "frogoe";
58
+
59
+ defineGame(({ stage, input, loop, finish }) => {
60
+ let y = stage.height / 2;
61
+ let vy = 0;
62
+
63
+ input.on("down", () => {
64
+ vy = -300;
65
+ });
66
+
67
+ loop.update = (dt) => {
68
+ vy += 900 * dt;
69
+ y += vy * dt;
70
+ };
71
+ loop.render = (ctx) => {
72
+ ctx.fillStyle = "#fff";
73
+ ctx.fillRect(stage.play.center - 12, y, 24, 24);
74
+ };
75
+ });
76
+ ```
77
+
78
+ The platform draws nothing — zero taste by construction. Games run from a
79
+ single pinned runtime (`frogoe.json` → `.frogoe/contract.js`), so every game
80
+ is an immutable artifact of exactly the contract it was built on.
81
+
82
+ ## License
83
+
84
+ Apache-2.0
package/dist/cli.js CHANGED
@@ -183,19 +183,18 @@ var init_init = __esm({
183
183
  // src/add.ts
184
184
  import { cpSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
185
185
  import path2 from "path";
186
- var STYLE_PATTERN, MARKUP_PATTERN, parseBlock, blockMarker, injectIntoHtml, addBlock;
186
+ var parseBlock, blockMarker, injectIntoHtml, addBlock;
187
187
  var init_add = __esm({
188
188
  "src/add.ts"() {
189
189
  "use strict";
190
190
  init_init();
191
- STYLE_PATTERN = /<style>([\s\S]*?)<\/style>/u;
192
- MARKUP_PATTERN = /<\/style>\s*([\s\S]*)$/u;
193
191
  parseBlock = (source) => {
194
- const styleMatch = STYLE_PATTERN.exec(source);
195
- const markupMatch = MARKUP_PATTERN.exec(source);
192
+ const styleOpen = source.indexOf("<style>");
193
+ const styleClose = source.indexOf("</style>");
194
+ const css = styleOpen === -1 || styleClose === -1 || styleClose < styleOpen ? null : source.slice(styleOpen + "<style>".length, styleClose);
196
195
  return {
197
- css: styleMatch?.[1]?.trim() ?? null,
198
- markup: (markupMatch?.[1] ?? "").trim()
196
+ css: css?.trim() ?? null,
197
+ markup: (styleClose === -1 ? "" : source.slice(styleClose + "</style>".length)).trim()
199
198
  };
200
199
  };
201
200
  blockMarker = (name) => `<!-- frogoe:block:${name} -->`;
@@ -371,8 +370,8 @@ var init_fetch_policy = __esm({
371
370
  url;
372
371
  status;
373
372
  };
374
- sleep = (ms) => new Promise((resolve) => {
375
- setTimeout(resolve, ms);
373
+ sleep = (ms) => new Promise((resolve2) => {
374
+ setTimeout(resolve2, ms);
376
375
  });
377
376
  fetchWithPolicy = async (url, options) => {
378
377
  const policy = { ...DEFAULT_FETCH_POLICY, ...options?.policy };
@@ -723,11 +722,32 @@ var init_bundle2 = __esm({
723
722
  });
724
723
 
725
724
  // ../lint/src/brief.ts
726
- var stripComment, parseBrief;
725
+ var KEY_PATTERN, WS, stripComment, parseLine, parseBrief;
727
726
  var init_brief = __esm({
728
727
  "../lint/src/brief.ts"() {
729
728
  "use strict";
730
- stripComment = (value) => value.replace(/\s+#.*$/u, "").trim().replace(/^["']|["']$/gu, "");
729
+ KEY_PATTERN = /^[a-z-]+$/u;
730
+ WS = /\s/u;
731
+ stripComment = (value) => {
732
+ let cut = -1;
733
+ for (let i = 1; i < value.length; i += 1) {
734
+ if (value[i] === "#" && WS.test(value[i - 1] ?? "")) {
735
+ cut = i;
736
+ break;
737
+ }
738
+ }
739
+ return (cut === -1 ? value : value.slice(0, cut)).trim().replace(/^["']|["']$/gu, "");
740
+ };
741
+ parseLine = (line) => {
742
+ let indent = 0;
743
+ while (indent < line.length && line[indent] === " ") indent += 1;
744
+ const body = line.slice(indent);
745
+ const colon = body.indexOf(":");
746
+ if (colon === -1) return null;
747
+ const key = body.slice(0, colon);
748
+ if (!KEY_PATTERN.test(key)) return null;
749
+ return { indent, key, rest: body.slice(colon + 1) };
750
+ };
731
751
  parseBrief = (source) => {
732
752
  const match = /^---\r?\n([\s\S]*?)\r?\n---/u.exec(source);
733
753
  if (!match) {
@@ -739,20 +759,20 @@ var init_brief = __esm({
739
759
  if (rawLine.trim() === "") {
740
760
  continue;
741
761
  }
742
- const nested = /^\s{2,}([a-z-]+):\s*(.*)$/u.exec(rawLine);
743
- const top = /^([a-z-]+):\s*(.*)$/u.exec(rawLine);
744
- if (nested && section === "palette") {
745
- const key = nested[1];
746
- brief[key] = stripComment(nested[2] ?? "");
762
+ const parsed = parseLine(rawLine);
763
+ if (!parsed) {
747
764
  continue;
748
765
  }
749
- if (top) {
750
- section = top[1] ?? "";
766
+ if (parsed.indent >= 2 && section === "palette") {
767
+ brief[parsed.key] = stripComment(parsed.rest);
768
+ continue;
769
+ }
770
+ if (parsed.indent === 0) {
771
+ section = parsed.key;
751
772
  if (section === "palette") {
752
773
  continue;
753
774
  }
754
- const key = section;
755
- brief[key] = stripComment(top[2] ?? "");
775
+ brief[parsed.key] = stripComment(parsed.rest);
756
776
  }
757
777
  }
758
778
  return brief;
@@ -1333,8 +1353,8 @@ var init_driver = __esm({
1333
1353
  async hold(x, y, ms) {
1334
1354
  await page.mouse.move(x, y);
1335
1355
  await page.mouse.down();
1336
- await new Promise((resolve) => {
1337
- setTimeout(resolve, ms);
1356
+ await new Promise((resolve2) => {
1357
+ setTimeout(resolve2, ms);
1338
1358
  });
1339
1359
  await page.mouse.up();
1340
1360
  },
@@ -1342,8 +1362,8 @@ var init_driver = __esm({
1342
1362
  await page.mouse.move(x1, y1);
1343
1363
  await page.mouse.down();
1344
1364
  await page.mouse.move(x2, y2, { steps: 6 });
1345
- await new Promise((resolve) => {
1346
- setTimeout(resolve, 80);
1365
+ await new Promise((resolve2) => {
1366
+ setTimeout(resolve2, 80);
1347
1367
  });
1348
1368
  await page.mouse.up();
1349
1369
  },
@@ -1369,8 +1389,8 @@ var init_driver = __esm({
1369
1389
  if (Date.now() - started > grace) {
1370
1390
  return false;
1371
1391
  }
1372
- await new Promise((resolve) => {
1373
- setTimeout(resolve, 100);
1392
+ await new Promise((resolve2) => {
1393
+ setTimeout(resolve2, 100);
1374
1394
  });
1375
1395
  }
1376
1396
  const navigated = page.waitForNavigation({ timeout: timeoutMs, waitUntil: "domcontentloaded" }).then(() => true).catch(() => false);
@@ -1721,8 +1741,8 @@ var init_phases = __esm({
1721
1741
  STABILITY_CYCLES = 2;
1722
1742
  START_BURST_TAPS = 3;
1723
1743
  DESKTOP_FPS_MS = 2e3;
1724
- sleep2 = (ms) => new Promise((resolve) => {
1725
- setTimeout(resolve, ms);
1744
+ sleep2 = (ms) => new Promise((resolve2) => {
1745
+ setTimeout(resolve2, ms);
1726
1746
  });
1727
1747
  jitterX = (step) => step * 37 % 121 - 60;
1728
1748
  jitterY = (step) => step * 53 % 181 - 90;
@@ -2375,9 +2395,9 @@ var init_run = __esm({
2375
2395
  return c.body(body, 200, { "content-type": type });
2376
2396
  });
2377
2397
  const server = createAdaptorServer({ fetch: app.fetch });
2378
- await new Promise((resolve, reject) => {
2398
+ await new Promise((resolve2, reject) => {
2379
2399
  server.once("error", reject);
2380
- server.listen(requestedPort, "0.0.0.0", () => resolve());
2400
+ server.listen(requestedPort, "0.0.0.0", () => resolve2());
2381
2401
  });
2382
2402
  const address = server.address();
2383
2403
  const port = typeof address === "object" && address ? address.port : 0;
@@ -2568,23 +2588,27 @@ var init_check3 = __esm({
2568
2588
  args: {
2569
2589
  dir: { type: "positional", required: false, description: "game folder (default: cwd)" },
2570
2590
  json: { type: "boolean", description: "machine-readable findings" },
2571
- live: { type: "boolean", description: "also run the headless-browser sandbox pass" }
2591
+ live: {
2592
+ type: "boolean",
2593
+ description: "deprecated no-op \u2014 the live sandbox always runs now"
2594
+ }
2572
2595
  },
2573
2596
  async run({ args }) {
2597
+ if (args.live) {
2598
+ console.error(" note: --live is deprecated \u2014 the live sandbox always runs now");
2599
+ }
2574
2600
  const dir = args.dir ? String(args.dir) : process.cwd();
2575
2601
  const result = checkProject(dir);
2576
- if (args.live) {
2577
- const { collectLive: collectLive2 } = await Promise.resolve().then(() => (init_live(), live_exports));
2578
- console.log(" live pass: boot \u2192 play \u2192 end \u2192 retry (headless chrome)\u2026");
2579
- const live = await collectLive2({ dir });
2580
- result.findings = [...result.findings, ...live.findings].sort(
2581
- (a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0)
2582
- );
2583
- result.errors = result.findings.filter((f) => f.severity === "error").length;
2584
- result.warnings = result.findings.filter((f) => f.severity === "warning").length;
2585
- if (live.screenshots.length > 0) {
2586
- console.log(` snapshots: ${live.screenshots.join(", ")}`);
2587
- }
2602
+ const { collectLive: collectLive2 } = await Promise.resolve().then(() => (init_live(), live_exports));
2603
+ console.log(" live pass: boot \u2192 play \u2192 end \u2192 retry (headless chrome)\u2026");
2604
+ const live = await collectLive2({ dir });
2605
+ result.findings = [...result.findings, ...live.findings].sort(
2606
+ (a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0)
2607
+ );
2608
+ result.errors = result.findings.filter((f) => f.severity === "error").length;
2609
+ result.warnings = result.findings.filter((f) => f.severity === "warning").length;
2610
+ if (live.screenshots.length > 0) {
2611
+ console.log(` snapshots: ${live.screenshots.join(", ")}`);
2588
2612
  }
2589
2613
  if (args.json) {
2590
2614
  console.log(JSON.stringify(result, null, 2));
@@ -2597,7 +2621,9 @@ var init_check3 = __esm({
2597
2621
  process.exitCode = 1;
2598
2622
  }
2599
2623
  },
2600
- meta: { description: "contract lint with stable finding codes" }
2624
+ meta: {
2625
+ description: "full gate: contract lint + live browser sandbox (FPS, playability, HUD outline, audio recovery, screenshots)"
2626
+ }
2601
2627
  });
2602
2628
  }
2603
2629
  });
@@ -2633,20 +2659,55 @@ var init_init2 = __esm({
2633
2659
  }
2634
2660
  });
2635
2661
 
2662
+ // src/commands/lint.ts
2663
+ var lint_exports = {};
2664
+ __export(lint_exports, {
2665
+ command: () => command5
2666
+ });
2667
+ import { defineCommand as defineCommand5 } from "citty";
2668
+ var command5;
2669
+ var init_lint = __esm({
2670
+ "src/commands/lint.ts"() {
2671
+ "use strict";
2672
+ init_check2();
2673
+ command5 = defineCommand5({
2674
+ args: {
2675
+ dir: { type: "positional", required: false, description: "game folder (default: cwd)" },
2676
+ json: { type: "boolean", description: "machine-readable findings" }
2677
+ },
2678
+ async run({ args }) {
2679
+ const dir = args.dir ? String(args.dir) : process.cwd();
2680
+ const result = checkProject(dir);
2681
+ if (args.json) {
2682
+ console.log(JSON.stringify(result, null, 2));
2683
+ } else {
2684
+ console.log(formatFindings(result));
2685
+ console.log(`
2686
+ ${result.errors} error(s), ${result.warnings} warning(s)`);
2687
+ }
2688
+ if (result.errors > 0) {
2689
+ process.exitCode = 1;
2690
+ }
2691
+ },
2692
+ meta: { description: "static contract lint only \u2014 fast iteration (check is the full gate)" }
2693
+ });
2694
+ }
2695
+ });
2696
+
2636
2697
  // src/commands/report.ts
2637
2698
  var report_exports = {};
2638
2699
  __export(report_exports, {
2639
- command: () => command5
2700
+ command: () => command6
2640
2701
  });
2641
2702
  import { readFileSync as readFileSync6 } from "fs";
2642
- import { defineCommand as defineCommand5 } from "citty";
2643
- var command5;
2703
+ import { defineCommand as defineCommand6 } from "citty";
2704
+ var command6;
2644
2705
  var init_report = __esm({
2645
2706
  "src/commands/report.ts"() {
2646
2707
  "use strict";
2647
2708
  init_records();
2648
2709
  init_session();
2649
- command5 = defineCommand5({
2710
+ command6 = defineCommand6({
2650
2711
  args: {
2651
2712
  dir: { type: "positional", required: false, description: "game folder (default: cwd)" }
2652
2713
  },
@@ -2758,12 +2819,21 @@ import { existsSync as existsSync6, mkdirSync as mkdirSync6, chmodSync, writeFil
2758
2819
  import os3 from "os";
2759
2820
  import path9 from "path";
2760
2821
  import { gunzipSync } from "zlib";
2761
- var URL_PATTERN, parseTunnelUrl, octalAt, extractSingleFile, ENV_PIN, assetName, binaryFileName, cacheBase, resolveLatestTag, mb, downloadWithProgress, binaryEchoes, resolveBinary, startTunnel;
2822
+ var URL_PATTERN, parseTunnelUrl, TAG_PATTERN, assertSafeTag, octalAt, extractSingleFile, ENV_PIN, assetName, binaryFileName, cacheBase, resolveLatestTag, mb, downloadWithProgress, binaryEchoes, resolveBinary, startTunnel;
2762
2823
  var init_tunnel = __esm({
2763
2824
  "src/net/tunnel.ts"() {
2764
2825
  "use strict";
2765
2826
  URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/iu;
2766
2827
  parseTunnelUrl = (chunk) => URL_PATTERN.exec(chunk)?.[0];
2828
+ TAG_PATTERN = /^\d{4}\.\d+\.\d+(-[A-Za-z0-9.]+)?$/u;
2829
+ assertSafeTag = (tag) => {
2830
+ if (!TAG_PATTERN.test(tag)) {
2831
+ throw new Error(
2832
+ `cloudflared version "${tag}" is not a valid release tag (expected e.g. 2025.10.1)`
2833
+ );
2834
+ }
2835
+ return tag;
2836
+ };
2767
2837
  octalAt = (block, offset, length) => {
2768
2838
  const raw = block.subarray(offset, offset + length).toString("utf-8");
2769
2839
  const digits = raw.replace(/[\0 ]/gu, "");
@@ -2856,10 +2926,15 @@ var init_tunnel = __esm({
2856
2926
  `cloudflared publishes no build for ${platform}/${process.arch} \u2014 install it and put \`cloudflared\` on PATH`
2857
2927
  );
2858
2928
  }
2859
- const tag = process.env[ENV_PIN] ?? await resolveLatestTag();
2929
+ const tag = process.env[ENV_PIN] ? assertSafeTag(process.env[ENV_PIN]) : assertSafeTag(await resolveLatestTag());
2860
2930
  const root = path9.join(cacheBase(platform, process.env, os3.homedir()), "frogoe", "cloudflared");
2861
2931
  const dir = path9.join(root, tag);
2862
2932
  const bin = path9.join(dir, binaryFileName(platform));
2933
+ const rootResolved = path9.resolve(root);
2934
+ const binResolved = path9.resolve(bin);
2935
+ if (!binResolved.startsWith(rootResolved + path9.sep)) {
2936
+ throw new Error("cloudflared binary path escaped the frogoe cache \u2014 refusing to execute");
2937
+ }
2863
2938
  if (existsSync6(bin) && binaryEchoes(bin, tag)) return { downloaded: false, path: bin };
2864
2939
  onProgress?.(`downloading cloudflared ${tag} (~25 MB, once)\u2026`);
2865
2940
  const res = await fetch(
@@ -2884,7 +2959,7 @@ var init_tunnel = __esm({
2884
2959
  startTunnel = async (port, options) => {
2885
2960
  const timeoutMs = options?.timeoutMs ?? 2e4;
2886
2961
  const bin = await resolveBinary(options?.onProgress);
2887
- return new Promise((resolve, reject) => {
2962
+ return new Promise((resolve2, reject) => {
2888
2963
  const child = spawn(
2889
2964
  bin.path,
2890
2965
  ["tunnel", "--url", `http://localhost:${port}`, "--no-autoupdate"],
@@ -2922,7 +2997,7 @@ var init_tunnel = __esm({
2922
2997
  reject(new Error(tail ? `${base} \u2014 ${tail}` : base));
2923
2998
  return;
2924
2999
  }
2925
- resolve({ exited, stop: killTree, url });
3000
+ resolve2({ exited, stop: killTree, url });
2926
3001
  };
2927
3002
  const timer = setTimeout(() => {
2928
3003
  finish(
@@ -2959,10 +3034,10 @@ var init_tunnel = __esm({
2959
3034
  // src/commands/run.ts
2960
3035
  var run_exports2 = {};
2961
3036
  __export(run_exports2, {
2962
- command: () => command6
3037
+ command: () => command7
2963
3038
  });
2964
- import { defineCommand as defineCommand6 } from "citty";
2965
- var NUDGE_MS, printQr, message, command6;
3039
+ import { defineCommand as defineCommand7 } from "citty";
3040
+ var NUDGE_MS, printQr, message, command7;
2966
3041
  var init_run2 = __esm({
2967
3042
  "src/commands/run.ts"() {
2968
3043
  "use strict";
@@ -2981,7 +3056,7 @@ var init_run2 = __esm({
2981
3056
  });
2982
3057
  };
2983
3058
  message = (error) => error instanceof Error ? error.message : String(error);
2984
- command6 = defineCommand6({
3059
+ command7 = defineCommand7({
2985
3060
  args: {
2986
3061
  dir: { type: "positional", required: false, description: "game folder (default: cwd)" },
2987
3062
  port: { type: "string", description: "port (default: random free)" },
@@ -3081,13 +3156,486 @@ var init_run2 = __esm({
3081
3156
  }
3082
3157
  });
3083
3158
 
3159
+ // src/utils/skillsManifest.ts
3160
+ import { execFile } from "child_process";
3161
+ import { createHash as createHash2 } from "crypto";
3162
+ import { existsSync as existsSync7, readdirSync as readdirSync3, readFileSync as readFileSync7, statSync as statSync2 } from "fs";
3163
+ import { homedir } from "os";
3164
+ import { isAbsolute, join, relative, resolve, sep } from "path";
3165
+ import { promisify } from "util";
3166
+ function isCoreSkill(name) {
3167
+ return name === ENTRY_SKILL || name.startsWith("frogoe-");
3168
+ }
3169
+ function listFilesSorted(dir) {
3170
+ const out = [];
3171
+ const walk = (d) => {
3172
+ for (const name of readdirSync3(d)) {
3173
+ if (name === ".DS_Store") continue;
3174
+ const p = join(d, name);
3175
+ if (statSync2(p).isDirectory()) walk(p);
3176
+ else out.push(p);
3177
+ }
3178
+ };
3179
+ walk(dir);
3180
+ return out.sort();
3181
+ }
3182
+ function hashSkillBundle(skillDir) {
3183
+ const files = listFilesSorted(skillDir);
3184
+ const h = createHash2("sha256");
3185
+ for (const f of files) {
3186
+ const rel = relative(skillDir, f).split(sep).join("/");
3187
+ h.update(rel);
3188
+ h.update("\0");
3189
+ const ext = rel.slice(rel.lastIndexOf("."));
3190
+ const buf = readFileSync7(f);
3191
+ if (TEXT_EXT.has(ext)) h.update(buf.toString("utf8").replace(/\r\n/g, "\n"), "utf8");
3192
+ else h.update(buf);
3193
+ h.update("\0");
3194
+ }
3195
+ return { hash: h.digest("hex").slice(0, 16), files: files.length };
3196
+ }
3197
+ function buildManifest(skillsRoot, meta) {
3198
+ const names = readdirSync3(skillsRoot).filter((n) => existsSync7(join(skillsRoot, n, "SKILL.md"))).sort();
3199
+ const skills = {};
3200
+ for (const name of names) skills[name] = hashSkillBundle(join(skillsRoot, name));
3201
+ return { source: meta.source, skills };
3202
+ }
3203
+ function agentLabel(hostDir) {
3204
+ const name = hostDir.replace(/^\.+/, "");
3205
+ return name === "claude" ? "claude-code" : name || "unknown";
3206
+ }
3207
+ function agentFromDir(dir) {
3208
+ const parts = dir.split(sep).filter(Boolean);
3209
+ const i = parts.lastIndexOf("skills");
3210
+ return agentLabel(i > 0 ? parts[i - 1] : parts[parts.length - 1] ?? "");
3211
+ }
3212
+ function listSubdirs(dir) {
3213
+ try {
3214
+ return readdirSync3(dir, { withFileTypes: true }).filter((e) => e.isDirectory() || e.isSymbolicLink()).map((e) => e.name);
3215
+ } catch {
3216
+ return [];
3217
+ }
3218
+ }
3219
+ function discoverSkillRoots(base, scope) {
3220
+ const candidates = [];
3221
+ const add = (hostBase, host) => {
3222
+ const dir = join(hostBase, host, "skills");
3223
+ if (existsSync7(dir) && statSync2(dir).isDirectory())
3224
+ candidates.push({ dir, agent: agentLabel(host), scope });
3225
+ };
3226
+ for (const host of listSubdirs(base)) add(base, host);
3227
+ const xdg = join(base, ".config");
3228
+ for (const host of listSubdirs(xdg)) add(xdg, host);
3229
+ return candidates.sort((a, b) => {
3230
+ if (a.agent !== b.agent) {
3231
+ if (a.agent === "claude-code") return -1;
3232
+ if (b.agent === "claude-code") return 1;
3233
+ return a.agent.localeCompare(b.agent);
3234
+ }
3235
+ return a.dir.localeCompare(b.dir);
3236
+ });
3237
+ }
3238
+ function scopeForDir(dir, home, cwd) {
3239
+ const norm = (p) => {
3240
+ const r = resolve(p);
3241
+ return r.endsWith(sep) ? r : r + sep;
3242
+ };
3243
+ const d = norm(dir);
3244
+ if (d.startsWith(norm(cwd))) return "project";
3245
+ if (d.startsWith(norm(home))) return "global";
3246
+ return "project";
3247
+ }
3248
+ function locateInstall(skillNames, opts = {}) {
3249
+ if (opts.dir) {
3250
+ return existsSync7(opts.dir) ? {
3251
+ dir: opts.dir,
3252
+ agent: agentFromDir(opts.dir),
3253
+ scope: scopeForDir(opts.dir, opts.home ?? homedir(), opts.cwd ?? process.cwd())
3254
+ } : null;
3255
+ }
3256
+ const roots = [
3257
+ ...discoverSkillRoots(opts.home ?? homedir(), "global"),
3258
+ ...discoverSkillRoots(opts.cwd ?? process.cwd(), "project")
3259
+ ];
3260
+ for (const root of roots) {
3261
+ if (skillNames.some((n) => existsSync7(join(root.dir, n, "SKILL.md")))) return root;
3262
+ }
3263
+ return null;
3264
+ }
3265
+ function hashInstalled(root, skillNames) {
3266
+ const out = {};
3267
+ for (const name of skillNames) {
3268
+ const skillDir = join(root.dir, name);
3269
+ if (existsSync7(join(skillDir, "SKILL.md"))) out[name] = hashSkillBundle(skillDir);
3270
+ }
3271
+ return out;
3272
+ }
3273
+ function diffSkills(installed, latest) {
3274
+ const skills = [];
3275
+ const summary = { current: 0, outdated: 0, missing: 0, coreMissing: 0 };
3276
+ for (const name of Object.keys(latest.skills).sort()) {
3277
+ const latestEntry = latest.skills[name];
3278
+ const installedEntry = installed[name];
3279
+ let status;
3280
+ if (!installedEntry) status = "missing";
3281
+ else if (installedEntry.hash === latestEntry.hash) status = "current";
3282
+ else status = "outdated";
3283
+ if (status === "current") summary.current++;
3284
+ else if (status === "outdated") summary.outdated++;
3285
+ else {
3286
+ summary.missing++;
3287
+ if (isCoreSkill(name)) summary.coreMissing++;
3288
+ }
3289
+ skills.push({
3290
+ name,
3291
+ status,
3292
+ installedHash: installedEntry?.hash,
3293
+ latestHash: latestEntry.hash
3294
+ });
3295
+ }
3296
+ return {
3297
+ updateAvailable: summary.outdated > 0 || summary.coreMissing > 0,
3298
+ summary,
3299
+ skills
3300
+ };
3301
+ }
3302
+ function findRepoManifest(cwd = process.cwd()) {
3303
+ let dir = cwd;
3304
+ for (let i = 0; i < 16; i++) {
3305
+ const p = join(dir, MANIFEST_FILE);
3306
+ if (existsSync7(p)) return p;
3307
+ const parent = join(dir, "..");
3308
+ if (parent === dir) break;
3309
+ dir = parent;
3310
+ }
3311
+ return null;
3312
+ }
3313
+ function asSkillsManifest(data, sourceLabel) {
3314
+ const m = data;
3315
+ if (!m || typeof m !== "object" || typeof m.skills !== "object" || m.skills === null) {
3316
+ throw new Error(`Malformed skills manifest from ${sourceLabel}`);
3317
+ }
3318
+ return m;
3319
+ }
3320
+ async function fetchManifest(url) {
3321
+ const controller = new AbortController();
3322
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
3323
+ try {
3324
+ const res = await fetch(url, { signal: controller.signal, headers: { Connection: "close" } });
3325
+ if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`);
3326
+ return asSkillsManifest(await res.json(), url);
3327
+ } finally {
3328
+ clearTimeout(timeout);
3329
+ }
3330
+ }
3331
+ async function remoteHeadSha(repoSlug) {
3332
+ try {
3333
+ const { stdout } = await execFileAsync(
3334
+ "git",
3335
+ ["ls-remote", `https://github.com/${repoSlug}.git`, "refs/heads/main"],
3336
+ { timeout: FETCH_TIMEOUT_MS, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } }
3337
+ );
3338
+ const sha = stdout.split(/\s+/)[0]?.trim() ?? "";
3339
+ return /^[0-9a-f]{40}$/.test(sha) ? sha : null;
3340
+ } catch {
3341
+ return null;
3342
+ }
3343
+ }
3344
+ function resolveLocalManifest(source) {
3345
+ const direct = source.endsWith(".json") ? source : join(source, MANIFEST_FILE);
3346
+ if (existsSync7(direct)) return JSON.parse(readFileSync7(direct, "utf8"));
3347
+ const skillsRoot = source.endsWith("skills") ? source : join(source, "skills");
3348
+ if (existsSync7(skillsRoot)) return buildManifest(skillsRoot, { source: skillsRoot });
3349
+ throw new Error(`No skills manifest found at: ${source}`);
3350
+ }
3351
+ async function fetchRemoteManifest(source) {
3352
+ if (source?.startsWith("http")) return fetchManifest(source);
3353
+ const repoSlug = source ?? DEFAULT_REPO_SLUG;
3354
+ const sha = await remoteHeadSha(repoSlug);
3355
+ if (sha) {
3356
+ try {
3357
+ return await fetchManifest(
3358
+ `https://raw.githubusercontent.com/${repoSlug}/${sha}/${MANIFEST_FILE}`
3359
+ );
3360
+ } catch {
3361
+ }
3362
+ }
3363
+ return fetchManifest(`https://raw.githubusercontent.com/${repoSlug}/main/${MANIFEST_FILE}`);
3364
+ }
3365
+ async function resolveLatestManifest(source, cwd = process.cwd(), opts = {}) {
3366
+ if (source && (source.startsWith(".") || isAbsolute(source))) {
3367
+ return resolveLocalManifest(source);
3368
+ }
3369
+ if (!source && !opts.canonical) {
3370
+ const repoManifest = findRepoManifest(cwd);
3371
+ if (repoManifest) return JSON.parse(readFileSync7(repoManifest, "utf8"));
3372
+ }
3373
+ return fetchRemoteManifest(source);
3374
+ }
3375
+ async function checkSkills(opts = {}) {
3376
+ const latest = await resolveLatestManifest(opts.source, opts.cwd, { canonical: opts.canonical });
3377
+ const skillNames = Object.keys(latest.skills);
3378
+ const root = locateInstall(skillNames, { dir: opts.dir, cwd: opts.cwd, home: opts.home });
3379
+ const installed = root ? hashInstalled(root, skillNames) : {};
3380
+ const diff = diffSkills(installed, latest);
3381
+ return {
3382
+ location: root?.dir ?? null,
3383
+ agent: root?.agent ?? null,
3384
+ scope: root?.scope ?? null,
3385
+ updateAvailable: diff.updateAvailable,
3386
+ summary: diff.summary,
3387
+ skills: diff.skills,
3388
+ lockMissing: false
3389
+ };
3390
+ }
3391
+ var execFileAsync, TEXT_EXT, DEFAULT_REPO_SLUG, MANIFEST_FILE, FETCH_TIMEOUT_MS, ENTRY_SKILL;
3392
+ var init_skillsManifest = __esm({
3393
+ "src/utils/skillsManifest.ts"() {
3394
+ "use strict";
3395
+ execFileAsync = promisify(execFile);
3396
+ TEXT_EXT = /* @__PURE__ */ new Set([
3397
+ ".md",
3398
+ ".txt",
3399
+ ".mjs",
3400
+ ".js",
3401
+ ".ts",
3402
+ ".jsx",
3403
+ ".tsx",
3404
+ ".html",
3405
+ ".css",
3406
+ ".json",
3407
+ ".svg",
3408
+ ".csv",
3409
+ ".yml",
3410
+ ".yaml"
3411
+ ]);
3412
+ DEFAULT_REPO_SLUG = "frogoe/engine";
3413
+ MANIFEST_FILE = "skills-manifest.json";
3414
+ FETCH_TIMEOUT_MS = 4e3;
3415
+ ENTRY_SKILL = "frogoe";
3416
+ }
3417
+ });
3418
+
3419
+ // src/commands/skills.ts
3420
+ var skills_exports = {};
3421
+ __export(skills_exports, {
3422
+ command: () => command8
3423
+ });
3424
+ import { defineCommand as defineCommand8 } from "citty";
3425
+ import { execFileSync, spawn as spawn2 } from "child_process";
3426
+ function hasNpx() {
3427
+ try {
3428
+ const cmd = process.platform === "win32" ? "npx.cmd" : "npx";
3429
+ const exe = process.platform === "win32" ? "cmd.exe" : cmd;
3430
+ const args = process.platform === "win32" ? ["/d", "/s", "/c", "npx.cmd", "--version"] : ["--version"];
3431
+ execFileSync(exe, args, { stdio: "ignore", timeout: 5e3 });
3432
+ return true;
3433
+ } catch {
3434
+ try {
3435
+ execFileSync("npx", ["--version"], { stdio: "ignore", timeout: 5e3 });
3436
+ return true;
3437
+ } catch {
3438
+ return false;
3439
+ }
3440
+ }
3441
+ }
3442
+ function hasGit() {
3443
+ try {
3444
+ execFileSync("git", ["--version"], { stdio: "ignore", timeout: 5e3 });
3445
+ return true;
3446
+ } catch {
3447
+ return false;
3448
+ }
3449
+ }
3450
+ function buildNpxCommand(args) {
3451
+ if (process.platform === "win32") {
3452
+ return { command: "cmd.exe", args: ["/d", "/s", "/c", "npx.cmd", ...args] };
3453
+ }
3454
+ return { command: "npx", args: [...args] };
3455
+ }
3456
+ function spawnNpx(args) {
3457
+ const npx = buildNpxCommand(args);
3458
+ return new Promise((resolve2, reject) => {
3459
+ const child = spawn2(npx.command, npx.args, {
3460
+ stdio: ["inherit", 2, 2],
3461
+ timeout: 3e5,
3462
+ env: {
3463
+ ...process.env,
3464
+ GIT_CLONE_PROTECTION_ACTIVE: "0",
3465
+ GIT_LFS_SKIP_SMUDGE: "1"
3466
+ }
3467
+ });
3468
+ child.on("close", (code, signal) => {
3469
+ if (code === 0) resolve2();
3470
+ else if (signal === "SIGINT" || code === 130) resolve2();
3471
+ else reject(new Error(`npx ${args.join(" ")} exited with code ${code}`));
3472
+ });
3473
+ child.on("error", reject);
3474
+ });
3475
+ }
3476
+ async function installSkills(selection) {
3477
+ const skillArgs = selection === "*" ? ["--skill", "*"] : selection.flatMap((n) => ["--skill", n]);
3478
+ if (!hasNpx()) throw new Error("npx not found. Install Node.js and retry.");
3479
+ if (!hasGit()) throw new Error("git not found. Install git and retry.");
3480
+ await spawnNpx(["skills", "add", SOURCE_URL, ...skillArgs, ...GLOBAL_INSTALL_ARGS_TAIL]);
3481
+ }
3482
+ function renderCheck(result) {
3483
+ console.log();
3484
+ console.log("frogoe skills");
3485
+ console.log();
3486
+ if (!result.location) {
3487
+ console.log(" No frogoe skills found in the usual locations.");
3488
+ console.log(" Install: npx skills add frogoe/engine");
3489
+ console.log(" Or: frogoe skills update");
3490
+ console.log();
3491
+ return;
3492
+ }
3493
+ console.log(` Location ${result.location} (${result.agent})`);
3494
+ console.log();
3495
+ const parts = [];
3496
+ parts.push(`\u2713 ${result.summary.current} current`);
3497
+ if (result.summary.outdated) parts.push(`\u2191 ${result.summary.outdated} outdated`);
3498
+ if (result.summary.coreMissing) parts.push(`\u25E6 ${result.summary.coreMissing} core not installed`);
3499
+ const onDemandMissing = result.summary.missing - result.summary.coreMissing;
3500
+ if (onDemandMissing) parts.push(`\u25E6 ${onDemandMissing} available on demand`);
3501
+ console.log(` ${parts.join(" ")}`);
3502
+ for (const s of result.skills.filter((x) => x.status === "outdated")) {
3503
+ console.log(` \u2191 ${s.name}`);
3504
+ }
3505
+ for (const s of result.skills.filter((x) => x.status === "missing" && isCoreSkill(x.name))) {
3506
+ console.log(` \u25E6 ${s.name} (core)`);
3507
+ }
3508
+ console.log();
3509
+ if (result.updateAvailable) {
3510
+ console.log(" Update: frogoe skills update or npx skills add frogoe/engine");
3511
+ } else {
3512
+ console.log(" Installed skills are up to date");
3513
+ }
3514
+ console.log();
3515
+ }
3516
+ var GLOBAL_INSTALL_ARGS_TAIL, SOURCE_URL, checkCommand, updateCommand, command8;
3517
+ var init_skills = __esm({
3518
+ "src/commands/skills.ts"() {
3519
+ "use strict";
3520
+ init_skillsManifest();
3521
+ GLOBAL_INSTALL_ARGS_TAIL = [
3522
+ "--global",
3523
+ "--agent",
3524
+ "claude-code",
3525
+ "universal",
3526
+ "--copy",
3527
+ "--full-depth",
3528
+ "--yes"
3529
+ ];
3530
+ SOURCE_URL = "https://github.com/frogoe/engine";
3531
+ checkCommand = defineCommand8({
3532
+ meta: { name: "check", description: "Check whether installed skills are the latest version" },
3533
+ args: {
3534
+ json: { type: "boolean", description: "Output as JSON", default: false },
3535
+ dir: { type: "string", description: "Skills directory to check" },
3536
+ source: { type: "string", description: "Where 'latest' comes from" }
3537
+ },
3538
+ async run({ args }) {
3539
+ try {
3540
+ const result = await checkSkills({
3541
+ dir: args.dir,
3542
+ source: args.source,
3543
+ canonical: true
3544
+ });
3545
+ if (args.json) {
3546
+ console.log(JSON.stringify(result, null, 2));
3547
+ } else {
3548
+ renderCheck(result);
3549
+ }
3550
+ if (result.updateAvailable) process.exitCode = 1;
3551
+ } catch (err) {
3552
+ const msg = err instanceof Error ? err.message : String(err);
3553
+ if (msg.includes("Malformed") || msg.includes("HTTP") || msg.includes("fetch")) {
3554
+ console.error(`Skills check failed (offline or GitHub unreachable): ${msg}`);
3555
+ console.error("Try: npx skills add frogoe/engine \u2014 or retry when online.");
3556
+ } else {
3557
+ console.error(`Skills check failed: ${msg}`);
3558
+ }
3559
+ process.exitCode = 1;
3560
+ }
3561
+ }
3562
+ });
3563
+ updateCommand = defineCommand8({
3564
+ meta: {
3565
+ name: "update",
3566
+ description: "Update frogoe skills to the latest (core + installed). Pass names to also install them."
3567
+ },
3568
+ args: {
3569
+ json: { type: "boolean", description: "Output as JSON", default: false }
3570
+ },
3571
+ async run({ args }) {
3572
+ const requested = (args._ ?? []).map(String).filter(Boolean);
3573
+ const invalid = requested.filter((n) => !/^[a-z0-9][a-z0-9._-]*$/i.test(n));
3574
+ if (invalid.length) {
3575
+ console.error(`Invalid skill name(s): ${invalid.join(", ")}`);
3576
+ process.exitCode = 1;
3577
+ return;
3578
+ }
3579
+ try {
3580
+ const check = await checkSkills({ canonical: true });
3581
+ const toInstall = /* @__PURE__ */ new Set();
3582
+ for (const s of check.skills) {
3583
+ if (s.status === "outdated" || s.status === "missing" && isCoreSkill(s.name))
3584
+ toInstall.add(s.name);
3585
+ }
3586
+ for (const n of requested) toInstall.add(n);
3587
+ if (toInstall.size === 0) {
3588
+ const msg = "Installed skills are already up to date.";
3589
+ if (args.json) console.log(JSON.stringify({ ...check, message: msg }, null, 2));
3590
+ else console.log(msg);
3591
+ return;
3592
+ }
3593
+ const list = [...toInstall];
3594
+ console.log(`Updating ${list.length} skill(s): ${list.join(", ")}`);
3595
+ await installSkills(list);
3596
+ const verify = await checkSkills({ canonical: true });
3597
+ if (args.json) console.log(JSON.stringify(verify, null, 2));
3598
+ else renderCheck(verify);
3599
+ if (verify.updateAvailable) process.exitCode = 1;
3600
+ } catch (err) {
3601
+ const msg = err instanceof Error ? err.message : String(err);
3602
+ console.error(`Update failed: ${msg}`);
3603
+ if (msg.includes("npx") || msg.includes("git")) {
3604
+ console.error("Install Node.js and git, then retry: npx skills add frogoe/engine");
3605
+ }
3606
+ process.exitCode = 1;
3607
+ }
3608
+ }
3609
+ });
3610
+ command8 = defineCommand8({
3611
+ meta: {
3612
+ name: "skills",
3613
+ description: "Install, check, and update frogoe skills for AI coding tools"
3614
+ },
3615
+ subCommands: { check: checkCommand, update: updateCommand },
3616
+ args: {},
3617
+ async run() {
3618
+ try {
3619
+ console.log("Installing all frogoe skills...");
3620
+ await installSkills("*");
3621
+ const result = await checkSkills({ canonical: true });
3622
+ renderCheck(result);
3623
+ } catch (err) {
3624
+ console.error(`Install failed: ${err instanceof Error ? err.message : String(err)}`);
3625
+ process.exitCode = 1;
3626
+ }
3627
+ }
3628
+ });
3629
+ }
3630
+ });
3631
+
3084
3632
  // src/cli.ts
3085
- import { defineCommand as defineCommand7, runMain } from "citty";
3633
+ import { defineCommand as defineCommand9, runMain } from "citty";
3086
3634
 
3087
3635
  // package.json
3088
3636
  var package_default = {
3089
3637
  name: "frogoe",
3090
- version: "0.2.2",
3638
+ version: "0.3.1",
3091
3639
  description: "froge CLI \u2014 the agent's hands: init, add, run, check, bundle",
3092
3640
  homepage: "https://github.com/frogoe/engine#readme",
3093
3641
  bugs: "https://github.com/frogoe/engine/issues",
@@ -3153,23 +3701,27 @@ if (process.argv.includes("--version") || process.argv.includes("-v")) {
3153
3701
  var HELP = `frogoe ${VERSION} \u2014 write a closure, ship a game
3154
3702
 
3155
3703
  Commands:
3156
- init [name] scaffold a runnable game folder
3157
- add <block> copy a registry HUD block into blocks/
3158
- run [dir] serve with live reload + phone QR (--tunnel: any network)
3159
- check [dir] contract lint (stable finding codes; --json)
3160
- report [dir] last playtest session: fps dips, errors, when
3161
- bundle [dir] dissolve externals \u2192 one self-contained HTML
3704
+ init [name] scaffold a runnable game folder
3705
+ add <block> copy a registry HUD block into blocks/
3706
+ run [dir] serve with live reload + phone QR (--tunnel: any network)
3707
+ lint [dir] static contract lint \u2014 fast iteration (stable codes; --json)
3708
+ check [dir] full gate: lint + live Chrome sandbox (FPS, HUD outline)
3709
+ report [dir] last playtest session: fps dips, errors, when
3710
+ bundle [dir] dissolve externals \u2192 one self-contained HTML
3711
+ skills [check|update] skill freshness \u2014 check or update via npx skills add
3162
3712
 
3163
3713
  Docs: skills/frogoe-core \u2014 the whole contract in five references.`;
3164
- var main = defineCommand7({
3714
+ var main = defineCommand9({
3165
3715
  meta: { description: HELP },
3166
3716
  subCommands: {
3167
3717
  add: () => Promise.resolve().then(() => (init_add2(), add_exports)).then((m) => m.command),
3168
3718
  bundle: () => Promise.resolve().then(() => (init_bundle2(), bundle_exports)).then((m) => m.command),
3169
3719
  check: () => Promise.resolve().then(() => (init_check3(), check_exports)).then((m) => m.command),
3170
3720
  init: () => Promise.resolve().then(() => (init_init2(), init_exports)).then((m) => m.command),
3721
+ lint: () => Promise.resolve().then(() => (init_lint(), lint_exports)).then((m) => m.command),
3171
3722
  report: () => Promise.resolve().then(() => (init_report(), report_exports)).then((m) => m.command),
3172
- run: () => Promise.resolve().then(() => (init_run2(), run_exports2)).then((m) => m.command)
3723
+ run: () => Promise.resolve().then(() => (init_run2(), run_exports2)).then((m) => m.command),
3724
+ skills: () => Promise.resolve().then(() => (init_skills(), skills_exports)).then((m) => m.command)
3173
3725
  }
3174
3726
  });
3175
3727
  await runMain(main);
@@ -4,14 +4,14 @@
4
4
 
5
5
  **Always read the relevant skill before writing or modifying game code.** Skills encode the frogoe contract and creative direction that generic docs don't cover. Skipping them produces broken games.
6
6
 
7
- **Doing anything with frogoe?** Read the `frogoe` skill — it confirms the BRIEF (verb, mood, palette) up front and routes every request. The domain skills it routes to:
7
+ **Doing anything with frogoe?** Read the `/frogoe` skill — it confirms the BRIEF (verb, mood, palette) up front and routes every request. The domain skills it routes to:
8
8
 
9
- - `frogoe-core` — the technical contract: folder form, `defineGame` closure, four nouns, HUD bindings, external libraries. Read before writing any game code.
10
- - `frogoe-creative` — house style: three dials (VARIANCE/MOTION/DENSITY), lazy defaults, typography, palettes, game feel. Read when choosing how a game looks.
11
- - `frogoe-cli` — CLI dev loop: init, add, run, check, bundle. Finding codes table for self-healing.
12
- - `frogoe-registry` — HUD block catalog: find, evaluate, install, author new blocks.
9
+ - `/frogoe-core` — the technical contract: folder form, `defineGame` closure, four nouns, HUD bindings, external libraries. Read before writing any game code.
10
+ - `/frogoe-creative` — house style: three dials (VARIANCE/MOTION/DENSITY), lazy defaults, typography, palettes, game feel. Read when choosing how a game looks.
11
+ - `/frogoe-cli` — CLI dev loop: init, add, run, check, bundle, report. Finding codes split into `finding-codes.md` / `live-sandbox.md` / `bundle.md` for self-healing.
12
+ - `/frogoe-registry` — HUD block catalog: find, evaluate, install, author new blocks.
13
13
 
14
- Skills live at `.agents/skills/` (install via `npx skills add frogoe/engine`). Missing or stale? Re-run the install command and restart the agent session.
14
+ Skills live at `.claude/skills/` and `.agents/skills/` (install via `npx skills add frogoe/engine`; both mirrors stay byte-identical). Missing or stale? Re-run the install and restart the agent session. Check freshness: `frogoe skills check`.
15
15
 
16
16
  ## The contract
17
17
 
@@ -54,15 +54,16 @@ The platform draws NOTHING. Everything visible is your code + HUD blocks from th
54
54
  ```bash
55
55
  frogoe run # serve with live reload + phone QR (safe-area only exists on real devices)
56
56
  frogoe run --tunnel # + public URL — phone works on any network (cloudflared, auto-downloaded once)
57
- frogoe check # static contract lint (stable finding codes; --json for CI)
58
- frogoe check --live # + headless Chrome: FPS, playability, HUD outline, screenshots
59
- frogoe bundle # one self-contained HTML (externals dissolved, zero runtime requests)
60
57
  frogoe add <block> # copy a HUD block into blocks/ (score, hearts, fuel, game-over, etc.)
58
+ frogoe lint # fast static contract lint (stable finding codes; --json for CI)
59
+ frogoe check # full gate: lint + headless Chrome — FPS, playability, HUD outline, screenshots
60
+ frogoe bundle # one self-contained HTML (externals dissolved) — only after check passes
61
61
  ```
62
62
 
63
63
  > **Agents must run `frogoe check` after ANY code change** and fix all errors before
64
- > presenting the result. Warnings should be reviewed before bundling. Use `--json` for
65
- > machine-readable findings that can be fixed programmatically.
64
+ > presenting the result. `frogoe lint` is the fast static half for iteration; `frogoe
65
+ check` is the full gate (static + live sandbox) and MUST exit 0 before `frogoe
66
+ bundle`. Use `--json` for machine-readable findings that can be fixed programmatically.
66
67
 
67
68
  ## Project structure
68
69
 
@@ -79,8 +80,8 @@ frogoe add <block> # copy a HUD block into blocks/ (score, hearts, fuel
79
80
  After creating or editing any file, **always** run:
80
81
 
81
82
  ```bash
82
- frogoe check # static: BRIEF validation, folder structure, input patterns
83
- frogoe check --live # browser: runtime errors, canvas painted, FPS, playability
83
+ frogoe lint # fast static: BRIEF validation, folder structure, input patterns
84
+ frogoe check # full gate: + browser — runtime errors, canvas painted, FPS, playability
84
85
  ```
85
86
 
86
87
  Fix all errors before presenting the result. Common findings:
@@ -4,14 +4,14 @@
4
4
 
5
5
  **Always read the relevant skill before writing or modifying game code.** Skills encode the frogoe contract and creative direction that generic docs don't cover. Skipping them produces broken games.
6
6
 
7
- **Doing anything with frogoe?** Start at the `frogoe` skill — it confirms the BRIEF (verb, mood, palette) up front and routes every request. The domain skills it routes to:
7
+ **Doing anything with frogoe?** Start at the `/frogoe` skill — it confirms the BRIEF (verb, mood, palette) up front and routes every request. The domain skills it routes to:
8
8
 
9
- - `frogoe-core` — the technical contract: folder form, `defineGame` closure, four nouns, HUD bindings, external libraries. Read before writing any game code.
10
- - `frogoe-creative` — house style: three dials (VARIANCE/MOTION/DENSITY), lazy defaults, typography, palettes, game feel. Read when choosing how a game looks.
11
- - `frogoe-cli` — CLI dev loop: init, add, run, check, bundle. Finding codes table for self-healing.
12
- - `frogoe-registry` — HUD block catalog: find, evaluate, install, author new blocks.
9
+ - `/frogoe-core` — the technical contract: folder form, `defineGame` closure, four nouns, HUD bindings, external libraries. Read before writing any game code.
10
+ - `/frogoe-creative` — house style: three dials (VARIANCE/MOTION/DENSITY), lazy defaults, typography, palettes, game feel. Read when choosing how a game looks.
11
+ - `/frogoe-cli` — CLI dev loop: init, add, run, check, bundle, report. Finding codes split into `finding-codes.md` / `live-sandbox.md` / `bundle.md` for self-healing.
12
+ - `/frogoe-registry` — HUD block catalog: find, evaluate, install, author new blocks.
13
13
 
14
- Skills live at `.agents/skills/` (install via `npx skills add frogoe/engine`). Missing or stale? Re-run the install command and restart the agent session.
14
+ Skills live at `.claude/skills/` and `.agents/skills/` (install via `npx skills add frogoe/engine`; both mirrors stay byte-identical). Missing or stale? Re-run the install and restart the agent session. Check freshness: `frogoe skills check`.
15
15
 
16
16
  ## The contract
17
17
 
@@ -54,15 +54,16 @@ The platform draws NOTHING. Everything visible is your code + HUD blocks from th
54
54
  ```bash
55
55
  frogoe run # serve with live reload + phone QR (safe-area only exists on real devices)
56
56
  frogoe run --tunnel # + public URL — phone works on any network (cloudflared, auto-downloaded once)
57
- frogoe check # static contract lint (stable finding codes; --json for CI)
58
- frogoe check --live # + headless Chrome: FPS, playability, HUD outline, screenshots
59
- frogoe bundle # one self-contained HTML (externals dissolved, zero runtime requests)
60
57
  frogoe add <block> # copy a HUD block into blocks/ (score, hearts, fuel, game-over, etc.)
58
+ frogoe lint # fast static contract lint (stable finding codes; --json for CI)
59
+ frogoe check # full gate: lint + headless Chrome — FPS, playability, HUD outline, screenshots
60
+ frogoe bundle # one self-contained HTML (externals dissolved) — only after check passes
61
61
  ```
62
62
 
63
63
  > **Agents must run `frogoe check` after ANY code change** and fix all errors before
64
- > presenting the result. Warnings should be reviewed before bundling. Use `--json` for
65
- > machine-readable findings that can be fixed programmatically.
64
+ > presenting the result. `frogoe lint` is the fast static half for iteration; `frogoe
65
+ check` is the full gate (static + live sandbox) and MUST exit 0 before `frogoe
66
+ bundle`. Use `--json` for machine-readable findings that can be fixed programmatically.
66
67
 
67
68
  ## Project structure
68
69
 
@@ -79,8 +80,8 @@ frogoe add <block> # copy a HUD block into blocks/ (score, hearts, fuel
79
80
  After creating or editing any file, **always** run:
80
81
 
81
82
  ```bash
82
- frogoe check # static: BRIEF validation, folder structure, input patterns
83
- frogoe check --live # browser: runtime errors, canvas painted, FPS, playability
83
+ frogoe lint # fast static: BRIEF validation, folder structure, input patterns
84
+ frogoe check # full gate: + browser — runtime errors, canvas painted, FPS, playability
84
85
  ```
85
86
 
86
87
  Fix all errors before presenting the result. Common findings:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frogoe",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "description": "froge CLI — the agent's hands: init, add, run, check, bundle",
5
5
  "homepage": "https://github.com/frogoe/engine#readme",
6
6
  "bugs": "https://github.com/frogoe/engine/issues",