recess-cli 2.0.0 → 2.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
@@ -3,311 +3,24 @@ import { execFile } from "node:child_process";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { RecessAdminApi, unwrap, withIdempotencyContext } from "./api.js";
6
- import { flagList, flagNumber, flagString, hasFlag, parseArgs, } from "./args.js";
6
+ import { flagNumber, flagString, hasFlag, parseArgs, } from "./args.js";
7
7
  import { login, pollDeviceAuth, requestDeviceAuth } from "./auth.js";
8
8
  import { clearStoredSession, deleteProfile, listProfiles, resolveConfig, saveProfile, useProfile, } from "./config.js";
9
9
  import { agentContext, buildCommandSchema, scopedHelp, validateInvocation, } from "./command-schema.js";
10
+ import { runApplicationsCommand } from "./commands/applications.js";
11
+ import { runOnboardingCommand } from "./commands/onboarding.js";
12
+ import { runSchoolCommand } from "./commands/school.js";
13
+ import { assertChoice, flagIdList, positional, readJsonFile, readJsonValue, } from "./commands/shared.js";
10
14
  import { CliError } from "./errors.js";
11
15
  import { listFeedback, submitFeedback } from "./feedback.js";
16
+ import { HELP } from "./help.js";
17
+ export { HELP };
12
18
  import { cliRequestHeaders, requireCliRequestReason } from "./http.js";
13
19
  import { requireConfirmation } from "./safety.js";
14
20
  import { installSkill, isEphemeralInstall, readBundledSkillVersion, readCliVersion, } from "./setup.js";
15
21
  import { compareVersions, updateSkillFromServer } from "./skill-update.js";
16
22
  import { readSkillCache, writeSkillCache } from "./skills-cache.js";
17
23
  import { appendJobEvent, getJob, listJobs, pruneJobs } from "./jobs.js";
18
- export const HELP = `recess — safe Recess administration and family AI tools
19
-
20
- Usage:
21
- recess [--json] --version
22
- recess [--json] agent-context
23
- recess [--json] setup [--skill-only]
24
- recess [--json] doctor
25
- recess [--json] profile list
26
- recess [--json] profile show <name>
27
- recess [--json] profile save <name> [--api-origin URL] [--web-origin URL]
28
- [--oauth-client-id ID]
29
- recess [--json] profile use <name>
30
- recess [--json] profile delete <name>
31
- recess [--json] jobs list [--limit 20]
32
- recess [--json] jobs get <operation-key>
33
- recess [--json] jobs prune [--older-than-days 30]
34
- recess [--json] feedback list [--limit 20]
35
- recess [--json] feedback submit <text> [--confirm]
36
- recess [--json] auth login [--client-id ID] [--callback-port 8765]
37
- recess [--json] auth request [--label TEXT]
38
- recess [--json] auth poll [--timeout 300]
39
- recess [--json] auth status|logout
40
- recess [--json] users search <name-or-id> [--limit 10]
41
- recess [--json] users get <user-id>
42
- recess [--json] users tier list-tiers
43
- recess [--json] users tier get <kid-id>
44
- recess [--json] users tier preview <kid-id> --tier social|academics|lite|complete|platform
45
- [--slots N]
46
- recess [--json] users tier set <kid-id> --tier social|academics|lite|complete|platform
47
- [--slots N] --expected-updated-at <iso> [--allow-strand] [--confirm]
48
- recess [--json] guides create --email <email> --first-name TEXT
49
- [--last-name TEXT] [--no-invite] [--confirm]
50
- recess [--json] guides invite --user <user-id> [--confirm]
51
- recess [--json] guardians invite --family <family-id> --email <email>
52
- --first-name TEXT [--last-name TEXT] [--no-invite] [--confirm]
53
- recess [--json] students upload-map-scores --student <kid-id>
54
- --file </path/to/map-report.pdf> [--confirm]
55
- recess [--json] students list
56
- recess [--json] students today --student <kid-id> [--date YYYY-MM-DD]
57
- recess [--json] students schedule --student <kid-id> [--days 14]
58
- recess [--json] students xp-history --student <kid-id>
59
- [--range week|month|quarter|year]
60
- recess [--json] enrollments list --user <user-id>
61
- recess [--json] subscriptions list --family <family-id> [--kid <kid-id>]
62
- recess [--json] invoices list --subscription <subscription-id>
63
- recess [--json] billing pause --subscription <id> [--until ISO_DATE] [--confirm]
64
- recess [--json] billing resume --subscription <id> [--confirm]
65
- recess [--json] invoices refund --invoice <id> --line-item <id>
66
- --method refund|credit|tokens [--full | --amount-cents N]
67
- [--who-pays guide|recess] [--reason TEXT] [--confirm]
68
- recess [--json] applications enroll <application-id> --quote <quote-id>
69
- --school <institution-slug> --kid "<quoteLineId>:<firstName>[:<age>]" (repeat per line)
70
- [--family <family-id>] [--unassign <kid-id>] (repeat) [--note TEXT] [--confirm]
71
- recess [--json] cohorts search <query>
72
- recess [--json] enrollments create --user <kid-id> --cohort <id>
73
- [--first-charge-at ISO_DATETIME] [--send-email] [--force]
74
- [--confirm --approval-token TOKEN]
75
- recess [--json] enrollments register-cohort --enrollment <id>
76
- --user <id> --cohort <id> [--confirm]
77
- recess [--json] enrollments unregister-cohort --user <id>
78
- --cohort <id> [--confirm]
79
- recess [--json] enrollments get-for-subscription --subscription <id>
80
- recess [--json] billing extend-trial --subscription <id>
81
- --trial-end ISO_DATE [--confirm]
82
- recess [--json] billing cancel-subscription --subscription <id>
83
- [--immediate] [--reason TEXT] [--restore] [--confirm]
84
- recess [--json] payout payruns list [--status A,B] [--schedule <id>]
85
- recess [--json] payout recipients list [--search <name>] [--user <id>] [--id <id>]
86
- [--limit 20] [--cursor <id>]
87
- recess [--json] payout invoices list [--payrun <id>] [--recipient <account-id>]
88
- [--user <id>] [--status A,B] (at least one filter)
89
- recess [--json] payout invoices get <invoice-id>
90
- recess [--json] payout invoices set-status <invoice-id>
91
- --status IN_REVIEW|OPEN|PAID|CANCELED [--send-email] [--confirm]
92
- recess [--json] payout items add --invoice <id> --amount-cents N
93
- --description TEXT [--date YYYY-MM-DD] [--confirm]
94
- recess [--json] payout items edit <item-id> [--amount-cents N]
95
- [--description TEXT] [--date YYYY-MM-DD] [--confirm]
96
- recess [--json] payout items delete <item-id> [--confirm]
97
- recess [--json] cohorts get <cohort-id> [--events-tab ACTIVE|ENDED|CANCELED|ARCHIVED]
98
- recess [--json] cohorts parent-emails <cohort-id>
99
- recess [--json] cohorts end <cohort-id> [--cancel-subscriptions] [--confirm]
100
- recess [--json] cohorts pause-billing <cohort-id> --weeks 1..6 [--confirm]
101
- recess [--json] cohorts resume-billing <cohort-id> [--confirm]
102
- recess [--json] cohorts email <cohort-id> --target ALL_PARENTS|ALL_PARENTS_GUIDES
103
- --content TEXT [--confirm]
104
- recess [--json] events get <event-id>
105
- recess [--json] events take-attendance <event-id> --attended <id,id,...>
106
- [--absent <id,id,...>] [--excused <id,id,...>] [--confirm]
107
- recess [--json] events cancel <event-id> --reason TEXT [--confirm]
108
- recess [--json] events set-status <event-id> --status ACTIVE|ENDED|CANCELED [--confirm]
109
- recess [--json] events reschedule --cohort <id> --event <event-id>
110
- --starts-at "YYYY-MM-DDTHH:MM" [--timezone <iana>] [--length-mins N] [--confirm]
111
- recess [--json] events add --cohort <id> --starts-at "YYYY-MM-DDTHH:MM"
112
- [--timezone <iana>] [--length-mins N] [--confirm]
113
- recess [--json] registrations approve --registration <id> [--confirm]
114
- recess [--json] registrations deny --cohort <id> --user <id> [--confirm]
115
- recess [--json] request get </path?query=value>
116
- recess [--json] onboarding status <family-id>
117
- recess [--json] onboarding kids [--time-period-days N] [--cohort <id>]
118
- [--limit N] [--stage-filter all|scheduled|oriented|course|converted|lost]
119
- recess [--json] onboarding intake-session <family-id>
120
- recess [--json] onboarding intake-session-create <family-id> [--confirm]
121
- recess [--json] onboarding set-stage <family-id>
122
- --stage LEGACY|PROVISIONED|PARENT_CONFIRMED|CLEARED_FOR_COHORT|COMPLETE [--confirm]
123
- recess [--json] onboarding set-account-state <family-id>
124
- --state ACTIVE|PENDING_PAYMENT|PAUSED|BOOTED [--note TEXT] [--confirm]
125
- recess [--json] onboarding attest <family-id>
126
- --condition app_downloaded|tutor_met|goals_loaded|ma_diagnostic [--revoke]
127
- [--note TEXT] [--confirm]
128
- recess [--json] onboarding set-intake <family-id> --session <id>
129
- --data <json> [--expected-updated-at <iso>] [--confirm]
130
- recess [--json] onboarding extract <family-id> --session <id>
131
- (--transcript-file <path> | --granola <ref>) [--confirm]
132
- recess [--json] village models list [--world village-1] [--query TEXT] [--archived]
133
- recess [--json] village models upload --file </path/model.glb>
134
- [--world village-1] [--name TEXT] [--id ID] [--description TEXT] [--tags A,B]
135
- [--visual-only] [--confirm]
136
- recess [--json] village models publish|archive <model-id> [--world village-1] [--confirm]
137
- recess [--json] village models place <model-id> --x N --z N
138
- [--y N] [--rotation 0..3] [--mirrored] [--no-collision] [--batch ID]
139
- [--world village-1] [--confirm]
140
- recess [--json] village models move <placement-id> --x N --z N
141
- [--y N] [--rotation 0..3] [--mirrored] [--world village-1] [--confirm]
142
- recess [--json] village models remove <placement-id> [--world village-1] [--confirm]
143
- recess [--json] village render --min-x N --min-z N --max-x N --max-z N
144
- [--world village-1]
145
- recess [--json] store-items list [--search TEXT]
146
- [--status ACTIVE|INACTIVE|COMING_SOON] [--item-type TYPE]
147
- [--page 0] [--limit 20] [--sort-by name|price|createdAt|updatedAt|order]
148
- [--sort-order asc|desc]
149
- recess [--json] store-items set-status <village-store-item-id>
150
- --status ACTIVE|INACTIVE|COMING_SOON [--confirm]
151
- recess [--json] content-library search <query> [--limit 8]
152
- recess [--json] content-library status <gem-id-or-url>
153
- recess [--json] content-library set-stage <gem-id-or-url...> [--file <path>]
154
- --stage review|polishing|live|archived [--wait] [--timeout 900] [--confirm]
155
- recess [--json] content-library submit <url...> [--file <path>]
156
- [--stage review|polish] [--title TEXT] [--summary TEXT]
157
- [--lane web-toys|mechanics|explorables|data-stories|sims|maps-scale|sound-art|puzzles|wonder|idea-games]
158
- [--wait] [--timeout 900] [--confirm]
159
- recess [--json] skills guardian list [--query TEXT] [--category TEXT]
160
- recess [--json] skills guardian get <skill-name>
161
- [--reference NAME | --all-references] [--refresh]
162
- recess [--json] skills admin list [--query TEXT] [--category TEXT]
163
- recess [--json] skills admin get <skill-name>
164
- [--reference NAME | --all-references]
165
- [--refresh]
166
- recess [--json] goal-templates list [--query TEXT] [--category TEXT]
167
- [--kind SIMPLE|BLUEPRINT] [--starter-only] [--include-deleted]
168
- [--limit 20] [--cursor <id>]
169
- recess [--json] goal-templates get <template-id|slug> [--spec-only]
170
- recess [--json] goal-templates versions <template-id> [--version N]
171
- recess [--json] goal-templates validate-spec --file <path/template.json>
172
- recess [--json] goal-templates create --file <path/template.json> [--confirm]
173
- recess [--json] goal-templates patch-spec <template-id|slug> --expected-version N
174
- --patches-file <path/patches.json> [--confirm --approval-token TOKEN]
175
- [--confirm-destructive-changes --destructive-change-token TOKEN]
176
- recess [--json] goal-templates set-metadata <template-id> --expected-version N
177
- [--title TEXT] [--description TEXT] [--emoji X] [--category TEXT] [--tags A,B]
178
- [--sort-order N] [--is-starter true|false]
179
- [--setup-audience KID_FRIENDLY|PARENT_SETUP] [--kind SIMPLE|BLUEPRINT]
180
- [--agent-instructions-file <path>]
181
- [--output-template-file <path>] [--confirm]
182
- recess [--json] goal-templates delete <template-id> --expected-version N [--confirm]
183
- recess [--json] goal-templates snapshot-files <template-id> [--path P]
184
- recess [--json] goal-templates capture-snapshot <template-id|slug>
185
- (--source-goal <goal-id> | --source-draft <draft-slug> --student <goal-owner-id>
186
- | --source-dir <local-checkout>)
187
- [--dry-run] [--confirm --approval-token TOKEN]
188
- recess [--json] goal-templates apply <template-id> --answers-file <path>
189
- [--dry-run] [--confirm --approval-token TOKEN]
190
- recess [--json] goal-templates apply-starter <template-id> --student <kid-id>
191
- [--answers-file <path>] [--confirm]
192
- recess [--json] goals list --student <kid-id>
193
- recess [--json] goals create [--student <kid-id>] --title TEXT
194
- (--description TEXT | --description-file <path>) [--target-date <iso>]
195
- [--schedule TEXT] [(--draft <draft-slug> | --source-dir <local-checkout>)
196
- (--enable-applet-follow-ups | --disable-applet-follow-ups)]
197
- [--confirm --approval-token TOKEN]
198
- recess [--json] goals edit <goal-id> --student <kid-id> --patch-file <path/patch.json>
199
- --delta TEXT [--confirm --approval-token TOKEN]
200
- recess [--json] goals delete <goal-id> --student <kid-id>
201
- [--confirm --approval-token TOKEN]
202
- recess [--json] goals queue get <goal-id> --student <kid-id>
203
- recess [--json] goals queue set <goal-id> --student <kid-id>
204
- --entries-file <path.json> --delta TEXT [--replace-description-pointer]
205
- [--confirm --approval-token TOKEN]
206
- recess [--json] todos create --student <kid-id> --title TEXT
207
- [--due-date YYYY-MM-DD] [--estimated-minutes N] [--url URL] [--confirm]
208
- recess [--json] todos edit <todo-id> --patch-file <path/patch.json>
209
- [--confirm --approval-token TOKEN]
210
- recess [--json] todos delete <todo-id>
211
- [--confirm --approval-token TOKEN]
212
- recess [--json] todos generate-applet <todo-id> --student <kid-id>
213
- [--due-date YYYY-MM-DD] [--confirm --approval-token TOKEN]
214
- recess [--json] memories context --student <kid-id>
215
- recess [--json] memories log --student <kid-id> --date YYYY-MM-DD
216
- recess [--json] rocky get --student <kid-id>
217
- recess [--json] rocky set --student <kid-id> --patch-file <path/patch.json>
218
- --expected-updated-at ISO|none [--confirm]
219
- recess [--json] goals files list --student <goal-owner-id> --goal <goal-id>
220
- recess [--json] goals files read --student <goal-owner-id> --goal <goal-id> --path P
221
- recess [--json] goals files init --student <goal-owner-id> --draft <draft-slug>
222
- --output-dir <local-dir>
223
- recess [--json] goals files checkout --student <goal-owner-id>
224
- (--goal <goal-id> | --draft <draft-slug>)
225
- --output-dir <local-dir>
226
- recess [--json] goals files push --source-dir <local-checkout>
227
- [--message TEXT] [--confirm --approval-token TOKEN]
228
- recess [--json] goals files write --student <goal-owner-id>
229
- (--goal <goal-id> | --draft <draft-slug>)
230
- (--source-dir <local-dir> | --source-file <local-file> --path P)
231
- [--message TEXT] [--confirm --approval-token TOKEN]
232
- recess [--json] goals pdf upload --student <goal-owner-id>
233
- (--goal <goal-id> | --draft <draft-slug>) --source-file <local.pdf>
234
- [--path uploads/name.pdf] [--message TEXT]
235
- [--confirm --approval-token TOKEN]
236
-
237
- Authoring notes: "skills" serves the in-product tutor skills (the private
238
- packages/skills workspace package) read-only over your admin session — they are never
239
- bundled into this npm package. Load os-v2-goal-template-builder and its
240
- references/deterministic-workflow-setup.md BEFORE authoring a template; that is
241
- the same guidance the recess.gg/ai agent follows, so there is exactly one
242
- standard. Responses cache under ~/.recess-cli/skills-cache/ (--refresh re-fetches).
243
- For goal workspace and PDF commands, --student names the goal owner. A
244
- full_admin session may use a KID or ADMIN user id, including its own ADMIN id;
245
- family_ai sessions remain limited to managed KID profiles.
246
- "goals create --source-dir" materializes a clean, fully pushed draft checkout as
247
- a personal module-backed goal and retargets that checkout to the resulting live
248
- goal. It does not create or apply a reusable template. Its preview is bound to
249
- the exact workspace revision, hash, module inventory, and applet follow-up choice.
250
- Every template created here is setupMode DETERMINISTIC_WORKFLOW and CANNOT be
251
- converted back, so "validate-spec" against the same file until it passes, then
252
- "create". "create" runs a real server-side validation before its gate, so the
253
- preview shows the handler/goalShape/step keys the SERVER resolved. "apply" runs
254
- the backend's own dryRun before the gate and previews the per-student outcome.
255
- "set-metadata" and "delete" require --expected-version (from "get"); a stale one
256
- 409s STALE_WRITE and writes nothing. The spec is unreachable from "set-metadata"
257
- by design — an existing spec is edited only through the guarded /ai patch path.
258
-
259
- Onboarding notes: "status" and "intake-session" are reads — "intake-session"
260
- looks up the current IN_PROGRESS session without creating one (prints a "none
261
- yet" result when absent). "intake-session-create" is the explicit write that
262
- mints a blank session, so it is gated behind --confirm. "set-stage" reads the
263
- current stage first and warns when a move re-locks progress; CLEARED_FOR_COHORT
264
- unlocks cohort registration. "set-account-state" PAUSED/BOOTED lock the family out of paid
265
- capabilities (--note is analytics-only). "set-intake --data" takes JSON (agents
266
- drive it with --json; humans rarely will); the preview is offline. Its
267
- optimistic-concurrency token resolves only on --confirm: pass
268
- --expected-updated-at <iso> (from an intake-session read's updatedAt) for strict
269
- CAS that 409s if a parent autosaved since; omit it and the CLI fetches the
270
- current token at confirm time and warns that post-preview edits are unprotected.
271
- "extract" is LLM-bound and can take ~30s; a 503 means Granola is not configured
272
- server-side.
273
-
274
- Class-ops notes: "events cancel" notifies families (chat + parent email blast +
275
- credit notes + Slack); "events set-status --status CANCELED" is a silent status
276
- change. Reschedule times are cohort-local wall-clock (zoneless).
277
-
278
- Payout notes: amounts are integer cents. "payout items add" without --date
279
- defaults the item date to the penultimate day of the invoice's cycle (its
280
- endDate minus one day) and shows the computed date in the preview.
281
-
282
- Auth notes: "auth login" runs the browser loopback flow for ADMIN or a GUARDIAN with
283
- access:ai. Guardian sessions are family-scoped and cannot call /admin. For a headless
284
- cloud agent, the ADMIN-only "auth request" prints an approval URL to hand a Recess admin; after they
285
- approve it in a browser, "auth poll" collects the 12h session. When it lapses, run
286
- "auth request" again for a fresh link. Both paths yield the same session.
287
-
288
- Skill notes: this CLI's own agent skill ships inside the npm package AND is served
289
- by the server, so wording/Gotcha updates arrive without an npm release. "setup"
290
- (or "setup --skill-only") installs the bundled copy, then upgrades it from the
291
- server when the served bundle's minCliVersion allows — an older binary keeps the
292
- bundled copy, and an unreachable server is not an error. "doctor" reports whether
293
- a newer skill exists and names the command; it never writes.
294
-
295
- Writes preview and exit 2 unless --confirm is supplied after explicit human approval.
296
- Every preview includes an operationKey; confirmed writes must echo it with
297
- --operation-key so an interrupted invocation can be retried without duplicating the write.
298
- Environment overrides: RECESS_CLI_API_ORIGIN, RECESS_CLI_WEB_ORIGIN,
299
- RECESS_CLI_OAUTH_CLIENT_ID, RECESS_CLI_COOKIE, RECESS_CLI_CONFIG,
300
- RECESS_CLI_PROFILE, RECESS_CLI_FEEDBACK_ENDPOINT.
301
-
302
- Every command that calls the Recess API, except auth commands, requires
303
- --reason TEXT: a non-empty human-readable purpose of at most 1024 characters.`;
304
- function positional(parsed, index, label) {
305
- const value = parsed.positionals[index];
306
- if (!value) {
307
- throw new CliError("invalid_arguments", `Missing ${label}.`);
308
- }
309
- return value;
310
- }
311
24
  function requiredRequestReason(parsed) {
312
25
  return requireCliRequestReason(flagString(parsed, "reason", { required: true }));
313
26
  }
@@ -999,12 +712,6 @@ async function readGoalWorkspaceWriteSource(parsed) {
999
712
  }
1000
713
  return { files, source, sizeBytes, sha256: hash.digest("hex") };
1001
714
  }
1002
- function assertChoice(value, choices, label) {
1003
- if (!choices.includes(value)) {
1004
- throw new CliError("invalid_arguments", `${label} must be one of: ${choices.join(", ")}.`);
1005
- }
1006
- return value;
1007
- }
1008
715
  const PAYRUN_STATUSES = [
1009
716
  "UPCOMING",
1010
717
  "DRAFT",
@@ -1028,36 +735,6 @@ const PAYOUT_STATUS_SIDE_EFFECTS = {
1028
735
  PAID: "marks the invoice paid and deducts its total from the recipient's account balance",
1029
736
  CANCELED: "cancels the invoice and reverses any balance items carried on it",
1030
737
  };
1031
- // Ascending onboarding stage order (mirrors STAGE_ORDER in
1032
- // apps/web-server/src/libs/onboarding-stage.ts); a lower index is earlier
1033
- // progress, so a target below the current stage is a backward move.
1034
- const ONBOARDING_STAGES = [
1035
- "LEGACY",
1036
- "PROVISIONED",
1037
- "PARENT_CONFIRMED",
1038
- "CLEARED_FOR_COHORT",
1039
- "COMPLETE",
1040
- ];
1041
- // What each stage unlocks/implies, surfaced in the set-stage preview.
1042
- const ONBOARDING_STAGE_SIDE_EFFECTS = {
1043
- LEGACY: "the pre-onboarding baseline",
1044
- PROVISIONED: "kid accounts provisioned, awaiting parent confirmation",
1045
- PARENT_CONFIRMED: "parent has confirmed setup",
1046
- CLEARED_FOR_COHORT: "unlocks cohort registration for school families",
1047
- COMPLETE: "onboarding finished",
1048
- };
1049
- const ONBOARDING_ACCOUNT_STATES = [
1050
- "ACTIVE",
1051
- "PENDING_PAYMENT",
1052
- "PAUSED",
1053
- "BOOTED",
1054
- ];
1055
- const ONBOARDING_CONDITIONS = [
1056
- "app_downloaded",
1057
- "tutor_met",
1058
- "goals_loaded",
1059
- "ma_diagnostic",
1060
- ];
1061
738
  const SCHOOL_TIER_OPTIONS = [
1062
739
  { id: "social", name: "Social", defaultClassSlots: 2 },
1063
740
  { id: "academics", name: "Academics", defaultClassSlots: 0 },
@@ -1110,41 +787,6 @@ const CONTENT_LIBRARY_DISCOVERY_LANES = [
1110
787
  "idea-games",
1111
788
  ];
1112
789
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1113
- /**
1114
- * Read and parse a JSON file supplied by an authoring agent. Large payloads —
1115
- * a setupWorkflowSpec, an answers map, a goal description — go through files
1116
- * rather than argv: a shell mangles embedded quotes and newlines, and a spec
1117
- * that survived a round trip through `--json '<...>'` is not the spec that was
1118
- * reviewed.
1119
- */
1120
- async function readJsonValue(filePath, label) {
1121
- const absolutePath = path.resolve(filePath);
1122
- let raw;
1123
- try {
1124
- raw = await fs.readFile(absolutePath, "utf8");
1125
- }
1126
- catch (error) {
1127
- if (error.code === "ENOENT") {
1128
- throw new CliError("invalid_arguments", `${label} does not exist: ${absolutePath}`);
1129
- }
1130
- throw error;
1131
- }
1132
- let parsed;
1133
- try {
1134
- parsed = JSON.parse(raw);
1135
- }
1136
- catch (error) {
1137
- throw new CliError("invalid_arguments", `${label} is not valid JSON (${absolutePath}): ${error instanceof Error ? error.message : String(error)}`);
1138
- }
1139
- return { absolutePath, raw, parsed };
1140
- }
1141
- async function readJsonFile(filePath, label) {
1142
- const { absolutePath, parsed } = await readJsonValue(filePath, label);
1143
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
1144
- throw new CliError("invalid_arguments", `${label} must be a JSON object (${absolutePath}).`);
1145
- }
1146
- return parsed;
1147
- }
1148
790
  function parseGoalQueueEntries(value, label) {
1149
791
  if (!Array.isArray(value)) {
1150
792
  throw new CliError("invalid_arguments", `${label} must be a JSON array of queue entries.`);
@@ -1515,6 +1157,14 @@ function parseGoalTemplateDocument(doc) {
1515
1157
  (typeof rawSortOrder !== "number" || !Number.isInteger(rawSortOrder))) {
1516
1158
  throw new CliError("invalid_arguments", 'Template file field "sortOrder" must be an integer.');
1517
1159
  }
1160
+ const rawCoinAmount = doc.coinAmount;
1161
+ if (rawCoinAmount !== undefined &&
1162
+ rawCoinAmount !== null &&
1163
+ (typeof rawCoinAmount !== "number" ||
1164
+ !Number.isInteger(rawCoinAmount) ||
1165
+ rawCoinAmount < 1)) {
1166
+ throw new CliError("invalid_arguments", 'Template file field "coinAmount" must be a positive integer.');
1167
+ }
1518
1168
  const kind = assertChoice(optionalDocString(doc, "kind") ?? "SIMPLE", GOAL_TEMPLATE_KINDS, "kind");
1519
1169
  const setupAudience = assertChoice(optionalDocString(doc, "setupAudience") ?? "KID_FRIENDLY", GOAL_TEMPLATE_SETUP_AUDIENCES, "setupAudience");
1520
1170
  const starterTierRaw = optionalDocString(doc, "starterTier");
@@ -1534,6 +1184,9 @@ function parseGoalTemplateDocument(doc) {
1534
1184
  ...(optionalDocString(doc, "imageUrl") === undefined
1535
1185
  ? {}
1536
1186
  : { imageUrl: optionalDocString(doc, "imageUrl") }),
1187
+ ...(rawCoinAmount === undefined || rawCoinAmount === null
1188
+ ? {}
1189
+ : { coinAmount: rawCoinAmount }),
1537
1190
  ...(optionalDocString(doc, "category") === undefined
1538
1191
  ? {}
1539
1192
  : { category: optionalDocString(doc, "category") }),
@@ -1630,34 +1283,6 @@ function flagStatusList(parsed, name, choices) {
1630
1283
  }
1631
1284
  return values.map((value) => assertChoice(value, choices, `--${name}`));
1632
1285
  }
1633
- /**
1634
- * Parse one `--kid` spec into an enroll roster entry.
1635
- *
1636
- * Shape: `<quoteLineId>:<firstName>[:<age>]` — the quote line FIRST, because
1637
- * the line id is the identity the enrollment matches on and the name is only a
1638
- * label. A staffer typing these reads them off the quote, in order.
1639
- *
1640
- * ⚠️ THE NAME MAY CONTAIN NOTHING SURPRISING, but an age must be a real number:
1641
- * age decides `birthdate` at creation AND rides in the frozen payment snapshot
1642
- * that a later takeover is compared against, so a silently-dropped or
1643
- * mistyped age is a money-adjacent error, not a cosmetic one. Absent is fine
1644
- * (the server treats it as "not stated"); unparseable is refused here.
1645
- */
1646
- function parseEnrollKidSpec(raw) {
1647
- const parts = raw.split(":").map((part) => part.trim());
1648
- const [quoteLineId, firstName, ageRaw, ...extra] = parts;
1649
- if (!quoteLineId || !firstName || extra.length > 0) {
1650
- throw new CliError("invalid_arguments", `--kid must be "<quoteLineId>:<firstName>[:<age>]"; got "${raw}".`);
1651
- }
1652
- if (ageRaw === undefined || ageRaw === "") {
1653
- return { quoteLineId, firstName };
1654
- }
1655
- const age = Number(ageRaw);
1656
- if (!Number.isInteger(age) || age < 1 || age > 25) {
1657
- throw new CliError("invalid_arguments", `--kid age must be a whole number from 1 to 25; got "${ageRaw}".`);
1658
- }
1659
- return { quoteLineId, firstName, age };
1660
- }
1661
1286
  function flagCents(parsed, name, options = {}) {
1662
1287
  const value = flagNumber(parsed, name);
1663
1288
  if (value === undefined) {
@@ -1753,19 +1378,6 @@ function flagLocalDateTime(parsed, name, options = {}) {
1753
1378
  }
1754
1379
  return match[2] ? raw : `${raw}:00`;
1755
1380
  }
1756
- function flagIdList(parsed, name) {
1757
- const raw = flagString(parsed, name);
1758
- if (raw === undefined)
1759
- return [];
1760
- const ids = raw
1761
- .split(",")
1762
- .map((value) => value.trim())
1763
- .filter(Boolean);
1764
- if (ids.length === 0) {
1765
- throw new CliError("invalid_arguments", `--${name} requires a value.`);
1766
- }
1767
- return ids;
1768
- }
1769
1381
  export function penultimateCycleDayMs(cycleEndDate) {
1770
1382
  const endMs = Date.parse(cycleEndDate);
1771
1383
  if (!Number.isFinite(endMs)) {
@@ -2041,17 +1653,180 @@ export async function runCommand(argv) {
2041
1653
  return value;
2042
1654
  };
2043
1655
  if (verb === "render") {
1656
+ const renderWorldId = flagString(parsed, "world") ??
1657
+ (config.user && config.user.role !== "ADMIN"
1658
+ ? `home-${config.user.id}`
1659
+ : targetWorldId);
2044
1660
  const params = new URLSearchParams({
2045
- worldId: targetWorldId,
2046
1661
  minX: String(numberFlag("min-x")),
2047
1662
  minZ: String(numberFlag("min-z")),
2048
1663
  maxX: String(numberFlag("max-x")),
2049
1664
  maxZ: String(numberFlag("max-z")),
2050
1665
  });
2051
- return api.villageRequest(`/admin/village/render?${params}`);
1666
+ return api.villageRead(`/api/cli/worlds/${encodeURIComponent(renderWorldId)}/render?${params}`);
1667
+ }
1668
+ const directWorldId = flagString(parsed, "world") ??
1669
+ (config.user?.id ? `home-${config.user.id}` : targetWorldId);
1670
+ if (verb === "library") {
1671
+ const action = positional(parsed, 2, "Village library action");
1672
+ const base = `/api/cli/worlds/${encodeURIComponent(directWorldId)}/library`;
1673
+ if (action === "search") {
1674
+ const limit = flagNumber(parsed, "limit") ?? 20;
1675
+ if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
1676
+ throw new CliError("invalid_arguments", "--limit must be an integer from 1 through 50.");
1677
+ }
1678
+ const query = flagString(parsed, "query") ??
1679
+ parsed.positionals.slice(3).join(" ").trim();
1680
+ const params = new URLSearchParams({ limit: String(limit) });
1681
+ if (query)
1682
+ params.set("q", query);
1683
+ return api.villageRead(`${base}?${params}`);
1684
+ }
1685
+ if (action === "get") {
1686
+ const modelId = positional(parsed, 3, "model ID");
1687
+ return api.villageRead(`${base}/${encodeURIComponent(modelId)}`);
1688
+ }
1689
+ throw new CliError("invalid_arguments", "Use village library search|get.");
1690
+ }
1691
+ if (verb === "objects") {
1692
+ const action = positional(parsed, 2, "Village objects action");
1693
+ const base = `/api/cli/worlds/${encodeURIComponent(directWorldId)}/objects`;
1694
+ if (action === "list") {
1695
+ const region = {
1696
+ minX: numberFlag("min-x", -20),
1697
+ minZ: numberFlag("min-z", -20),
1698
+ maxX: numberFlag("max-x", 20),
1699
+ maxZ: numberFlag("max-z", 20),
1700
+ };
1701
+ if (region.minX > region.maxX ||
1702
+ region.minZ > region.maxZ ||
1703
+ region.maxX - region.minX > 128 ||
1704
+ region.maxZ - region.minZ > 128) {
1705
+ throw new CliError("invalid_arguments", "Object bounds must be ordered and no wider than 128m per side.");
1706
+ }
1707
+ const params = new URLSearchParams(Object.fromEntries(Object.entries(region).map(([key, value]) => [key, String(value)])));
1708
+ return api.villageRead(`${base}?${params}`);
1709
+ }
1710
+ if (action === "get") {
1711
+ const objectId = positional(parsed, 3, "object ID");
1712
+ return api.villageRead(`${base}/${encodeURIComponent(objectId)}`);
1713
+ }
1714
+ throw new CliError("invalid_arguments", "Use village objects list|get.");
1715
+ }
1716
+ if (verb === "build") {
1717
+ const action = positional(parsed, 2, "Village build action");
1718
+ if (action !== "cmd") {
1719
+ throw new CliError("invalid_arguments", "Use village build cmd '<command-json>' or --file <command.json>.");
1720
+ }
1721
+ const commandFile = flagString(parsed, "file");
1722
+ const inlineCommand = parsed.positionals[3];
1723
+ if (Boolean(commandFile) === Boolean(inlineCommand)) {
1724
+ throw new CliError("invalid_arguments", "Pass exactly one command JSON argument or --file <command.json>.");
1725
+ }
1726
+ let command;
1727
+ let source;
1728
+ if (commandFile) {
1729
+ const value = await readJsonValue(commandFile, "Village command file");
1730
+ if (value.parsed === null ||
1731
+ typeof value.parsed !== "object" ||
1732
+ Array.isArray(value.parsed)) {
1733
+ throw new CliError("invalid_arguments", "Village command file must contain one JSON object.");
1734
+ }
1735
+ command = value.parsed;
1736
+ source = {
1737
+ file: value.absolutePath,
1738
+ sha256: createHash("sha256").update(value.raw).digest("hex"),
1739
+ };
1740
+ }
1741
+ else {
1742
+ try {
1743
+ const value = JSON.parse(inlineCommand);
1744
+ if (value === null ||
1745
+ typeof value !== "object" ||
1746
+ Array.isArray(value)) {
1747
+ throw new Error("command must be an object");
1748
+ }
1749
+ command = value;
1750
+ }
1751
+ catch (error) {
1752
+ throw new CliError("invalid_arguments", `Village command is not a JSON object: ${error instanceof Error ? error.message : String(error)}`);
1753
+ }
1754
+ source = { inline: true };
1755
+ }
1756
+ const buildWorldId = flagString(parsed, "world") ??
1757
+ (config.user?.id ? `home-${config.user.id}` : undefined);
1758
+ if (!buildWorldId) {
1759
+ throw new CliError("invalid_arguments", "Missing --world. Login normally to default to your home world, or pass the exact world ID.");
1760
+ }
1761
+ return writeCommand(parsed, {
1762
+ action: "run one authenticated Village build command",
1763
+ target: { worldId: buildWorldId },
1764
+ request: { command, ...source },
1765
+ details: {
1766
+ consequence: "The Village server applies the same ownership, block-limit, and broadcast rules as an in-world wrench edit.",
1767
+ },
1768
+ }, () => api.villageCommand(buildWorldId, command));
1769
+ }
1770
+ if (verb === "worlds") {
1771
+ const action = positional(parsed, 2, "Village world action");
1772
+ const worldId = positional(parsed, 3, "world ID");
1773
+ const bridgePath = `/admin/village/worlds/${encodeURIComponent(worldId)}`;
1774
+ if (action === "export") {
1775
+ const output = flagString(parsed, "out");
1776
+ if (!output) {
1777
+ return api.villageRequest(`${bridgePath}/export`);
1778
+ }
1779
+ const absolutePath = path.resolve(output);
1780
+ return writeCommand(parsed, {
1781
+ action: "export a Village world bundle to a local file",
1782
+ target: { worldId, file: absolutePath },
1783
+ request: { overwrite: true },
1784
+ }, async () => {
1785
+ const bundle = await api.villageRequest(`${bridgePath}/export`);
1786
+ const contents = `${JSON.stringify(bundle, null, 2)}\n`;
1787
+ await fs.writeFile(absolutePath, contents, "utf8");
1788
+ return {
1789
+ worldId,
1790
+ file: absolutePath,
1791
+ sizeBytes: Buffer.byteLength(contents),
1792
+ };
1793
+ });
1794
+ }
1795
+ if (action === "import") {
1796
+ const value = await readJsonValue(flagString(parsed, "file", { required: true }), "Village world bundle");
1797
+ return writeCommand(parsed, {
1798
+ action: "replace a Village world from an imported bundle",
1799
+ target: { worldId },
1800
+ request: {
1801
+ file: value.absolutePath,
1802
+ sizeBytes: Buffer.byteLength(value.raw),
1803
+ sha256: createHash("sha256").update(value.raw).digest("hex"),
1804
+ },
1805
+ details: {
1806
+ consequence: "The imported rows replace this world's current data and broadcast a live world reset.",
1807
+ },
1808
+ }, () => api.villageRequest(`${bridgePath}/import`, {
1809
+ method: "POST",
1810
+ body: { bundle: value.parsed, confirm: "import" },
1811
+ }));
1812
+ }
1813
+ if (action === "promote") {
1814
+ return writeCommand(parsed, {
1815
+ action: "promote a Village mirror or archive into the live hub",
1816
+ target: { worldId },
1817
+ request: { confirm: "promote" },
1818
+ details: {
1819
+ consequence: "The current hub is archived, this world replaces it atomically, and connected players receive a live world reset.",
1820
+ },
1821
+ }, () => api.villageRequest(`${bridgePath}/promote`, {
1822
+ method: "POST",
1823
+ body: { confirm: "promote" },
1824
+ }));
1825
+ }
1826
+ throw new CliError("invalid_arguments", "Use village worlds export|import|promote.");
2052
1827
  }
2053
1828
  if (verb !== "models") {
2054
- throw new CliError("invalid_arguments", "Use village models or village render …");
1829
+ throw new CliError("invalid_arguments", "Use village build …, village library …, village objects …, village worlds …, village models …, or village render …");
2055
1830
  }
2056
1831
  const action = positional(parsed, 2, "Village model action");
2057
1832
  if (action === "list") {
@@ -2482,47 +2257,11 @@ export async function runCommand(argv) {
2482
2257
  params: { query: { subscriptionId } },
2483
2258
  }));
2484
2259
  }
2485
- if (noun === "applications" && verb === "enroll") {
2486
- const applicationId = positional(parsed, 2, "application ID");
2487
- const quoteId = flagString(parsed, "quote", { required: true });
2488
- const institutionSlug = flagString(parsed, "school", { required: true });
2489
- const familyId = flagString(parsed, "family");
2490
- const note = flagString(parsed, "note");
2491
- // Repeatable --kid, one per PRICED LINE on the quote. The server refuses a
2492
- // partial roster (every priced student must be enrolled), so this is
2493
- // deliberately not a convenience list — it is the whole quote, echoed back.
2494
- const kidSpecs = flagList(parsed, "kid");
2495
- if (kidSpecs.length === 0) {
2496
- throw new CliError("invalid_arguments", 'Missing --kid. Pass one per quote line: --kid "<quoteLineId>:<firstName>[:<age>]".');
2497
- }
2498
- const kids = kidSpecs.map(parseEnrollKidSpec);
2499
- // Every OTHER live child in the family must be named explicitly. The server
2500
- // refuses the whole enrollment otherwise, listing who was unlisted — so the
2501
- // failure is legible either way, but naming them here is how a staffer says
2502
- // "yes, I know, they are not enrolling".
2503
- const dispositions = flagList(parsed, "unassign").map((kidUserId) => ({
2504
- kidUserId,
2505
- action: "unassigned",
2506
- }));
2507
- return writeCommand(parsed, {
2508
- // One short clause, like every other preview in this file. The
2509
- // consequences are enumerated in `request` below, which is what the
2510
- // confirmation prompt prints in full — restating them here would make
2511
- // this the only preview a staffer has to read twice.
2512
- action: "enroll an application from its accepted quote (creates children, charges the first month, cancels marketplace subscriptions)",
2513
- target: { applicationId, quoteId, institutionSlug, familyId },
2514
- request: { kids, dispositions, note },
2515
- }, async () => unwrap(await api.client.POST("/admin/applications/{applicationId}/enroll", {
2516
- params: { path: { applicationId } },
2517
- body: {
2518
- quoteId,
2519
- institutionSlug,
2520
- kids,
2521
- ...(familyId ? { familyId } : {}),
2522
- ...(dispositions.length > 0 ? { dispositions } : {}),
2523
- ...(note ? { note } : {}),
2524
- },
2525
- })));
2260
+ if (noun === "applications" || noun === "quotes") {
2261
+ return runApplicationsCommand({ parsed, api, writeCommand });
2262
+ }
2263
+ if (noun === "school") {
2264
+ return runSchoolCommand({ parsed, api, writeCommand });
2526
2265
  }
2527
2266
  if (noun === "cohorts" && verb === "search") {
2528
2267
  const search = parsed.positionals.slice(2).join(" ").trim();
@@ -3103,215 +2842,7 @@ export async function runCommand(argv) {
3103
2842
  }, async () => unwrap(await api.client.POST("/admin/cohorts/unregister/", { body })));
3104
2843
  }
3105
2844
  if (noun === "onboarding") {
3106
- if (verb === "status") {
3107
- const familyId = positional(parsed, 2, "family ID");
3108
- return unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/status", {
3109
- params: { path: { familyId } },
3110
- }));
3111
- }
3112
- if (verb === "kids") {
3113
- const timePeriodDays = flagNumber(parsed, "time-period-days");
3114
- const cohortId = flagString(parsed, "cohort");
3115
- const limit = flagNumber(parsed, "limit");
3116
- const stageFilterRaw = flagString(parsed, "stage-filter");
3117
- const stageFilter = stageFilterRaw
3118
- ? assertChoice(stageFilterRaw, ["all", "scheduled", "oriented", "course", "converted", "lost"], "--stage-filter")
3119
- : undefined;
3120
- return unwrap(await api.client.GET("/admin/onboarding/kids/", {
3121
- params: {
3122
- query: {
3123
- ...(timePeriodDays !== undefined ? { timePeriodDays } : {}),
3124
- ...(cohortId ? { cohortId } : {}),
3125
- ...(limit !== undefined ? { limit } : {}),
3126
- ...(stageFilter ? { stageFilter } : {}),
3127
- },
3128
- },
3129
- }));
3130
- }
3131
- if (verb === "intake-session") {
3132
- const familyId = positional(parsed, 2, "family ID");
3133
- // A true read: look up the family's current IN_PROGRESS intake session
3134
- // WITHOUT minting one (get.family-intake-session.ts). Merely viewing a
3135
- // family must not create a blank session — use intake-session-create to
3136
- // mint one. A 404 means "none yet" and is a clean result, not an error.
3137
- try {
3138
- return await api.rawGet(`/admin/onboarding/families/${encodeURIComponent(familyId)}/intake-session`);
3139
- }
3140
- catch (error) {
3141
- if (error instanceof CliError &&
3142
- typeof error.details === "object" &&
3143
- error.details !== null &&
3144
- error.details.status === 404) {
3145
- return {
3146
- intakeSession: null,
3147
- familyId,
3148
- message: "No intake session for this family yet.",
3149
- };
3150
- }
3151
- throw error;
3152
- }
3153
- }
3154
- if (verb === "intake-session-create") {
3155
- const familyId = positional(parsed, 2, "family ID");
3156
- // The explicit create: get-or-create the intake session. Mints a blank
3157
- // IN_PROGRESS session when none exists (post.family-intake-session.ts,
3158
- // freshIfFinished), so it is a write and goes through the confirmation
3159
- // gate like every other mutating command.
3160
- return writeCommand(parsed, {
3161
- action: "create the family's parent intake session (mints a blank IN_PROGRESS session if none exists)",
3162
- target: { familyId },
3163
- request: {},
3164
- }, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/intake-session", { params: { path: { familyId } } })));
3165
- }
3166
- if (verb === "set-stage") {
3167
- const familyId = positional(parsed, 2, "family ID");
3168
- const stage = assertChoice(flagString(parsed, "stage", { required: true }), ONBOARDING_STAGES, "--stage");
3169
- // Read the current stage first so the preview can name the transition and
3170
- // warn when it moves progress backward (re-locks capabilities).
3171
- const current = unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/status", {
3172
- params: { path: { familyId } },
3173
- }));
3174
- const from = current.onboardingStage;
3175
- const backward = ONBOARDING_STAGES.indexOf(stage) < ONBOARDING_STAGES.indexOf(from);
3176
- const body = { stage };
3177
- return writeCommand(parsed, {
3178
- action: `${backward ? "MOVE BACKWARD — re-locks progress: " : ""}set onboarding stage to ${stage} — ${ONBOARDING_STAGE_SIDE_EFFECTS[stage]}`,
3179
- target: { familyId, from },
3180
- request: body,
3181
- }, async () => unwrap(await api.client.PATCH("/admin/onboarding/families/{familyId}/stage", { params: { path: { familyId } }, body })));
3182
- }
3183
- if (verb === "set-account-state") {
3184
- const familyId = positional(parsed, 2, "family ID");
3185
- const state = assertChoice(flagString(parsed, "state", { required: true }), ONBOARDING_ACCOUNT_STATES, "--state");
3186
- const note = flagString(parsed, "note");
3187
- const lockout = state === "PAUSED" || state === "BOOTED"
3188
- ? " — locks the family out of paid capabilities"
3189
- : "";
3190
- const body = { state, ...(note !== undefined ? { note } : {}) };
3191
- return writeCommand(parsed, {
3192
- action: `set account state to ${state}${lockout}`,
3193
- target: { familyId },
3194
- request: body,
3195
- }, async () => unwrap(await api.client.PATCH("/admin/onboarding/families/{familyId}/account-state", { params: { path: { familyId } }, body })));
3196
- }
3197
- if (verb === "attest") {
3198
- const familyId = positional(parsed, 2, "family ID");
3199
- const condition = assertChoice(flagString(parsed, "condition", { required: true }), ONBOARDING_CONDITIONS, "--condition");
3200
- const revoke = hasFlag(parsed, "revoke");
3201
- const note = flagString(parsed, "note");
3202
- const body = {
3203
- condition,
3204
- attested: !revoke,
3205
- ...(note !== undefined ? { note } : {}),
3206
- };
3207
- return writeCommand(parsed, {
3208
- action: revoke
3209
- ? `REVOKE attestation ${condition}`
3210
- : `attest ${condition}`,
3211
- target: { familyId },
3212
- request: body,
3213
- }, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/attest", { params: { path: { familyId } }, body })));
3214
- }
3215
- if (verb === "set-intake") {
3216
- const familyId = positional(parsed, 2, "family ID");
3217
- const sessionId = flagString(parsed, "session", { required: true });
3218
- const dataRaw = flagString(parsed, "data", { required: true });
3219
- const expectedUpdatedAt = flagString(parsed, "expected-updated-at");
3220
- let collectedData;
3221
- try {
3222
- collectedData = JSON.parse(dataRaw);
3223
- }
3224
- catch {
3225
- throw new CliError("invalid_arguments", "--data must be valid JSON for the intake collectedData object.");
3226
- }
3227
- if (expectedUpdatedAt !== undefined &&
3228
- Number.isNaN(Date.parse(expectedUpdatedAt))) {
3229
- throw new CliError("invalid_arguments", "--expected-updated-at must be an ISO-8601 timestamp (the intake-session read's updatedAt).");
3230
- }
3231
- const collected = collectedData;
3232
- // The preview must do ZERO network so an offline preview stays honest
3233
- // (admin-cli offline-preview contract). The optimistic-concurrency token
3234
- // is resolved only on --confirm, inside the execute closure below — so a
3235
- // plain preview never reaches out. Strict mode (--expected-updated-at)
3236
- // shows the pinned token in the preview; the default fetches it at confirm.
3237
- const previewRequest = {
3238
- sessionId,
3239
- collectedData: collected,
3240
- ...(expectedUpdatedAt !== undefined ? { expectedUpdatedAt } : {}),
3241
- };
3242
- return writeCommand(parsed, {
3243
- action: "write structured parent intake data for the family",
3244
- target: { familyId, sessionId },
3245
- request: previewRequest,
3246
- }, async () => {
3247
- // Resolve the CAS token now (only reached on --confirm):
3248
- // - strict mode: pin the operator-supplied token. A stale one 409s
3249
- // (unwrap → CliError → non-zero exit), protecting a parent's
3250
- // concurrent confirm-flow autosave.
3251
- // - default: fetch the CURRENT token and warn that edits made since
3252
- // the preview are unprotected. The CLI is a last-resort admin tool;
3253
- // pragmatic default + honest warning + strict opt-in.
3254
- let token = expectedUpdatedAt;
3255
- if (token === undefined) {
3256
- const current = unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/intake-session", { params: { path: { familyId } } }));
3257
- token = current.updatedAt;
3258
- process.stderr.write("Warning: using current session token; edits made since your preview are not protected — pass --expected-updated-at for strict CAS.\n");
3259
- }
3260
- const body = {
3261
- sessionId,
3262
- collectedData: collected,
3263
- expectedUpdatedAt: token,
3264
- };
3265
- return unwrap(await api.client.PUT("/admin/onboarding/families/{familyId}/intake", { params: { path: { familyId } }, body }));
3266
- });
3267
- }
3268
- if (verb === "extract") {
3269
- const familyId = positional(parsed, 2, "family ID");
3270
- const sessionId = flagString(parsed, "session", { required: true });
3271
- const transcriptFile = flagString(parsed, "transcript-file");
3272
- const granolaRef = flagString(parsed, "granola");
3273
- if ((transcriptFile && granolaRef) || (!transcriptFile && !granolaRef)) {
3274
- throw new CliError("invalid_arguments", "Pass exactly one source: --transcript-file <path> or --granola <ref>.");
3275
- }
3276
- let transcript;
3277
- if (transcriptFile) {
3278
- try {
3279
- transcript = await fs.readFile(path.resolve(transcriptFile), "utf8");
3280
- }
3281
- catch (error) {
3282
- throw new CliError("invalid_arguments", `Could not read transcript file ${transcriptFile}: ${error instanceof Error ? error.message : String(error)}`);
3283
- }
3284
- }
3285
- const body = {
3286
- sessionId,
3287
- ...(transcript !== undefined ? { transcript } : {}),
3288
- ...(granolaRef ? { granolaRef } : {}),
3289
- };
3290
- // Keep raw transcript PII out of the confirmation preview (it prints to
3291
- // stderr, agent logs, and approval records); the full body still goes to
3292
- // the API on --confirm.
3293
- const previewRequest = transcript !== undefined && transcriptFile
3294
- ? {
3295
- sessionId,
3296
- path: path.resolve(transcriptFile),
3297
- byteCount: Buffer.byteLength(transcript, "utf8"),
3298
- sha256Prefix: createHash("sha256")
3299
- .update(transcript)
3300
- .digest("hex")
3301
- .slice(0, 12),
3302
- }
3303
- : { sessionId, ...(granolaRef ? { granolaRef } : {}) };
3304
- return writeCommand(parsed, {
3305
- action: "extract intake from transcript via Claude and merge into session",
3306
- target: {
3307
- familyId,
3308
- sessionId,
3309
- source: transcriptFile ? "transcript" : "granola",
3310
- },
3311
- request: previewRequest,
3312
- }, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/intake-extract", { params: { path: { familyId } }, body })));
3313
- }
3314
- throw new CliError("invalid_arguments", "Use onboarding status|kids|intake-session|intake-session-create|set-stage|set-account-state|attest|set-intake|extract.");
2845
+ return runOnboardingCommand({ parsed, api, writeCommand });
3315
2846
  }
3316
2847
  if (noun === "skills") {
3317
2848
  // Reads only — no confirmation gate. Audience is explicit so an agent never
@@ -3568,7 +3099,18 @@ export async function runCommand(argv) {
3568
3099
  }
3569
3100
  if (verb === "create") {
3570
3101
  const filePath = flagString(parsed, "file", { required: true });
3571
- const document = parseGoalTemplateDocument(await readJsonFile(filePath, "Template file"));
3102
+ const authoredDocument = parseGoalTemplateDocument(await readJsonFile(filePath, "Template file"));
3103
+ const coinAmountOverride = flagNumber(parsed, "coin-amount");
3104
+ if (coinAmountOverride !== undefined &&
3105
+ (!Number.isInteger(coinAmountOverride) || coinAmountOverride < 1)) {
3106
+ throw new CliError("invalid_arguments", "--coin-amount must be a positive integer.");
3107
+ }
3108
+ const document = {
3109
+ ...authoredDocument,
3110
+ ...(coinAmountOverride === undefined
3111
+ ? {}
3112
+ : { coinAmount: coinAmountOverride }),
3113
+ };
3572
3114
  // C3 read-only preflight, for the same reason `enrollments create` has
3573
3115
  // one: the consequences an approver must weigh are resolved SERVER-side.
3574
3116
  // Which setupHandler runs, what shape of goal students get, and which
@@ -3594,6 +3136,7 @@ export async function runCommand(argv) {
3594
3136
  category: document.category ?? null,
3595
3137
  tags: document.tags,
3596
3138
  isStarter: document.isStarter ?? false,
3139
+ coinAmount: document.coinAmount ?? null,
3597
3140
  },
3598
3141
  details: {
3599
3142
  resolvedSetupHandler: validation.setupHandler,
@@ -3690,6 +3233,11 @@ export async function runCommand(argv) {
3690
3233
  const tags = flagString(parsed, "tags");
3691
3234
  const kind = flagString(parsed, "kind");
3692
3235
  const sortOrder = flagNumber(parsed, "sort-order");
3236
+ const coinAmount = flagNumber(parsed, "coin-amount");
3237
+ if (coinAmount !== undefined &&
3238
+ (!Number.isInteger(coinAmount) || coinAmount < 1)) {
3239
+ throw new CliError("invalid_arguments", "--coin-amount must be a positive integer.");
3240
+ }
3693
3241
  const isStarterRaw = flagString(parsed, "is-starter");
3694
3242
  const setupAudienceRaw = flagString(parsed, "setup-audience");
3695
3243
  const isStarter = isStarterRaw === undefined
@@ -3706,6 +3254,10 @@ export async function runCommand(argv) {
3706
3254
  ...(flagString(parsed, "emoji")
3707
3255
  ? { emoji: flagString(parsed, "emoji") }
3708
3256
  : {}),
3257
+ ...(flagString(parsed, "image-url")
3258
+ ? { imageUrl: flagString(parsed, "image-url") }
3259
+ : {}),
3260
+ ...(coinAmount === undefined ? {} : { coinAmount }),
3709
3261
  ...(flagString(parsed, "category")
3710
3262
  ? { category: flagString(parsed, "category") }
3711
3263
  : {}),
@@ -3743,7 +3295,7 @@ export async function runCommand(argv) {
3743
3295
  // shape that caused the template incident; editing an existing spec goes
3744
3296
  // through the guarded /ai patch path with its destructive-change token.
3745
3297
  if (Object.keys(body).length === 1) {
3746
- throw new CliError("invalid_arguments", "Pass at least one field to change (--title, --description, --emoji, --category, --tags, --sort-order, --is-starter, --setup-audience, --kind, --agent-instructions-file, --output-template-file).");
3298
+ throw new CliError("invalid_arguments", "Pass at least one field to change (--title, --description, --emoji, --image-url, --coin-amount, --category, --tags, --sort-order, --is-starter, --setup-audience, --kind, --agent-instructions-file, --output-template-file).");
3747
3299
  }
3748
3300
  return writeCommand(parsed, {
3749
3301
  action: "update goal template metadata (never its setupWorkflowSpec)",
@@ -3754,6 +3306,28 @@ export async function runCommand(argv) {
3754
3306
  body,
3755
3307
  })));
3756
3308
  }
3309
+ if (verb === "generate-image") {
3310
+ const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
3311
+ const current = unwrap(await api.client.GET("/ai/goal-templates/{id}", {
3312
+ params: { path: { id } },
3313
+ }));
3314
+ const prompt = flagString(parsed, "prompt");
3315
+ return writeCommand(parsed, {
3316
+ action: "queue paid GPT-Image-2 art regeneration",
3317
+ target: {
3318
+ templateId: id,
3319
+ slug: current.slug,
3320
+ title: current.title,
3321
+ },
3322
+ request: { prompt: prompt ?? null },
3323
+ details: {
3324
+ costNote: "This queues one paid 1024×1024 GPT-Image-2 generation and replaces the template image when it finishes. New templates already generate art automatically.",
3325
+ },
3326
+ }, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/generate-image", {
3327
+ params: { path: { id } },
3328
+ body: prompt ? { prompt } : {},
3329
+ })));
3330
+ }
3757
3331
  if (verb === "delete") {
3758
3332
  const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
3759
3333
  const expectedVersion = requiredExpectedVersion(parsed);
@@ -3960,9 +3534,31 @@ export async function runCommand(argv) {
3960
3534
  body: { studentUserId, answers },
3961
3535
  })));
3962
3536
  }
3963
- 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.");
3537
+ throw new CliError("invalid_arguments", "Use goal-templates list|get|versions|validate-spec|create|patch-spec|set-metadata|generate-image|delete|snapshot-files|capture-snapshot|apply|apply-starter.");
3964
3538
  }
3965
3539
  if (noun === "goals" && verb !== "files" && verb !== "pdf") {
3540
+ if (verb === "complete" || verb === "undo-completion") {
3541
+ const goalId = positional(parsed, 2, "goal ID");
3542
+ const undo = verb === "undo-completion";
3543
+ return writeCommand(parsed, {
3544
+ action: undo
3545
+ ? "undo goal completion and reverse its coin reward"
3546
+ : "complete a goal and grant its one-time coin reward",
3547
+ target: { goalId },
3548
+ request: { source: "STAFF" },
3549
+ details: undo
3550
+ ? {
3551
+ note: "Undo is refused if the student has already spent enough coins that the reward cannot be reversed.",
3552
+ }
3553
+ : undefined,
3554
+ }, async () => unwrap(undo
3555
+ ? await api.client.POST("/ai/goals/{goalId}/undo-completion", {
3556
+ params: { path: { goalId } },
3557
+ })
3558
+ : await api.client.POST("/ai/goals/{goalId}/complete", {
3559
+ params: { path: { goalId } },
3560
+ })));
3561
+ }
3966
3562
  if (verb === "list") {
3967
3563
  const userId = flagString(parsed, "student", { required: true });
3968
3564
  return unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
@@ -4273,7 +3869,7 @@ export async function runCommand(argv) {
4273
3869
  }
4274
3870
  throw new CliError("invalid_arguments", "Use goals queue get|set.");
4275
3871
  }
4276
- throw new CliError("invalid_arguments", "Use goals list|create|edit|delete|queue|files|pdf.");
3872
+ throw new CliError("invalid_arguments", "Use goals list|create|edit|delete|complete|undo-completion|queue|files|pdf.");
4277
3873
  }
4278
3874
  if (noun === "students") {
4279
3875
  if (verb === "list") {