@genex-ai/cli-demo 0.23.0 → 0.25.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/README.md CHANGED
@@ -28,8 +28,10 @@ genex controller <type> # install a tuned character|car|drone controller →
28
28
  agents/ + commands/), Codex (`~/.codex/skills`), and Cursor
29
29
  (`~/.cursor/skills`). genex-owned skills (`genex-*`) are **always refreshed**
30
30
  to the package version so a stale copy can't linger; your own files are never
31
- overwritten. Pick agents explicitly with `--agents claude,codex,cursor`, or a
32
- single custom dir with `--dir`.
31
+ overwritten. After init, **every** `genex` command re-syncs them to the
32
+ installed CLI version automatically (printing `🔄 Genex skills updated to
33
+ X.Y.Z` when it does). Pick agents explicitly with `--agents
34
+ claude,codex,cursor`, or a single custom dir with `--dir`.
33
35
  2. **Authorizes you** — opens the Genex auth site (web) in your browser. If the
34
36
  browser can't open, it prints the URL to open manually.
35
37
  3. **Saves your token** — writes `GENEX_TOKEN` to `~/.genex/env` (per-user;
package/dist/index.js CHANGED
@@ -1,12 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // src/index.ts
4
- import { readFileSync } from "fs";
5
- import path12 from "path";
6
- import { fileURLToPath as fileURLToPath2 } from "url";
7
-
8
3
  // src/commands/init.ts
9
- import path7 from "path";
4
+ import path8 from "path";
10
5
 
11
6
  // src/config.ts
12
7
  import fs from "fs";
@@ -43,6 +38,17 @@ function getTemplatesDir() {
43
38
  const here = path.dirname(fileURLToPath(import.meta.url));
44
39
  return path.resolve(here, "..", "templates");
45
40
  }
41
+ function getCliVersion() {
42
+ try {
43
+ const here = path.dirname(fileURLToPath(import.meta.url));
44
+ const pkg = JSON.parse(
45
+ fs.readFileSync(path.resolve(here, "..", "package.json"), "utf8")
46
+ );
47
+ return pkg.version ?? "0.0.0";
48
+ } catch {
49
+ return "0.0.0";
50
+ }
51
+ }
46
52
  var KNOWN_AGENTS = {
47
53
  claude: { label: "Claude Code", dirName: ".claude", full: true },
48
54
  codex: { label: "Codex", dirName: ".codex", full: false },
@@ -121,6 +127,172 @@ async function exists(p) {
121
127
  }
122
128
  }
123
129
 
130
+ // src/lib/updates.ts
131
+ import fs3 from "fs/promises";
132
+ import path3 from "path";
133
+ function parseSemver(v) {
134
+ const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(v.trim());
135
+ if (!m) return null;
136
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
137
+ }
138
+ function isNewerVersion(a, b) {
139
+ const pa = parseSemver(a);
140
+ const pb = parseSemver(b);
141
+ if (!pa || !pb) return false;
142
+ for (let i = 0; i < 3; i++) {
143
+ if (pa[i] !== pb[i]) return pa[i] > pb[i];
144
+ }
145
+ return false;
146
+ }
147
+ var SKILLS_VERSION_MARKER = "genex-skills-version.json";
148
+ async function readSkillsMarker(skillsDir) {
149
+ try {
150
+ const raw = await fs3.readFile(path3.join(skillsDir, SKILLS_VERSION_MARKER), "utf8");
151
+ const parsed = JSON.parse(raw);
152
+ return typeof parsed.version === "string" ? parsed.version : null;
153
+ } catch {
154
+ return null;
155
+ }
156
+ }
157
+ async function writeSkillsMarker(skillsDir, version = getCliVersion()) {
158
+ await fs3.mkdir(skillsDir, { recursive: true });
159
+ await fs3.writeFile(
160
+ path3.join(skillsDir, SKILLS_VERSION_MARKER),
161
+ JSON.stringify({ version, syncedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2) + "\n"
162
+ );
163
+ }
164
+ async function hasGenexSkills(skillsDir) {
165
+ try {
166
+ const entries = await fs3.readdir(skillsDir);
167
+ return entries.some((name) => name.startsWith("genex-"));
168
+ } catch {
169
+ return false;
170
+ }
171
+ }
172
+ async function syncSkillsForTarget(target, templatesDir = getTemplatesDir(), version = getCliVersion()) {
173
+ const skillsDir = path3.join(target.baseDir, "skills");
174
+ if (!await hasGenexSkills(skillsDir)) return false;
175
+ if (await readSkillsMarker(skillsDir) === version) return false;
176
+ const src = target.full ? templatesDir : path3.join(templatesDir, "skills");
177
+ const dest = target.full ? target.baseDir : skillsDir;
178
+ await copyTemplates(src, dest, { exclude: ["controllers"] });
179
+ await writeSkillsMarker(skillsDir, version);
180
+ return true;
181
+ }
182
+ async function syncSkills(log) {
183
+ try {
184
+ const version = getCliVersion();
185
+ const templatesDir = getTemplatesDir();
186
+ let refreshed = false;
187
+ for (const target of resolveAgentTargets()) {
188
+ try {
189
+ refreshed = await syncSkillsForTarget(target, templatesDir, version) || refreshed;
190
+ } catch {
191
+ }
192
+ }
193
+ if (refreshed) log.plain(`\u{1F504} Genex skills updated to ${version}`);
194
+ } catch {
195
+ }
196
+ }
197
+ var PUBLISHED_PACKAGES = [
198
+ { name: "@genex-ai/cli-demo", label: "CLI", install: "npm i -D @genex-ai/cli-demo@latest" },
199
+ { name: "@genex-ai/embed-sdk", label: "embed SDK", install: "npm i @genex-ai/embed-sdk@latest" },
200
+ {
201
+ name: "@genex-ai/multiplayer",
202
+ label: "multiplayer SDK",
203
+ install: "npm i @genex-ai/multiplayer@latest"
204
+ }
205
+ ];
206
+ var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
207
+ var REGISTRY_TIMEOUT_MS = 1500;
208
+ function getUpdateCachePath() {
209
+ return path3.join(getGenexDir(), "update-check.json");
210
+ }
211
+ function isCacheFresh(cache, nowMs) {
212
+ const at = Date.parse(cache.checkedAt);
213
+ return Number.isFinite(at) && nowMs - at < CHECK_INTERVAL_MS;
214
+ }
215
+ async function readUpdateCache() {
216
+ try {
217
+ const raw = await fs3.readFile(getUpdateCachePath(), "utf8");
218
+ const parsed = JSON.parse(raw);
219
+ if (typeof parsed.checkedAt !== "string" || typeof parsed.latest !== "object") return null;
220
+ return parsed;
221
+ } catch {
222
+ return null;
223
+ }
224
+ }
225
+ async function fetchLatestFromRegistry(name) {
226
+ try {
227
+ const res = await fetch(
228
+ `https://registry.npmjs.org/-/package/${encodeURIComponent(name)}/dist-tags`,
229
+ { signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS) }
230
+ );
231
+ if (!res.ok) return null;
232
+ const tags = await res.json();
233
+ return typeof tags.latest === "string" && parseSemver(tags.latest) ? tags.latest : null;
234
+ } catch {
235
+ return null;
236
+ }
237
+ }
238
+ function startUpdateCheck() {
239
+ return (async () => {
240
+ try {
241
+ const cached = await readUpdateCache();
242
+ if (cached && isCacheFresh(cached, Date.now())) return cached;
243
+ const latest = { ...cached?.latest ?? {} };
244
+ const results = await Promise.allSettled(
245
+ PUBLISHED_PACKAGES.map((p) => fetchLatestFromRegistry(p.name))
246
+ );
247
+ results.forEach((r, i) => {
248
+ if (r.status === "fulfilled" && r.value) latest[PUBLISHED_PACKAGES[i].name] = r.value;
249
+ });
250
+ const next = { checkedAt: (/* @__PURE__ */ new Date()).toISOString(), latest };
251
+ await fs3.mkdir(getGenexDir(), { recursive: true });
252
+ await fs3.writeFile(getUpdateCachePath(), JSON.stringify(next, null, 2) + "\n");
253
+ return next;
254
+ } catch {
255
+ return null;
256
+ }
257
+ })();
258
+ }
259
+ async function installedPackageVersion(cwd, name) {
260
+ try {
261
+ const raw = await fs3.readFile(path3.join(cwd, "node_modules", name, "package.json"), "utf8");
262
+ const pkg = JSON.parse(raw);
263
+ return typeof pkg.version === "string" ? pkg.version : null;
264
+ } catch {
265
+ return null;
266
+ }
267
+ }
268
+ function formatNudge(label, latest, installed, install) {
269
+ return `\u2B06 Genex ${label} ${latest} available (installed ${installed}) \u2014 run: ${install}`;
270
+ }
271
+ async function reportUpdateNudges(check, log, cwd = process.cwd()) {
272
+ try {
273
+ const cache = await check;
274
+ if (!cache) return;
275
+ const lines = [];
276
+ let whatsNew = null;
277
+ for (const pkg of PUBLISHED_PACKAGES) {
278
+ const latest = cache.latest[pkg.name];
279
+ if (!latest) continue;
280
+ const installed = pkg.name === "@genex-ai/cli-demo" ? getCliVersion() : await installedPackageVersion(cwd, pkg.name);
281
+ if (!installed) continue;
282
+ if (isNewerVersion(latest, installed)) {
283
+ lines.push(formatNudge(pkg.label, latest, installed, pkg.install));
284
+ whatsNew ??= `https://www.npmjs.com/package/${pkg.name}`;
285
+ }
286
+ }
287
+ if (lines.length === 0) return;
288
+ for (const line of lines) log.plain(line);
289
+ log.dim(
290
+ ` Apply at a safe moment (never mid-task) \u2014 see the genex-updates skill. What's new: ${whatsNew}`
291
+ );
292
+ } catch {
293
+ }
294
+ }
295
+
124
296
  // src/lib/auth.ts
125
297
  import http from "http";
126
298
  import crypto from "crypto";
@@ -388,6 +560,29 @@ var c = {
388
560
  gray: code(90, 39)
389
561
  };
390
562
 
563
+ // src/lib/api.ts
564
+ var CLI_VERSION_HEADER = "x-genex-cli-version";
565
+ function formatUpdateRequired(body) {
566
+ const action = body.action ?? "npm i -D @genex-ai/cli-demo@latest";
567
+ const message = body.message ?? `Genex CLI ${body.clientVersion ?? getCliVersion()} is below the minimum supported version${body.minVersion ? ` ${body.minVersion}` : ""}.`;
568
+ return [`${c.red("\u2717")} ${message}`, ` Update now \u2014 run: ${action} (then re-run this command)`];
569
+ }
570
+ async function apiFetch(url, init = {}) {
571
+ const headers = new Headers(init.headers);
572
+ if (!headers.has(CLI_VERSION_HEADER)) headers.set(CLI_VERSION_HEADER, getCliVersion());
573
+ const res = await fetch(url, { ...init, headers });
574
+ if (res.status === 426) {
575
+ try {
576
+ const body = await res.clone().json();
577
+ if (body?.error === "cli_update_required") {
578
+ for (const line of formatUpdateRequired(body)) process.stderr.write(line + "\n");
579
+ }
580
+ } catch {
581
+ }
582
+ }
583
+ return res;
584
+ }
585
+
391
586
  // src/lib/project.ts
392
587
  async function createDraftProject(opts) {
393
588
  const { apiUrl, token, deployKey, colyseusUrl, dashboardUrl, log } = opts;
@@ -397,7 +592,7 @@ async function createDraftProject(opts) {
397
592
  const name = names[i];
398
593
  let res;
399
594
  try {
400
- res = await fetch(`${apiUrl}/api/projects`, {
595
+ res = await apiFetch(`${apiUrl}/api/projects`, {
401
596
  method: "POST",
402
597
  headers: {
403
598
  "Content-Type": "application/json",
@@ -453,15 +648,15 @@ function randomSuffix() {
453
648
  }
454
649
 
455
650
  // src/lib/ssh.ts
456
- import fs3 from "fs/promises";
457
- import path3 from "path";
651
+ import fs4 from "fs/promises";
652
+ import path4 from "path";
458
653
  import { spawn as spawn2 } from "child_process";
459
654
  var KEY_NAME = "genex_key";
460
655
  async function generateSshKeypair(dir, log) {
461
- const keyPath = path3.join(dir, KEY_NAME);
656
+ const keyPath = path4.join(dir, KEY_NAME);
462
657
  const pubPath = `${keyPath}.pub`;
463
658
  try {
464
- const existing = (await fs3.readFile(pubPath, "utf8")).trim();
659
+ const existing = (await fs4.readFile(pubPath, "utf8")).trim();
465
660
  if (existing) {
466
661
  log.dim(`Reusing existing deploy key (${KEY_NAME}).`);
467
662
  return { publicKey: existing };
@@ -472,12 +667,12 @@ async function generateSshKeypair(dir, log) {
472
667
  const ok = await runSshKeygen(keyPath, log);
473
668
  if (!ok) return null;
474
669
  try {
475
- const pub = (await fs3.readFile(pubPath, "utf8")).trim();
670
+ const pub = (await fs4.readFile(pubPath, "utf8")).trim();
476
671
  if (!pub) {
477
672
  log.warn("ssh-keygen produced no public key.");
478
673
  return null;
479
674
  }
480
- await fs3.chmod(keyPath, 384).catch(() => {
675
+ await fs4.chmod(keyPath, 384).catch(() => {
481
676
  });
482
677
  return { publicKey: pub };
483
678
  } catch (err) {
@@ -507,10 +702,10 @@ function runSshKeygen(keyPath, log) {
507
702
  });
508
703
  }
509
704
  async function writeGitignore(dir, log) {
510
- const file = path3.join(dir, ".gitignore");
705
+ const file = path4.join(dir, ".gitignore");
511
706
  let content = "";
512
707
  try {
513
- content = await fs3.readFile(file, "utf8");
708
+ content = await fs4.readFile(file, "utf8");
514
709
  } catch {
515
710
  }
516
711
  const present = new Set(content.split("\n").map((l) => l.trim()));
@@ -520,23 +715,23 @@ async function writeGitignore(dir, log) {
520
715
  if (next.length > 0 && !next.endsWith("\n")) next += "\n";
521
716
  if (!content.trim()) next += "# genex (deploy key + local metadata \u2014 never publish)\n";
522
717
  next += toAdd.join("\n") + "\n";
523
- await fs3.writeFile(file, next);
718
+ await fs4.writeFile(file, next);
524
719
  log.dim(`Updated .gitignore (${toAdd.join(", ")}).`);
525
720
  }
526
721
 
527
722
  // src/lib/store.ts
528
- import fs5 from "fs/promises";
529
- import path5 from "path";
723
+ import fs6 from "fs/promises";
724
+ import path6 from "path";
530
725
 
531
726
  // src/lib/env.ts
532
- import fs4 from "fs/promises";
533
- import path4 from "path";
727
+ import fs5 from "fs/promises";
728
+ import path5 from "path";
534
729
  import { spawn as spawn3 } from "child_process";
535
730
  async function writeEnvVar(envPath, key, value) {
536
731
  let content = "";
537
732
  let existed = false;
538
733
  try {
539
- content = await fs4.readFile(envPath, "utf8");
734
+ content = await fs5.readFile(envPath, "utf8");
540
735
  existed = true;
541
736
  } catch {
542
737
  }
@@ -556,14 +751,14 @@ async function writeEnvVar(envPath, key, value) {
556
751
  next = prefix + assignment + "\n";
557
752
  mode = existed ? "appended" : "created";
558
753
  }
559
- await fs4.mkdir(path4.dirname(envPath), { recursive: true });
560
- await fs4.writeFile(envPath, next, { mode: 384 });
754
+ await fs5.mkdir(path5.dirname(envPath), { recursive: true });
755
+ await fs5.writeFile(envPath, next, { mode: 384 });
561
756
  await restrictFilePermissions(envPath);
562
757
  return { mode, path: envPath };
563
758
  }
564
759
  async function restrictFilePermissions(filePath) {
565
760
  if (process.platform !== "win32") {
566
- await fs4.chmod(filePath, 384).catch(() => {
761
+ await fs5.chmod(filePath, 384).catch(() => {
567
762
  });
568
763
  return;
569
764
  }
@@ -595,7 +790,7 @@ function escapeRegExp(s) {
595
790
 
596
791
  // src/lib/store.ts
597
792
  function getProjectMetadataPath(cwd = process.cwd()) {
598
- return path5.join(cwd, ".genex", "project.json");
793
+ return path6.join(cwd, ".genex", "project.json");
599
794
  }
600
795
  async function writeUserToken(token, envPath) {
601
796
  const { path: written } = await writeEnvVar(getGenexEnvPath(envPath), ENV_TOKEN_KEY, token);
@@ -605,14 +800,14 @@ async function readUserToken(envPath) {
605
800
  const fromGenex = await readTokenFromFile(getGenexEnvPath(envPath));
606
801
  if (fromGenex) return fromGenex;
607
802
  if (!envPath) {
608
- return readTokenFromFile(path5.join(process.cwd(), ".env"));
803
+ return readTokenFromFile(path6.join(process.cwd(), ".env"));
609
804
  }
610
805
  return null;
611
806
  }
612
807
  async function readTokenFromFile(file) {
613
808
  let content;
614
809
  try {
615
- content = await fs5.readFile(file, "utf8");
810
+ content = await fs6.readFile(file, "utf8");
616
811
  } catch {
617
812
  return null;
618
813
  }
@@ -628,7 +823,7 @@ function stripQuotes(v) {
628
823
  }
629
824
  async function readProject(cwd = process.cwd()) {
630
825
  try {
631
- const raw = await fs5.readFile(getProjectMetadataPath(cwd), "utf8");
826
+ const raw = await fs6.readFile(getProjectMetadataPath(cwd), "utf8");
632
827
  return JSON.parse(raw);
633
828
  } catch {
634
829
  return null;
@@ -636,16 +831,16 @@ async function readProject(cwd = process.cwd()) {
636
831
  }
637
832
  async function writeProject(meta, cwd = process.cwd()) {
638
833
  const file = getProjectMetadataPath(cwd);
639
- await fs5.mkdir(path5.dirname(file), { recursive: true });
640
- await fs5.writeFile(file, JSON.stringify(meta, null, 2) + "\n", { mode: 384 });
641
- await fs5.chmod(file, 384).catch(() => {
834
+ await fs6.mkdir(path6.dirname(file), { recursive: true });
835
+ await fs6.writeFile(file, JSON.stringify(meta, null, 2) + "\n", { mode: 384 });
836
+ await fs6.chmod(file, 384).catch(() => {
642
837
  });
643
838
  return { path: file };
644
839
  }
645
840
 
646
841
  // src/lib/game-config.ts
647
- import fs6 from "fs/promises";
648
- import path6 from "path";
842
+ import fs7 from "fs/promises";
843
+ import path7 from "path";
649
844
  var DEFAULT_DASHBOARD_ORIGIN = new URL(DEFAULT_AUTH_URL).origin;
650
845
  function renderGenexConfig() {
651
846
  return `// src/genex.config.ts \u2014 written by \`genex init\`. DO NOT hardcode URLs here.
@@ -686,7 +881,7 @@ ${overrides.join("\n")}
686
881
  }
687
882
  async function writeIfAbsent(file, content, log) {
688
883
  try {
689
- await fs6.writeFile(file, content, { flag: "wx" });
884
+ await fs7.writeFile(file, content, { flag: "wx" });
690
885
  log.dim(` wrote ${c.cyan(file)}`);
691
886
  return true;
692
887
  } catch (err) {
@@ -698,12 +893,12 @@ async function writeIfAbsent(file, content, log) {
698
893
  }
699
894
  }
700
895
  async function writeGameConfigFiles(meta, log, cwd = process.cwd()) {
701
- await fs6.mkdir(path6.join(cwd, "src"), { recursive: true });
702
- await writeIfAbsent(path6.join(cwd, "src", "genex.config.ts"), renderGenexConfig(), log);
703
- await writeIfAbsent(path6.join(cwd, ".env"), renderSlugEnv(meta.slug), log);
896
+ await fs7.mkdir(path7.join(cwd, "src"), { recursive: true });
897
+ await writeIfAbsent(path7.join(cwd, "src", "genex.config.ts"), renderGenexConfig(), log);
898
+ await writeIfAbsent(path7.join(cwd, ".env"), renderSlugEnv(meta.slug), log);
704
899
  const overrides = renderDevOverrides(meta);
705
900
  if (overrides) {
706
- await writeIfAbsent(path6.join(cwd, ".env.development.local"), overrides, log);
901
+ await writeIfAbsent(path7.join(cwd, ".env.development.local"), overrides, log);
707
902
  }
708
903
  }
709
904
 
@@ -739,12 +934,13 @@ async function runInit(opts) {
739
934
  let totalNew = 0;
740
935
  let totalUpdated = 0;
741
936
  for (const t of targets) {
742
- const src = t.full ? templatesDir : path7.join(templatesDir, "skills");
743
- const dest = t.full ? t.baseDir : path7.join(t.baseDir, "skills");
937
+ const src = t.full ? templatesDir : path8.join(templatesDir, "skills");
938
+ const dest = t.full ? t.baseDir : path8.join(t.baseDir, "skills");
744
939
  const { copied, updated } = await copyTemplates(src, dest, {
745
940
  force: opts.force,
746
941
  exclude: ["controllers"]
747
942
  });
943
+ await writeSkillsMarker(path8.join(t.baseDir, "skills"));
748
944
  const added = copied.length - updated.length;
749
945
  totalNew += added;
750
946
  totalUpdated += updated.length;
@@ -787,7 +983,7 @@ async function runInit(opts) {
787
983
  }
788
984
  const apiUrl = getApiUrl(opts.apiUrl);
789
985
  const colyseusUrl = getColyseusUrl(opts.colyseusUrl);
790
- const projectName = opts.name?.trim() || path7.basename(process.cwd());
986
+ const projectName = opts.name?.trim() || path8.basename(process.cwd());
791
987
  const meta = await createDraftProject({
792
988
  apiUrl,
793
989
  token,
@@ -807,8 +1003,8 @@ async function runInit(opts) {
807
1003
  }
808
1004
 
809
1005
  // src/commands/link.ts
810
- import fs7 from "fs/promises";
811
- import path8 from "path";
1006
+ import fs8 from "fs/promises";
1007
+ import path9 from "path";
812
1008
  async function runLink(opts) {
813
1009
  const log = createLogger({ quiet: opts.quiet });
814
1010
  log.plain(c.bold("genex link"));
@@ -879,29 +1075,29 @@ async function runLink(opts) {
879
1075
  if (project.playUrl) log.dim(` play URL: ${project.playUrl}`);
880
1076
  }
881
1077
  async function ensureSlugEnv(slug, log, cwd = process.cwd()) {
882
- const file = path8.join(cwd, ".env");
1078
+ const file = path9.join(cwd, ".env");
883
1079
  let content;
884
1080
  try {
885
- content = await fs7.readFile(file, "utf8");
1081
+ content = await fs8.readFile(file, "utf8");
886
1082
  } catch {
887
1083
  return;
888
1084
  }
889
1085
  const re = /^(\s*VITE_GENEX_SLUG=)(.*)$/m;
890
1086
  const m = content.match(re);
891
1087
  if (!m) {
892
- await fs7.appendFile(file, `${content.endsWith("\n") ? "" : "\n"}VITE_GENEX_SLUG=${slug}
1088
+ await fs8.appendFile(file, `${content.endsWith("\n") ? "" : "\n"}VITE_GENEX_SLUG=${slug}
893
1089
  `);
894
1090
  log.dim(` added VITE_GENEX_SLUG=${slug} to .env`);
895
1091
  return;
896
1092
  }
897
1093
  if (m[2].trim() === slug) return;
898
- await fs7.writeFile(file, content.replace(re, `$1${slug}`));
1094
+ await fs8.writeFile(file, content.replace(re, `$1${slug}`));
899
1095
  log.dim(` updated .env: VITE_GENEX_SLUG=${slug} (was ${m[2].trim()})`);
900
1096
  }
901
1097
  async function fetchOwnProject(apiUrl, token, slug, log) {
902
1098
  let res;
903
1099
  try {
904
- res = await fetch(`${apiUrl}/api/projects/by-slug/${encodeURIComponent(slug)}`, {
1100
+ res = await apiFetch(`${apiUrl}/api/projects/by-slug/${encodeURIComponent(slug)}`, {
905
1101
  headers: { Authorization: `Bearer ${token}` }
906
1102
  });
907
1103
  } catch (err) {
@@ -933,7 +1129,7 @@ async function fetchOwnProject(apiUrl, token, slug, log) {
933
1129
  }
934
1130
  async function listOwnSlugs(apiUrl, token, log) {
935
1131
  try {
936
- const res = await fetch(`${apiUrl}/api/projects`, {
1132
+ const res = await apiFetch(`${apiUrl}/api/projects`, {
937
1133
  headers: { Authorization: `Bearer ${token}` }
938
1134
  });
939
1135
  if (!res.ok) return;
@@ -950,7 +1146,7 @@ async function listOwnSlugs(apiUrl, token, log) {
950
1146
  async function registerDeployKey(apiUrl, token, projectId, deployKey, log) {
951
1147
  let res;
952
1148
  try {
953
- res = await fetch(`${apiUrl}/api/projects/${projectId}/deploy-key`, {
1149
+ res = await apiFetch(`${apiUrl}/api/projects/${projectId}/deploy-key`, {
954
1150
  method: "POST",
955
1151
  headers: {
956
1152
  "Content-Type": "application/json",
@@ -982,9 +1178,9 @@ async function registerDeployKey(apiUrl, token, projectId, deployKey, log) {
982
1178
  // src/lib/deploy.ts
983
1179
  import { spawn as spawn4 } from "child_process";
984
1180
  import crypto3 from "crypto";
985
- import fs8 from "fs/promises";
1181
+ import fs9 from "fs/promises";
986
1182
  import os2 from "os";
987
- import path9 from "path";
1183
+ import path10 from "path";
988
1184
  function run(cmd, args, env) {
989
1185
  return new Promise((resolve) => {
990
1186
  let child;
@@ -1016,9 +1212,9 @@ async function deployGame(ctx, opts, log) {
1016
1212
  }
1017
1213
  log.success("Built.");
1018
1214
  }
1019
- const distDir = path9.join(cwd, "dist");
1215
+ const distDir = path10.join(cwd, "dist");
1020
1216
  const siteDir = await isDir2(distDir) ? distDir : cwd;
1021
- const rel = path9.relative(cwd, siteDir) || ".";
1217
+ const rel = path10.relative(cwd, siteDir) || ".";
1022
1218
  if (siteDir === cwd) await writeGitignore(cwd, log);
1023
1219
  const files = await collectFiles(siteDir);
1024
1220
  if (files.some((f) => /(^|\/)genex_key(\.pub)?$/.test(f.relPath))) {
@@ -1057,7 +1253,7 @@ async function deployGame(ctx, opts, log) {
1057
1253
  log.error("Couldn't upload your game \u2014 please try again.");
1058
1254
  return false;
1059
1255
  }
1060
- const keyPath = path9.resolve(cwd, KEY_NAME);
1256
+ const keyPath = path10.resolve(cwd, KEY_NAME);
1061
1257
  if (!await fileExists(keyPath)) {
1062
1258
  log.error(`No deploy key (${KEY_NAME}) here \u2014 run \`genex init\` in this folder first.`);
1063
1259
  return false;
@@ -1071,7 +1267,7 @@ async function deployGame(ctx, opts, log) {
1071
1267
  }
1072
1268
  async function hasBuildScript(cwd) {
1073
1269
  try {
1074
- const pkg = JSON.parse(await fs8.readFile(path9.join(cwd, "package.json"), "utf8"));
1270
+ const pkg = JSON.parse(await fs9.readFile(path10.join(cwd, "package.json"), "utf8"));
1075
1271
  return Boolean(pkg.scripts?.build);
1076
1272
  } catch {
1077
1273
  return false;
@@ -1080,12 +1276,12 @@ async function hasBuildScript(cwd) {
1080
1276
  async function collectFiles(root) {
1081
1277
  const out = [];
1082
1278
  const walk2 = async (dir, prefix) => {
1083
- for (const e of await fs8.readdir(dir, { withFileTypes: true })) {
1279
+ for (const e of await fs9.readdir(dir, { withFileTypes: true })) {
1084
1280
  const relPath = prefix ? `${prefix}/${e.name}` : e.name;
1085
1281
  if (e.isDirectory()) {
1086
- if (!EXCLUDE_DIRS.has(e.name)) await walk2(path9.join(dir, e.name), relPath);
1282
+ if (!EXCLUDE_DIRS.has(e.name)) await walk2(path10.join(dir, e.name), relPath);
1087
1283
  } else if (e.isFile() && e.name !== KEY_NAME && e.name !== `${KEY_NAME}.pub`) {
1088
- out.push({ relPath, bytes: await fs8.readFile(path9.join(dir, e.name)) });
1284
+ out.push({ relPath, bytes: await fs9.readFile(path10.join(dir, e.name)) });
1089
1285
  }
1090
1286
  }
1091
1287
  };
@@ -1104,7 +1300,7 @@ function contentCommit(files) {
1104
1300
  async function getUploadToken(ctx, commit, log) {
1105
1301
  let res;
1106
1302
  try {
1107
- res = await fetch(`${ctx.apiUrl}/api/games/${ctx.projectId}/upload-token`, {
1303
+ res = await apiFetch(`${ctx.apiUrl}/api/games/${ctx.projectId}/upload-token`, {
1108
1304
  method: "POST",
1109
1305
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${ctx.token}` },
1110
1306
  body: JSON.stringify({ commit })
@@ -1151,7 +1347,7 @@ async function uploadAll(uploadUrl, uploadToken, files, log) {
1151
1347
  async function callPublish(ctx, commit, opts, log) {
1152
1348
  let res;
1153
1349
  try {
1154
- res = await fetch(`${ctx.apiUrl}/api/games/${ctx.projectId}/publish`, {
1350
+ res = await apiFetch(`${ctx.apiUrl}/api/games/${ctx.projectId}/publish`, {
1155
1351
  method: "POST",
1156
1352
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${ctx.token}` },
1157
1353
  // Detections ride every go-live (preview AND publish): matchmaking is
@@ -1181,7 +1377,7 @@ async function pushSource(cwd, sshUrl, keyPath, log) {
1181
1377
  log.error("Couldn't save your game's source \u2014 please try again.");
1182
1378
  return false;
1183
1379
  };
1184
- const gitDir = await fs8.mkdtemp(path9.join(os2.tmpdir(), "genex-source-"));
1380
+ const gitDir = await fs9.mkdtemp(path10.join(os2.tmpdir(), "genex-source-"));
1185
1381
  const base = { GIT_DIR: gitDir };
1186
1382
  const ident = {
1187
1383
  GIT_AUTHOR_NAME: "genex",
@@ -1191,11 +1387,11 @@ async function pushSource(cwd, sshUrl, keyPath, log) {
1191
1387
  };
1192
1388
  try {
1193
1389
  if ((await run("git", ["init", "-q"], base)).code !== 0) return failed();
1194
- await fs8.writeFile(
1195
- path9.join(gitDir, "info", "exclude"),
1390
+ await fs9.writeFile(
1391
+ path10.join(gitDir, "info", "exclude"),
1196
1392
  ["node_modules/", "dist/", ".git/", KEY_NAME, `${KEY_NAME}.pub`, ".genex/", ""].join("\n")
1197
1393
  );
1198
- const env = { ...base, GIT_WORK_TREE: cwd, GIT_INDEX_FILE: path9.join(gitDir, "index-source") };
1394
+ const env = { ...base, GIT_WORK_TREE: cwd, GIT_INDEX_FILE: path10.join(gitDir, "index-source") };
1199
1395
  await run("git", ["add", "-A"], env);
1200
1396
  const tracked = await run("git", ["ls-files"], env);
1201
1397
  if (/(^|\/)genex_key(\.pub)?$/m.test(tracked.out)) {
@@ -1219,20 +1415,20 @@ async function pushSource(cwd, sshUrl, keyPath, log) {
1219
1415
  } catch {
1220
1416
  return failed();
1221
1417
  } finally {
1222
- await fs8.rm(gitDir, { recursive: true, force: true }).catch(() => {
1418
+ await fs9.rm(gitDir, { recursive: true, force: true }).catch(() => {
1223
1419
  });
1224
1420
  }
1225
1421
  }
1226
1422
  async function isDir2(p) {
1227
1423
  try {
1228
- return (await fs8.stat(p)).isDirectory();
1424
+ return (await fs9.stat(p)).isDirectory();
1229
1425
  } catch {
1230
1426
  return false;
1231
1427
  }
1232
1428
  }
1233
1429
  async function fileExists(p) {
1234
1430
  try {
1235
- await fs8.access(p);
1431
+ await fs9.access(p);
1236
1432
  return true;
1237
1433
  } catch {
1238
1434
  return false;
@@ -1266,11 +1462,11 @@ async function waitUntilLive(playUrl, fingerprint, timeoutMs, log) {
1266
1462
  }
1267
1463
 
1268
1464
  // src/lib/detect-features.ts
1269
- import fs9 from "fs/promises";
1270
- import path10 from "path";
1465
+ import fs10 from "fs/promises";
1466
+ import path11 from "path";
1271
1467
  async function detectEmbedSdkVersion(cwd = process.cwd()) {
1272
1468
  try {
1273
- const raw = await fs9.readFile(path10.join(cwd, "package.json"), "utf8");
1469
+ const raw = await fs10.readFile(path11.join(cwd, "package.json"), "utf8");
1274
1470
  const pkg = JSON.parse(raw);
1275
1471
  const version = pkg.dependencies?.["@genex-ai/embed-sdk"] ?? pkg.devDependencies?.["@genex-ai/embed-sdk"];
1276
1472
  return typeof version === "string" && version ? version : null;
@@ -1280,7 +1476,7 @@ async function detectEmbedSdkVersion(cwd = process.cwd()) {
1280
1476
  }
1281
1477
  async function detectMultiplayer(cwd = process.cwd()) {
1282
1478
  try {
1283
- const raw = await fs9.readFile(path10.join(cwd, "package.json"), "utf8");
1479
+ const raw = await fs10.readFile(path11.join(cwd, "package.json"), "utf8");
1284
1480
  const pkg = JSON.parse(raw);
1285
1481
  return Boolean(
1286
1482
  pkg.dependencies?.["@genex-ai/multiplayer"] ?? pkg.devDependencies?.["@genex-ai/multiplayer"]
@@ -1292,7 +1488,7 @@ async function detectMultiplayer(cwd = process.cwd()) {
1292
1488
  async function detectMatchmaking(log, cwd = process.cwd()) {
1293
1489
  let pkg;
1294
1490
  try {
1295
- pkg = JSON.parse(await fs9.readFile(path10.join(cwd, "package.json"), "utf8"));
1491
+ pkg = JSON.parse(await fs10.readFile(path11.join(cwd, "package.json"), "utf8"));
1296
1492
  } catch (err) {
1297
1493
  log.dim(` (skipping matchmaking \u2014 couldn't read package.json: ${String(err)})`);
1298
1494
  return null;
@@ -1309,10 +1505,10 @@ async function detectMatchmaking(log, cwd = process.cwd()) {
1309
1505
  }
1310
1506
  var GAME_STATE_CALLS = /savePlayerState|saveWorldState|submitScore/;
1311
1507
  async function detectGameStateUsage(cwd = process.cwd()) {
1312
- const srcDir = path10.join(cwd, "src");
1508
+ const srcDir = path11.join(cwd, "src");
1313
1509
  let entries;
1314
1510
  try {
1315
- entries = await fs9.readdir(srcDir, { recursive: true });
1511
+ entries = await fs10.readdir(srcDir, { recursive: true });
1316
1512
  } catch {
1317
1513
  return false;
1318
1514
  }
@@ -1320,7 +1516,7 @@ async function detectGameStateUsage(cwd = process.cwd()) {
1320
1516
  if (rel.includes("node_modules")) continue;
1321
1517
  if (!/\.(ts|js|mts|mjs|tsx|jsx)$/.test(rel)) continue;
1322
1518
  try {
1323
- const content = await fs9.readFile(path10.join(srcDir, rel), "utf8");
1519
+ const content = await fs10.readFile(path11.join(srcDir, rel), "utf8");
1324
1520
  if (GAME_STATE_CALLS.test(content)) return true;
1325
1521
  } catch {
1326
1522
  }
@@ -1406,7 +1602,7 @@ async function runPublish(opts) {
1406
1602
  body.multiplayer = detections.multiplayer;
1407
1603
  body.matchmaking = detections.matchmaking ?? null;
1408
1604
  body.gameStateUsed = detections.gameStateUsed;
1409
- res = await fetch(`${apiUrl}/api/projects/${meta.id}/publish`, {
1605
+ res = await apiFetch(`${apiUrl}/api/projects/${meta.id}/publish`, {
1410
1606
  method: "POST",
1411
1607
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
1412
1608
  body: JSON.stringify(body)
@@ -1533,7 +1729,7 @@ async function runGenerate(kind, opts) {
1533
1729
  log.plain("");
1534
1730
  let id;
1535
1731
  try {
1536
- const res = await fetch(`${apiUrl}/api/generations`, {
1732
+ const res = await apiFetch(`${apiUrl}/api/generations`, {
1537
1733
  method: "POST",
1538
1734
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
1539
1735
  body: JSON.stringify({ kind, prompt, options })
@@ -1599,7 +1795,7 @@ async function waitViaSSE(apiUrl, token, id, onProgress, deadline) {
1599
1795
  try {
1600
1796
  let res;
1601
1797
  try {
1602
- res = await fetch(`${apiUrl}/api/generations/${id}/events`, {
1798
+ res = await apiFetch(`${apiUrl}/api/generations/${id}/events`, {
1603
1799
  headers: { Authorization: `Bearer ${token}`, Accept: "text/event-stream" },
1604
1800
  signal: abort.signal
1605
1801
  });
@@ -1646,7 +1842,7 @@ async function poll(apiUrl, token, id, onProgress) {
1646
1842
  let last = -1;
1647
1843
  while (Date.now() < deadline) {
1648
1844
  try {
1649
- const res = await fetch(`${apiUrl}/api/generations/${id}`, {
1845
+ const res = await apiFetch(`${apiUrl}/api/generations/${id}`, {
1650
1846
  headers: { Authorization: `Bearer ${token}` }
1651
1847
  });
1652
1848
  if (res.ok) {
@@ -1668,7 +1864,7 @@ async function poll(apiUrl, token, id, onProgress) {
1668
1864
  function printHint(kind, files, log) {
1669
1865
  const url = files[0]?.url ?? "";
1670
1866
  const hint = {
1671
- model: `Load with GLTFLoader from the URL and add it to the scene \u2014 see the genex-ai-model skill. url = "${url}"`,
1867
+ model: `Meshopt-compressed GLB \u2014 wire the decoder once (loader.setMeshoptDecoder(MeshoptDecoder)), then load with GLTFLoader from the URL \u2014 see the genex-ai-model skill. url = "${url}"`,
1672
1868
  skybox: `Load as an equirectangular texture \u2192 scene.background + scene.environment \u2014 see genex-ai-skybox. url = "${url}"`,
1673
1869
  sfx: `Load with AudioLoader into a THREE.PositionalAudio (camera needs an AudioListener) \u2014 see genex-ai-sfx. url = "${url}"`,
1674
1870
  texture: `Load each map with TextureLoader (RepeatWrapping) into a MeshStandardMaterial \u2014 see genex-ai-texture. Use the URLs above by role.`
@@ -1678,8 +1874,8 @@ function printHint(kind, files, log) {
1678
1874
  }
1679
1875
 
1680
1876
  // src/commands/controller.ts
1681
- import fs10 from "fs/promises";
1682
- import path11 from "path";
1877
+ import fs11 from "fs/promises";
1878
+ import path12 from "path";
1683
1879
  var CONTROLLER_KINDS = ["character", "car", "drone"];
1684
1880
  var SHARED = [
1685
1881
  "shared/math.ts",
@@ -1758,8 +1954,8 @@ var CONTROLLER_FILE_SETS = {
1758
1954
  ]
1759
1955
  }
1760
1956
  };
1761
- var CODE_DEST = path11.join("src", "controllers");
1762
- var ASSETS_DEST = path11.join("public", "assets");
1957
+ var CODE_DEST = path12.join("src", "controllers");
1958
+ var ASSETS_DEST = path12.join("public", "assets");
1763
1959
  async function runController(opts) {
1764
1960
  const log = createLogger({ quiet: opts.quiet });
1765
1961
  const kind = opts.kind?.trim();
@@ -1772,31 +1968,31 @@ async function runController(opts) {
1772
1968
  process.exitCode = 1;
1773
1969
  return;
1774
1970
  }
1775
- const srcDir = path11.join(getTemplatesDir(), "controllers");
1971
+ const srcDir = path12.join(getTemplatesDir(), "controllers");
1776
1972
  const root = opts.cwd ?? process.cwd();
1777
1973
  const set = CONTROLLER_FILE_SETS[kind];
1778
1974
  log.plain(c.bold(`genex controller ${kind}`));
1779
1975
  log.plain("");
1780
- log.step(`Installing the ${kind} controller into ${c.cyan(CODE_DEST + path11.sep)}`);
1976
+ log.step(`Installing the ${kind} controller into ${c.cyan(CODE_DEST + path12.sep)}`);
1781
1977
  const plan = [
1782
- ...set.code.map((rel) => ({ from: rel, rel: path11.join(CODE_DEST, rel) })),
1978
+ ...set.code.map((rel) => ({ from: rel, rel: path12.join(CODE_DEST, rel) })),
1783
1979
  ...set.assets.map((rel) => ({
1784
1980
  from: rel,
1785
- rel: path11.join(ASSETS_DEST, path11.basename(rel))
1981
+ rel: path12.join(ASSETS_DEST, path12.basename(rel))
1786
1982
  }))
1787
1983
  ];
1788
1984
  let copied = 0;
1789
1985
  let skipped = 0;
1790
1986
  try {
1791
1987
  for (const file of plan) {
1792
- const dest = path11.join(root, file.rel);
1988
+ const dest = path12.join(root, file.rel);
1793
1989
  if (!opts.force && await exists2(dest)) {
1794
1990
  skipped++;
1795
1991
  log.dim(` skipped ${file.rel} (exists \u2014 use --force to overwrite)`);
1796
1992
  continue;
1797
1993
  }
1798
- await fs10.mkdir(path11.dirname(dest), { recursive: true });
1799
- await fs10.copyFile(path11.join(srcDir, file.from), dest);
1994
+ await fs11.mkdir(path12.dirname(dest), { recursive: true });
1995
+ await fs11.copyFile(path12.join(srcDir, file.from), dest);
1800
1996
  copied++;
1801
1997
  log.dim(` ${file.rel}`);
1802
1998
  }
@@ -1828,11 +2024,11 @@ async function runController(opts) {
1828
2024
  }
1829
2025
  async function installOwnerAvatar(args) {
1830
2026
  const { root, srcDir, apiUrl, token, log } = args;
1831
- const dest = path11.join(root, ASSETS_DEST, "avatar.vrm");
1832
- await fs10.mkdir(path11.dirname(dest), { recursive: true });
2027
+ const dest = path12.join(root, ASSETS_DEST, "avatar.vrm");
2028
+ await fs11.mkdir(path12.dirname(dest), { recursive: true });
1833
2029
  if (token) {
1834
2030
  try {
1835
- const res = await fetch(`${apiUrl}/api/avatars/me`, {
2031
+ const res = await apiFetch(`${apiUrl}/api/avatars/me`, {
1836
2032
  headers: { Authorization: `Bearer ${token}` }
1837
2033
  });
1838
2034
  if (res.ok) {
@@ -1841,7 +2037,7 @@ async function installOwnerAvatar(args) {
1841
2037
  const vrmRes = await fetch(me.vrmUrl);
1842
2038
  if (vrmRes.ok) {
1843
2039
  const buf = Buffer.from(await vrmRes.arrayBuffer());
1844
- await fs10.writeFile(dest, buf);
2040
+ await fs11.writeFile(dest, buf);
1845
2041
  log.dim(` public/assets/avatar.vrm (your avatar \u2014 ${(buf.length / 1e6).toFixed(1)} MB)`);
1846
2042
  return;
1847
2043
  }
@@ -1852,14 +2048,14 @@ async function installOwnerAvatar(args) {
1852
2048
  log.dim(" avatar fetch failed (offline?); using the bundled default.");
1853
2049
  }
1854
2050
  }
1855
- await fs10.copyFile(path11.join(srcDir, "assets", "default-avatar.vrm"), dest);
2051
+ await fs11.copyFile(path12.join(srcDir, "assets", "default-avatar.vrm"), dest);
1856
2052
  log.dim(
1857
2053
  token ? " public/assets/avatar.vrm (bundled default)" : " public/assets/avatar.vrm (bundled default \u2014 sign in and re-run for your own)"
1858
2054
  );
1859
2055
  }
1860
2056
  async function exists2(p) {
1861
2057
  try {
1862
- await fs10.access(p);
2058
+ await fs11.access(p);
1863
2059
  return true;
1864
2060
  } catch {
1865
2061
  return false;
@@ -1873,7 +2069,7 @@ async function runExplore(opts) {
1873
2069
  const apiUrl = getApiUrl(opts.apiUrl);
1874
2070
  let res;
1875
2071
  try {
1876
- res = await fetch(`${apiUrl}/api/gallery/search-corpus?curated=1`);
2072
+ res = await apiFetch(`${apiUrl}/api/gallery/search-corpus?curated=1`);
1877
2073
  } catch {
1878
2074
  log.error("Couldn't reach Genex \u2014 please try again.");
1879
2075
  process.exitCode = 1;
@@ -1933,17 +2129,6 @@ function rank(items, query) {
1933
2129
 
1934
2130
  // src/index.ts
1935
2131
  var GEN_KINDS = /* @__PURE__ */ new Set(["model", "skybox", "sfx", "texture"]);
1936
- function getVersion() {
1937
- try {
1938
- const here = path12.dirname(fileURLToPath2(import.meta.url));
1939
- const pkg = JSON.parse(
1940
- readFileSync(path12.resolve(here, "..", "package.json"), "utf8")
1941
- );
1942
- return pkg.version ?? "0.0.0";
1943
- } catch {
1944
- return "0.0.0";
1945
- }
1946
- }
1947
2132
  var HELP = `${c.bold("genex")} \u2014 set up your ~/.claude workspace, authorize, and publish 3D games.
1948
2133
 
1949
2134
  ${c.bold("Usage")}
@@ -2206,43 +2391,50 @@ async function main() {
2206
2391
  return;
2207
2392
  }
2208
2393
  if (parsed.version) {
2209
- log.plain(getVersion());
2394
+ log.plain(getCliVersion());
2210
2395
  return;
2211
2396
  }
2212
2397
  if (parsed.help || parsed.command === void 0) {
2213
2398
  log.plain(HELP);
2214
2399
  return;
2215
2400
  }
2216
- if (GEN_KINDS.has(parsed.command)) {
2217
- await runGenerate(parsed.command, {
2218
- ...parsed.options,
2219
- prompt: parsed.options.name
2220
- });
2221
- return;
2222
- }
2223
- switch (parsed.command) {
2224
- case "init":
2225
- await runInit(parsed.options);
2226
- break;
2227
- case "link":
2228
- await runLink(parsed.options);
2229
- break;
2230
- case "controller":
2231
- await runController({ ...parsed.options, kind: parsed.options.name });
2232
- break;
2233
- case "preview":
2234
- await runPreview(parsed.options);
2235
- break;
2236
- case "publish":
2237
- await runPublish(parsed.options);
2238
- break;
2239
- case "explore":
2240
- await runExplore({ ...parsed.options, query: parsed.options.name });
2241
- break;
2242
- default:
2243
- log.error(`Unknown command: ${parsed.command}`);
2244
- log.plain(`Run ${c.cyan("genex --help")} for usage.`);
2245
- process.exitCode = 1;
2401
+ const updateLog = createLogger({ quiet: parsed.options.quiet });
2402
+ if (parsed.command !== "init") await syncSkills(updateLog);
2403
+ const updateCheck = startUpdateCheck();
2404
+ try {
2405
+ if (GEN_KINDS.has(parsed.command)) {
2406
+ await runGenerate(parsed.command, {
2407
+ ...parsed.options,
2408
+ prompt: parsed.options.name
2409
+ });
2410
+ return;
2411
+ }
2412
+ switch (parsed.command) {
2413
+ case "init":
2414
+ await runInit(parsed.options);
2415
+ break;
2416
+ case "link":
2417
+ await runLink(parsed.options);
2418
+ break;
2419
+ case "controller":
2420
+ await runController({ ...parsed.options, kind: parsed.options.name });
2421
+ break;
2422
+ case "preview":
2423
+ await runPreview(parsed.options);
2424
+ break;
2425
+ case "publish":
2426
+ await runPublish(parsed.options);
2427
+ break;
2428
+ case "explore":
2429
+ await runExplore({ ...parsed.options, query: parsed.options.name });
2430
+ break;
2431
+ default:
2432
+ log.error(`Unknown command: ${parsed.command}`);
2433
+ log.plain(`Run ${c.cyan("genex --help")} for usage.`);
2434
+ process.exitCode = 1;
2435
+ }
2436
+ } finally {
2437
+ await reportUpdateNudges(updateCheck, updateLog);
2246
2438
  }
2247
2439
  }
2248
2440
  main().catch((err) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/cli-demo",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "description": "Set up your ~/.claude workspace, authorize, create a game project, generate AI assets, and publish (genex CLI).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,9 +8,9 @@ for making 3D games in the browser.
8
8
  Genex is built around agent superpowers for browser games: Three.js skills,
9
9
  one-click publishing, multiplayer-ready architecture, and team workflows.
10
10
 
11
- Files whose names start with `genex-` are managed by Genex: re-running
12
- `genex init` refreshes them to the latest version. Anything you add yourself is
13
- never touched.
11
+ Files whose names start with `genex-` are managed by Genex: every `genex`
12
+ command keeps them in sync with the installed CLI version automatically
13
+ (re-running `genex init` does too). Anything you add yourself is never touched.
14
14
 
15
15
  - `skills/` - reusable Genex skills for 3D game creation.
16
16
  - `agents/` - example subagent definitions.
@@ -63,13 +63,18 @@ side — reads as broken at a glance.
63
63
 
64
64
  ## Load it into the scene
65
65
 
66
- Use Three.js `GLTFLoader` with the URL the command printed (R2 sends the right CORS
67
- headers, so cross-origin loading just works):
66
+ Generated model GLBs are **meshopt-compressed** on the Genex servers before
67
+ storage (same visuals, several times smaller faster loads on every play).
68
+ Wire the decoder once — it's harmless for uncompressed files — then use
69
+ Three.js `GLTFLoader` with the URL the command printed (R2 sends the right
70
+ CORS headers, so cross-origin loading just works):
68
71
 
69
72
  ```ts
70
73
  import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
74
+ import { MeshoptDecoder } from "three/addons/libs/meshopt_decoder.module.js";
71
75
 
72
76
  const loader = new GLTFLoader();
77
+ loader.setMeshoptDecoder(MeshoptDecoder); // required for genex model GLBs
73
78
  // the URL `npx genex model` printed:
74
79
  const MODEL_URL = "https://assets.genex.technology/generations/<id>/model-glb";
75
80
  const gltf = await loader.loadAsync(MODEL_URL);
@@ -13,9 +13,9 @@ architecture, and team-ready workflows.
13
13
 
14
14
  `genex init` installs the Genex skills into **every coding agent it detects** —
15
15
  Claude Code (`~/.claude`), Codex (`~/.codex`), and Cursor (`~/.cursor`) — so the
16
- same skills are available whichever agent you build with. Re-running `init`
17
- always refreshes the genex-owned skills to the latest version (your own files
18
- are never touched).
16
+ same skills are available whichever agent you build with. After that, every
17
+ `genex` command keeps the genex-owned skills in sync with the installed CLI
18
+ automatically (re-running `init` does too); your own files are never touched.
19
19
 
20
20
  - **skills/** - reusable Genex skills for 3D browser-game work (all agents).
21
21
  - **agents/** - example subagent definitions (Claude Code).
@@ -110,6 +110,13 @@ After it, `npx genex preview` / `npx genex publish` update the **same live
110
110
  game** — plays, likes, and comments stay. It also fixes a folder whose
111
111
  authorization went stale ("Not authorized" from `preview`/`publish`).
112
112
 
113
+ ## Staying up to date
114
+
115
+ Genex commands print an update nudge when a newer package exists
116
+ (`⬆ Genex … available — run: …`). Follow `$genex-updates`: apply the printed
117
+ command at a safe moment (never mid-task), then tell the user what changed.
118
+ Skills need no action — they sync automatically on every `genex` command.
119
+
113
120
  ## Re-running setup
114
121
 
115
122
  Safe to run any time — genex-owned skills are refreshed to the latest version,
@@ -273,10 +273,13 @@ and a gate capture is NOT visual evidence.
273
273
  - Do NOT work around the gate: don't dig through the SDK's internals for
274
274
  undocumented URL fragments, and don't drive the user's own signed-in
275
275
  browser.
276
- - For the visual pass on a draft, hand it off plainly: "open your draft page
277
- and tell me what you see" the owner's view is the real check. Once the
278
- game is **published**, any fresh browser gets in as a guest, so your own
279
- test browser works again for full visual validation.
276
+ - For the visual pass on a draft, the owner IS the QA loop not a fallback:
277
+ push `genex preview` at each playable milestone and hand it off plainly
278
+ ("check the draft you can now X; ping me if something feels off"), then
279
+ keep building while they look. Their run-around catches exactly what the
280
+ gate hides from you: facing, proportions, feel. Once the game is
281
+ **published**, any fresh browser gets in as a guest, so your own test
282
+ browser works again for full visual validation.
280
283
 
281
284
  ## Checklist
282
285
 
@@ -96,21 +96,32 @@ treat the player as drivable ground.
96
96
  ## Custom generated bodies (`npx genex model`)
97
97
 
98
98
  A generated GLB works as the visual chassis — add it under `car.chassisObject`
99
- exactly like `carBodyMesh` above. Two rules keep it looking right:
100
-
101
- - **Generate the body WITHOUT wheels** prompt it explicitly, e.g.
102
- `npx genex model "red sports car body, no wheels"`. Wheels are code-driven:
103
- each `ShapeCastWheel` steers, bounces, and spins whatever sits in
104
- `wheel.modelObject`, so wheels baked into the body mesh stand frozen while
105
- the invisible real wheels do the work it reads as a broken car.
106
- - **Wheel-less is a valid look.** The suspension shapecasts need no wheel
107
- visuals at all: a body with empty `modelObject`s sits and drives correctly.
108
- Add per-corner wheel meshes (the cylinder in the wiring above, or a small
109
- generated wheel model) only when you want wheels visible.
110
-
111
- Scale and center the GLB to hug the chassis colliders, and mind the
112
- +Z-forward convention when orienting it. Collider strategy for GLBs lives in
113
- `$genex-threejs-physics-rapier`.
99
+ exactly like `carBodyMesh` above. Generated car models arrive as ONE mesh with
100
+ the wheels baked in (prompting "no wheels" does not reliably remove them) —
101
+ that is the expected input. Three rules make it read right:
102
+
103
+ - **Never add visible wheel meshes to a generated body.** Leave every
104
+ `wheel.modelObject` empty the suspension shapecasts drive the body
105
+ correctly with no wheel visuals, and code wheels next to the baked-in ones
106
+ read as a six-wheeled monster truck. The baked wheels simply don't spin;
107
+ at game speed that's the accepted look. (The cylinder wheels in the wiring
108
+ above are for hand-built box-chassis cars only.)
109
+ - **Fit the invisible wheels to the body, not the body to the preset.**
110
+ Scale the GLB by LENGTH toward the preset's footprint (body length ≈
111
+ wheelbase + 1.5 m; race-grip/muscle-drift wheelbase is 3.0 m, track
112
+ 1.7 m) never a min-axis bounding-box fit, which shrinks the body and
113
+ strands the wheel slots outside its silhouette. Then move the wheel slots
114
+ to the model's own wheel arches (from its bounding box: axles at roughly
115
+ ±0.33 × body length on z, x at ±(half width − 0.1)) and set
116
+ `rayShapeR`/`wheelModelRadius` so the body STANDS on the road — baked
117
+ tires touching the ground, nothing floating.
118
+ - **Verify the facing before anything else.** +Z must be the nose:
119
+ generation regularly comes back rotated 180° (the classic "car drives
120
+ backward" bug) — apply the one-time yaw wrapper from `$genex-ai-model`,
121
+ then confirm in your smoke test that the NOSE leads under forward input.
122
+
123
+ Center the GLB over the chassis colliders. Collider strategy for GLBs lives
124
+ in `$genex-threejs-physics-rapier`.
114
125
 
115
126
  ## Presets and provenance
116
127
 
@@ -41,6 +41,12 @@ this is the whole acceptance gate, and it is also the minimum for every game:
41
41
  direction, and NPCs driven by chase/aim code face their target. A model
42
42
  rotated 90° reads as broken — `$genex-ai-model` has the one-time facing
43
43
  fix.
44
+ 5. Vehicles get one extra pass: drive forward once and confirm the NOSE
45
+ leads (a 180° body is this bug's favorite disguise), and check the body
46
+ stands ON the road with its wheels inside the silhouette — a body
47
+ floating above detached wheels means the model was box-fit against the
48
+ preset's wheelbase (the vehicle skill's "Custom generated bodies" rules
49
+ fix it).
44
50
 
45
51
  Everything deeper (baselines, seed sweeps, mosaics, budgets) belongs to
46
52
  visual-system work — the sequence above.
@@ -0,0 +1,66 @@
1
+ ---
2
+ name: genex-updates
3
+ description: Apply Genex platform updates safely. Use when a genex command prints an update nudge ("⬆ Genex … available — run: …"), a skills-refresh line ("🔄 Genex skills updated"), or an update-required refusal (HTTP 426, cli_update_required), or when the user asks about updating Genex packages.
4
+ ---
5
+
6
+ # Genex Updates — apply platform updates at safe moments
7
+
8
+ Genex ships improvements continuously. The `genex` CLI tells you when something
9
+ is stale; you (the agent) apply the update at the right moment and tell the
10
+ user what happened. The user never manages versions themselves.
11
+
12
+ ## The three signals in `genex` output
13
+
14
+ | Line | Meaning | What you do |
15
+ | -- | -- | -- |
16
+ | `🔄 Genex skills updated to X.Y.Z` | Skills were auto-refreshed to match the installed CLI. | Nothing — informational. Skills stay in sync on their own. |
17
+ | `⬆ Genex <package> X.Y.Z available (installed A.B.C) — run: <command>` | A newer npm package exists. | Run the printed command at a **safe moment** (below), then tell the user. |
18
+ | `✗ … below the minimum supported version … Update now — run: <command>` | The API refuses this CLI version (HTTP 426, `cli_update_required`). | Run the printed command **now**, then re-run the refused genex command. |
19
+
20
+ ## Safe moments — when to apply a nudge
21
+
22
+ Update when nothing is in flight:
23
+
24
+ - at the start of a session, before you begin the user's task;
25
+ - between tasks, after the current change is finished and working;
26
+ - right before a `genex preview`/`publish` you have **not** started yet.
27
+
28
+ Never mid-task: not during a build or deploy, not halfway through a code
29
+ change, not while the user is waiting for something else. A nudge is a
30
+ suggestion — if now is not a safe moment, finish first; the nudge reappears on
31
+ the next command. (The 426 refusal is the exception: nothing works until you
32
+ update, so update immediately.)
33
+
34
+ ## How to apply
35
+
36
+ Run exactly the command the nudge printed, from the game project root:
37
+
38
+ ```bash
39
+ npm i -D @genex-ai/cli-demo@latest # the genex CLI (a dev dependency)
40
+ npm i @genex-ai/embed-sdk@latest # identity/saves SDK (ships inside the game)
41
+ npm i @genex-ai/multiplayer@latest # multiplayer SDK (only if the game uses it)
42
+ ```
43
+
44
+ Skills refresh automatically on the next `genex` command after a CLI update —
45
+ no extra step. Then tell the user in one short line what changed and why it
46
+ matters, e.g. "I also updated Genex to 0.22 — skills and SDK refreshed." For
47
+ what's new, check the package page: https://www.npmjs.com/package/@genex-ai/cli-demo
48
+
49
+ ## Hard rules
50
+
51
+ - **Never touch game code as part of an update.** Everything in the game's
52
+ `src/` — including vendored controller code in `src/controllers/` — is
53
+ user-owned and may carry customizations. Updates never overwrite it. If a
54
+ newer controller exists you may *mention* that `genex controller <type>
55
+ --force` re-vendors it, but run that only when the user explicitly opts in
56
+ (it overwrites their changes).
57
+ - **Never update silently.** `npm i …@latest` changes `package.json` — that is
58
+ the sanctioned path, but always say in chat that you did it.
59
+ - **Never downgrade**, and never pin to an old version to "fix" an error —
60
+ report the error instead.
61
+ - **Verify after updating**: the game should still build/run. If it does not,
62
+ say exactly what broke rather than quietly reverting.
63
+ - If `npm i` fails (offline, registry down), say so and continue on the
64
+ current version — everything keeps working, and the nudge will reappear.
65
+ - A published game is never affected by any of this: its live bundle is frozen
66
+ and keeps working regardless of local package versions.