recess-cli 1.0.1 → 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/dist/cli.js CHANGED
@@ -2,15 +2,18 @@ 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";
9
9
  import { requireConfirmation } from "./safety.js";
10
- import { installSkill, isEphemeralInstall } from "./setup.js";
10
+ import { installSkill, isEphemeralInstall, readBundledSkillVersion, readCliVersion, } from "./setup.js";
11
+ import { compareVersions, updateSkillFromServer } from "./skill-update.js";
12
+ import { readSkillCache, writeSkillCache } from "./skills-cache.js";
11
13
  export const HELP = `recess — safe Recess administration from the command line
12
14
 
13
15
  Usage:
16
+ recess [--json] --version
14
17
  recess [--json] setup [--skill-only]
15
18
  recess [--json] doctor
16
19
  recess [--json] auth login [--client-id ID] [--callback-port 8765]
@@ -35,6 +38,9 @@ Usage:
35
38
  recess [--json] invoices refund --invoice <id> --line-item <id>
36
39
  --method refund|credit|tokens [--full | --amount-cents N]
37
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]
38
44
  recess [--json] cohorts search <query>
39
45
  recess [--json] enrollments create --user <kid-id> --cohort <id>
40
46
  [--first-charge-at ISO_DATETIME] [--send-email] [--force] [--confirm]
@@ -107,6 +113,56 @@ Usage:
107
113
  recess [--json] village models remove <placement-id> [--world village-1] [--confirm]
108
114
  recess [--json] village render --min-x N --min-z N --max-x N --max-z N
109
115
  [--world village-1]
116
+ recess [--json] skills list [--query TEXT] [--category TEXT]
117
+ recess [--json] skills get <skill-name> [--reference NAME | --all-references]
118
+ [--refresh]
119
+ recess [--json] goal-templates list [--query TEXT] [--category TEXT]
120
+ [--kind SIMPLE|BLUEPRINT] [--starter-only] [--include-deleted]
121
+ recess [--json] goal-templates get <template-id|slug> [--spec-only]
122
+ recess [--json] goal-templates versions <template-id> [--version N]
123
+ recess [--json] goal-templates validate-spec --file <path/template.json>
124
+ recess [--json] goal-templates create --file <path/template.json> [--confirm]
125
+ recess [--json] goal-templates patch-spec <template-id|slug> --expected-version N
126
+ --patches-file <path/patches.json> [--confirm]
127
+ [--confirm-destructive-changes --destructive-change-token TOKEN]
128
+ recess [--json] goal-templates set-metadata <template-id> --expected-version N
129
+ [--title TEXT] [--description TEXT] [--emoji X] [--category TEXT] [--tags A,B]
130
+ [--sort-order N] [--kind SIMPLE|BLUEPRINT] [--agent-instructions-file <path>]
131
+ [--confirm]
132
+ recess [--json] goal-templates delete <template-id> --expected-version N [--confirm]
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]
137
+ recess [--json] goal-templates apply <template-id> --answers-file <path>
138
+ [--dry-run] [--confirm]
139
+ recess [--json] goal-templates apply-starter <template-id> --student <kid-id>
140
+ [--answers-file <path>] [--confirm]
141
+ recess [--json] goals list --student <kid-id>
142
+ recess [--json] goals create --student <kid-id> --title TEXT
143
+ (--description TEXT | --description-file <path>) [--target-date <iso>]
144
+ [--schedule TEXT] [--confirm]
145
+ recess [--json] mesa files list --student <kid-id> --goal <goal-id>
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]
151
+
152
+ Authoring notes: "skills" serves the in-product tutor skills (the PRIVATE
153
+ packages/skills submodule) read-only over your admin session — they are never
154
+ bundled into this npm package. Load os-v2-goal-template-builder and its
155
+ references/deterministic-workflow-setup.md BEFORE authoring a template; that is
156
+ the same guidance the recess.gg/ai agent follows, so there is exactly one
157
+ standard. Responses cache under ~/.recess-cli/skills-cache/ (--refresh re-fetches).
158
+ Every template created here is setupMode DETERMINISTIC_WORKFLOW and CANNOT be
159
+ converted back, so "validate-spec" against the same file until it passes, then
160
+ "create". "create" runs a real server-side validation before its gate, so the
161
+ preview shows the handler/goalShape/step keys the SERVER resolved. "apply" runs
162
+ the backend's own dryRun before the gate and previews the per-student outcome.
163
+ "set-metadata" and "delete" require --expected-version (from "get"); a stale one
164
+ 409s STALE_WRITE and writes nothing. The spec is unreachable from "set-metadata"
165
+ by design — an existing spec is edited only through the guarded /ai patch path.
110
166
 
111
167
  Onboarding notes: "status" and "intake-session" are reads — "intake-session"
112
168
  looks up the current IN_PROGRESS session without creating one (prints a "none
@@ -136,6 +192,13 @@ cloud agent, "auth request" prints an approval URL to hand a Recess admin; after
136
192
  approve it in a browser, "auth poll" collects the 12h session. When it lapses, run
137
193
  "auth request" again for a fresh link. Both paths yield the same session.
138
194
 
195
+ Skill notes: this CLI's own agent skill ships inside the npm package AND is served
196
+ by the server, so wording/Gotcha updates arrive without an npm release. "setup"
197
+ (or "setup --skill-only") installs the bundled copy, then upgrades it from the
198
+ server when the served bundle's minCliVersion allows — an older binary keeps the
199
+ bundled copy, and an unreachable server is not an error. "doctor" reports whether
200
+ a newer skill exists and names the command; it never writes.
201
+
139
202
  Writes preview and exit 2 unless --confirm is supplied after explicit human approval.
140
203
  Environment overrides: RECESS_CLI_API_ORIGIN, RECESS_CLI_WEB_ORIGIN,
141
204
  RECESS_CLI_OAUTH_CLIENT_ID, RECESS_CLI_COOKIE, RECESS_CLI_CONFIG.`;
@@ -189,12 +252,139 @@ async function doctor(config) {
189
252
  message: error instanceof Error ? error.message : String(error),
190
253
  };
191
254
  }
255
+ checks.skill = await skillStatus(config);
192
256
  return checks;
193
257
  }
258
+ /**
259
+ * Report whether a newer agent skill is available. Deliberately READ-ONLY:
260
+ * `doctor` is run reflexively at the start of a session, and a diagnostic that
261
+ * silently rewrote `~/.claude/skills` mid-session would change what the agent is
262
+ * reading out from under it. It names the command instead.
263
+ */
264
+ async function skillStatus(config) {
265
+ const cliVersion = await readCliVersion();
266
+ const installedVersion = await readBundledSkillVersion();
267
+ const base = { cliVersion, bundledSkillVersion: installedVersion };
268
+ if (!config.sessionCookie) {
269
+ return { ...base, checked: false, reason: "no session" };
270
+ }
271
+ try {
272
+ const response = await fetch(new URL("/admin/cli-skill/", config.apiOrigin), {
273
+ headers: { cookie: config.sessionCookie },
274
+ signal: AbortSignal.timeout(5000),
275
+ });
276
+ if (!response.ok) {
277
+ return { ...base, checked: false, reason: `server ${response.status}` };
278
+ }
279
+ const bundle = (await response.json());
280
+ const serverVersion = bundle.version ?? "0.0.0";
281
+ const minCliVersion = bundle.minCliVersion ?? "0.0.0";
282
+ const cliTooOld = compareVersions(cliVersion, minCliVersion) < 0;
283
+ const upToDate = compareVersions(installedVersion, serverVersion) >= 0;
284
+ return {
285
+ ...base,
286
+ checked: true,
287
+ serverSkillVersion: serverVersion,
288
+ minCliVersion,
289
+ upToDate,
290
+ ...(upToDate
291
+ ? {}
292
+ : cliTooOld
293
+ ? {
294
+ action: `Server skill ${serverVersion} needs recess >= ${minCliVersion}; upgrade with \`npm install -g recess-cli\`.`,
295
+ }
296
+ : { action: "Run `recess setup --skill-only` to install it." }),
297
+ };
298
+ }
299
+ catch (error) {
300
+ return {
301
+ ...base,
302
+ checked: false,
303
+ reason: error instanceof Error ? error.message : String(error),
304
+ };
305
+ }
306
+ }
194
307
  async function writeCommand(parsed, preview, execute) {
195
308
  requireConfirmation(hasFlag(parsed, "confirm"), preview);
196
309
  return execute();
197
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
+ }
198
388
  function assertChoice(value, choices, label) {
199
389
  if (!choices.includes(value)) {
200
390
  throw new CliError("invalid_arguments", `${label} must be one of: ${choices.join(", ")}.`);
@@ -263,6 +453,220 @@ const SCHOOL_TIER_OPTIONS = [
263
453
  ];
264
454
  const SCHOOL_TIER_IDS = SCHOOL_TIER_OPTIONS.map((tier) => tier.id);
265
455
  const MAX_MAP_PDF_BYTES = 15 * 1024 * 1024;
456
+ const GOAL_TEMPLATE_KINDS = ["SIMPLE", "BLUEPRINT"];
457
+ const GOAL_TEMPLATE_SETUP_AUDIENCES = ["KID_FRIENDLY", "PARENT_SETUP"];
458
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
459
+ /**
460
+ * Read and parse a JSON file supplied by an authoring agent. Large payloads —
461
+ * a setupWorkflowSpec, an answers map, a goal description — go through files
462
+ * rather than argv: a shell mangles embedded quotes and newlines, and a spec
463
+ * that survived a round trip through `--json '<...>'` is not the spec that was
464
+ * reviewed.
465
+ */
466
+ async function readJsonValue(filePath, label) {
467
+ const absolutePath = path.resolve(filePath);
468
+ let raw;
469
+ try {
470
+ raw = await fs.readFile(absolutePath, "utf8");
471
+ }
472
+ catch (error) {
473
+ if (error.code === "ENOENT") {
474
+ throw new CliError("invalid_arguments", `${label} does not exist: ${absolutePath}`);
475
+ }
476
+ throw error;
477
+ }
478
+ let parsed;
479
+ try {
480
+ parsed = JSON.parse(raw);
481
+ }
482
+ catch (error) {
483
+ throw new CliError("invalid_arguments", `${label} is not valid JSON (${absolutePath}): ${error instanceof Error ? error.message : String(error)}`);
484
+ }
485
+ return { absolutePath, raw, parsed };
486
+ }
487
+ async function readJsonFile(filePath, label) {
488
+ const { absolutePath, parsed } = await readJsonValue(filePath, label);
489
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
490
+ throw new CliError("invalid_arguments", `${label} must be a JSON object (${absolutePath}).`);
491
+ }
492
+ return parsed;
493
+ }
494
+ async function readGoalTemplateSpecPatches(filePath) {
495
+ const { absolutePath, raw, parsed } = await readJsonValue(filePath, "Goal-template patch file");
496
+ if (!Array.isArray(parsed) || parsed.length === 0) {
497
+ throw new CliError("invalid_arguments", `Goal-template patch file must be a non-empty JSON array (${absolutePath}).`);
498
+ }
499
+ if (parsed.length > 50) {
500
+ throw new CliError("invalid_arguments", "Goal-template patch files support at most 50 operations.");
501
+ }
502
+ const patches = parsed.map((entry, index) => {
503
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
504
+ throw new CliError("invalid_arguments", `Patch operation ${index} must be an object.`);
505
+ }
506
+ const patch = entry;
507
+ const op = patch.op;
508
+ if (op !== "add" && op !== "copy" && op !== "replace" && op !== "remove") {
509
+ throw new CliError("invalid_arguments", `Patch operation ${index}.op must be add, copy, replace, or remove.`);
510
+ }
511
+ if (typeof patch.path !== "string" || !patch.path.startsWith("/")) {
512
+ throw new CliError("invalid_arguments", `Patch operation ${index}.path must be a JSON Pointer starting with "/".`);
513
+ }
514
+ const hasValue = Object.prototype.hasOwnProperty.call(patch, "value");
515
+ if ((op === "add" || op === "replace") && !hasValue) {
516
+ throw new CliError("invalid_arguments", `Patch operation ${index}.value is required for ${op}.`);
517
+ }
518
+ if ((op === "copy" || op === "remove") && hasValue) {
519
+ throw new CliError("invalid_arguments", `Patch operation ${index}.value is not allowed for ${op}.`);
520
+ }
521
+ if (op === "copy" &&
522
+ (typeof patch.from !== "string" || !patch.from.startsWith("/"))) {
523
+ throw new CliError("invalid_arguments", `Patch operation ${index}.from must be a JSON Pointer starting with "/" for copy.`);
524
+ }
525
+ return {
526
+ op,
527
+ path: patch.path,
528
+ ...(op === "copy" ? { from: patch.from } : {}),
529
+ ...(hasValue ? { value: patch.value } : {}),
530
+ };
531
+ });
532
+ return {
533
+ absolutePath,
534
+ sizeBytes: Buffer.byteLength(raw),
535
+ sha256: createHash("sha256").update(raw).digest("hex"),
536
+ patches,
537
+ };
538
+ }
539
+ function requiredDocString(doc, key) {
540
+ const value = doc[key];
541
+ if (typeof value !== "string" || value.trim().length === 0) {
542
+ throw new CliError("invalid_arguments", `Template file is missing required string "${key}".`);
543
+ }
544
+ return value;
545
+ }
546
+ function optionalDocString(doc, key) {
547
+ const value = doc[key];
548
+ if (value === undefined || value === null)
549
+ return undefined;
550
+ if (typeof value !== "string") {
551
+ throw new CliError("invalid_arguments", `Template file field "${key}" must be a string.`);
552
+ }
553
+ return value;
554
+ }
555
+ /**
556
+ * Coerce an authored template file into the exact `POST /admin/goal-templates/`
557
+ * body. Every unknown key is dropped rather than forwarded, so a stale field
558
+ * copied from an old export cannot ride along into a create, and the shape
559
+ * failures an agent actually makes (missing slug, spec as a string, tags as a
560
+ * comma list) are named locally before any network call.
561
+ *
562
+ * `setupMode` is deliberately absent: the route accepts only
563
+ * DETERMINISTIC_WORKFLOW and a deterministic template can never be converted
564
+ * back, so there is no choice to express.
565
+ */
566
+ function parseGoalTemplateDocument(doc) {
567
+ if (doc.setupMode !== undefined &&
568
+ doc.setupMode !== "DETERMINISTIC_WORKFLOW") {
569
+ throw new CliError("invalid_arguments", `Template file sets setupMode "${String(doc.setupMode)}". New templates are DETERMINISTIC_WORKFLOW only — omit the field.`);
570
+ }
571
+ const spec = doc.setupWorkflowSpec;
572
+ if (spec === null || typeof spec !== "object" || Array.isArray(spec)) {
573
+ throw new CliError("invalid_arguments", 'Template file requires an object "setupWorkflowSpec" (the fixed wizard). Author it with the os-v2-goal-template-builder skill: `recess --json skills get os-v2-goal-template-builder --all-references`.');
574
+ }
575
+ const rawTags = doc.tags;
576
+ let tags = [];
577
+ if (Array.isArray(rawTags)) {
578
+ if (!rawTags.every((tag) => typeof tag === "string")) {
579
+ throw new CliError("invalid_arguments", 'Template file field "tags" must be an array of strings.');
580
+ }
581
+ tags = rawTags;
582
+ }
583
+ else if (typeof rawTags === "string") {
584
+ // A comma list is what a human writes by hand; accept it rather than
585
+ // failing an otherwise-valid template on punctuation.
586
+ tags = rawTags
587
+ .split(",")
588
+ .map((tag) => tag.trim())
589
+ .filter(Boolean);
590
+ }
591
+ else if (rawTags !== undefined && rawTags !== null) {
592
+ throw new CliError("invalid_arguments", 'Template file field "tags" must be an array of strings.');
593
+ }
594
+ const rawSortOrder = doc.sortOrder;
595
+ if (rawSortOrder !== undefined &&
596
+ rawSortOrder !== null &&
597
+ (typeof rawSortOrder !== "number" || !Number.isInteger(rawSortOrder))) {
598
+ throw new CliError("invalid_arguments", 'Template file field "sortOrder" must be an integer.');
599
+ }
600
+ const kind = assertChoice(optionalDocString(doc, "kind") ?? "SIMPLE", GOAL_TEMPLATE_KINDS, "kind");
601
+ const setupAudience = assertChoice(optionalDocString(doc, "setupAudience") ?? "KID_FRIENDLY", GOAL_TEMPLATE_SETUP_AUDIENCES, "setupAudience");
602
+ const starterTierRaw = optionalDocString(doc, "starterTier");
603
+ const parentSetupSpec = doc.parentSetupSpec;
604
+ if (parentSetupSpec !== undefined &&
605
+ parentSetupSpec !== null &&
606
+ (typeof parentSetupSpec !== "object" || Array.isArray(parentSetupSpec))) {
607
+ throw new CliError("invalid_arguments", 'Template file field "parentSetupSpec" must be an object.');
608
+ }
609
+ return {
610
+ slug: requiredDocString(doc, "slug"),
611
+ title: requiredDocString(doc, "title"),
612
+ description: requiredDocString(doc, "description"),
613
+ ...(optionalDocString(doc, "emoji") === undefined
614
+ ? {}
615
+ : { emoji: optionalDocString(doc, "emoji") }),
616
+ ...(optionalDocString(doc, "imageUrl") === undefined
617
+ ? {}
618
+ : { imageUrl: optionalDocString(doc, "imageUrl") }),
619
+ ...(optionalDocString(doc, "category") === undefined
620
+ ? {}
621
+ : { category: optionalDocString(doc, "category") }),
622
+ tags,
623
+ sortOrder: rawSortOrder ?? 0,
624
+ ...(doc.isStarter === undefined
625
+ ? {}
626
+ : { isStarter: Boolean(doc.isStarter) }),
627
+ ...(starterTierRaw === undefined
628
+ ? {}
629
+ : {
630
+ starterTier: assertChoice(starterTierRaw, ["CORE", "EXTRA"], "starterTier"),
631
+ }),
632
+ kind,
633
+ setupAudience,
634
+ setupWorkflowSpec: spec,
635
+ ...(parentSetupSpec === undefined || parentSetupSpec === null
636
+ ? {}
637
+ : { parentSetupSpec: parentSetupSpec }),
638
+ agentInstructions: requiredDocString(doc, "agentInstructions"),
639
+ ...(optionalDocString(doc, "outputTemplate") === undefined
640
+ ? {}
641
+ : { outputTemplate: optionalDocString(doc, "outputTemplate") }),
642
+ };
643
+ }
644
+ /**
645
+ * Resolve a template reference that may be a UUID or a slug. Slugs are what an
646
+ * author types and what the skill's prose uses; the routes take a UUID only.
647
+ */
648
+ async function resolveGoalTemplateId(api, reference) {
649
+ if (UUID_RE.test(reference))
650
+ return reference;
651
+ const list = unwrap(await api.client.GET("/admin/goal-templates/", {
652
+ params: { query: { includeDeleted: "true" } },
653
+ }));
654
+ const match = list.items.find((item) => item.slug === reference);
655
+ if (!match) {
656
+ throw new CliError("invalid_arguments", `No goal template with id or slug "${reference}".`);
657
+ }
658
+ return match.id;
659
+ }
660
+ function requiredExpectedVersion(parsed) {
661
+ const value = flagNumber(parsed, "expected-version");
662
+ if (value === undefined) {
663
+ throw new CliError("invalid_arguments", "Missing required --expected-version. Read it from `goal-templates get <id>` (the `version` field); a stale value 409s without writing.");
664
+ }
665
+ if (!Number.isInteger(value) || value < 1) {
666
+ throw new CliError("invalid_arguments", "--expected-version must be a positive integer.");
667
+ }
668
+ return value;
669
+ }
266
670
  async function readMapScorePdf(filePath) {
267
671
  const absolutePath = path.resolve(filePath);
268
672
  if (path.extname(absolutePath).toLowerCase() !== ".pdf") {
@@ -308,6 +712,34 @@ function flagStatusList(parsed, name, choices) {
308
712
  }
309
713
  return values.map((value) => assertChoice(value, choices, `--${name}`));
310
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
+ }
311
743
  function flagCents(parsed, name, options = {}) {
312
744
  const value = flagNumber(parsed, name);
313
745
  if (value === undefined) {
@@ -407,6 +839,14 @@ function tierSlots(parsed) {
407
839
  export async function runCommand(argv) {
408
840
  const parsed = parseArgs(argv);
409
841
  const [noun, verb] = parsed.positionals;
842
+ // Before the help branch: `--version` parses as a FLAG, so `noun` is
843
+ // undefined and `!noun` would return help instead. (Found by running it.)
844
+ if (noun === "version" || hasFlag(parsed, "version")) {
845
+ return {
846
+ cliVersion: await readCliVersion(),
847
+ skillVersion: await readBundledSkillVersion(),
848
+ };
849
+ }
410
850
  if (!noun || noun === "help" || hasFlag(parsed, "help")) {
411
851
  return { help: HELP };
412
852
  }
@@ -414,7 +854,15 @@ export async function runCommand(argv) {
414
854
  if (noun === "doctor")
415
855
  return doctor(config);
416
856
  if (noun === "setup") {
857
+ // Always lay down the bundled copy first: it is the floor, and it is the
858
+ // only copy guaranteed to match this binary. The served upgrade below is
859
+ // strictly additive on top of it.
417
860
  const skills = await installSkill();
861
+ const skillUpdate = await updateSkillFromServer({
862
+ apiOrigin: config.apiOrigin,
863
+ sessionCookie: config.sessionCookie,
864
+ cliVersion: await readCliVersion(),
865
+ });
418
866
  const ephemeral = isEphemeralInstall();
419
867
  // Reuse a session that is still live; a missing or already-expired one is
420
868
  // worth spending the browser round trip on now rather than at first use.
@@ -430,6 +878,7 @@ export async function runCommand(argv) {
430
878
  });
431
879
  return {
432
880
  skills,
881
+ skillUpdate,
433
882
  session: loggedIn ?? (sessionIsLive ? "existing" : null),
434
883
  ...(ephemeral
435
884
  ? {
@@ -743,6 +1192,48 @@ export async function runCommand(argv) {
743
1192
  params: { query: { subscriptionId } },
744
1193
  }));
745
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
+ }
746
1237
  if (noun === "cohorts" && verb === "search") {
747
1238
  const search = parsed.positionals.slice(2).join(" ").trim();
748
1239
  if (!search)
@@ -1529,6 +2020,603 @@ export async function runCommand(argv) {
1529
2020
  }
1530
2021
  throw new CliError("invalid_arguments", "Use onboarding status|kids|intake-session|intake-session-create|set-stage|set-account-state|attest|set-intake|extract.");
1531
2022
  }
2023
+ if (noun === "skills") {
2024
+ // Reads only — no gate. The value of this noun is that CLI agents author
2025
+ // against the SAME documents the in-product tutor loads; nothing is copied
2026
+ // into this package, so a skills-repo change reaches agents with no release.
2027
+ const refresh = hasFlag(parsed, "refresh");
2028
+ if (verb === "list") {
2029
+ const query = flagString(parsed, "query");
2030
+ const category = flagString(parsed, "category");
2031
+ const cacheKey = `list:${query ?? ""}:${category ?? ""}`;
2032
+ if (!refresh) {
2033
+ const cached = await readSkillCache(config.apiOrigin, cacheKey);
2034
+ if (cached)
2035
+ return { ...cached, cached: true };
2036
+ }
2037
+ const data = unwrap(await api.client.GET("/admin/skills/", {
2038
+ params: {
2039
+ query: {
2040
+ ...(query ? { query } : {}),
2041
+ ...(category ? { category } : {}),
2042
+ },
2043
+ },
2044
+ }));
2045
+ await writeSkillCache(config.apiOrigin, cacheKey, data);
2046
+ return { ...data, cached: false };
2047
+ }
2048
+ if (verb === "get") {
2049
+ const name = positional(parsed, 2, "skill name");
2050
+ const reference = flagString(parsed, "reference");
2051
+ const allReferences = hasFlag(parsed, "all-references");
2052
+ if (reference && allReferences) {
2053
+ throw new CliError("invalid_arguments", "Pass --reference <name> for one reference or --all-references for the whole tree, not both.");
2054
+ }
2055
+ const cacheKey = `get:${name}:${reference ?? ""}:${allReferences}`;
2056
+ if (!refresh) {
2057
+ const cached = await readSkillCache(config.apiOrigin, cacheKey);
2058
+ if (cached)
2059
+ return { ...cached, cached: true };
2060
+ }
2061
+ const data = unwrap(await api.client.GET("/admin/skills/{name}", {
2062
+ params: {
2063
+ path: { name },
2064
+ query: {
2065
+ ...(reference ? { reference } : {}),
2066
+ ...(allReferences ? { references: "all" } : {}),
2067
+ },
2068
+ },
2069
+ }));
2070
+ await writeSkillCache(config.apiOrigin, cacheKey, data);
2071
+ return { ...data, cached: false };
2072
+ }
2073
+ throw new CliError("invalid_arguments", "Use skills list|get.");
2074
+ }
2075
+ if (noun === "goal-templates") {
2076
+ if (verb === "list") {
2077
+ const query = flagString(parsed, "query")?.toLowerCase();
2078
+ const kind = flagString(parsed, "kind");
2079
+ const data = unwrap(await api.client.GET("/admin/goal-templates/", {
2080
+ params: {
2081
+ query: {
2082
+ ...(flagString(parsed, "category")
2083
+ ? { category: flagString(parsed, "category") }
2084
+ : {}),
2085
+ includeDeleted: hasFlag(parsed, "include-deleted")
2086
+ ? "true"
2087
+ : "false",
2088
+ starterOnly: hasFlag(parsed, "starter-only")
2089
+ ? "true"
2090
+ : "false",
2091
+ },
2092
+ },
2093
+ }));
2094
+ // The route filters by category/starter/deleted only; `--query` and
2095
+ // `--kind` narrow the returned page here rather than pretending the
2096
+ // backend supports them.
2097
+ const wantedKind = kind
2098
+ ? assertChoice(kind, GOAL_TEMPLATE_KINDS, "--kind")
2099
+ : undefined;
2100
+ const items = data.items.filter((item) => {
2101
+ if (wantedKind && item.kind !== wantedKind)
2102
+ return false;
2103
+ if (!query)
2104
+ return true;
2105
+ return [item.title, item.slug, item.description, item.category ?? ""]
2106
+ .join("\n")
2107
+ .toLowerCase()
2108
+ .includes(query);
2109
+ });
2110
+ return { items, totalBeforeFilter: data.items.length };
2111
+ }
2112
+ if (verb === "get") {
2113
+ const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
2114
+ const template = unwrap(await api.client.GET("/admin/goal-templates/{id}", {
2115
+ params: { path: { id } },
2116
+ }));
2117
+ // A full spec is large; `--spec-only` is what an agent pipes into a file
2118
+ // before editing, without the surrounding metadata noise.
2119
+ if (hasFlag(parsed, "spec-only")) {
2120
+ return {
2121
+ id: template.id,
2122
+ slug: template.slug,
2123
+ version: template.version,
2124
+ setupWorkflowSpec: template.setupWorkflowSpec,
2125
+ };
2126
+ }
2127
+ return template;
2128
+ }
2129
+ if (verb === "versions") {
2130
+ const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
2131
+ const data = unwrap(await api.client.GET("/admin/goal-templates/{id}/versions", {
2132
+ params: { path: { id } },
2133
+ }));
2134
+ const wanted = flagNumber(parsed, "version");
2135
+ if (wanted === undefined) {
2136
+ // Version bodies carry the whole frozen spec; the default listing is
2137
+ // metadata so history stays readable, and `--version N` fetches one.
2138
+ return {
2139
+ versions: data.versions.map(({ setupWorkflowSpec, agentInstructions, ...rest }) => ({
2140
+ ...rest,
2141
+ hasSetupWorkflowSpec: setupWorkflowSpec !== null,
2142
+ agentInstructionsChars: agentInstructions.length,
2143
+ })),
2144
+ };
2145
+ }
2146
+ const version = data.versions.find((entry) => entry.version === wanted);
2147
+ if (!version) {
2148
+ throw new CliError("invalid_arguments", `Template has no version ${wanted}.`);
2149
+ }
2150
+ return version;
2151
+ }
2152
+ if (verb === "validate-spec") {
2153
+ const document = parseGoalTemplateDocument(await readJsonFile(flagString(parsed, "file", { required: true }), "Template file"));
2154
+ // A read-only validation: no gate, and iterating on it is the whole point.
2155
+ return {
2156
+ slug: document.slug,
2157
+ kind: document.kind,
2158
+ ...unwrap(await api.client.POST("/admin/goal-templates/validate-spec", {
2159
+ body: {
2160
+ setupWorkflowSpec: document.setupWorkflowSpec,
2161
+ kind: document.kind,
2162
+ },
2163
+ })),
2164
+ };
2165
+ }
2166
+ if (verb === "create") {
2167
+ const filePath = flagString(parsed, "file", { required: true });
2168
+ const document = parseGoalTemplateDocument(await readJsonFile(filePath, "Template file"));
2169
+ // C3 read-only preflight, for the same reason `enrollments create` has
2170
+ // one: the consequences an approver must weigh are resolved SERVER-side.
2171
+ // Which setupHandler runs, what shape of goal students get, and which
2172
+ // answer keys `apply` will demand all come out of the spec's own
2173
+ // validation — a locally guessed preview could name a different handler
2174
+ // than the one that lands. Validation writes nothing.
2175
+ const validation = unwrap(await api.client.POST("/admin/goal-templates/validate-spec", {
2176
+ body: {
2177
+ setupWorkflowSpec: document.setupWorkflowSpec,
2178
+ kind: document.kind,
2179
+ },
2180
+ }));
2181
+ if (!validation.valid) {
2182
+ throw new CliError("invalid_spec", `The setupWorkflowSpec is invalid, so nothing was created: ${validation.error}`, 1, { file: path.resolve(filePath) });
2183
+ }
2184
+ return writeCommand(parsed, {
2185
+ action: "create a NEW global goal template (DETERMINISTIC_WORKFLOW — this cannot be converted back)",
2186
+ target: { slug: document.slug, title: document.title },
2187
+ request: {
2188
+ file: path.resolve(filePath),
2189
+ kind: document.kind,
2190
+ setupAudience: document.setupAudience,
2191
+ category: document.category ?? null,
2192
+ tags: document.tags,
2193
+ isStarter: document.isStarter ?? false,
2194
+ },
2195
+ details: {
2196
+ resolvedSetupHandler: validation.setupHandler,
2197
+ resolvedGoalShape: validation.goalShape,
2198
+ wizardStepKeys: validation.stepKeys,
2199
+ specSha256: validation.sha256,
2200
+ specInventory: validation.totals,
2201
+ note: "Setup mode is DETERMINISTIC_WORKFLOW and is one-way: this template can never be converted back to a chat-driven setup.",
2202
+ },
2203
+ }, async () => unwrap(await api.client.POST("/admin/goal-templates/", {
2204
+ body: document,
2205
+ })));
2206
+ }
2207
+ if (verb === "patch-spec") {
2208
+ const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
2209
+ const expectedVersion = requiredExpectedVersion(parsed);
2210
+ const patchFile = await readGoalTemplateSpecPatches(flagString(parsed, "patches-file", { required: true }));
2211
+ // The server owns JSON Pointer semantics, strict final-spec validation,
2212
+ // protected-inventory counting, and the token binding the approved loss
2213
+ // to this exact version + result hash. Always ask it for a fresh preview,
2214
+ // including on a confirmed run, before permitting the write request.
2215
+ const preflight = unwrap(await api.client.POST("/admin/goal-templates/{id}/setup-workflow-spec/patch", {
2216
+ params: { path: { id } },
2217
+ body: {
2218
+ expectedVersion,
2219
+ patches: patchFile.patches,
2220
+ dryRun: true,
2221
+ },
2222
+ }));
2223
+ if (preflight.action !== "preview_setup_workflow_spec_patch") {
2224
+ throw new CliError("unexpected_response", "The goal-template patch preflight did not return a preview; nothing was changed.");
2225
+ }
2226
+ const preview = {
2227
+ action: preflight.preview.destructiveChanges
2228
+ ? "patch a global goal template setupWorkflowSpec (REMOVES protected template data)"
2229
+ : "patch a global goal template setupWorkflowSpec",
2230
+ target: {
2231
+ templateId: preflight.template.id,
2232
+ slug: preflight.template.slug,
2233
+ title: preflight.template.title,
2234
+ version: preflight.template.version,
2235
+ },
2236
+ request: {
2237
+ expectedVersion,
2238
+ patchesFile: patchFile.absolutePath,
2239
+ patchesFileSizeBytes: patchFile.sizeBytes,
2240
+ patchesFileSha256: patchFile.sha256,
2241
+ patches: patchFile.patches,
2242
+ },
2243
+ details: {
2244
+ patchesApplied: preflight.patchesApplied,
2245
+ safety: preflight.preview,
2246
+ ...(preflight.preview.destructiveChanges
2247
+ ? {
2248
+ destructiveApproval: "After explicit approval of the exact removed inventory, rerun with --confirm --confirm-destructive-changes and --destructive-change-token set to this preview's token.",
2249
+ }
2250
+ : {}),
2251
+ },
2252
+ };
2253
+ requireConfirmation(hasFlag(parsed, "confirm"), preview);
2254
+ const destructiveChangeToken = flagString(parsed, "destructive-change-token");
2255
+ if (preflight.preview.destructiveChanges &&
2256
+ (!hasFlag(parsed, "confirm-destructive-changes") ||
2257
+ !destructiveChangeToken ||
2258
+ destructiveChangeToken !== preflight.preview.destructiveChangeToken)) {
2259
+ throw new CliError("destructive_confirmation_required", "This patch removes protected template data. Review the fresh preview and rerun with --confirm-destructive-changes plus its exact --destructive-change-token; nothing was changed.", 2, {
2260
+ preview,
2261
+ requiredFlags: [
2262
+ "--confirm",
2263
+ "--confirm-destructive-changes",
2264
+ "--destructive-change-token",
2265
+ ],
2266
+ expectedDestructiveChangeToken: preflight.preview.destructiveChangeToken,
2267
+ });
2268
+ }
2269
+ return unwrap(await api.client.POST("/admin/goal-templates/{id}/setup-workflow-spec/patch", {
2270
+ params: { path: { id } },
2271
+ body: {
2272
+ expectedVersion,
2273
+ patches: patchFile.patches,
2274
+ dryRun: false,
2275
+ confirmDestructiveChanges: preflight.preview.destructiveChanges,
2276
+ ...(destructiveChangeToken ? { destructiveChangeToken } : {}),
2277
+ },
2278
+ }));
2279
+ }
2280
+ if (verb === "set-metadata") {
2281
+ const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
2282
+ const expectedVersion = requiredExpectedVersion(parsed);
2283
+ const agentInstructionsFile = flagString(parsed, "agent-instructions-file");
2284
+ const tags = flagString(parsed, "tags");
2285
+ const kind = flagString(parsed, "kind");
2286
+ const sortOrder = flagNumber(parsed, "sort-order");
2287
+ const body = {
2288
+ expectedVersion,
2289
+ ...(flagString(parsed, "title")
2290
+ ? { title: flagString(parsed, "title") }
2291
+ : {}),
2292
+ ...(flagString(parsed, "description")
2293
+ ? { description: flagString(parsed, "description") }
2294
+ : {}),
2295
+ ...(flagString(parsed, "emoji")
2296
+ ? { emoji: flagString(parsed, "emoji") }
2297
+ : {}),
2298
+ ...(flagString(parsed, "category")
2299
+ ? { category: flagString(parsed, "category") }
2300
+ : {}),
2301
+ ...(tags
2302
+ ? {
2303
+ tags: tags
2304
+ .split(",")
2305
+ .map((tag) => tag.trim())
2306
+ .filter(Boolean),
2307
+ }
2308
+ : {}),
2309
+ ...(sortOrder === undefined ? {} : { sortOrder }),
2310
+ ...(kind
2311
+ ? { kind: assertChoice(kind, GOAL_TEMPLATE_KINDS, "--kind") }
2312
+ : {}),
2313
+ ...(agentInstructionsFile
2314
+ ? {
2315
+ agentInstructions: await fs.readFile(path.resolve(agentInstructionsFile), "utf8"),
2316
+ }
2317
+ : {}),
2318
+ };
2319
+ // `setupWorkflowSpec` is unreachable from this command by construction.
2320
+ // The route still accepts one, but a wholesale spec replacement is the
2321
+ // shape that caused the template incident; editing an existing spec goes
2322
+ // through the guarded /ai patch path with its destructive-change token.
2323
+ if (Object.keys(body).length === 1) {
2324
+ throw new CliError("invalid_arguments", "Pass at least one field to change (--title, --description, --emoji, --category, --tags, --sort-order, --kind, --agent-instructions-file).");
2325
+ }
2326
+ return writeCommand(parsed, {
2327
+ action: "update goal template metadata (never its setupWorkflowSpec)",
2328
+ target: { templateId: id, expectedVersion },
2329
+ request: body,
2330
+ }, async () => unwrap(await api.client.PUT("/admin/goal-templates/{id}", {
2331
+ params: { path: { id } },
2332
+ body,
2333
+ })));
2334
+ }
2335
+ if (verb === "delete") {
2336
+ const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
2337
+ const expectedVersion = requiredExpectedVersion(parsed);
2338
+ // Read the current version so the gate refuses a stale delete locally,
2339
+ // and so the preview names the template a human is being asked to approve
2340
+ // rather than only its UUID.
2341
+ const current = unwrap(await api.client.GET("/admin/goal-templates/{id}", {
2342
+ params: { path: { id } },
2343
+ }));
2344
+ if (current.version !== expectedVersion) {
2345
+ throw new CliError("stale_write", `Goal template is at version ${current.version}, not ${expectedVersion}. Re-read it before deleting; nothing was deleted.`, 1, { templateId: id, currentVersion: current.version });
2346
+ }
2347
+ return writeCommand(parsed, {
2348
+ action: "soft-delete a global goal template",
2349
+ target: {
2350
+ templateId: id,
2351
+ slug: current.slug,
2352
+ title: current.title,
2353
+ version: current.version,
2354
+ },
2355
+ request: { expectedVersion },
2356
+ details: {
2357
+ note: "Soft delete: the row keeps its deletedAt and drops out of every list. Existing goals already applied from it are unaffected.",
2358
+ },
2359
+ }, async () => unwrap(await api.client.DELETE("/admin/goal-templates/{id}", {
2360
+ params: { path: { id } },
2361
+ })));
2362
+ }
2363
+ if (verb === "snapshot-files") {
2364
+ const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
2365
+ const filePath = flagString(parsed, "path");
2366
+ if (filePath) {
2367
+ return unwrap(await api.client.GET("/admin/goal-templates/{id}/snapshot/file", {
2368
+ params: { path: { id }, query: { path: filePath } },
2369
+ }));
2370
+ }
2371
+ return unwrap(await api.client.GET("/admin/goal-templates/{id}/snapshot/files", {
2372
+ params: { path: { id } },
2373
+ }));
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
+ }
2433
+ if (verb === "apply") {
2434
+ const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
2435
+ const answers = await readJsonFile(flagString(parsed, "answers-file", { required: true }), "Answers file");
2436
+ // `--dry-run` is a read: it resolves the whole plan and writes nothing,
2437
+ // so it does not gate. Run it, read the per-student results, then confirm.
2438
+ if (hasFlag(parsed, "dry-run")) {
2439
+ return unwrap(await api.client.POST("/ai/goal-templates/{id}/apply-workflow", {
2440
+ params: { path: { id } },
2441
+ body: { answers, dryRun: true },
2442
+ }));
2443
+ }
2444
+ // The real consequence — which students get which goals, which are
2445
+ // skipped as already-existing, and what coverage is missing — is only
2446
+ // knowable from the backend's own planner. Run its dryRun before the gate
2447
+ // (writes nothing) so the approver sees the actual roster.
2448
+ const preflight = unwrap(await api.client.POST("/ai/goal-templates/{id}/apply-workflow", {
2449
+ params: { path: { id } },
2450
+ body: { answers, dryRun: true },
2451
+ }));
2452
+ const counts = preflight.results.reduce((totals, result) => ({
2453
+ ...totals,
2454
+ [result.action]: (totals[result.action] ?? 0) + 1,
2455
+ }), {});
2456
+ return writeCommand(parsed, {
2457
+ action: "apply a goal template to students (creates goals and todos)",
2458
+ target: { templateId: id, students: preflight.results.length },
2459
+ request: { answers },
2460
+ details: {
2461
+ counts,
2462
+ results: preflight.results.map((result) => ({
2463
+ student: result.studentName,
2464
+ studentUserId: result.studentUserId,
2465
+ action: result.action,
2466
+ goalTitle: result.goalTitle,
2467
+ todoTitle: result.todoTitle,
2468
+ warnings: result.warnings,
2469
+ })),
2470
+ missingCoverage: preflight.missingCoverage,
2471
+ note: "Counts come from the backend's own dry run. `skipped_existing` items are idempotent — re-applying does not duplicate them.",
2472
+ },
2473
+ }, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/apply-workflow", {
2474
+ params: { path: { id } },
2475
+ body: { answers, dryRun: false },
2476
+ })));
2477
+ }
2478
+ if (verb === "apply-starter") {
2479
+ const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
2480
+ const studentUserId = flagString(parsed, "student", { required: true });
2481
+ const answersFile = flagString(parsed, "answers-file");
2482
+ const answers = answersFile
2483
+ ? await readJsonFile(answersFile, "Answers file")
2484
+ : {};
2485
+ // apply-starter has no dryRun — it is the one-tap starter path — so this
2486
+ // preview is offline. It is also flag-gated per actor server-side
2487
+ // (school-onboarding-v1); a flag-off admin gets a 404 rather than a write.
2488
+ return writeCommand(parsed, {
2489
+ action: "apply ONE starter goal template to ONE student (no dry run exists for this route)",
2490
+ target: { templateId: id, studentUserId },
2491
+ request: { answers },
2492
+ details: {
2493
+ note: "Gated behind the school-onboarding-v1 flag for the acting admin; a 404 means the flag is off, not that the template is missing. Prefer `goal-templates apply --dry-run` when the template supports it.",
2494
+ },
2495
+ }, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/apply-starter", {
2496
+ params: { path: { id } },
2497
+ body: { studentUserId, answers },
2498
+ })));
2499
+ }
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.");
2501
+ }
2502
+ if (noun === "goals") {
2503
+ if (verb === "list") {
2504
+ const userId = flagString(parsed, "student", { required: true });
2505
+ return unwrap(await api.client.GET("/admin/browser/students/{userId}/goals/", {
2506
+ params: { path: { userId } },
2507
+ }));
2508
+ }
2509
+ if (verb === "create") {
2510
+ const userId = flagString(parsed, "student", { required: true });
2511
+ const title = flagString(parsed, "title", { required: true });
2512
+ const descriptionFile = flagString(parsed, "description-file");
2513
+ const descriptionFlag = flagString(parsed, "description");
2514
+ if ((descriptionFile && descriptionFlag) ||
2515
+ (!descriptionFile && !descriptionFlag)) {
2516
+ throw new CliError("invalid_arguments", "Pass exactly one of --description <text> or --description-file <path>.");
2517
+ }
2518
+ const description = descriptionFile
2519
+ ? await fs.readFile(path.resolve(descriptionFile), "utf8")
2520
+ : descriptionFlag;
2521
+ const targetDate = flagString(parsed, "target-date");
2522
+ if (targetDate !== undefined && Number.isNaN(Date.parse(targetDate))) {
2523
+ throw new CliError("invalid_arguments", "--target-date must be an ISO 8601 datetime.");
2524
+ }
2525
+ const schedule = flagString(parsed, "schedule");
2526
+ const body = {
2527
+ title,
2528
+ description,
2529
+ ...(targetDate ? { targetDate } : {}),
2530
+ ...(schedule ? { schedule } : {}),
2531
+ };
2532
+ return writeCommand(parsed, {
2533
+ action: "create a goal directly on a kid (visible to them immediately)",
2534
+ target: { studentUserId: userId },
2535
+ request: {
2536
+ title,
2537
+ descriptionChars: description.length,
2538
+ targetDate: targetDate ?? null,
2539
+ schedule: schedule ?? null,
2540
+ },
2541
+ details: {
2542
+ note: "Creates a description-only goal with no GoalModules and no Mesa workspace. For a module-backed course, apply a BLUEPRINT template instead. A 409 GOAL_LIMIT_REACHED means the kid is at capacity and nothing was created.",
2543
+ },
2544
+ }, async () => unwrap(await api.client.POST("/admin/browser/students/{userId}/goals/", {
2545
+ params: { path: { userId } },
2546
+ body,
2547
+ })));
2548
+ }
2549
+ throw new CliError("invalid_arguments", "Use goals list|create.");
2550
+ }
2551
+ if (noun === "mesa" && verb === "files") {
2552
+ const action = positional(parsed, 2, "mesa files action");
2553
+ const studentId = flagString(parsed, "student", { required: true });
2554
+ if (action === "list") {
2555
+ const goalId = flagString(parsed, "goal", { required: true });
2556
+ return unwrap(await api.client.GET("/tutor/students/{studentId}/goals/{goalId}/workspace/files", { params: { path: { studentId, goalId } } }));
2557
+ }
2558
+ if (action === "read") {
2559
+ const goalId = flagString(parsed, "goal", { required: true });
2560
+ return unwrap(await api.client.GET("/tutor/students/{studentId}/goals/{goalId}/workspace/file", {
2561
+ params: {
2562
+ path: { studentId, goalId },
2563
+ query: { path: flagString(parsed, "path", { required: true }) },
2564
+ },
2565
+ }));
2566
+ }
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.");
2619
+ }
1532
2620
  if (noun === "request" && verb === "get") {
1533
2621
  return api.rawGet(positional(parsed, 2, "request path"));
1534
2622
  }