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 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
- flags.set(value.slice(2, equalsAt), value.slice(equalsAt + 1));
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
- throw new CliError("invalid_arguments", "Use mesa files list|read.");
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,6 +1,6 @@
1
1
  {
2
2
  "name": "recess-cli",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Safe Recess staff administration from the command line, for humans and coding agents.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -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, and read the Mesa workspace files behind a goal or a template snapshot.
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.3.0
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.2.0
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); getting one
31
- costs read-only calls, never a write. **When `details` is present it is part of the preview show it
32
- to the human too.** Approving from `action`/`request` alone while ignoring `details` is how an
33
- override gets rubber-stamped.
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`, `capture_snapshot`, `clear_snapshot`, `mesa_write_file`, `mesa_edit_file`, `mesa_manage_module` | **No CLI command.** Restoring a version, capturing/clearing a snapshot, and writing workspace files stay in `recess.gg/ai`. Say so and hand the human the web tool. |
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 half-reachable.** You can create the BLUEPRINT and read a snapshot
351
- (`snapshot-files`), but `capture_snapshot` has no command, so a template only becomes Prebuilt
352
- through `recess.gg/ai`. Applying a MODULE_BACKED template with no snapshot 400s that is the
353
- server refusing correctly, not a bug to route around.
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, reading Mesa workspace files | [`reference/goal-authoring.md`](reference/goal-authoring.md) |
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, capturing/clearing a snapshot, and writing Mesa workspace files.** Reads exist; these writes do not. Existing-spec edits are the exception: use only `goal-templates patch-spec`, never `set-metadata` or a raw request.
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. Capturing a snapshot has no CLI command that
70
- step is `recess.gg/ai` only.
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. Read the workspaces
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