recess-cli 1.0.1 → 1.2.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
@@ -7,10 +7,13 @@ 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]
@@ -107,6 +110,49 @@ Usage:
107
110
  recess [--json] village models remove <placement-id> [--world village-1] [--confirm]
108
111
  recess [--json] village render --min-x N --min-z N --max-x N --max-z N
109
112
  [--world village-1]
113
+ recess [--json] skills list [--query TEXT] [--category TEXT]
114
+ recess [--json] skills get <skill-name> [--reference NAME | --all-references]
115
+ [--refresh]
116
+ recess [--json] goal-templates list [--query TEXT] [--category TEXT]
117
+ [--kind SIMPLE|BLUEPRINT] [--starter-only] [--include-deleted]
118
+ recess [--json] goal-templates get <template-id|slug> [--spec-only]
119
+ recess [--json] goal-templates versions <template-id> [--version N]
120
+ recess [--json] goal-templates validate-spec --file <path/template.json>
121
+ recess [--json] goal-templates create --file <path/template.json> [--confirm]
122
+ recess [--json] goal-templates patch-spec <template-id|slug> --expected-version N
123
+ --patches-file <path/patches.json> [--confirm]
124
+ [--confirm-destructive-changes --destructive-change-token TOKEN]
125
+ recess [--json] goal-templates set-metadata <template-id> --expected-version N
126
+ [--title TEXT] [--description TEXT] [--emoji X] [--category TEXT] [--tags A,B]
127
+ [--sort-order N] [--kind SIMPLE|BLUEPRINT] [--agent-instructions-file <path>]
128
+ [--confirm]
129
+ recess [--json] goal-templates delete <template-id> --expected-version N [--confirm]
130
+ recess [--json] goal-templates snapshot-files <template-id> [--path P]
131
+ recess [--json] goal-templates apply <template-id> --answers-file <path>
132
+ [--dry-run] [--confirm]
133
+ recess [--json] goal-templates apply-starter <template-id> --student <kid-id>
134
+ [--answers-file <path>] [--confirm]
135
+ recess [--json] goals list --student <kid-id>
136
+ recess [--json] goals create --student <kid-id> --title TEXT
137
+ (--description TEXT | --description-file <path>) [--target-date <iso>]
138
+ [--schedule TEXT] [--confirm]
139
+ recess [--json] mesa files list --student <kid-id> --goal <goal-id>
140
+ recess [--json] mesa files read --student <kid-id> --goal <goal-id> --path P
141
+
142
+ Authoring notes: "skills" serves the in-product tutor skills (the PRIVATE
143
+ packages/skills submodule) read-only over your admin session — they are never
144
+ bundled into this npm package. Load os-v2-goal-template-builder and its
145
+ references/deterministic-workflow-setup.md BEFORE authoring a template; that is
146
+ the same guidance the recess.gg/ai agent follows, so there is exactly one
147
+ standard. Responses cache under ~/.recess-cli/skills-cache/ (--refresh re-fetches).
148
+ Every template created here is setupMode DETERMINISTIC_WORKFLOW and CANNOT be
149
+ converted back, so "validate-spec" against the same file until it passes, then
150
+ "create". "create" runs a real server-side validation before its gate, so the
151
+ preview shows the handler/goalShape/step keys the SERVER resolved. "apply" runs
152
+ the backend's own dryRun before the gate and previews the per-student outcome.
153
+ "set-metadata" and "delete" require --expected-version (from "get"); a stale one
154
+ 409s STALE_WRITE and writes nothing. The spec is unreachable from "set-metadata"
155
+ by design — an existing spec is edited only through the guarded /ai patch path.
110
156
 
111
157
  Onboarding notes: "status" and "intake-session" are reads — "intake-session"
112
158
  looks up the current IN_PROGRESS session without creating one (prints a "none
@@ -136,6 +182,13 @@ cloud agent, "auth request" prints an approval URL to hand a Recess admin; after
136
182
  approve it in a browser, "auth poll" collects the 12h session. When it lapses, run
137
183
  "auth request" again for a fresh link. Both paths yield the same session.
138
184
 
185
+ Skill notes: this CLI's own agent skill ships inside the npm package AND is served
186
+ by the server, so wording/Gotcha updates arrive without an npm release. "setup"
187
+ (or "setup --skill-only") installs the bundled copy, then upgrades it from the
188
+ server when the served bundle's minCliVersion allows — an older binary keeps the
189
+ bundled copy, and an unreachable server is not an error. "doctor" reports whether
190
+ a newer skill exists and names the command; it never writes.
191
+
139
192
  Writes preview and exit 2 unless --confirm is supplied after explicit human approval.
140
193
  Environment overrides: RECESS_CLI_API_ORIGIN, RECESS_CLI_WEB_ORIGIN,
141
194
  RECESS_CLI_OAUTH_CLIENT_ID, RECESS_CLI_COOKIE, RECESS_CLI_CONFIG.`;
@@ -189,8 +242,58 @@ async function doctor(config) {
189
242
  message: error instanceof Error ? error.message : String(error),
190
243
  };
191
244
  }
245
+ checks.skill = await skillStatus(config);
192
246
  return checks;
193
247
  }
248
+ /**
249
+ * Report whether a newer agent skill is available. Deliberately READ-ONLY:
250
+ * `doctor` is run reflexively at the start of a session, and a diagnostic that
251
+ * silently rewrote `~/.claude/skills` mid-session would change what the agent is
252
+ * reading out from under it. It names the command instead.
253
+ */
254
+ async function skillStatus(config) {
255
+ const cliVersion = await readCliVersion();
256
+ const installedVersion = await readBundledSkillVersion();
257
+ const base = { cliVersion, bundledSkillVersion: installedVersion };
258
+ if (!config.sessionCookie) {
259
+ return { ...base, checked: false, reason: "no session" };
260
+ }
261
+ try {
262
+ const response = await fetch(new URL("/admin/cli-skill/", config.apiOrigin), {
263
+ headers: { cookie: config.sessionCookie },
264
+ signal: AbortSignal.timeout(5000),
265
+ });
266
+ if (!response.ok) {
267
+ return { ...base, checked: false, reason: `server ${response.status}` };
268
+ }
269
+ const bundle = (await response.json());
270
+ const serverVersion = bundle.version ?? "0.0.0";
271
+ const minCliVersion = bundle.minCliVersion ?? "0.0.0";
272
+ const cliTooOld = compareVersions(cliVersion, minCliVersion) < 0;
273
+ const upToDate = compareVersions(installedVersion, serverVersion) >= 0;
274
+ return {
275
+ ...base,
276
+ checked: true,
277
+ serverSkillVersion: serverVersion,
278
+ minCliVersion,
279
+ upToDate,
280
+ ...(upToDate
281
+ ? {}
282
+ : cliTooOld
283
+ ? {
284
+ action: `Server skill ${serverVersion} needs recess >= ${minCliVersion}; upgrade with \`npm install -g recess-cli\`.`,
285
+ }
286
+ : { action: "Run `recess setup --skill-only` to install it." }),
287
+ };
288
+ }
289
+ catch (error) {
290
+ return {
291
+ ...base,
292
+ checked: false,
293
+ reason: error instanceof Error ? error.message : String(error),
294
+ };
295
+ }
296
+ }
194
297
  async function writeCommand(parsed, preview, execute) {
195
298
  requireConfirmation(hasFlag(parsed, "confirm"), preview);
196
299
  return execute();
@@ -263,6 +366,220 @@ const SCHOOL_TIER_OPTIONS = [
263
366
  ];
264
367
  const SCHOOL_TIER_IDS = SCHOOL_TIER_OPTIONS.map((tier) => tier.id);
265
368
  const MAX_MAP_PDF_BYTES = 15 * 1024 * 1024;
369
+ const GOAL_TEMPLATE_KINDS = ["SIMPLE", "BLUEPRINT"];
370
+ const GOAL_TEMPLATE_SETUP_AUDIENCES = ["KID_FRIENDLY", "PARENT_SETUP"];
371
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
372
+ /**
373
+ * Read and parse a JSON file supplied by an authoring agent. Large payloads —
374
+ * a setupWorkflowSpec, an answers map, a goal description — go through files
375
+ * rather than argv: a shell mangles embedded quotes and newlines, and a spec
376
+ * that survived a round trip through `--json '<...>'` is not the spec that was
377
+ * reviewed.
378
+ */
379
+ async function readJsonValue(filePath, label) {
380
+ const absolutePath = path.resolve(filePath);
381
+ let raw;
382
+ try {
383
+ raw = await fs.readFile(absolutePath, "utf8");
384
+ }
385
+ catch (error) {
386
+ if (error.code === "ENOENT") {
387
+ throw new CliError("invalid_arguments", `${label} does not exist: ${absolutePath}`);
388
+ }
389
+ throw error;
390
+ }
391
+ let parsed;
392
+ try {
393
+ parsed = JSON.parse(raw);
394
+ }
395
+ catch (error) {
396
+ throw new CliError("invalid_arguments", `${label} is not valid JSON (${absolutePath}): ${error instanceof Error ? error.message : String(error)}`);
397
+ }
398
+ return { absolutePath, raw, parsed };
399
+ }
400
+ async function readJsonFile(filePath, label) {
401
+ const { absolutePath, parsed } = await readJsonValue(filePath, label);
402
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
403
+ throw new CliError("invalid_arguments", `${label} must be a JSON object (${absolutePath}).`);
404
+ }
405
+ return parsed;
406
+ }
407
+ async function readGoalTemplateSpecPatches(filePath) {
408
+ const { absolutePath, raw, parsed } = await readJsonValue(filePath, "Goal-template patch file");
409
+ if (!Array.isArray(parsed) || parsed.length === 0) {
410
+ throw new CliError("invalid_arguments", `Goal-template patch file must be a non-empty JSON array (${absolutePath}).`);
411
+ }
412
+ if (parsed.length > 50) {
413
+ throw new CliError("invalid_arguments", "Goal-template patch files support at most 50 operations.");
414
+ }
415
+ const patches = parsed.map((entry, index) => {
416
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
417
+ throw new CliError("invalid_arguments", `Patch operation ${index} must be an object.`);
418
+ }
419
+ const patch = entry;
420
+ const op = patch.op;
421
+ if (op !== "add" && op !== "copy" && op !== "replace" && op !== "remove") {
422
+ throw new CliError("invalid_arguments", `Patch operation ${index}.op must be add, copy, replace, or remove.`);
423
+ }
424
+ if (typeof patch.path !== "string" || !patch.path.startsWith("/")) {
425
+ throw new CliError("invalid_arguments", `Patch operation ${index}.path must be a JSON Pointer starting with "/".`);
426
+ }
427
+ const hasValue = Object.prototype.hasOwnProperty.call(patch, "value");
428
+ if ((op === "add" || op === "replace") && !hasValue) {
429
+ throw new CliError("invalid_arguments", `Patch operation ${index}.value is required for ${op}.`);
430
+ }
431
+ if ((op === "copy" || op === "remove") && hasValue) {
432
+ throw new CliError("invalid_arguments", `Patch operation ${index}.value is not allowed for ${op}.`);
433
+ }
434
+ if (op === "copy" &&
435
+ (typeof patch.from !== "string" || !patch.from.startsWith("/"))) {
436
+ throw new CliError("invalid_arguments", `Patch operation ${index}.from must be a JSON Pointer starting with "/" for copy.`);
437
+ }
438
+ return {
439
+ op,
440
+ path: patch.path,
441
+ ...(op === "copy" ? { from: patch.from } : {}),
442
+ ...(hasValue ? { value: patch.value } : {}),
443
+ };
444
+ });
445
+ return {
446
+ absolutePath,
447
+ sizeBytes: Buffer.byteLength(raw),
448
+ sha256: createHash("sha256").update(raw).digest("hex"),
449
+ patches,
450
+ };
451
+ }
452
+ function requiredDocString(doc, key) {
453
+ const value = doc[key];
454
+ if (typeof value !== "string" || value.trim().length === 0) {
455
+ throw new CliError("invalid_arguments", `Template file is missing required string "${key}".`);
456
+ }
457
+ return value;
458
+ }
459
+ function optionalDocString(doc, key) {
460
+ const value = doc[key];
461
+ if (value === undefined || value === null)
462
+ return undefined;
463
+ if (typeof value !== "string") {
464
+ throw new CliError("invalid_arguments", `Template file field "${key}" must be a string.`);
465
+ }
466
+ return value;
467
+ }
468
+ /**
469
+ * Coerce an authored template file into the exact `POST /admin/goal-templates/`
470
+ * body. Every unknown key is dropped rather than forwarded, so a stale field
471
+ * copied from an old export cannot ride along into a create, and the shape
472
+ * failures an agent actually makes (missing slug, spec as a string, tags as a
473
+ * comma list) are named locally before any network call.
474
+ *
475
+ * `setupMode` is deliberately absent: the route accepts only
476
+ * DETERMINISTIC_WORKFLOW and a deterministic template can never be converted
477
+ * back, so there is no choice to express.
478
+ */
479
+ function parseGoalTemplateDocument(doc) {
480
+ if (doc.setupMode !== undefined &&
481
+ doc.setupMode !== "DETERMINISTIC_WORKFLOW") {
482
+ throw new CliError("invalid_arguments", `Template file sets setupMode "${String(doc.setupMode)}". New templates are DETERMINISTIC_WORKFLOW only — omit the field.`);
483
+ }
484
+ const spec = doc.setupWorkflowSpec;
485
+ if (spec === null || typeof spec !== "object" || Array.isArray(spec)) {
486
+ 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`.');
487
+ }
488
+ const rawTags = doc.tags;
489
+ let tags = [];
490
+ if (Array.isArray(rawTags)) {
491
+ if (!rawTags.every((tag) => typeof tag === "string")) {
492
+ throw new CliError("invalid_arguments", 'Template file field "tags" must be an array of strings.');
493
+ }
494
+ tags = rawTags;
495
+ }
496
+ else if (typeof rawTags === "string") {
497
+ // A comma list is what a human writes by hand; accept it rather than
498
+ // failing an otherwise-valid template on punctuation.
499
+ tags = rawTags
500
+ .split(",")
501
+ .map((tag) => tag.trim())
502
+ .filter(Boolean);
503
+ }
504
+ else if (rawTags !== undefined && rawTags !== null) {
505
+ throw new CliError("invalid_arguments", 'Template file field "tags" must be an array of strings.');
506
+ }
507
+ const rawSortOrder = doc.sortOrder;
508
+ if (rawSortOrder !== undefined &&
509
+ rawSortOrder !== null &&
510
+ (typeof rawSortOrder !== "number" || !Number.isInteger(rawSortOrder))) {
511
+ throw new CliError("invalid_arguments", 'Template file field "sortOrder" must be an integer.');
512
+ }
513
+ const kind = assertChoice(optionalDocString(doc, "kind") ?? "SIMPLE", GOAL_TEMPLATE_KINDS, "kind");
514
+ const setupAudience = assertChoice(optionalDocString(doc, "setupAudience") ?? "KID_FRIENDLY", GOAL_TEMPLATE_SETUP_AUDIENCES, "setupAudience");
515
+ const starterTierRaw = optionalDocString(doc, "starterTier");
516
+ const parentSetupSpec = doc.parentSetupSpec;
517
+ if (parentSetupSpec !== undefined &&
518
+ parentSetupSpec !== null &&
519
+ (typeof parentSetupSpec !== "object" || Array.isArray(parentSetupSpec))) {
520
+ throw new CliError("invalid_arguments", 'Template file field "parentSetupSpec" must be an object.');
521
+ }
522
+ return {
523
+ slug: requiredDocString(doc, "slug"),
524
+ title: requiredDocString(doc, "title"),
525
+ description: requiredDocString(doc, "description"),
526
+ ...(optionalDocString(doc, "emoji") === undefined
527
+ ? {}
528
+ : { emoji: optionalDocString(doc, "emoji") }),
529
+ ...(optionalDocString(doc, "imageUrl") === undefined
530
+ ? {}
531
+ : { imageUrl: optionalDocString(doc, "imageUrl") }),
532
+ ...(optionalDocString(doc, "category") === undefined
533
+ ? {}
534
+ : { category: optionalDocString(doc, "category") }),
535
+ tags,
536
+ sortOrder: rawSortOrder ?? 0,
537
+ ...(doc.isStarter === undefined
538
+ ? {}
539
+ : { isStarter: Boolean(doc.isStarter) }),
540
+ ...(starterTierRaw === undefined
541
+ ? {}
542
+ : {
543
+ starterTier: assertChoice(starterTierRaw, ["CORE", "EXTRA"], "starterTier"),
544
+ }),
545
+ kind,
546
+ setupAudience,
547
+ setupWorkflowSpec: spec,
548
+ ...(parentSetupSpec === undefined || parentSetupSpec === null
549
+ ? {}
550
+ : { parentSetupSpec: parentSetupSpec }),
551
+ agentInstructions: requiredDocString(doc, "agentInstructions"),
552
+ ...(optionalDocString(doc, "outputTemplate") === undefined
553
+ ? {}
554
+ : { outputTemplate: optionalDocString(doc, "outputTemplate") }),
555
+ };
556
+ }
557
+ /**
558
+ * Resolve a template reference that may be a UUID or a slug. Slugs are what an
559
+ * author types and what the skill's prose uses; the routes take a UUID only.
560
+ */
561
+ async function resolveGoalTemplateId(api, reference) {
562
+ if (UUID_RE.test(reference))
563
+ return reference;
564
+ const list = unwrap(await api.client.GET("/admin/goal-templates/", {
565
+ params: { query: { includeDeleted: "true" } },
566
+ }));
567
+ const match = list.items.find((item) => item.slug === reference);
568
+ if (!match) {
569
+ throw new CliError("invalid_arguments", `No goal template with id or slug "${reference}".`);
570
+ }
571
+ return match.id;
572
+ }
573
+ function requiredExpectedVersion(parsed) {
574
+ const value = flagNumber(parsed, "expected-version");
575
+ if (value === undefined) {
576
+ 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.");
577
+ }
578
+ if (!Number.isInteger(value) || value < 1) {
579
+ throw new CliError("invalid_arguments", "--expected-version must be a positive integer.");
580
+ }
581
+ return value;
582
+ }
266
583
  async function readMapScorePdf(filePath) {
267
584
  const absolutePath = path.resolve(filePath);
268
585
  if (path.extname(absolutePath).toLowerCase() !== ".pdf") {
@@ -407,6 +724,14 @@ function tierSlots(parsed) {
407
724
  export async function runCommand(argv) {
408
725
  const parsed = parseArgs(argv);
409
726
  const [noun, verb] = parsed.positionals;
727
+ // Before the help branch: `--version` parses as a FLAG, so `noun` is
728
+ // undefined and `!noun` would return help instead. (Found by running it.)
729
+ if (noun === "version" || hasFlag(parsed, "version")) {
730
+ return {
731
+ cliVersion: await readCliVersion(),
732
+ skillVersion: await readBundledSkillVersion(),
733
+ };
734
+ }
410
735
  if (!noun || noun === "help" || hasFlag(parsed, "help")) {
411
736
  return { help: HELP };
412
737
  }
@@ -414,7 +739,15 @@ export async function runCommand(argv) {
414
739
  if (noun === "doctor")
415
740
  return doctor(config);
416
741
  if (noun === "setup") {
742
+ // Always lay down the bundled copy first: it is the floor, and it is the
743
+ // only copy guaranteed to match this binary. The served upgrade below is
744
+ // strictly additive on top of it.
417
745
  const skills = await installSkill();
746
+ const skillUpdate = await updateSkillFromServer({
747
+ apiOrigin: config.apiOrigin,
748
+ sessionCookie: config.sessionCookie,
749
+ cliVersion: await readCliVersion(),
750
+ });
418
751
  const ephemeral = isEphemeralInstall();
419
752
  // Reuse a session that is still live; a missing or already-expired one is
420
753
  // worth spending the browser round trip on now rather than at first use.
@@ -430,6 +763,7 @@ export async function runCommand(argv) {
430
763
  });
431
764
  return {
432
765
  skills,
766
+ skillUpdate,
433
767
  session: loggedIn ?? (sessionIsLive ? "existing" : null),
434
768
  ...(ephemeral
435
769
  ? {
@@ -1529,6 +1863,493 @@ export async function runCommand(argv) {
1529
1863
  }
1530
1864
  throw new CliError("invalid_arguments", "Use onboarding status|kids|intake-session|intake-session-create|set-stage|set-account-state|attest|set-intake|extract.");
1531
1865
  }
1866
+ if (noun === "skills") {
1867
+ // Reads only — no gate. The value of this noun is that CLI agents author
1868
+ // against the SAME documents the in-product tutor loads; nothing is copied
1869
+ // into this package, so a skills-repo change reaches agents with no release.
1870
+ const refresh = hasFlag(parsed, "refresh");
1871
+ if (verb === "list") {
1872
+ const query = flagString(parsed, "query");
1873
+ const category = flagString(parsed, "category");
1874
+ const cacheKey = `list:${query ?? ""}:${category ?? ""}`;
1875
+ if (!refresh) {
1876
+ const cached = await readSkillCache(config.apiOrigin, cacheKey);
1877
+ if (cached)
1878
+ return { ...cached, cached: true };
1879
+ }
1880
+ const data = unwrap(await api.client.GET("/admin/skills/", {
1881
+ params: {
1882
+ query: {
1883
+ ...(query ? { query } : {}),
1884
+ ...(category ? { category } : {}),
1885
+ },
1886
+ },
1887
+ }));
1888
+ await writeSkillCache(config.apiOrigin, cacheKey, data);
1889
+ return { ...data, cached: false };
1890
+ }
1891
+ if (verb === "get") {
1892
+ const name = positional(parsed, 2, "skill name");
1893
+ const reference = flagString(parsed, "reference");
1894
+ const allReferences = hasFlag(parsed, "all-references");
1895
+ if (reference && allReferences) {
1896
+ throw new CliError("invalid_arguments", "Pass --reference <name> for one reference or --all-references for the whole tree, not both.");
1897
+ }
1898
+ const cacheKey = `get:${name}:${reference ?? ""}:${allReferences}`;
1899
+ if (!refresh) {
1900
+ const cached = await readSkillCache(config.apiOrigin, cacheKey);
1901
+ if (cached)
1902
+ return { ...cached, cached: true };
1903
+ }
1904
+ const data = unwrap(await api.client.GET("/admin/skills/{name}", {
1905
+ params: {
1906
+ path: { name },
1907
+ query: {
1908
+ ...(reference ? { reference } : {}),
1909
+ ...(allReferences ? { references: "all" } : {}),
1910
+ },
1911
+ },
1912
+ }));
1913
+ await writeSkillCache(config.apiOrigin, cacheKey, data);
1914
+ return { ...data, cached: false };
1915
+ }
1916
+ throw new CliError("invalid_arguments", "Use skills list|get.");
1917
+ }
1918
+ if (noun === "goal-templates") {
1919
+ if (verb === "list") {
1920
+ const query = flagString(parsed, "query")?.toLowerCase();
1921
+ const kind = flagString(parsed, "kind");
1922
+ const data = unwrap(await api.client.GET("/admin/goal-templates/", {
1923
+ params: {
1924
+ query: {
1925
+ ...(flagString(parsed, "category")
1926
+ ? { category: flagString(parsed, "category") }
1927
+ : {}),
1928
+ includeDeleted: hasFlag(parsed, "include-deleted")
1929
+ ? "true"
1930
+ : "false",
1931
+ starterOnly: hasFlag(parsed, "starter-only")
1932
+ ? "true"
1933
+ : "false",
1934
+ },
1935
+ },
1936
+ }));
1937
+ // The route filters by category/starter/deleted only; `--query` and
1938
+ // `--kind` narrow the returned page here rather than pretending the
1939
+ // backend supports them.
1940
+ const wantedKind = kind
1941
+ ? assertChoice(kind, GOAL_TEMPLATE_KINDS, "--kind")
1942
+ : undefined;
1943
+ const items = data.items.filter((item) => {
1944
+ if (wantedKind && item.kind !== wantedKind)
1945
+ return false;
1946
+ if (!query)
1947
+ return true;
1948
+ return [item.title, item.slug, item.description, item.category ?? ""]
1949
+ .join("\n")
1950
+ .toLowerCase()
1951
+ .includes(query);
1952
+ });
1953
+ return { items, totalBeforeFilter: data.items.length };
1954
+ }
1955
+ if (verb === "get") {
1956
+ const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
1957
+ const template = unwrap(await api.client.GET("/admin/goal-templates/{id}", {
1958
+ params: { path: { id } },
1959
+ }));
1960
+ // A full spec is large; `--spec-only` is what an agent pipes into a file
1961
+ // before editing, without the surrounding metadata noise.
1962
+ if (hasFlag(parsed, "spec-only")) {
1963
+ return {
1964
+ id: template.id,
1965
+ slug: template.slug,
1966
+ version: template.version,
1967
+ setupWorkflowSpec: template.setupWorkflowSpec,
1968
+ };
1969
+ }
1970
+ return template;
1971
+ }
1972
+ if (verb === "versions") {
1973
+ const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
1974
+ const data = unwrap(await api.client.GET("/admin/goal-templates/{id}/versions", {
1975
+ params: { path: { id } },
1976
+ }));
1977
+ const wanted = flagNumber(parsed, "version");
1978
+ if (wanted === undefined) {
1979
+ // Version bodies carry the whole frozen spec; the default listing is
1980
+ // metadata so history stays readable, and `--version N` fetches one.
1981
+ return {
1982
+ versions: data.versions.map(({ setupWorkflowSpec, agentInstructions, ...rest }) => ({
1983
+ ...rest,
1984
+ hasSetupWorkflowSpec: setupWorkflowSpec !== null,
1985
+ agentInstructionsChars: agentInstructions.length,
1986
+ })),
1987
+ };
1988
+ }
1989
+ const version = data.versions.find((entry) => entry.version === wanted);
1990
+ if (!version) {
1991
+ throw new CliError("invalid_arguments", `Template has no version ${wanted}.`);
1992
+ }
1993
+ return version;
1994
+ }
1995
+ if (verb === "validate-spec") {
1996
+ const document = parseGoalTemplateDocument(await readJsonFile(flagString(parsed, "file", { required: true }), "Template file"));
1997
+ // A read-only validation: no gate, and iterating on it is the whole point.
1998
+ return {
1999
+ slug: document.slug,
2000
+ kind: document.kind,
2001
+ ...unwrap(await api.client.POST("/admin/goal-templates/validate-spec", {
2002
+ body: {
2003
+ setupWorkflowSpec: document.setupWorkflowSpec,
2004
+ kind: document.kind,
2005
+ },
2006
+ })),
2007
+ };
2008
+ }
2009
+ if (verb === "create") {
2010
+ const filePath = flagString(parsed, "file", { required: true });
2011
+ const document = parseGoalTemplateDocument(await readJsonFile(filePath, "Template file"));
2012
+ // C3 read-only preflight, for the same reason `enrollments create` has
2013
+ // one: the consequences an approver must weigh are resolved SERVER-side.
2014
+ // Which setupHandler runs, what shape of goal students get, and which
2015
+ // answer keys `apply` will demand all come out of the spec's own
2016
+ // validation — a locally guessed preview could name a different handler
2017
+ // than the one that lands. Validation writes nothing.
2018
+ const validation = unwrap(await api.client.POST("/admin/goal-templates/validate-spec", {
2019
+ body: {
2020
+ setupWorkflowSpec: document.setupWorkflowSpec,
2021
+ kind: document.kind,
2022
+ },
2023
+ }));
2024
+ if (!validation.valid) {
2025
+ throw new CliError("invalid_spec", `The setupWorkflowSpec is invalid, so nothing was created: ${validation.error}`, 1, { file: path.resolve(filePath) });
2026
+ }
2027
+ return writeCommand(parsed, {
2028
+ action: "create a NEW global goal template (DETERMINISTIC_WORKFLOW — this cannot be converted back)",
2029
+ target: { slug: document.slug, title: document.title },
2030
+ request: {
2031
+ file: path.resolve(filePath),
2032
+ kind: document.kind,
2033
+ setupAudience: document.setupAudience,
2034
+ category: document.category ?? null,
2035
+ tags: document.tags,
2036
+ isStarter: document.isStarter ?? false,
2037
+ },
2038
+ details: {
2039
+ resolvedSetupHandler: validation.setupHandler,
2040
+ resolvedGoalShape: validation.goalShape,
2041
+ wizardStepKeys: validation.stepKeys,
2042
+ specSha256: validation.sha256,
2043
+ specInventory: validation.totals,
2044
+ note: "Setup mode is DETERMINISTIC_WORKFLOW and is one-way: this template can never be converted back to a chat-driven setup.",
2045
+ },
2046
+ }, async () => unwrap(await api.client.POST("/admin/goal-templates/", {
2047
+ body: document,
2048
+ })));
2049
+ }
2050
+ if (verb === "patch-spec") {
2051
+ const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
2052
+ const expectedVersion = requiredExpectedVersion(parsed);
2053
+ const patchFile = await readGoalTemplateSpecPatches(flagString(parsed, "patches-file", { required: true }));
2054
+ // The server owns JSON Pointer semantics, strict final-spec validation,
2055
+ // protected-inventory counting, and the token binding the approved loss
2056
+ // to this exact version + result hash. Always ask it for a fresh preview,
2057
+ // including on a confirmed run, before permitting the write request.
2058
+ const preflight = unwrap(await api.client.POST("/admin/goal-templates/{id}/setup-workflow-spec/patch", {
2059
+ params: { path: { id } },
2060
+ body: {
2061
+ expectedVersion,
2062
+ patches: patchFile.patches,
2063
+ dryRun: true,
2064
+ },
2065
+ }));
2066
+ if (preflight.action !== "preview_setup_workflow_spec_patch") {
2067
+ throw new CliError("unexpected_response", "The goal-template patch preflight did not return a preview; nothing was changed.");
2068
+ }
2069
+ const preview = {
2070
+ action: preflight.preview.destructiveChanges
2071
+ ? "patch a global goal template setupWorkflowSpec (REMOVES protected template data)"
2072
+ : "patch a global goal template setupWorkflowSpec",
2073
+ target: {
2074
+ templateId: preflight.template.id,
2075
+ slug: preflight.template.slug,
2076
+ title: preflight.template.title,
2077
+ version: preflight.template.version,
2078
+ },
2079
+ request: {
2080
+ expectedVersion,
2081
+ patchesFile: patchFile.absolutePath,
2082
+ patchesFileSizeBytes: patchFile.sizeBytes,
2083
+ patchesFileSha256: patchFile.sha256,
2084
+ patches: patchFile.patches,
2085
+ },
2086
+ details: {
2087
+ patchesApplied: preflight.patchesApplied,
2088
+ safety: preflight.preview,
2089
+ ...(preflight.preview.destructiveChanges
2090
+ ? {
2091
+ 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.",
2092
+ }
2093
+ : {}),
2094
+ },
2095
+ };
2096
+ requireConfirmation(hasFlag(parsed, "confirm"), preview);
2097
+ const destructiveChangeToken = flagString(parsed, "destructive-change-token");
2098
+ if (preflight.preview.destructiveChanges &&
2099
+ (!hasFlag(parsed, "confirm-destructive-changes") ||
2100
+ !destructiveChangeToken ||
2101
+ destructiveChangeToken !== preflight.preview.destructiveChangeToken)) {
2102
+ 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, {
2103
+ preview,
2104
+ requiredFlags: [
2105
+ "--confirm",
2106
+ "--confirm-destructive-changes",
2107
+ "--destructive-change-token",
2108
+ ],
2109
+ expectedDestructiveChangeToken: preflight.preview.destructiveChangeToken,
2110
+ });
2111
+ }
2112
+ return unwrap(await api.client.POST("/admin/goal-templates/{id}/setup-workflow-spec/patch", {
2113
+ params: { path: { id } },
2114
+ body: {
2115
+ expectedVersion,
2116
+ patches: patchFile.patches,
2117
+ dryRun: false,
2118
+ confirmDestructiveChanges: preflight.preview.destructiveChanges,
2119
+ ...(destructiveChangeToken ? { destructiveChangeToken } : {}),
2120
+ },
2121
+ }));
2122
+ }
2123
+ if (verb === "set-metadata") {
2124
+ const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
2125
+ const expectedVersion = requiredExpectedVersion(parsed);
2126
+ const agentInstructionsFile = flagString(parsed, "agent-instructions-file");
2127
+ const tags = flagString(parsed, "tags");
2128
+ const kind = flagString(parsed, "kind");
2129
+ const sortOrder = flagNumber(parsed, "sort-order");
2130
+ const body = {
2131
+ expectedVersion,
2132
+ ...(flagString(parsed, "title")
2133
+ ? { title: flagString(parsed, "title") }
2134
+ : {}),
2135
+ ...(flagString(parsed, "description")
2136
+ ? { description: flagString(parsed, "description") }
2137
+ : {}),
2138
+ ...(flagString(parsed, "emoji")
2139
+ ? { emoji: flagString(parsed, "emoji") }
2140
+ : {}),
2141
+ ...(flagString(parsed, "category")
2142
+ ? { category: flagString(parsed, "category") }
2143
+ : {}),
2144
+ ...(tags
2145
+ ? {
2146
+ tags: tags
2147
+ .split(",")
2148
+ .map((tag) => tag.trim())
2149
+ .filter(Boolean),
2150
+ }
2151
+ : {}),
2152
+ ...(sortOrder === undefined ? {} : { sortOrder }),
2153
+ ...(kind
2154
+ ? { kind: assertChoice(kind, GOAL_TEMPLATE_KINDS, "--kind") }
2155
+ : {}),
2156
+ ...(agentInstructionsFile
2157
+ ? {
2158
+ agentInstructions: await fs.readFile(path.resolve(agentInstructionsFile), "utf8"),
2159
+ }
2160
+ : {}),
2161
+ };
2162
+ // `setupWorkflowSpec` is unreachable from this command by construction.
2163
+ // The route still accepts one, but a wholesale spec replacement is the
2164
+ // shape that caused the template incident; editing an existing spec goes
2165
+ // through the guarded /ai patch path with its destructive-change token.
2166
+ if (Object.keys(body).length === 1) {
2167
+ throw new CliError("invalid_arguments", "Pass at least one field to change (--title, --description, --emoji, --category, --tags, --sort-order, --kind, --agent-instructions-file).");
2168
+ }
2169
+ return writeCommand(parsed, {
2170
+ action: "update goal template metadata (never its setupWorkflowSpec)",
2171
+ target: { templateId: id, expectedVersion },
2172
+ request: body,
2173
+ }, async () => unwrap(await api.client.PUT("/admin/goal-templates/{id}", {
2174
+ params: { path: { id } },
2175
+ body,
2176
+ })));
2177
+ }
2178
+ if (verb === "delete") {
2179
+ const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
2180
+ const expectedVersion = requiredExpectedVersion(parsed);
2181
+ // Read the current version so the gate refuses a stale delete locally,
2182
+ // and so the preview names the template a human is being asked to approve
2183
+ // rather than only its UUID.
2184
+ const current = unwrap(await api.client.GET("/admin/goal-templates/{id}", {
2185
+ params: { path: { id } },
2186
+ }));
2187
+ if (current.version !== expectedVersion) {
2188
+ 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 });
2189
+ }
2190
+ return writeCommand(parsed, {
2191
+ action: "soft-delete a global goal template",
2192
+ target: {
2193
+ templateId: id,
2194
+ slug: current.slug,
2195
+ title: current.title,
2196
+ version: current.version,
2197
+ },
2198
+ request: { expectedVersion },
2199
+ details: {
2200
+ note: "Soft delete: the row keeps its deletedAt and drops out of every list. Existing goals already applied from it are unaffected.",
2201
+ },
2202
+ }, async () => unwrap(await api.client.DELETE("/admin/goal-templates/{id}", {
2203
+ params: { path: { id } },
2204
+ })));
2205
+ }
2206
+ if (verb === "snapshot-files") {
2207
+ const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
2208
+ const filePath = flagString(parsed, "path");
2209
+ if (filePath) {
2210
+ return unwrap(await api.client.GET("/admin/goal-templates/{id}/snapshot/file", {
2211
+ params: { path: { id }, query: { path: filePath } },
2212
+ }));
2213
+ }
2214
+ return unwrap(await api.client.GET("/admin/goal-templates/{id}/snapshot/files", {
2215
+ params: { path: { id } },
2216
+ }));
2217
+ }
2218
+ if (verb === "apply") {
2219
+ const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
2220
+ const answers = await readJsonFile(flagString(parsed, "answers-file", { required: true }), "Answers file");
2221
+ // `--dry-run` is a read: it resolves the whole plan and writes nothing,
2222
+ // so it does not gate. Run it, read the per-student results, then confirm.
2223
+ if (hasFlag(parsed, "dry-run")) {
2224
+ return unwrap(await api.client.POST("/ai/goal-templates/{id}/apply-workflow", {
2225
+ params: { path: { id } },
2226
+ body: { answers, dryRun: true },
2227
+ }));
2228
+ }
2229
+ // The real consequence — which students get which goals, which are
2230
+ // skipped as already-existing, and what coverage is missing — is only
2231
+ // knowable from the backend's own planner. Run its dryRun before the gate
2232
+ // (writes nothing) so the approver sees the actual roster.
2233
+ const preflight = unwrap(await api.client.POST("/ai/goal-templates/{id}/apply-workflow", {
2234
+ params: { path: { id } },
2235
+ body: { answers, dryRun: true },
2236
+ }));
2237
+ const counts = preflight.results.reduce((totals, result) => ({
2238
+ ...totals,
2239
+ [result.action]: (totals[result.action] ?? 0) + 1,
2240
+ }), {});
2241
+ return writeCommand(parsed, {
2242
+ action: "apply a goal template to students (creates goals and todos)",
2243
+ target: { templateId: id, students: preflight.results.length },
2244
+ request: { answers },
2245
+ details: {
2246
+ counts,
2247
+ results: preflight.results.map((result) => ({
2248
+ student: result.studentName,
2249
+ studentUserId: result.studentUserId,
2250
+ action: result.action,
2251
+ goalTitle: result.goalTitle,
2252
+ todoTitle: result.todoTitle,
2253
+ warnings: result.warnings,
2254
+ })),
2255
+ missingCoverage: preflight.missingCoverage,
2256
+ note: "Counts come from the backend's own dry run. `skipped_existing` items are idempotent — re-applying does not duplicate them.",
2257
+ },
2258
+ }, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/apply-workflow", {
2259
+ params: { path: { id } },
2260
+ body: { answers, dryRun: false },
2261
+ })));
2262
+ }
2263
+ if (verb === "apply-starter") {
2264
+ const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
2265
+ const studentUserId = flagString(parsed, "student", { required: true });
2266
+ const answersFile = flagString(parsed, "answers-file");
2267
+ const answers = answersFile
2268
+ ? await readJsonFile(answersFile, "Answers file")
2269
+ : {};
2270
+ // apply-starter has no dryRun — it is the one-tap starter path — so this
2271
+ // preview is offline. It is also flag-gated per actor server-side
2272
+ // (school-onboarding-v1); a flag-off admin gets a 404 rather than a write.
2273
+ return writeCommand(parsed, {
2274
+ action: "apply ONE starter goal template to ONE student (no dry run exists for this route)",
2275
+ target: { templateId: id, studentUserId },
2276
+ request: { answers },
2277
+ details: {
2278
+ 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.",
2279
+ },
2280
+ }, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/apply-starter", {
2281
+ params: { path: { id } },
2282
+ body: { studentUserId, answers },
2283
+ })));
2284
+ }
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.");
2286
+ }
2287
+ if (noun === "goals") {
2288
+ if (verb === "list") {
2289
+ const userId = flagString(parsed, "student", { required: true });
2290
+ return unwrap(await api.client.GET("/admin/browser/students/{userId}/goals/", {
2291
+ params: { path: { userId } },
2292
+ }));
2293
+ }
2294
+ if (verb === "create") {
2295
+ const userId = flagString(parsed, "student", { required: true });
2296
+ const title = flagString(parsed, "title", { required: true });
2297
+ const descriptionFile = flagString(parsed, "description-file");
2298
+ const descriptionFlag = flagString(parsed, "description");
2299
+ if ((descriptionFile && descriptionFlag) ||
2300
+ (!descriptionFile && !descriptionFlag)) {
2301
+ throw new CliError("invalid_arguments", "Pass exactly one of --description <text> or --description-file <path>.");
2302
+ }
2303
+ const description = descriptionFile
2304
+ ? await fs.readFile(path.resolve(descriptionFile), "utf8")
2305
+ : descriptionFlag;
2306
+ const targetDate = flagString(parsed, "target-date");
2307
+ if (targetDate !== undefined && Number.isNaN(Date.parse(targetDate))) {
2308
+ throw new CliError("invalid_arguments", "--target-date must be an ISO 8601 datetime.");
2309
+ }
2310
+ const schedule = flagString(parsed, "schedule");
2311
+ const body = {
2312
+ title,
2313
+ description,
2314
+ ...(targetDate ? { targetDate } : {}),
2315
+ ...(schedule ? { schedule } : {}),
2316
+ };
2317
+ return writeCommand(parsed, {
2318
+ action: "create a goal directly on a kid (visible to them immediately)",
2319
+ target: { studentUserId: userId },
2320
+ request: {
2321
+ title,
2322
+ descriptionChars: description.length,
2323
+ targetDate: targetDate ?? null,
2324
+ schedule: schedule ?? null,
2325
+ },
2326
+ details: {
2327
+ 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.",
2328
+ },
2329
+ }, async () => unwrap(await api.client.POST("/admin/browser/students/{userId}/goals/", {
2330
+ params: { path: { userId } },
2331
+ body,
2332
+ })));
2333
+ }
2334
+ throw new CliError("invalid_arguments", "Use goals list|create.");
2335
+ }
2336
+ if (noun === "mesa" && verb === "files") {
2337
+ const action = positional(parsed, 2, "mesa files action");
2338
+ const studentId = flagString(parsed, "student", { required: true });
2339
+ const goalId = flagString(parsed, "goal", { required: true });
2340
+ if (action === "list") {
2341
+ return unwrap(await api.client.GET("/tutor/students/{studentId}/goals/{goalId}/workspace/files", { params: { path: { studentId, goalId } } }));
2342
+ }
2343
+ if (action === "read") {
2344
+ return unwrap(await api.client.GET("/tutor/students/{studentId}/goals/{goalId}/workspace/file", {
2345
+ params: {
2346
+ path: { studentId, goalId },
2347
+ query: { path: flagString(parsed, "path", { required: true }) },
2348
+ },
2349
+ }));
2350
+ }
2351
+ throw new CliError("invalid_arguments", "Use mesa files list|read.");
2352
+ }
1532
2353
  if (noun === "request" && verb === "get") {
1533
2354
  return api.rawGet(positional(parsed, 2, "request path"));
1534
2355
  }