recess-cli 1.2.0 → 1.3.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 +9 -1
- package/dist/args.js +37 -2
- package/dist/cli.js +271 -4
- package/package.json +1 -1
- package/skill/recess-cli/SKILL.md +21 -14
- package/skill/recess-cli/reference/goal-authoring.md +37 -6
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ pnpm --dir apps/admin-cli run client:generate
|
|
|
25
25
|
pnpm --dir apps/admin-cli run install-persistent
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
`install-persistent` copies a self-contained build (non-test `dist/` JS + the `openapi-fetch` runtime dep) to `~/.recess-cli/cli/` and points `~/.local/bin/recess` at it — the install keeps working after the checkout or worktree it was built from is deleted. Use it on any machine that operates on production. `install-local` instead symlinks `~/.local/bin/recess` straight to this checkout's `dist/index.js` so rebuilds are picked up live — use it only while actively developing the CLI, and expect the link to die with the worktree. Both targets install the bundled skill for Codex at `${CODEX_HOME:-~/.codex}/skills/recess-cli` and Claude at `${CLAUDE_CONFIG_DIR:-~/.claude}/skills/recess-cli`. The skill is a multi-file bundle: `skill/recess-cli/SKILL.md` carries the safety model, auth troubleshooting, JSON contract, and command quick reference, and routes to the deep workflow playbooks in `skill/recess-cli/reference/` (billing, MAP scores, payouts, class ops).
|
|
28
|
+
`install-persistent` copies a self-contained build (non-test `dist/` JS + the `openapi-fetch` runtime dep) to `~/.recess-cli/cli/` and points `~/.local/bin/recess` at it — the install keeps working after the checkout or worktree it was built from is deleted. Use it on any machine that operates on production. `install-local` instead symlinks `~/.local/bin/recess` straight to this checkout's `dist/index.js` so rebuilds are picked up live — use it only while actively developing the CLI, and expect the link to die with the worktree. Both targets install the bundled skill for Codex at `${CODEX_HOME:-~/.codex}/skills/recess-cli` and Claude at `${CLAUDE_CONFIG_DIR:-~/.claude}/skills/recess-cli`. The skill is a multi-file bundle: `skill/recess-cli/SKILL.md` carries the safety model, auth troubleshooting, JSON contract, and command quick reference, and routes to the deep workflow playbooks in `skill/recess-cli/reference/` (billing, MAP scores, payouts, class ops, onboarding, and goal authoring).
|
|
29
29
|
|
|
30
30
|
## One-time SSO setup
|
|
31
31
|
|
|
@@ -100,6 +100,8 @@ recess --json goal-templates validate-spec --file ./template.json # iterate; w
|
|
|
100
100
|
recess --json goal-templates create --file ./template.json # preview, exit 2
|
|
101
101
|
recess --json goal-templates create --file ./template.json --confirm
|
|
102
102
|
recess --json goal-templates patch-spec <id-or-slug> --expected-version 7 --patches-file ./patches.json
|
|
103
|
+
recess --json mesa files write --student <kid-id> --draft <draft-slug> --source-dir ./workspace
|
|
104
|
+
recess --json goal-templates capture-snapshot <id-or-slug> --source-draft <draft-slug> --student <kid-id> --dry-run
|
|
103
105
|
recess --json goal-templates apply <id-or-slug> --answers-file ./answers.json --dry-run
|
|
104
106
|
recess --json goals create --student <kid-id> --title "..." --description-file ./goal.md
|
|
105
107
|
recess --json mesa files list --student <kid-id> --goal <goal-id>
|
|
@@ -137,4 +139,10 @@ previews the per-student outcome. `set-metadata` and `delete` require `--expecte
|
|
|
137
139
|
it always runs the backend's guarded preview first and requires the preview's exact loss token in
|
|
138
140
|
addition to `--confirm` when protected template data would be removed.
|
|
139
141
|
|
|
142
|
+
MODULE_BACKED BLUEPRINT content is authored as a local workspace tree, batch-upserted to a named
|
|
143
|
+
Mesa draft, then attached with `capture-snapshot`. Both mutations run server previews before the
|
|
144
|
+
confirmation gate. Mesa upserts are compare-and-set against the previewed repo change; capture is
|
|
145
|
+
fenced to both that source change and the template version. Direct live-goal `modules/` and
|
|
146
|
+
`state/` writes are blocked because those files have database projections.
|
|
147
|
+
|
|
140
148
|
See `recess --help` for the complete command surface. The raw escape hatch is intentionally read-only: `recess --json request get /path`.
|
package/dist/args.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { CliError } from "./errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* Flags that may legitimately appear more than once. Reading one through
|
|
4
|
+
* `flagString` still yields the LAST value, which is why every consumer of a
|
|
5
|
+
* repeatable flag must use `flagList` instead.
|
|
6
|
+
*/
|
|
7
|
+
export const REPEATABLE_FLAGS = new Set(["kid", "unassign"]);
|
|
2
8
|
const BOOLEAN_FLAGS = new Set([
|
|
3
9
|
"all-references",
|
|
4
10
|
"allow-strand",
|
|
@@ -24,6 +30,16 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
24
30
|
export function parseArgs(args) {
|
|
25
31
|
const positionals = [];
|
|
26
32
|
const flags = new Map();
|
|
33
|
+
const repeated = new Map();
|
|
34
|
+
const record = (name, value) => {
|
|
35
|
+
if (!REPEATABLE_FLAGS.has(name))
|
|
36
|
+
return;
|
|
37
|
+
const existing = repeated.get(name);
|
|
38
|
+
if (existing)
|
|
39
|
+
existing.push(value);
|
|
40
|
+
else
|
|
41
|
+
repeated.set(name, [value]);
|
|
42
|
+
};
|
|
27
43
|
for (let index = 0; index < args.length; index += 1) {
|
|
28
44
|
const value = args[index];
|
|
29
45
|
if (!value.startsWith("--")) {
|
|
@@ -32,7 +48,10 @@ export function parseArgs(args) {
|
|
|
32
48
|
}
|
|
33
49
|
const equalsAt = value.indexOf("=");
|
|
34
50
|
if (equalsAt > 2) {
|
|
35
|
-
|
|
51
|
+
const name = value.slice(2, equalsAt);
|
|
52
|
+
const flagValue = value.slice(equalsAt + 1);
|
|
53
|
+
flags.set(name, flagValue);
|
|
54
|
+
record(name, flagValue);
|
|
36
55
|
continue;
|
|
37
56
|
}
|
|
38
57
|
const name = value.slice(2);
|
|
@@ -43,13 +62,29 @@ export function parseArgs(args) {
|
|
|
43
62
|
const next = args[index + 1];
|
|
44
63
|
if (next && !next.startsWith("--")) {
|
|
45
64
|
flags.set(name, next);
|
|
65
|
+
record(name, next);
|
|
46
66
|
index += 1;
|
|
47
67
|
}
|
|
48
68
|
else {
|
|
49
69
|
flags.set(name, true);
|
|
50
70
|
}
|
|
51
71
|
}
|
|
52
|
-
return { positionals, flags };
|
|
72
|
+
return { positionals, flags, repeated };
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Every value given for a repeatable flag, in order.
|
|
76
|
+
*
|
|
77
|
+
* Returns `[]` when the flag is absent, so a caller distinguishes "none given"
|
|
78
|
+
* from "given empty" by checking length rather than by a null dance.
|
|
79
|
+
*/
|
|
80
|
+
export function flagList(parsed, name) {
|
|
81
|
+
if (!REPEATABLE_FLAGS.has(name)) {
|
|
82
|
+
// A wiring defect, not a user error: reading a non-repeatable flag as a
|
|
83
|
+
// list would silently return [] however many times it was passed.
|
|
84
|
+
throw new CliError("invalid_arguments", `--${name} is not declared repeatable; add it to REPEATABLE_FLAGS.`);
|
|
85
|
+
}
|
|
86
|
+
const values = parsed.repeated.get(name) ?? [];
|
|
87
|
+
return values.map((value) => value.trim()).filter(Boolean);
|
|
53
88
|
}
|
|
54
89
|
export function flagString(parsed, name, options = {}) {
|
|
55
90
|
const value = parsed.flags.get(name);
|
package/dist/cli.js
CHANGED
|
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { RecessAdminApi, unwrap } from "./api.js";
|
|
5
|
-
import { flagNumber, flagString, hasFlag, parseArgs, } from "./args.js";
|
|
5
|
+
import { flagList, flagNumber, flagString, hasFlag, parseArgs, } from "./args.js";
|
|
6
6
|
import { login, pollDeviceAuth, requestDeviceAuth } from "./auth.js";
|
|
7
7
|
import { clearStoredSession, resolveConfig, } from "./config.js";
|
|
8
8
|
import { CliError } from "./errors.js";
|
|
@@ -38,6 +38,9 @@ Usage:
|
|
|
38
38
|
recess [--json] invoices refund --invoice <id> --line-item <id>
|
|
39
39
|
--method refund|credit|tokens [--full | --amount-cents N]
|
|
40
40
|
[--who-pays guide|recess] [--reason TEXT] [--confirm]
|
|
41
|
+
recess [--json] applications enroll <application-id> --quote <quote-id>
|
|
42
|
+
--school <institution-slug> --kid "<quoteLineId>:<firstName>[:<age>]" (repeat per line)
|
|
43
|
+
[--family <family-id>] [--unassign <kid-id>] (repeat) [--note TEXT] [--confirm]
|
|
41
44
|
recess [--json] cohorts search <query>
|
|
42
45
|
recess [--json] enrollments create --user <kid-id> --cohort <id>
|
|
43
46
|
[--first-charge-at ISO_DATETIME] [--send-email] [--force] [--confirm]
|
|
@@ -128,6 +131,9 @@ Usage:
|
|
|
128
131
|
[--confirm]
|
|
129
132
|
recess [--json] goal-templates delete <template-id> --expected-version N [--confirm]
|
|
130
133
|
recess [--json] goal-templates snapshot-files <template-id> [--path P]
|
|
134
|
+
recess [--json] goal-templates capture-snapshot <template-id|slug>
|
|
135
|
+
(--source-goal <goal-id> | --source-draft <draft-slug> --student <kid-id>)
|
|
136
|
+
[--dry-run] [--confirm]
|
|
131
137
|
recess [--json] goal-templates apply <template-id> --answers-file <path>
|
|
132
138
|
[--dry-run] [--confirm]
|
|
133
139
|
recess [--json] goal-templates apply-starter <template-id> --student <kid-id>
|
|
@@ -138,6 +144,10 @@ Usage:
|
|
|
138
144
|
[--schedule TEXT] [--confirm]
|
|
139
145
|
recess [--json] mesa files list --student <kid-id> --goal <goal-id>
|
|
140
146
|
recess [--json] mesa files read --student <kid-id> --goal <goal-id> --path P
|
|
147
|
+
recess [--json] mesa files write --student <kid-id>
|
|
148
|
+
(--goal <goal-id> | --draft <draft-slug>)
|
|
149
|
+
(--source-dir <local-dir> | --source-file <local-file> --path P)
|
|
150
|
+
[--message TEXT] [--confirm]
|
|
141
151
|
|
|
142
152
|
Authoring notes: "skills" serves the in-product tutor skills (the PRIVATE
|
|
143
153
|
packages/skills submodule) read-only over your admin session — they are never
|
|
@@ -298,6 +308,83 @@ async function writeCommand(parsed, preview, execute) {
|
|
|
298
308
|
requireConfirmation(hasFlag(parsed, "confirm"), preview);
|
|
299
309
|
return execute();
|
|
300
310
|
}
|
|
311
|
+
const MESA_WRITE_MAX_FILES = 1_000;
|
|
312
|
+
const MESA_WRITE_MAX_TOTAL_BYTES = 20 * 1024 * 1024;
|
|
313
|
+
async function readMesaWriteSource(parsed) {
|
|
314
|
+
const sourceDirectory = flagString(parsed, "source-dir");
|
|
315
|
+
const sourceFile = flagString(parsed, "source-file");
|
|
316
|
+
if (Boolean(sourceDirectory) === Boolean(sourceFile)) {
|
|
317
|
+
throw new CliError("invalid_arguments", "Pass exactly one of --source-dir or --source-file.");
|
|
318
|
+
}
|
|
319
|
+
const files = [];
|
|
320
|
+
const hash = createHash("sha256");
|
|
321
|
+
let sizeBytes = 0;
|
|
322
|
+
let source;
|
|
323
|
+
const addFile = async (absolutePath, workspacePath) => {
|
|
324
|
+
if (files.length >= MESA_WRITE_MAX_FILES) {
|
|
325
|
+
throw new CliError("invalid_arguments", `Mesa workspace writes are limited to ${MESA_WRITE_MAX_FILES} files.`);
|
|
326
|
+
}
|
|
327
|
+
const fileStat = await fs.stat(absolutePath);
|
|
328
|
+
if (sizeBytes + fileStat.size > MESA_WRITE_MAX_TOTAL_BYTES) {
|
|
329
|
+
throw new CliError("invalid_arguments", `Mesa workspace writes are limited to ${MESA_WRITE_MAX_TOTAL_BYTES} decoded bytes.`);
|
|
330
|
+
}
|
|
331
|
+
const bytes = await fs.readFile(absolutePath);
|
|
332
|
+
sizeBytes += bytes.byteLength;
|
|
333
|
+
files.push({
|
|
334
|
+
path: workspacePath.replaceAll(path.sep, "/"),
|
|
335
|
+
content: bytes.toString("base64"),
|
|
336
|
+
contentEncoding: "base64",
|
|
337
|
+
});
|
|
338
|
+
};
|
|
339
|
+
if (sourceDirectory) {
|
|
340
|
+
const root = path.resolve(sourceDirectory);
|
|
341
|
+
const rootStat = await fs.lstat(root).catch(() => null);
|
|
342
|
+
if (!rootStat?.isDirectory() || rootStat.isSymbolicLink()) {
|
|
343
|
+
throw new CliError("invalid_arguments", `--source-dir is not a directory: ${root}`);
|
|
344
|
+
}
|
|
345
|
+
source = { kind: "directory", absolutePath: root };
|
|
346
|
+
const walk = async (directory, relativeRoot = "") => {
|
|
347
|
+
const entries = await fs.readdir(directory, { withFileTypes: true });
|
|
348
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
349
|
+
for (const entry of entries) {
|
|
350
|
+
const relativePath = relativeRoot
|
|
351
|
+
? path.join(relativeRoot, entry.name)
|
|
352
|
+
: entry.name;
|
|
353
|
+
const absolutePath = path.join(directory, entry.name);
|
|
354
|
+
if (entry.isSymbolicLink()) {
|
|
355
|
+
throw new CliError("invalid_arguments", `Refusing symbolic link in --source-dir: ${absolutePath}`);
|
|
356
|
+
}
|
|
357
|
+
if (entry.isDirectory()) {
|
|
358
|
+
await walk(absolutePath, relativePath);
|
|
359
|
+
}
|
|
360
|
+
else if (entry.isFile()) {
|
|
361
|
+
await addFile(absolutePath, relativePath);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
await walk(root);
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
const absolutePath = path.resolve(sourceFile);
|
|
369
|
+
const fileStat = await fs.lstat(absolutePath).catch(() => null);
|
|
370
|
+
if (!fileStat?.isFile() || fileStat.isSymbolicLink()) {
|
|
371
|
+
throw new CliError("invalid_arguments", `--source-file is not a file: ${absolutePath}`);
|
|
372
|
+
}
|
|
373
|
+
const workspacePath = flagString(parsed, "path", { required: true });
|
|
374
|
+
source = { kind: "file", absolutePath };
|
|
375
|
+
await addFile(absolutePath, workspacePath);
|
|
376
|
+
}
|
|
377
|
+
if (files.length === 0) {
|
|
378
|
+
throw new CliError("invalid_arguments", "The Mesa write source contains no files.");
|
|
379
|
+
}
|
|
380
|
+
for (const file of files) {
|
|
381
|
+
hash.update(file.path);
|
|
382
|
+
hash.update("\0");
|
|
383
|
+
hash.update(file.content, "base64");
|
|
384
|
+
hash.update("\0");
|
|
385
|
+
}
|
|
386
|
+
return { files, source, sizeBytes, sha256: hash.digest("hex") };
|
|
387
|
+
}
|
|
301
388
|
function assertChoice(value, choices, label) {
|
|
302
389
|
if (!choices.includes(value)) {
|
|
303
390
|
throw new CliError("invalid_arguments", `${label} must be one of: ${choices.join(", ")}.`);
|
|
@@ -625,6 +712,34 @@ function flagStatusList(parsed, name, choices) {
|
|
|
625
712
|
}
|
|
626
713
|
return values.map((value) => assertChoice(value, choices, `--${name}`));
|
|
627
714
|
}
|
|
715
|
+
/**
|
|
716
|
+
* Parse one `--kid` spec into an enroll roster entry.
|
|
717
|
+
*
|
|
718
|
+
* Shape: `<quoteLineId>:<firstName>[:<age>]` — the quote line FIRST, because
|
|
719
|
+
* the line id is the identity the enrollment matches on and the name is only a
|
|
720
|
+
* label. A staffer typing these reads them off the quote, in order.
|
|
721
|
+
*
|
|
722
|
+
* ⚠️ THE NAME MAY CONTAIN NOTHING SURPRISING, but an age must be a real number:
|
|
723
|
+
* age decides `birthdate` at creation AND rides in the frozen payment snapshot
|
|
724
|
+
* that a later takeover is compared against, so a silently-dropped or
|
|
725
|
+
* mistyped age is a money-adjacent error, not a cosmetic one. Absent is fine
|
|
726
|
+
* (the server treats it as "not stated"); unparseable is refused here.
|
|
727
|
+
*/
|
|
728
|
+
function parseEnrollKidSpec(raw) {
|
|
729
|
+
const parts = raw.split(":").map((part) => part.trim());
|
|
730
|
+
const [quoteLineId, firstName, ageRaw, ...extra] = parts;
|
|
731
|
+
if (!quoteLineId || !firstName || extra.length > 0) {
|
|
732
|
+
throw new CliError("invalid_arguments", `--kid must be "<quoteLineId>:<firstName>[:<age>]"; got "${raw}".`);
|
|
733
|
+
}
|
|
734
|
+
if (ageRaw === undefined || ageRaw === "") {
|
|
735
|
+
return { quoteLineId, firstName };
|
|
736
|
+
}
|
|
737
|
+
const age = Number(ageRaw);
|
|
738
|
+
if (!Number.isInteger(age) || age < 1 || age > 25) {
|
|
739
|
+
throw new CliError("invalid_arguments", `--kid age must be a whole number from 1 to 25; got "${ageRaw}".`);
|
|
740
|
+
}
|
|
741
|
+
return { quoteLineId, firstName, age };
|
|
742
|
+
}
|
|
628
743
|
function flagCents(parsed, name, options = {}) {
|
|
629
744
|
const value = flagNumber(parsed, name);
|
|
630
745
|
if (value === undefined) {
|
|
@@ -1077,6 +1192,48 @@ export async function runCommand(argv) {
|
|
|
1077
1192
|
params: { query: { subscriptionId } },
|
|
1078
1193
|
}));
|
|
1079
1194
|
}
|
|
1195
|
+
if (noun === "applications" && verb === "enroll") {
|
|
1196
|
+
const applicationId = positional(parsed, 2, "application ID");
|
|
1197
|
+
const quoteId = flagString(parsed, "quote", { required: true });
|
|
1198
|
+
const institutionSlug = flagString(parsed, "school", { required: true });
|
|
1199
|
+
const familyId = flagString(parsed, "family");
|
|
1200
|
+
const note = flagString(parsed, "note");
|
|
1201
|
+
// Repeatable --kid, one per PRICED LINE on the quote. The server refuses a
|
|
1202
|
+
// partial roster (every priced student must be enrolled), so this is
|
|
1203
|
+
// deliberately not a convenience list — it is the whole quote, echoed back.
|
|
1204
|
+
const kidSpecs = flagList(parsed, "kid");
|
|
1205
|
+
if (kidSpecs.length === 0) {
|
|
1206
|
+
throw new CliError("invalid_arguments", 'Missing --kid. Pass one per quote line: --kid "<quoteLineId>:<firstName>[:<age>]".');
|
|
1207
|
+
}
|
|
1208
|
+
const kids = kidSpecs.map(parseEnrollKidSpec);
|
|
1209
|
+
// Every OTHER live child in the family must be named explicitly. The server
|
|
1210
|
+
// refuses the whole enrollment otherwise, listing who was unlisted — so the
|
|
1211
|
+
// failure is legible either way, but naming them here is how a staffer says
|
|
1212
|
+
// "yes, I know, they are not enrolling".
|
|
1213
|
+
const dispositions = flagList(parsed, "unassign").map((kidUserId) => ({
|
|
1214
|
+
kidUserId,
|
|
1215
|
+
action: "unassigned",
|
|
1216
|
+
}));
|
|
1217
|
+
return writeCommand(parsed, {
|
|
1218
|
+
// One short clause, like every other preview in this file. The
|
|
1219
|
+
// consequences are enumerated in `request` below, which is what the
|
|
1220
|
+
// confirmation prompt prints in full — restating them here would make
|
|
1221
|
+
// this the only preview a staffer has to read twice.
|
|
1222
|
+
action: "enroll an application from its accepted quote (creates children, charges the first month, cancels marketplace subscriptions)",
|
|
1223
|
+
target: { applicationId, quoteId, institutionSlug, familyId },
|
|
1224
|
+
request: { kids, dispositions, note },
|
|
1225
|
+
}, async () => unwrap(await api.client.POST("/admin/applications/{applicationId}/enroll", {
|
|
1226
|
+
params: { path: { applicationId } },
|
|
1227
|
+
body: {
|
|
1228
|
+
quoteId,
|
|
1229
|
+
institutionSlug,
|
|
1230
|
+
kids,
|
|
1231
|
+
...(familyId ? { familyId } : {}),
|
|
1232
|
+
...(dispositions.length > 0 ? { dispositions } : {}),
|
|
1233
|
+
...(note ? { note } : {}),
|
|
1234
|
+
},
|
|
1235
|
+
})));
|
|
1236
|
+
}
|
|
1080
1237
|
if (noun === "cohorts" && verb === "search") {
|
|
1081
1238
|
const search = parsed.positionals.slice(2).join(" ").trim();
|
|
1082
1239
|
if (!search)
|
|
@@ -2215,6 +2372,64 @@ export async function runCommand(argv) {
|
|
|
2215
2372
|
params: { path: { id } },
|
|
2216
2373
|
}));
|
|
2217
2374
|
}
|
|
2375
|
+
if (verb === "capture-snapshot") {
|
|
2376
|
+
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
2377
|
+
const sourceGoalId = flagString(parsed, "source-goal");
|
|
2378
|
+
const sourceDraft = flagString(parsed, "source-draft");
|
|
2379
|
+
if (Boolean(sourceGoalId) === Boolean(sourceDraft)) {
|
|
2380
|
+
throw new CliError("invalid_arguments", "Pass exactly one of --source-goal or --source-draft.");
|
|
2381
|
+
}
|
|
2382
|
+
const sourceStudentUserId = sourceDraft
|
|
2383
|
+
? flagString(parsed, "student", { required: true })
|
|
2384
|
+
: undefined;
|
|
2385
|
+
const source = sourceGoalId
|
|
2386
|
+
? { sourceGoalId }
|
|
2387
|
+
: {
|
|
2388
|
+
sourceWorkspacePath: `drafts/${sourceDraft}/workspace`,
|
|
2389
|
+
sourceStudentUserId,
|
|
2390
|
+
};
|
|
2391
|
+
const preflight = unwrap(await api.client.POST("/admin/goal-templates/{id}/snapshot/capture", {
|
|
2392
|
+
params: { path: { id } },
|
|
2393
|
+
body: { ...source, dryRun: true },
|
|
2394
|
+
}));
|
|
2395
|
+
if (preflight.action !== "preview_capture_snapshot") {
|
|
2396
|
+
throw new CliError("unexpected_response", "Snapshot capture did not return a preview; nothing was captured.");
|
|
2397
|
+
}
|
|
2398
|
+
if (hasFlag(parsed, "dry-run"))
|
|
2399
|
+
return preflight;
|
|
2400
|
+
const preview = {
|
|
2401
|
+
action: "capture a validated Mesa workspace as this global template's instant-apply snapshot",
|
|
2402
|
+
target: {
|
|
2403
|
+
templateId: preflight.template.id,
|
|
2404
|
+
slug: preflight.template.slug,
|
|
2405
|
+
title: preflight.template.title,
|
|
2406
|
+
version: preflight.template.version,
|
|
2407
|
+
},
|
|
2408
|
+
request: {
|
|
2409
|
+
source: preflight.source,
|
|
2410
|
+
moduleCount: preflight.snapshot.moduleCount,
|
|
2411
|
+
fileCount: preflight.snapshot.fileCount,
|
|
2412
|
+
sizeBytes: preflight.snapshot.sizeBytes,
|
|
2413
|
+
sha256: preflight.snapshot.sha256,
|
|
2414
|
+
},
|
|
2415
|
+
details: {
|
|
2416
|
+
modules: preflight.snapshot.modules,
|
|
2417
|
+
paths: preflight.snapshot.paths,
|
|
2418
|
+
warnings: preflight.warnings,
|
|
2419
|
+
note: "The confirmed request is fenced to both this template version and this exact Mesa source change. Capturing bumps the template version unless the snapshot is unchanged.",
|
|
2420
|
+
},
|
|
2421
|
+
};
|
|
2422
|
+
requireConfirmation(hasFlag(parsed, "confirm"), preview);
|
|
2423
|
+
return unwrap(await api.client.POST("/admin/goal-templates/{id}/snapshot/capture", {
|
|
2424
|
+
params: { path: { id } },
|
|
2425
|
+
body: {
|
|
2426
|
+
...source,
|
|
2427
|
+
dryRun: false,
|
|
2428
|
+
expectedTemplateVersion: preflight.template.version,
|
|
2429
|
+
expectedSourceChangeId: preflight.source.changeId,
|
|
2430
|
+
},
|
|
2431
|
+
}));
|
|
2432
|
+
}
|
|
2218
2433
|
if (verb === "apply") {
|
|
2219
2434
|
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
2220
2435
|
const answers = await readJsonFile(flagString(parsed, "answers-file", { required: true }), "Answers file");
|
|
@@ -2282,7 +2497,7 @@ export async function runCommand(argv) {
|
|
|
2282
2497
|
body: { studentUserId, answers },
|
|
2283
2498
|
})));
|
|
2284
2499
|
}
|
|
2285
|
-
throw new CliError("invalid_arguments", "Use goal-templates list|get|versions|validate-spec|create|patch-spec|set-metadata|delete|snapshot-files|apply|apply-starter.");
|
|
2500
|
+
throw new CliError("invalid_arguments", "Use goal-templates list|get|versions|validate-spec|create|patch-spec|set-metadata|delete|snapshot-files|capture-snapshot|apply|apply-starter.");
|
|
2286
2501
|
}
|
|
2287
2502
|
if (noun === "goals") {
|
|
2288
2503
|
if (verb === "list") {
|
|
@@ -2336,11 +2551,12 @@ export async function runCommand(argv) {
|
|
|
2336
2551
|
if (noun === "mesa" && verb === "files") {
|
|
2337
2552
|
const action = positional(parsed, 2, "mesa files action");
|
|
2338
2553
|
const studentId = flagString(parsed, "student", { required: true });
|
|
2339
|
-
const goalId = flagString(parsed, "goal", { required: true });
|
|
2340
2554
|
if (action === "list") {
|
|
2555
|
+
const goalId = flagString(parsed, "goal", { required: true });
|
|
2341
2556
|
return unwrap(await api.client.GET("/tutor/students/{studentId}/goals/{goalId}/workspace/files", { params: { path: { studentId, goalId } } }));
|
|
2342
2557
|
}
|
|
2343
2558
|
if (action === "read") {
|
|
2559
|
+
const goalId = flagString(parsed, "goal", { required: true });
|
|
2344
2560
|
return unwrap(await api.client.GET("/tutor/students/{studentId}/goals/{goalId}/workspace/file", {
|
|
2345
2561
|
params: {
|
|
2346
2562
|
path: { studentId, goalId },
|
|
@@ -2348,7 +2564,58 @@ export async function runCommand(argv) {
|
|
|
2348
2564
|
},
|
|
2349
2565
|
}));
|
|
2350
2566
|
}
|
|
2351
|
-
|
|
2567
|
+
if (action === "write") {
|
|
2568
|
+
const goalId = flagString(parsed, "goal");
|
|
2569
|
+
const draftSlug = flagString(parsed, "draft");
|
|
2570
|
+
if (Boolean(goalId) === Boolean(draftSlug)) {
|
|
2571
|
+
throw new CliError("invalid_arguments", "Pass exactly one of --goal or --draft for mesa files write.");
|
|
2572
|
+
}
|
|
2573
|
+
const input = await readMesaWriteSource(parsed);
|
|
2574
|
+
const target = goalId
|
|
2575
|
+
? { kind: "goal", goalId }
|
|
2576
|
+
: { kind: "draft", draftSlug: draftSlug };
|
|
2577
|
+
const message = flagString(parsed, "message") ??
|
|
2578
|
+
`recess-cli: write ${input.files.length} workspace file${input.files.length === 1 ? "" : "s"}`;
|
|
2579
|
+
const body = {
|
|
2580
|
+
studentUserId: studentId,
|
|
2581
|
+
target,
|
|
2582
|
+
message,
|
|
2583
|
+
files: input.files,
|
|
2584
|
+
};
|
|
2585
|
+
const preflight = unwrap(await api.client.POST("/admin/mesa/workspace-files", {
|
|
2586
|
+
body: { ...body, dryRun: true },
|
|
2587
|
+
}));
|
|
2588
|
+
if (preflight.action !== "preview_workspace_files_write") {
|
|
2589
|
+
throw new CliError("unexpected_response", "Mesa workspace write did not return a preview; nothing was written.");
|
|
2590
|
+
}
|
|
2591
|
+
const preview = {
|
|
2592
|
+
action: "batch-upsert files in a student's Mesa workspace",
|
|
2593
|
+
target: preflight.target,
|
|
2594
|
+
request: {
|
|
2595
|
+
source: input.source,
|
|
2596
|
+
sourceSha256: input.sha256,
|
|
2597
|
+
fileCount: input.files.length,
|
|
2598
|
+
sizeBytes: input.sizeBytes,
|
|
2599
|
+
message,
|
|
2600
|
+
},
|
|
2601
|
+
details: {
|
|
2602
|
+
currentChangeId: preflight.currentChangeId,
|
|
2603
|
+
files: preflight.files,
|
|
2604
|
+
note: target.kind === "draft"
|
|
2605
|
+
? "This writes a complete authoring tree under drafts/<slug>/workspace. Capture it only after the server's goal-workspace validation passes."
|
|
2606
|
+
: "Live goal modules/ and state/ are blocked here because they have database projections; use a draft + template capture for structural course changes.",
|
|
2607
|
+
},
|
|
2608
|
+
};
|
|
2609
|
+
requireConfirmation(hasFlag(parsed, "confirm"), preview);
|
|
2610
|
+
return unwrap(await api.client.POST("/admin/mesa/workspace-files", {
|
|
2611
|
+
body: {
|
|
2612
|
+
...body,
|
|
2613
|
+
dryRun: false,
|
|
2614
|
+
expectedChangeId: preflight.currentChangeId,
|
|
2615
|
+
},
|
|
2616
|
+
}));
|
|
2617
|
+
}
|
|
2618
|
+
throw new CliError("invalid_arguments", "Use mesa files list|read|write.");
|
|
2352
2619
|
}
|
|
2353
2620
|
if (noun === "request" && verb === "get") {
|
|
2354
2621
|
return api.rawGet(positional(parsed, 2, "request path"));
|
package/package.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: recess-cli
|
|
3
|
-
description: Safely perform Recess staff administration through the recess CLI. Use when a Recess admin asks Codex to find a kid, parent, family, enrollment, subscription, invoice, or cohort; inspect or change a school kid's tier and capability gates; upload a kid's MAP Growth report; pause or resume billing; refund or credit an invoice item; extend a trial; cancel or restore a subscription; register or unregister a cohort against an enrollment; switch or move kids from one cohort to another; manage guide payout invoices (biweekly pay-cycle line-item changes, invoice status moves, payout recipient lookups); run class operations (take attendance, cancel or reschedule a class session, add a one-off session, end a cohort, pause cohort billing, email a cohort's families, approve or deny pending registrations); process class-cancellation credits (the "Please credit these students accordingly" Slack message — credit every registered kid for a guide-canceled session); or author learning content — build, validate, publish, and loss-safely patch a deterministic GoalTemplate, apply a template to a kid or a roster, create a goal directly on a kid,
|
|
3
|
+
description: Safely perform Recess staff administration through the recess CLI. Use when a Recess admin asks Codex to find a kid, parent, family, enrollment, subscription, invoice, or cohort; inspect or change a school kid's tier and capability gates; upload a kid's MAP Growth report; pause or resume billing; refund or credit an invoice item; extend a trial; cancel or restore a subscription; register or unregister a cohort against an enrollment; switch or move kids from one cohort to another; manage guide payout invoices (biweekly pay-cycle line-item changes, invoice status moves, payout recipient lookups); run class operations (take attendance, cancel or reschedule a class session, add a one-off session, end a cohort, pause cohort billing, email a cohort's families, approve or deny pending registrations); process class-cancellation credits (the "Please credit these students accordingly" Slack message — credit every registered kid for a guide-canceled session); or author learning content — build, validate, publish, and loss-safely patch a deterministic GoalTemplate, apply a template to a kid or a roster, create a goal directly on a kid, write a Mesa draft or goal workspace, capture a template snapshot, and read Mesa workspace or snapshot files.
|
|
4
4
|
# Bundle version. Bump on every substantive edit; the CLI reports it and `doctor`
|
|
5
5
|
# compares it against the served copy to tell an operator a refresh is available.
|
|
6
|
-
version: 1.
|
|
6
|
+
version: 1.4.0
|
|
7
7
|
# The lowest `recess` version this bundle is safe to install onto. Raise it ONLY
|
|
8
8
|
# when the bundle documents a command, flag, or changed semantic that an older
|
|
9
9
|
# binary does not have — an older CLI keeps its bundled copy instead of taking
|
|
10
10
|
# this one. Prose, formatting, and Gotcha edits must NOT raise it; that is the
|
|
11
11
|
# whole point of serving the bundle.
|
|
12
|
-
minCliVersion: 1.
|
|
12
|
+
minCliVersion: 1.3.0
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
# Recess CLI (`recess`)
|
|
@@ -27,10 +27,12 @@ Every mutating command is two-step. Run it **without** `--confirm` first: the CL
|
|
|
27
27
|
Some previews also carry a `details` object — server-resolved facts that cannot be known offline.
|
|
28
28
|
Today those include `enrollments create` (real price, no-charge reuse, and capacity/slot
|
|
29
29
|
violations), `users tier set` (current/proposed capability locks and class-slot consequences), and
|
|
30
|
-
`goal-templates patch-spec` (validated before/after hashes and protected-inventory loss)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
`goal-templates patch-spec` (validated before/after hashes and protected-inventory loss),
|
|
31
|
+
`mesa files write` (resolved workspace root, Mesa change fence, and file hashes), and
|
|
32
|
+
`goal-templates capture-snapshot` (validated modules/files plus template/source fences); getting
|
|
33
|
+
one costs read-only calls, never a write. **When `details` is present it is part of the preview —
|
|
34
|
+
show it to the human too.** Approving from `action`/`request` alone while ignoring `details` is how
|
|
35
|
+
an override gets rubber-stamped.
|
|
34
36
|
|
|
35
37
|
1. Show that preview to the human, verbatim.
|
|
36
38
|
2. Request explicit escalated approval for that exact action using the execution tool's approval mechanism.
|
|
@@ -221,12 +223,14 @@ recess --json goal-templates patch-spec <id-or-slug> --expected-version N --patc
|
|
|
221
223
|
recess --json goal-templates set-metadata <id-or-slug> --expected-version N [--title …] [--confirm]
|
|
222
224
|
recess --json goal-templates delete <id-or-slug> --expected-version N [--confirm]
|
|
223
225
|
recess --json goal-templates snapshot-files <id-or-slug> [--path P]
|
|
226
|
+
recess --json goal-templates capture-snapshot <id-or-slug> (--source-goal <goal-id> | --source-draft <draft-slug> --student <kid-id>) [--dry-run] [--confirm]
|
|
224
227
|
recess --json goal-templates apply <id-or-slug> --answers-file <path> [--dry-run] [--confirm]
|
|
225
228
|
recess --json goal-templates apply-starter <id-or-slug> --student <kid-id> [--confirm]
|
|
226
229
|
recess --json goals list --student <kid-id>
|
|
227
230
|
recess --json goals create --student <kid-id> --title TEXT --description TEXT [--confirm]
|
|
228
231
|
recess --json mesa files list --student <kid-id> --goal <goal-id>
|
|
229
232
|
recess --json mesa files read --student <kid-id> --goal <goal-id> --path P
|
|
233
|
+
recess --json mesa files write --student <kid-id> (--goal <goal-id> | --draft <draft-slug>) (--source-dir <local-dir> | --source-file <local-file> --path P) [--message TEXT] [--confirm]
|
|
230
234
|
|
|
231
235
|
# Read-only escape hatch (GET only — no raw writes exist)
|
|
232
236
|
recess --json request get /path?query=value
|
|
@@ -294,10 +298,12 @@ Translate as you read:
|
|
|
294
298
|
| `manage_goal_template action:"preview_setup_workflow_spec_patch"` / `"patch_setup_workflow_spec"` | `goal-templates patch-spec <id-or-slug> --expected-version N --patches-file …` (omit `--confirm` for the required server preview; destructive changes also require its exact token) |
|
|
295
299
|
| `manage_goal_template action:"update"` (metadata) | `goal-templates set-metadata <id> --expected-version N … --confirm` |
|
|
296
300
|
| `manage_goal_template action:"delete"` | `goal-templates delete <id> --expected-version N --confirm` |
|
|
301
|
+
| `manage_goal_template action:"capture_snapshot"` | `goal-templates capture-snapshot <id-or-slug> --source-goal <goal-id>` or `--source-draft <slug> --student <kid-id>` |
|
|
297
302
|
| `create_goal` | `goals create --student <kid-id> … --confirm`, or `goal-templates apply` when a template exists |
|
|
298
303
|
| `mesa_list_files` / `mesa_read_file` | `mesa files list` / `mesa files read` |
|
|
304
|
+
| `mesa_write_file` (batch upserts) | `mesa files write --student … (--goal … | --draft …) (--source-dir … | --source-file … --path …)` |
|
|
299
305
|
| `load_skill` / `load_skill_reference` | `skills get <name>` / `skills get <name> --reference <ref>` |
|
|
300
|
-
| `restore_version`, `
|
|
306
|
+
| `restore_version`, `clear_snapshot`, `mesa_edit_file`, `mesa_manage_module` | **No CLI command.** Version restore, snapshot clearing, string edits/deletes, and projected live-goal module/state mutations stay in `recess.gg/ai`. |
|
|
301
307
|
|
|
302
308
|
#### Research tools → your own harness
|
|
303
309
|
|
|
@@ -347,10 +353,10 @@ rather than pretend:
|
|
|
347
353
|
- **The skill treats create as one step.** Here it is two: the unconfirmed run returns the
|
|
348
354
|
SERVER-resolved `details` (`resolvedSetupHandler`, `resolvedGoalShape`, `wizardStepKeys`,
|
|
349
355
|
`specInventory`). Put those in the approval request — they are what the template will actually do.
|
|
350
|
-
- **Prebuilt templates are
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
356
|
+
- **Prebuilt templates are a draft → validate → capture flow.** Write the complete OS-V2 tree to a
|
|
357
|
+
named Mesa draft, preview `capture-snapshot`, then confirm it. Capture rejects an incomplete
|
|
358
|
+
draft and filters student `state/`, conversations, and `.recess/` bookkeeping from the frozen
|
|
359
|
+
snapshot. Applying a MODULE_BACKED template with no snapshot still 400s correctly.
|
|
354
360
|
|
|
355
361
|
#### The one-way rules
|
|
356
362
|
|
|
@@ -377,7 +383,7 @@ Each domain has a full playbook in this skill's `reference/` directory. Read the
|
|
|
377
383
|
| Class-cancellation credits — the "Please credit these students accordingly" Slack workflow | [`reference/cancellation-credits.md`](reference/cancellation-credits.md) |
|
|
378
384
|
| Non-flexible course one-off time shift when `events reschedule` 400s (`allowFlexibleScheduling: false`) | [`reference/class-ops-reschedule.md`](reference/class-ops-reschedule.md) |
|
|
379
385
|
| Family onboarding — stage, account state, attestation checklist, parent intake session (fill / LLM-extract) | [`reference/onboarding.md`](reference/onboarding.md) |
|
|
380
|
-
| **Authoring learning content** — deterministic GoalTemplates, applying a template to a kid or roster, creating a goal on a kid
|
|
386
|
+
| **Authoring learning content** — deterministic GoalTemplates, Mesa draft/goal workspace writes, snapshot capture, applying a template to a kid or roster, creating a goal on a kid | [`reference/goal-authoring.md`](reference/goal-authoring.md) |
|
|
381
387
|
|
|
382
388
|
## Deliberately out of scope
|
|
383
389
|
|
|
@@ -387,7 +393,7 @@ These are excluded from the CLI on purpose. If asked, direct the human to the we
|
|
|
387
393
|
- **Money movement:** initiating Mercury payouts, advancing whole pay runs, generating/regenerating payout invoices → `/admin/payout` in the web admin.
|
|
388
394
|
- **Enrollment cancellation** as a standalone action (`unregister-cohort` deliberately preserves the enrollment; only `registrations deny` cancels one, because that is what the web Deny button does).
|
|
389
395
|
- **Cohort creation and schedule editing:** create, start, full-edit/RRULE regeneration, generate-events, guides management → web admin cohort pages.
|
|
390
|
-
- **Restoring a template version
|
|
396
|
+
- **Restoring a template version or clearing a snapshot; deleting/editing Mesa files; direct live-goal `modules/` or `state/` writes.** Use `recess.gg/ai` for these projected mutations. The CLI intentionally supports upserts only and routes structural course authoring through a draft + capture.
|
|
391
397
|
|
|
392
398
|
## Guardrails
|
|
393
399
|
|
|
@@ -472,3 +478,4 @@ Dated, newest last. Add an entry every time reality surprises you.
|
|
|
472
478
|
- 2026-08-04 — **`--version` is a flag, not a noun.** Anything the arg parser sees starting with `--` lands in `flags`, leaving `positionals` empty — so a `noun`-based check for it sits behind the `!noun → print help` branch and is unreachable. Same trap for any future `--foo` top-level command: check the flag before the help branch. Observed live: `recess --json --version` printed the whole help text.
|
|
473
479
|
- 2026-08-04 — A `Makefile install-persistent` copy is NOT the npm package: it synthesizes its own `package.json`. If that manifest lacks `version`, or `skill/` is not copied alongside `dist/`, then `--version` reports `0.0.0` and the `minCliVersion` fence **fails closed** — every served skill upgrade is silently refused with `cli_too_old`. Both are now copied; if you add another packaged artifact the CLI reads at runtime, add it to that target too.
|
|
474
480
|
- 2026-08-04 — **`goal-templates patch-spec` performs a server preview even on a confirmed run.** The first POST is `dryRun:true`, never a write; it binds the current template version, before/after hashes, and protected removals. A destructive second POST is impossible without `--confirm-destructive-changes` and that fresh preview token. If the template or patch file changes, preview again and obtain new approval.
|
|
481
|
+
- 2026-08-05 — **A deterministic MODULE_BACKED spec is not the course content.** `goal-templates create` can create a valid BLUEPRINT while `snapshot` remains null; Goal Preview stays empty and apply correctly says there is no valid snapshot. Build the complete tree locally, `mesa files write --draft … --source-dir …`, then `goal-templates capture-snapshot --source-draft … --student …`. Both writes server-preview first. Mesa writes are fenced to the previewed repo change; capture is fenced to that source change and the template version. Direct live-goal `modules/`/`state/` writes are deliberately blocked because those paths have DB projections.
|
|
@@ -66,8 +66,8 @@ the human directly, in the skill's language, and wait. Do not say "materialize",
|
|
|
66
66
|
"module-backed", or "snapshot" to a tutor.
|
|
67
67
|
|
|
68
68
|
A MODULE_BACKED spec **requires** `BLUEPRINT`; the server enforces it, and it additionally refuses to
|
|
69
|
-
apply a MODULE_BACKED template that has no snapshot.
|
|
70
|
-
|
|
69
|
+
apply a MODULE_BACKED template that has no snapshot. Build a complete Mesa draft and capture it with
|
|
70
|
+
the CLI workflow in §8 before applying the template.
|
|
71
71
|
|
|
72
72
|
## 2. Write one template file
|
|
73
73
|
|
|
@@ -194,7 +194,41 @@ the human which goal to retire.
|
|
|
194
194
|
Load `goal-creation` (and `student-research` to read the kid first) before writing the description.
|
|
195
195
|
An unresearched goal is the failure mode this whole surface exists to prevent.
|
|
196
196
|
|
|
197
|
-
## 8.
|
|
197
|
+
## 8. Author, capture, and read workspaces
|
|
198
|
+
|
|
199
|
+
For a new Prebuilt/instant-apply course, author the complete OS-V2 tree locally and upsert it into a
|
|
200
|
+
named draft. The directory root becomes `drafts/<slug>/workspace` in the student's Mesa repo:
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
recess --json mesa files write --student <kid-id> --draft <draft-slug> --source-dir ./workspace
|
|
204
|
+
# show the preview, obtain approval, then rerun unchanged:
|
|
205
|
+
recess --json mesa files write --student <kid-id> --draft <draft-slug> --source-dir ./workspace --confirm
|
|
206
|
+
|
|
207
|
+
recess --json goal-templates capture-snapshot <id-or-slug> --source-draft <draft-slug> --student <kid-id> --dry-run
|
|
208
|
+
recess --json goal-templates capture-snapshot <id-or-slug> --source-draft <draft-slug> --student <kid-id>
|
|
209
|
+
# show the preview, obtain approval, then rerun unchanged:
|
|
210
|
+
recess --json goal-templates capture-snapshot <id-or-slug> --source-draft <draft-slug> --student <kid-id> --confirm
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
The draft must pass the full goal-builder contract: required top-level instructions, required fresh
|
|
214
|
+
`state/` files, directory content, and at least one parseable runtime module. Capture validates that
|
|
215
|
+
tree, then excludes `state/`, `conversations/`, and `.recess/` from the reusable snapshot. Its
|
|
216
|
+
preview carries the template version, Mesa change id, module/file inventory, size, and SHA-256; the
|
|
217
|
+
confirmed request is compare-and-set against both fences. If either changed, preview again and get
|
|
218
|
+
fresh approval.
|
|
219
|
+
|
|
220
|
+
Use `--source-file <local-file> --path <workspace-relative-path>` for one-file upserts. A live goal
|
|
221
|
+
may be targeted with `--goal <goal-id>`, but the CLI rejects direct `modules/` and `state/` writes
|
|
222
|
+
because those paths have database projections. Structural course changes belong in a draft, then a
|
|
223
|
+
captured/applied template.
|
|
224
|
+
|
|
225
|
+
An already-built OS-V2 goal can be captured directly:
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
recess --json goal-templates capture-snapshot <id-or-slug> --source-goal <goal-id> --dry-run
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Read the resulting workspaces and snapshot with:
|
|
198
232
|
|
|
199
233
|
```bash
|
|
200
234
|
recess --json mesa files list --student <kid-id> --goal <goal-id>
|
|
@@ -203,9 +237,6 @@ recess --json goal-templates snapshot-files <id-or-slug>
|
|
|
203
237
|
recess --json goal-templates snapshot-files <id-or-slug> --path modules/01/index.md
|
|
204
238
|
```
|
|
205
239
|
|
|
206
|
-
Reads only — there is no `mesa files write`. Writing workspace files stays in `recess.gg/ai`, which
|
|
207
|
-
validates a workspace before it lands in a live kid's session.
|
|
208
|
-
|
|
209
240
|
`mesa files list` returning `{"files":[]}` means the goal has no Mesa workspace at all — expected for
|
|
210
241
|
a SIMPLE goal, and the signal that a BLUEPRINT goal has not been built yet.
|
|
211
242
|
|