@genex-ai/cli-demo 0.32.0 → 0.34.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 +1 -0
- package/dist/index.js +161 -3
- package/package.json +1 -1
- package/templates/skills/genex-ai-model/SKILL.md +9 -0
- package/templates/skills/genex-ai-sfx/SKILL.md +9 -0
- package/templates/skills/genex-ai-skybox/SKILL.md +10 -0
- package/templates/skills/genex-ai-texture/SKILL.md +10 -0
- package/templates/skills/genex-updates/SKILL.md +3 -1
package/README.md
CHANGED
|
@@ -8,6 +8,7 @@ genex init <name> # authorize + create the draft project
|
|
|
8
8
|
genex link <slug> # re-link this folder to an existing game of yours (recovery)
|
|
9
9
|
genex preview # build + push to the hosted draft URL (unlisted)
|
|
10
10
|
genex publish # build + push, then list the game in the gallery
|
|
11
|
+
genex make-remixable # make this game remixable — migrate a private source to a public repo
|
|
11
12
|
genex model "<prompt>" # generate a 3D model → prints an asset URL
|
|
12
13
|
genex skybox "<prompt>" # generate a 360° sky → prints an asset URL
|
|
13
14
|
genex sfx "<prompt>" # generate a sound fx → prints an asset URL
|
package/dist/index.js
CHANGED
|
@@ -567,6 +567,34 @@ function formatUpdateRequired(body) {
|
|
|
567
567
|
const message = body.message ?? `Genex CLI ${body.clientVersion ?? getCliVersion()} is below the minimum supported version${body.minVersion ? ` ${body.minVersion}` : ""}.`;
|
|
568
568
|
return [`${c.red("\u2717")} ${message}`, ` Update now \u2014 run: ${action} (then re-run this command)`];
|
|
569
569
|
}
|
|
570
|
+
function shortDate(iso) {
|
|
571
|
+
const d = new Date(iso);
|
|
572
|
+
if (Number.isNaN(d.getTime())) return iso;
|
|
573
|
+
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
574
|
+
}
|
|
575
|
+
function formatInsufficientCredits(body) {
|
|
576
|
+
const message = body.message ?? `this generation costs ${body.price ?? "?"} credits; your balance is ${body.balance ?? 0}.`;
|
|
577
|
+
const lines = [`${c.red("\u2717")} Out of credits \u2014 ${lowerFirst(message)}`];
|
|
578
|
+
if (body.refillAt && body.refillTo) {
|
|
579
|
+
lines.push(` Credits refill to ${body.refillTo} on ${shortDate(body.refillAt)}.`);
|
|
580
|
+
}
|
|
581
|
+
if (body.url) lines.push(` Get more or check your balance: ${body.url}`);
|
|
582
|
+
return lines;
|
|
583
|
+
}
|
|
584
|
+
function formatVerificationRequired(body) {
|
|
585
|
+
const lines = [
|
|
586
|
+
`${c.red("\u2717")} Email not verified \u2014 verify your email to unlock your free generation credits.`
|
|
587
|
+
];
|
|
588
|
+
if (body.url) lines.push(` Verify here: ${body.url} (then re-run this command)`);
|
|
589
|
+
return lines;
|
|
590
|
+
}
|
|
591
|
+
function lowerFirst(s) {
|
|
592
|
+
return s ? s[0].toLowerCase() + s.slice(1) : s;
|
|
593
|
+
}
|
|
594
|
+
var structuredPrinted = /* @__PURE__ */ new WeakSet();
|
|
595
|
+
function printedStructuredError(res) {
|
|
596
|
+
return structuredPrinted.has(res);
|
|
597
|
+
}
|
|
570
598
|
async function apiFetch(url, init = {}) {
|
|
571
599
|
const headers = new Headers(init.headers);
|
|
572
600
|
if (!headers.has(CLI_VERSION_HEADER)) headers.set(CLI_VERSION_HEADER, getCliVersion());
|
|
@@ -580,6 +608,39 @@ async function apiFetch(url, init = {}) {
|
|
|
580
608
|
} catch {
|
|
581
609
|
}
|
|
582
610
|
}
|
|
611
|
+
if (res.status === 402) {
|
|
612
|
+
try {
|
|
613
|
+
const body = await res.clone().json();
|
|
614
|
+
if (body?.error === "insufficient_credits") {
|
|
615
|
+
for (const line of formatInsufficientCredits(body)) process.stderr.write(line + "\n");
|
|
616
|
+
structuredPrinted.add(res);
|
|
617
|
+
}
|
|
618
|
+
} catch {
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
if (res.status === 403) {
|
|
622
|
+
try {
|
|
623
|
+
const body = await res.clone().json();
|
|
624
|
+
if (body?.error === "email_verification_required") {
|
|
625
|
+
for (const line of formatVerificationRequired(body)) process.stderr.write(line + "\n");
|
|
626
|
+
structuredPrinted.add(res);
|
|
627
|
+
}
|
|
628
|
+
} catch {
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
if (res.status === 503) {
|
|
632
|
+
try {
|
|
633
|
+
const body = await res.clone().json();
|
|
634
|
+
if (body?.error === "generation_paused") {
|
|
635
|
+
process.stderr.write(
|
|
636
|
+
`${c.red("\u2717")} ${body.message ?? "Generation is temporarily paused platform-wide. Try again later."}
|
|
637
|
+
`
|
|
638
|
+
);
|
|
639
|
+
structuredPrinted.add(res);
|
|
640
|
+
}
|
|
641
|
+
} catch {
|
|
642
|
+
}
|
|
643
|
+
}
|
|
583
644
|
return res;
|
|
584
645
|
}
|
|
585
646
|
|
|
@@ -598,7 +659,12 @@ async function createDraftProject(opts) {
|
|
|
598
659
|
"Content-Type": "application/json",
|
|
599
660
|
Authorization: `Bearer ${token}`
|
|
600
661
|
},
|
|
601
|
-
body: JSON.stringify(
|
|
662
|
+
body: JSON.stringify({
|
|
663
|
+
name,
|
|
664
|
+
...opts.repoUrl ? { repoUrl: opts.repoUrl } : {},
|
|
665
|
+
...opts.private ? { private: true } : {},
|
|
666
|
+
...opts.remixedFromSlug ? { remixedFromSlug: opts.remixedFromSlug } : {}
|
|
667
|
+
})
|
|
602
668
|
});
|
|
603
669
|
} catch (err) {
|
|
604
670
|
log.warn(`Couldn't reach the API at ${apiUrl} to create the project.`);
|
|
@@ -929,6 +995,8 @@ async function runInit(opts) {
|
|
|
929
995
|
token,
|
|
930
996
|
name: projectName,
|
|
931
997
|
repoUrl: opts.repo?.trim() || void 0,
|
|
998
|
+
private: opts.private,
|
|
999
|
+
remixedFromSlug: opts.remixedFrom?.trim() || void 0,
|
|
932
1000
|
colyseusUrl,
|
|
933
1001
|
dashboardUrl: authBaseUrl,
|
|
934
1002
|
log
|
|
@@ -1267,10 +1335,12 @@ async function callPublish(ctx, commit, opts, log) {
|
|
|
1267
1335
|
return true;
|
|
1268
1336
|
}
|
|
1269
1337
|
async function pushSource(cwd, ctx, log) {
|
|
1270
|
-
const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
1271
1338
|
const target = await fetchPushUrl(ctx, log);
|
|
1272
1339
|
if (!target) return false;
|
|
1273
|
-
|
|
1340
|
+
return pushWorktree(cwd, target.pushUrl, target.managed, log);
|
|
1341
|
+
}
|
|
1342
|
+
async function pushWorktree(cwd, pushUrl, managed, log) {
|
|
1343
|
+
const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
1274
1344
|
const failed = () => {
|
|
1275
1345
|
log.error("Couldn't save your game's source \u2014 please try again.");
|
|
1276
1346
|
return false;
|
|
@@ -1379,6 +1449,75 @@ async function waitUntilLive(playUrl, fingerprint, timeoutMs, log) {
|
|
|
1379
1449
|
log.dim(" (If the previous build shows, hard-refresh in a few seconds.)");
|
|
1380
1450
|
}
|
|
1381
1451
|
|
|
1452
|
+
// src/commands/make-remixable.ts
|
|
1453
|
+
async function runMakeRemixable(opts) {
|
|
1454
|
+
const log = createLogger({ quiet: opts.quiet });
|
|
1455
|
+
log.plain(c.bold("genex make-remixable"));
|
|
1456
|
+
log.plain("");
|
|
1457
|
+
const meta = await readProject();
|
|
1458
|
+
if (!meta) {
|
|
1459
|
+
log.error("No genex project here. Run `genex init` in this directory first.");
|
|
1460
|
+
process.exitCode = 1;
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
const token = await readUserToken(opts.envPath);
|
|
1464
|
+
if (!token) {
|
|
1465
|
+
log.error("Not authorized. Run `genex init` first to sign in.");
|
|
1466
|
+
process.exitCode = 1;
|
|
1467
|
+
return;
|
|
1468
|
+
}
|
|
1469
|
+
const apiUrl = getApiUrl(opts.apiUrl ?? meta.apiUrl);
|
|
1470
|
+
log.step("Preparing a public repo for your game\u2026");
|
|
1471
|
+
let res;
|
|
1472
|
+
try {
|
|
1473
|
+
res = await apiFetch(`${apiUrl}/api/projects/${meta.id}/make-remixable`, {
|
|
1474
|
+
method: "POST",
|
|
1475
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
1476
|
+
});
|
|
1477
|
+
} catch (err) {
|
|
1478
|
+
log.error(`Couldn't reach the API at ${apiUrl}: ${String(err)}`);
|
|
1479
|
+
process.exitCode = 1;
|
|
1480
|
+
return;
|
|
1481
|
+
}
|
|
1482
|
+
if (res.status === 401) {
|
|
1483
|
+
log.error("Not authorized \u2014 your token may have expired. Re-run `genex init`.");
|
|
1484
|
+
process.exitCode = 1;
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
if (res.status === 404) {
|
|
1488
|
+
log.error("That game wasn't found on your account. Re-run `genex init` or `genex link <slug>`.");
|
|
1489
|
+
process.exitCode = 1;
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
if (!res.ok) {
|
|
1493
|
+
log.error(`Couldn't make the game public (HTTP ${res.status}).`);
|
|
1494
|
+
process.exitCode = 1;
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1497
|
+
const data = await res.json().catch(() => null);
|
|
1498
|
+
if (!data?.pushUrl) {
|
|
1499
|
+
log.error("The API didn't return a push URL.");
|
|
1500
|
+
process.exitCode = 1;
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1503
|
+
log.step("Copying your game's source to the public repo\u2026");
|
|
1504
|
+
if (!await pushWorktree(process.cwd(), data.pushUrl, true, log)) {
|
|
1505
|
+
process.exitCode = 1;
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
const newCloneUrl = data.project?.cloneUrl;
|
|
1509
|
+
if (newCloneUrl && newCloneUrl !== meta.cloneUrl) {
|
|
1510
|
+
await writeProject({ ...meta, cloneUrl: newCloneUrl });
|
|
1511
|
+
}
|
|
1512
|
+
log.plain("");
|
|
1513
|
+
log.success("Your game is public \u2014 anyone can remix it now. \u{1F310}");
|
|
1514
|
+
const dashboard = meta.dashboardOrigins?.[0];
|
|
1515
|
+
if (dashboard) {
|
|
1516
|
+
const page = meta.status === "published" ? "world" : "draft";
|
|
1517
|
+
log.plain(` your game's page: ${c.cyan(`${dashboard}/${page}/${meta.slug}`)}`);
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1382
1521
|
// src/lib/detect-features.ts
|
|
1383
1522
|
import fs10 from "fs/promises";
|
|
1384
1523
|
import path11 from "path";
|
|
@@ -1664,6 +1803,10 @@ async function runGenerate(kind, opts) {
|
|
|
1664
1803
|
process.exitCode = 1;
|
|
1665
1804
|
return;
|
|
1666
1805
|
}
|
|
1806
|
+
if (printedStructuredError(res)) {
|
|
1807
|
+
process.exitCode = 1;
|
|
1808
|
+
return;
|
|
1809
|
+
}
|
|
1667
1810
|
if (!res.ok) {
|
|
1668
1811
|
log.error(`Generation request failed (HTTP ${res.status}).`);
|
|
1669
1812
|
process.exitCode = 1;
|
|
@@ -2063,6 +2206,8 @@ ${c.bold("Usage")}
|
|
|
2063
2206
|
update the same live game. Never creates a project.
|
|
2064
2207
|
genex preview [options] Build + push to the hosted draft URL (unlisted).
|
|
2065
2208
|
genex publish [options] Build + push, then list the game in the gallery.
|
|
2209
|
+
genex make-remixable [options] Make THIS game remixable by everyone \u2014 migrates a
|
|
2210
|
+
private source onto a public managed genex repo.
|
|
2066
2211
|
genex model "<prompt>" [options] Generate a 3D model (GLB) into public/assets/models.
|
|
2067
2212
|
genex skybox "<prompt>" [options] Generate a skybox (equirect) into public/assets/skybox.
|
|
2068
2213
|
genex sfx "<prompt>" [options] Generate a sound effect (mp3) into public/assets/sfx.
|
|
@@ -2085,6 +2230,8 @@ ${c.bold("Options for `init`")}
|
|
|
2085
2230
|
--name <name> Same as the positional name.
|
|
2086
2231
|
--repo <url> Host the source in your own git repo (https/ssh) instead of a managed one;
|
|
2087
2232
|
preview/publish push there with your git credentials.
|
|
2233
|
+
--private Create the game private \u2014 only you can remix it (managed repos only).
|
|
2234
|
+
--remixed-from <slug> Record the game this one was remixed from (lineage).
|
|
2088
2235
|
--agents <list> Agents to install skills for (claude,codex,cursor; default: auto-detect).
|
|
2089
2236
|
--dir <path> Single destination workspace (overrides --agents).
|
|
2090
2237
|
--env <path> Token env file (default: ~/.genex/env).
|
|
@@ -2142,6 +2289,7 @@ ${c.bold("Examples")}
|
|
|
2142
2289
|
genex preview
|
|
2143
2290
|
genex publish
|
|
2144
2291
|
genex publish --categories games,vfx
|
|
2292
|
+
genex make-remixable
|
|
2145
2293
|
genex publish --no-push --title "My Game"
|
|
2146
2294
|
genex model "weathered wooden barrel with iron bands"
|
|
2147
2295
|
genex skybox "golden hour over a misty mountain range"
|
|
@@ -2167,6 +2315,7 @@ function parseArgs(argv) {
|
|
|
2167
2315
|
"--agents",
|
|
2168
2316
|
"--name",
|
|
2169
2317
|
"--repo",
|
|
2318
|
+
"--remixed-from",
|
|
2170
2319
|
"--title",
|
|
2171
2320
|
"--description",
|
|
2172
2321
|
"--categories",
|
|
@@ -2210,6 +2359,9 @@ function parseArgs(argv) {
|
|
|
2210
2359
|
case "--regenerate-cover":
|
|
2211
2360
|
parsed.options.regenerateCover = true;
|
|
2212
2361
|
break;
|
|
2362
|
+
case "--private":
|
|
2363
|
+
parsed.options.private = true;
|
|
2364
|
+
break;
|
|
2213
2365
|
case "--quiet":
|
|
2214
2366
|
parsed.options.quiet = true;
|
|
2215
2367
|
break;
|
|
@@ -2269,6 +2421,9 @@ function applyValueFlag(options, flag, value) {
|
|
|
2269
2421
|
case "--repo":
|
|
2270
2422
|
options.repo = value;
|
|
2271
2423
|
break;
|
|
2424
|
+
case "--remixed-from":
|
|
2425
|
+
options.remixedFrom = value;
|
|
2426
|
+
break;
|
|
2272
2427
|
case "--title":
|
|
2273
2428
|
options.title = value;
|
|
2274
2429
|
break;
|
|
@@ -2347,6 +2502,9 @@ async function main() {
|
|
|
2347
2502
|
case "link":
|
|
2348
2503
|
await runLink(parsed.options);
|
|
2349
2504
|
break;
|
|
2505
|
+
case "make-remixable":
|
|
2506
|
+
await runMakeRemixable(parsed.options);
|
|
2507
|
+
break;
|
|
2350
2508
|
case "controller":
|
|
2351
2509
|
await runController({ ...parsed.options, kind: parsed.options.name });
|
|
2352
2510
|
break;
|
package/package.json
CHANGED
|
@@ -106,5 +106,14 @@ scene is a ghost: players and objects pass straight through it.
|
|
|
106
106
|
## Troubleshooting
|
|
107
107
|
|
|
108
108
|
- **"Not authorized"** — run `npx @genex-ai/cli-demo@latest init` first (it writes your `GENEX_TOKEN`).
|
|
109
|
+
- **"Out of credits" (`insufficient_credits`)** — the account has no credits left for
|
|
110
|
+
this model generation. Tell the user the facts the CLI printed: their balance, this
|
|
111
|
+
generation's cost, and when their credits refill. Then offer to continue the build
|
|
112
|
+
with a procedurally-coded placeholder (a simple Three.js primitive-based stand-in
|
|
113
|
+
mesh) and mark the spot with `// TODO(genex): regenerate when credits refill` so
|
|
114
|
+
the real asset is one command away later. Do not stop the session over this.
|
|
115
|
+
- **"Email not verified" (`email_verification_required`)** — generation credits
|
|
116
|
+
unlock after the account's email is verified. Give the user the verify link the
|
|
117
|
+
CLI printed, wait for them to confirm, then re-run the command.
|
|
109
118
|
- **The mesh looks low-detail / wrong** — make the prompt more specific
|
|
110
119
|
(materials, style, parts) and regenerate; each run is a fresh asset.
|
|
@@ -73,6 +73,15 @@ Reuse one loaded `buffer` across many plays; create a fresh `Audio`/`PositionalA
|
|
|
73
73
|
## Troubleshooting
|
|
74
74
|
|
|
75
75
|
- **"Not authorized"** — run `npx @genex-ai/cli-demo@latest init` first (it writes your `GENEX_TOKEN`).
|
|
76
|
+
- **"Out of credits" (`insufficient_credits`)** — the account has no credits left for
|
|
77
|
+
this sound generation. Tell the user the facts the CLI printed: their balance, this
|
|
78
|
+
generation's cost, and when their credits refill. Then offer to continue the build
|
|
79
|
+
with a procedurally-coded placeholder (a small WebAudio-synthesized stub sound)
|
|
80
|
+
and mark the spot with `// TODO(genex): regenerate when credits refill` so the
|
|
81
|
+
real asset is one command away later. Do not stop the session over this.
|
|
82
|
+
- **"Email not verified" (`email_verification_required`)** — generation credits
|
|
83
|
+
unlock after the account's email is verified. Give the user the verify link the
|
|
84
|
+
CLI printed, wait for them to confirm, then re-run the command.
|
|
76
85
|
- **No sound** — the `AudioContext` is suspended until a user gesture; trigger the
|
|
77
86
|
first play from a click/keydown. Confirm the camera has an `AudioListener`.
|
|
78
87
|
- **Too quiet/loud** — `sound.setVolume(0..1)`; for positional, tune
|
|
@@ -77,6 +77,16 @@ scene.background = texture; // keep the raw texture for the visible sky
|
|
|
77
77
|
## Troubleshooting
|
|
78
78
|
|
|
79
79
|
- **"Not authorized"** — run `npx @genex-ai/cli-demo@latest init` first (it writes your `GENEX_TOKEN`).
|
|
80
|
+
- **"Out of credits" (`insufficient_credits`)** — the account has no credits left for
|
|
81
|
+
this skybox generation. Tell the user the facts the CLI printed: their balance, this
|
|
82
|
+
generation's cost, and when their credits refill. Then offer to continue the build
|
|
83
|
+
with a procedurally-coded placeholder (a gradient/procedural sky or a plain
|
|
84
|
+
`scene.background` color) and mark the spot with
|
|
85
|
+
`// TODO(genex): regenerate when credits refill` so the real asset is one command
|
|
86
|
+
away later. Do not stop the session over this.
|
|
87
|
+
- **"Email not verified" (`email_verification_required`)** — generation credits
|
|
88
|
+
unlock after the account's email is verified. Give the user the verify link the
|
|
89
|
+
CLI printed, wait for them to confirm, then re-run the command.
|
|
80
90
|
- **Sky looks too dark/bright** — adjust `renderer.toneMappingExposure`, or scale
|
|
81
91
|
`scene.environment` influence via material `envMapIntensity`.
|
|
82
92
|
- **Seam/pole artifacts** — that's inherent to equirect images; keep the camera
|
|
@@ -82,6 +82,16 @@ scene.add(ground);
|
|
|
82
82
|
## Troubleshooting
|
|
83
83
|
|
|
84
84
|
- **"Not authorized"** — run `npx @genex-ai/cli-demo@latest init` first (it writes your `GENEX_TOKEN`).
|
|
85
|
+
- **"Out of credits" (`insufficient_credits`)** — the account has no credits left for
|
|
86
|
+
this texture generation. Tell the user the facts the CLI printed: their balance,
|
|
87
|
+
this generation's cost, and when their credits refill. Then offer to continue the
|
|
88
|
+
build with a procedurally-coded placeholder (a procedural Three.js material in a
|
|
89
|
+
matching color) and mark the spot with
|
|
90
|
+
`// TODO(genex): regenerate when credits refill` so the real asset is one command
|
|
91
|
+
away later. Do not stop the session over this.
|
|
92
|
+
- **"Email not verified" (`email_verification_required`)** — generation credits
|
|
93
|
+
unlock after the account's email is verified. Give the user the verify link the
|
|
94
|
+
CLI printed, wait for them to confirm, then re-run the command.
|
|
85
95
|
- **Visible tiling seams** — use `--terrain` (tuned for seamless edges), lower the
|
|
86
96
|
`repeat`, or blend two textures. Perfect seamlessness is a known v1 limitation.
|
|
87
97
|
- **Colors look washed/dark** — ensure `map.colorSpace = THREE.SRGBColorSpace`.
|
|
@@ -9,13 +9,15 @@ Genex ships improvements continuously. The `genex` CLI tells you when something
|
|
|
9
9
|
is stale; you (the agent) apply the update at the right moment and tell the
|
|
10
10
|
user what happened. The user never manages versions themselves.
|
|
11
11
|
|
|
12
|
-
## The
|
|
12
|
+
## The signals in `genex` output
|
|
13
13
|
|
|
14
14
|
| Line | Meaning | What you do |
|
|
15
15
|
| -- | -- | -- |
|
|
16
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
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
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
|
+
| `✗ Out of credits — … costs N credits; your balance is M.` | The account can't cover this generation (HTTP 402, `insufficient_credits`). | Relay the printed facts (balance, cost, refill date, link) to the user; offer a procedural placeholder so the build continues — see the generation skill's Troubleshooting. |
|
|
20
|
+
| `✗ Email not verified — …` | Generation credits are locked until the email is verified (HTTP 403, `email_verification_required`). | Give the user the printed verify link, wait for them to confirm, then re-run the command. |
|
|
19
21
|
|
|
20
22
|
## Safe moments — when to apply a nudge
|
|
21
23
|
|