recess-cli 1.0.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 ADDED
@@ -0,0 +1,1537 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { RecessAdminApi, unwrap } from "./api.js";
5
+ import { flagNumber, flagString, hasFlag, parseArgs, } from "./args.js";
6
+ import { login, pollDeviceAuth, requestDeviceAuth } from "./auth.js";
7
+ import { clearStoredSession, resolveConfig, } from "./config.js";
8
+ import { CliError } from "./errors.js";
9
+ import { requireConfirmation } from "./safety.js";
10
+ import { installSkill, isEphemeralInstall } from "./setup.js";
11
+ export const HELP = `recess — safe Recess administration from the command line
12
+
13
+ Usage:
14
+ recess [--json] setup [--skill-only]
15
+ recess [--json] doctor
16
+ recess [--json] auth login [--client-id ID] [--callback-port 8765]
17
+ recess [--json] auth request [--label TEXT]
18
+ recess [--json] auth poll [--timeout 300]
19
+ recess [--json] auth status|logout
20
+ recess [--json] users search <name-or-id> [--limit 10]
21
+ recess [--json] users get <user-id>
22
+ recess [--json] users tier list-tiers
23
+ recess [--json] users tier get <kid-id>
24
+ recess [--json] users tier preview <kid-id> --tier social|academics|lite|complete|platform
25
+ [--slots N]
26
+ recess [--json] users tier set <kid-id> --tier social|academics|lite|complete|platform
27
+ [--slots N] --expected-updated-at <iso> [--allow-strand] [--confirm]
28
+ recess [--json] students upload-map-scores --student <kid-id>
29
+ --file </path/to/map-report.pdf> [--confirm]
30
+ recess [--json] enrollments list --user <user-id>
31
+ recess [--json] subscriptions list --family <family-id> [--kid <kid-id>]
32
+ recess [--json] invoices list --subscription <subscription-id>
33
+ recess [--json] billing pause --subscription <id> [--until ISO_DATE] [--confirm]
34
+ recess [--json] billing resume --subscription <id> [--confirm]
35
+ recess [--json] invoices refund --invoice <id> --line-item <id>
36
+ --method refund|credit|tokens [--full | --amount-cents N]
37
+ [--who-pays guide|recess] [--reason TEXT] [--confirm]
38
+ recess [--json] cohorts search <query>
39
+ recess [--json] enrollments create --user <kid-id> --cohort <id>
40
+ [--first-charge-at ISO_DATETIME] [--send-email] [--force] [--confirm]
41
+ recess [--json] enrollments register-cohort --enrollment <id>
42
+ --user <id> --cohort <id> [--confirm]
43
+ recess [--json] enrollments unregister-cohort --user <id>
44
+ --cohort <id> [--confirm]
45
+ recess [--json] enrollments get-for-subscription --subscription <id>
46
+ recess [--json] billing extend-trial --subscription <id>
47
+ --trial-end ISO_DATE [--confirm]
48
+ recess [--json] billing cancel-subscription --subscription <id>
49
+ [--immediate] [--reason TEXT] [--restore] [--confirm]
50
+ recess [--json] payout payruns list [--status A,B] [--schedule <id>]
51
+ recess [--json] payout recipients list [--search <name>] [--user <id>] [--id <id>]
52
+ recess [--json] payout invoices list [--payrun <id>] [--recipient <account-id>]
53
+ [--user <id>] [--status A,B] (at least one filter)
54
+ recess [--json] payout invoices get <invoice-id>
55
+ recess [--json] payout invoices set-status <invoice-id>
56
+ --status IN_REVIEW|OPEN|PAID|CANCELED [--send-email] [--confirm]
57
+ recess [--json] payout items add --invoice <id> --amount-cents N
58
+ --description TEXT [--date YYYY-MM-DD] [--confirm]
59
+ recess [--json] payout items edit <item-id> [--amount-cents N]
60
+ [--description TEXT] [--date YYYY-MM-DD] [--confirm]
61
+ recess [--json] payout items delete <item-id> [--confirm]
62
+ recess [--json] cohorts get <cohort-id> [--events-tab ACTIVE|ENDED|CANCELED|ARCHIVED]
63
+ recess [--json] cohorts parent-emails <cohort-id>
64
+ recess [--json] cohorts end <cohort-id> [--cancel-subscriptions] [--confirm]
65
+ recess [--json] cohorts pause-billing <cohort-id> --weeks 1..6 [--confirm]
66
+ recess [--json] cohorts resume-billing <cohort-id> [--confirm]
67
+ recess [--json] cohorts email <cohort-id> --target ALL_PARENTS|ALL_PARENTS_GUIDES
68
+ --content TEXT [--confirm]
69
+ recess [--json] events get <event-id>
70
+ recess [--json] events take-attendance <event-id> --attended <id,id,...>
71
+ [--absent <id,id,...>] [--excused <id,id,...>] [--confirm]
72
+ recess [--json] events cancel <event-id> --reason TEXT [--confirm]
73
+ recess [--json] events set-status <event-id> --status ACTIVE|ENDED|CANCELED [--confirm]
74
+ recess [--json] events reschedule --cohort <id> --event <event-id>
75
+ --starts-at "YYYY-MM-DDTHH:MM" [--timezone <iana>] [--length-mins N] [--confirm]
76
+ recess [--json] events add --cohort <id> --starts-at "YYYY-MM-DDTHH:MM"
77
+ [--timezone <iana>] [--length-mins N] [--confirm]
78
+ recess [--json] registrations approve --registration <id> [--confirm]
79
+ recess [--json] registrations deny --cohort <id> --user <id> [--confirm]
80
+ recess [--json] request get </path?query=value>
81
+ recess [--json] onboarding status <family-id>
82
+ recess [--json] onboarding kids [--time-period-days N] [--cohort <id>]
83
+ [--limit N] [--stage-filter all|scheduled|oriented|course|converted|lost]
84
+ recess [--json] onboarding intake-session <family-id>
85
+ recess [--json] onboarding intake-session-create <family-id> [--confirm]
86
+ recess [--json] onboarding set-stage <family-id>
87
+ --stage LEGACY|PROVISIONED|PARENT_CONFIRMED|CLEARED_FOR_COHORT|COMPLETE [--confirm]
88
+ recess [--json] onboarding set-account-state <family-id>
89
+ --state ACTIVE|PENDING_PAYMENT|PAUSED|BOOTED [--note TEXT] [--confirm]
90
+ recess [--json] onboarding attest <family-id>
91
+ --condition app_downloaded|tutor_met|goals_loaded|ma_diagnostic [--revoke]
92
+ [--note TEXT] [--confirm]
93
+ recess [--json] onboarding set-intake <family-id> --session <id>
94
+ --data <json> [--expected-updated-at <iso>] [--confirm]
95
+ recess [--json] onboarding extract <family-id> --session <id>
96
+ (--transcript-file <path> | --granola <ref>) [--confirm]
97
+ recess [--json] village models list [--world village-1] [--query TEXT] [--archived]
98
+ recess [--json] village models upload --file </path/model.glb>
99
+ [--world village-1] [--name TEXT] [--id ID] [--description TEXT] [--tags A,B]
100
+ [--visual-only] [--confirm]
101
+ recess [--json] village models publish|archive <model-id> [--world village-1] [--confirm]
102
+ recess [--json] village models place <model-id> --x N --z N
103
+ [--y N] [--rotation 0..3] [--mirrored] [--no-collision] [--batch ID]
104
+ [--world village-1] [--confirm]
105
+ recess [--json] village models move <placement-id> --x N --z N
106
+ [--y N] [--rotation 0..3] [--mirrored] [--world village-1] [--confirm]
107
+ recess [--json] village models remove <placement-id> [--world village-1] [--confirm]
108
+ recess [--json] village render --min-x N --min-z N --max-x N --max-z N
109
+ [--world village-1]
110
+
111
+ Onboarding notes: "status" and "intake-session" are reads — "intake-session"
112
+ looks up the current IN_PROGRESS session without creating one (prints a "none
113
+ yet" result when absent). "intake-session-create" is the explicit write that
114
+ mints a blank session, so it is gated behind --confirm. "set-stage" reads the
115
+ current stage first and warns when a move re-locks progress; CLEARED_FOR_COHORT
116
+ unlocks cohort registration. "set-account-state" PAUSED/BOOTED lock the family out of paid
117
+ capabilities (--note is analytics-only). "set-intake --data" takes JSON (agents
118
+ drive it with --json; humans rarely will); the preview is offline. Its
119
+ optimistic-concurrency token resolves only on --confirm: pass
120
+ --expected-updated-at <iso> (from an intake-session read's updatedAt) for strict
121
+ CAS that 409s if a parent autosaved since; omit it and the CLI fetches the
122
+ current token at confirm time and warns that post-preview edits are unprotected.
123
+ "extract" is LLM-bound and can take ~30s; a 503 means Granola is not configured
124
+ server-side.
125
+
126
+ Class-ops notes: "events cancel" notifies families (chat + parent email blast +
127
+ credit notes + Slack); "events set-status --status CANCELED" is a silent status
128
+ change. Reschedule times are cohort-local wall-clock (zoneless).
129
+
130
+ Payout notes: amounts are integer cents. "payout items add" without --date
131
+ defaults the item date to the penultimate day of the invoice's cycle (its
132
+ endDate minus one day) and shows the computed date in the preview.
133
+
134
+ Auth notes: "auth login" runs the browser loopback flow (local use). For a headless
135
+ cloud agent, "auth request" prints an approval URL to hand a Recess admin; after they
136
+ approve it in a browser, "auth poll" collects the 12h session. When it lapses, run
137
+ "auth request" again for a fresh link. Both paths yield the same session.
138
+
139
+ Writes preview and exit 2 unless --confirm is supplied after explicit human approval.
140
+ Environment overrides: RECESS_CLI_API_ORIGIN, RECESS_CLI_WEB_ORIGIN,
141
+ RECESS_CLI_OAUTH_CLIENT_ID, RECESS_CLI_COOKIE, RECESS_CLI_CONFIG.`;
142
+ function positional(parsed, index, label) {
143
+ const value = parsed.positionals[index];
144
+ if (!value) {
145
+ throw new CliError("invalid_arguments", `Missing ${label}.`);
146
+ }
147
+ return value;
148
+ }
149
+ async function sessionStatus(config) {
150
+ if (!config.sessionCookie) {
151
+ return {
152
+ authenticated: false,
153
+ authSource: "missing",
154
+ message: "Run `recess auth login`.",
155
+ };
156
+ }
157
+ const api = new RecessAdminApi(config);
158
+ const result = await api.client.GET("/auth/admin-cli/session/");
159
+ if (!result.response.ok || !result.data) {
160
+ return {
161
+ authenticated: false,
162
+ authSource: config.authSource,
163
+ status: result.response.status,
164
+ expiresAt: config.sessionExpiresAt ?? null,
165
+ message: "The stored admin session is invalid or expired. Run `recess auth login`.",
166
+ };
167
+ }
168
+ return { ...result.data, authSource: config.authSource };
169
+ }
170
+ async function doctor(config) {
171
+ const checks = {
172
+ config: {
173
+ path: config.configPath,
174
+ apiOrigin: config.apiOrigin,
175
+ webOrigin: config.webOrigin,
176
+ oauthClientIdConfigured: Boolean(config.oauthClientId),
177
+ },
178
+ auth: await sessionStatus(config),
179
+ };
180
+ try {
181
+ const response = await fetch(new URL("/health", config.apiOrigin), {
182
+ signal: AbortSignal.timeout(5000),
183
+ });
184
+ checks.api = { reachable: response.ok, status: response.status };
185
+ }
186
+ catch (error) {
187
+ checks.api = {
188
+ reachable: false,
189
+ message: error instanceof Error ? error.message : String(error),
190
+ };
191
+ }
192
+ return checks;
193
+ }
194
+ async function writeCommand(parsed, preview, execute) {
195
+ requireConfirmation(hasFlag(parsed, "confirm"), preview);
196
+ return execute();
197
+ }
198
+ function assertChoice(value, choices, label) {
199
+ if (!choices.includes(value)) {
200
+ throw new CliError("invalid_arguments", `${label} must be one of: ${choices.join(", ")}.`);
201
+ }
202
+ return value;
203
+ }
204
+ const PAYRUN_STATUSES = [
205
+ "UPCOMING",
206
+ "DRAFT",
207
+ "IN_REVIEW",
208
+ "APPROVED",
209
+ "PAID",
210
+ "CANCELED",
211
+ ];
212
+ const PAYOUT_INVOICE_STATUSES = [
213
+ "DRAFT",
214
+ "IN_REVIEW",
215
+ "OPEN",
216
+ "PENDING_PAYMENT",
217
+ "PAID",
218
+ "CANCELED",
219
+ ];
220
+ // The guide email on IN_REVIEW/OPEN only fires when sendEmail is true.
221
+ const PAYOUT_STATUS_SIDE_EFFECTS = {
222
+ IN_REVIEW: "moves the invoice into review",
223
+ OPEN: "FINALIZES the invoice: adds its total to the recipient's account balance",
224
+ PAID: "marks the invoice paid and deducts its total from the recipient's account balance",
225
+ CANCELED: "cancels the invoice and reverses any balance items carried on it",
226
+ };
227
+ // Ascending onboarding stage order (mirrors STAGE_ORDER in
228
+ // apps/web-server/src/libs/onboarding-stage.ts); a lower index is earlier
229
+ // progress, so a target below the current stage is a backward move.
230
+ const ONBOARDING_STAGES = [
231
+ "LEGACY",
232
+ "PROVISIONED",
233
+ "PARENT_CONFIRMED",
234
+ "CLEARED_FOR_COHORT",
235
+ "COMPLETE",
236
+ ];
237
+ // What each stage unlocks/implies, surfaced in the set-stage preview.
238
+ const ONBOARDING_STAGE_SIDE_EFFECTS = {
239
+ LEGACY: "the pre-onboarding baseline",
240
+ PROVISIONED: "kid accounts provisioned, awaiting parent confirmation",
241
+ PARENT_CONFIRMED: "parent has confirmed setup",
242
+ CLEARED_FOR_COHORT: "unlocks cohort registration for school families",
243
+ COMPLETE: "onboarding finished",
244
+ };
245
+ const ONBOARDING_ACCOUNT_STATES = [
246
+ "ACTIVE",
247
+ "PENDING_PAYMENT",
248
+ "PAUSED",
249
+ "BOOTED",
250
+ ];
251
+ const ONBOARDING_CONDITIONS = [
252
+ "app_downloaded",
253
+ "tutor_met",
254
+ "goals_loaded",
255
+ "ma_diagnostic",
256
+ ];
257
+ const SCHOOL_TIER_OPTIONS = [
258
+ { id: "social", name: "Social", defaultClassSlots: 2 },
259
+ { id: "academics", name: "Academics", defaultClassSlots: 0 },
260
+ { id: "lite", name: "Lite", defaultClassSlots: 1 },
261
+ { id: "complete", name: "Complete", defaultClassSlots: 3 },
262
+ { id: "platform", name: "Platform Only", defaultClassSlots: 0 },
263
+ ];
264
+ const SCHOOL_TIER_IDS = SCHOOL_TIER_OPTIONS.map((tier) => tier.id);
265
+ const MAX_MAP_PDF_BYTES = 15 * 1024 * 1024;
266
+ async function readMapScorePdf(filePath) {
267
+ const absolutePath = path.resolve(filePath);
268
+ if (path.extname(absolutePath).toLowerCase() !== ".pdf") {
269
+ throw new CliError("invalid_arguments", "--file must point to a PDF report.");
270
+ }
271
+ let stat;
272
+ try {
273
+ stat = await fs.stat(absolutePath);
274
+ }
275
+ catch (error) {
276
+ if (error.code === "ENOENT") {
277
+ throw new CliError("invalid_arguments", `MAP score PDF does not exist: ${absolutePath}`);
278
+ }
279
+ throw error;
280
+ }
281
+ if (!stat.isFile()) {
282
+ throw new CliError("invalid_arguments", `MAP score PDF is not a regular file: ${absolutePath}`);
283
+ }
284
+ if (stat.size === 0) {
285
+ throw new CliError("invalid_arguments", "MAP score PDF is empty.");
286
+ }
287
+ if (stat.size > MAX_MAP_PDF_BYTES) {
288
+ throw new CliError("invalid_arguments", "MAP score PDF exceeds the 15 MB upload limit.");
289
+ }
290
+ const bytes = await fs.readFile(absolutePath);
291
+ return {
292
+ absolutePath,
293
+ bytes,
294
+ fileName: path.basename(absolutePath),
295
+ sha256: createHash("sha256").update(bytes).digest("hex"),
296
+ };
297
+ }
298
+ function flagStatusList(parsed, name, choices) {
299
+ const raw = flagString(parsed, name);
300
+ if (raw === undefined)
301
+ return undefined;
302
+ const values = raw
303
+ .split(",")
304
+ .map((value) => value.trim())
305
+ .filter(Boolean);
306
+ if (values.length === 0) {
307
+ throw new CliError("invalid_arguments", `--${name} requires a value.`);
308
+ }
309
+ return values.map((value) => assertChoice(value, choices, `--${name}`));
310
+ }
311
+ function flagCents(parsed, name, options = {}) {
312
+ const value = flagNumber(parsed, name);
313
+ if (value === undefined) {
314
+ if (options.required) {
315
+ throw new CliError("invalid_arguments", `Missing required --${name}.`);
316
+ }
317
+ return undefined;
318
+ }
319
+ if (!Number.isInteger(value) || value <= 0) {
320
+ throw new CliError("invalid_arguments", `--${name} must be a positive integer number of cents.`);
321
+ }
322
+ return value;
323
+ }
324
+ function flagItemDateMs(parsed) {
325
+ const raw = flagString(parsed, "date");
326
+ if (raw === undefined)
327
+ return undefined;
328
+ // A bare YYYY-MM-DD is anchored to UTC noon so the calendar day survives
329
+ // timezone conversion on the server.
330
+ const iso = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? `${raw}T12:00:00.000Z` : raw;
331
+ const ms = Date.parse(iso);
332
+ if (!Number.isFinite(ms)) {
333
+ throw new CliError("invalid_arguments", "--date must be YYYY-MM-DD or a full ISO timestamp.");
334
+ }
335
+ return ms;
336
+ }
337
+ const DAY_MS = 24 * 60 * 60 * 1000;
338
+ // The cohort event routes take zoneless local wall-clock datetimes
339
+ // (interpreted in the cohort's timezone server-side).
340
+ function flagLocalDateTime(parsed, name, options = {}) {
341
+ const raw = flagString(parsed, name, options);
342
+ if (raw === undefined)
343
+ return undefined;
344
+ const match = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2})(:\d{2})?$/.exec(raw);
345
+ if (!match) {
346
+ throw new CliError("invalid_arguments", `--${name} must be a zoneless local datetime like 2026-07-20T15:00 (no timezone offset).`);
347
+ }
348
+ return match[2] ? raw : `${raw}:00`;
349
+ }
350
+ function flagIdList(parsed, name) {
351
+ const raw = flagString(parsed, name);
352
+ if (raw === undefined)
353
+ return [];
354
+ const ids = raw
355
+ .split(",")
356
+ .map((value) => value.trim())
357
+ .filter(Boolean);
358
+ if (ids.length === 0) {
359
+ throw new CliError("invalid_arguments", `--${name} requires a value.`);
360
+ }
361
+ return ids;
362
+ }
363
+ export function penultimateCycleDayMs(cycleEndDate) {
364
+ const endMs = Date.parse(cycleEndDate);
365
+ if (!Number.isFinite(endMs)) {
366
+ throw new CliError("api_error", `Invoice endDate is not a valid date: ${cycleEndDate}`);
367
+ }
368
+ return endMs - DAY_MS;
369
+ }
370
+ async function resolveSchoolKidContext(api, kidId) {
371
+ const search = unwrap(await api.client.GET("/admin/users/operations-search", {
372
+ params: { query: { search: kidId, limit: 1 } },
373
+ }));
374
+ const result = search.results.find((candidate) => candidate.members.some((member) => member.id === kidId));
375
+ const kid = result?.members.find((member) => member.id === kidId);
376
+ if (!result || !kid) {
377
+ throw new CliError("not_found", `No user found for ${kidId}.`);
378
+ }
379
+ if (kid.role !== "KID") {
380
+ throw new CliError("invalid_arguments", `School tiers can only be read or set for KID users; ${kidId} is ${kid.role}.`);
381
+ }
382
+ if (!result.family) {
383
+ throw new CliError("not_found", `Kid ${kidId} does not belong to a family with a school institution.`);
384
+ }
385
+ const status = unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/status", {
386
+ params: { path: { familyId: result.family.id } },
387
+ }));
388
+ if (!status.kids.some((candidate) => candidate.id === kidId)) {
389
+ throw new CliError("not_found", `Kid ${kidId} was not found in family ${result.family.id}.`);
390
+ }
391
+ if (status.programType !== "SCHOOL" || !status.partnerSlug) {
392
+ throw new CliError("invalid_arguments", `Kid ${kidId} is not assigned to an internal school program.`);
393
+ }
394
+ return {
395
+ familyId: result.family.id,
396
+ institutionSlug: status.partnerSlug,
397
+ };
398
+ }
399
+ function tierSlots(parsed) {
400
+ const slots = flagNumber(parsed, "slots");
401
+ if (slots !== undefined &&
402
+ (!Number.isInteger(slots) || slots < 0 || slots > 20)) {
403
+ throw new CliError("invalid_arguments", "--slots must be an integer from 0 through 20.");
404
+ }
405
+ return slots;
406
+ }
407
+ export async function runCommand(argv) {
408
+ const parsed = parseArgs(argv);
409
+ const [noun, verb] = parsed.positionals;
410
+ if (!noun || noun === "help" || hasFlag(parsed, "help")) {
411
+ return { help: HELP };
412
+ }
413
+ const config = await resolveConfig();
414
+ if (noun === "doctor")
415
+ return doctor(config);
416
+ if (noun === "setup") {
417
+ const skills = await installSkill();
418
+ const ephemeral = isEphemeralInstall();
419
+ // Reuse a session that is still live; a missing or already-expired one is
420
+ // worth spending the browser round trip on now rather than at first use.
421
+ const sessionIsLive = config.authSource !== "missing" &&
422
+ (config.authSource === "env" ||
423
+ (config.sessionExpiresAt !== undefined &&
424
+ Date.parse(config.sessionExpiresAt) > Date.now()));
425
+ const loggedIn = hasFlag(parsed, "skill-only") || sessionIsLive
426
+ ? null
427
+ : await login(config, {
428
+ callbackPort: 8765,
429
+ oauthClientId: config.oauthClientId,
430
+ });
431
+ return {
432
+ skills,
433
+ session: loggedIn ?? (sessionIsLive ? "existing" : null),
434
+ ...(ephemeral
435
+ ? {
436
+ warning: "Running from a temporary npx cache: the installed skill calls `recess-cli`, which is not on your PATH. Run `npm install -g recess-cli` for a persistent command.",
437
+ }
438
+ : {}),
439
+ };
440
+ }
441
+ if (noun === "auth") {
442
+ if (verb === "login") {
443
+ const oauthClientId = flagString(parsed, "client-id") ?? config.oauthClientId;
444
+ const callbackPort = flagNumber(parsed, "callback-port") ?? 8765;
445
+ if (!Number.isInteger(callbackPort) ||
446
+ callbackPort < 1024 ||
447
+ callbackPort > 65535) {
448
+ throw new CliError("invalid_arguments", "--callback-port must be an integer from 1024 through 65535.");
449
+ }
450
+ return login(config, { callbackPort, oauthClientId });
451
+ }
452
+ if (verb === "request") {
453
+ const label = flagString(parsed, "label");
454
+ return requestDeviceAuth(config, label ? { label } : {});
455
+ }
456
+ if (verb === "poll") {
457
+ const timeoutSeconds = flagNumber(parsed, "timeout") ?? 300;
458
+ if (!Number.isInteger(timeoutSeconds) ||
459
+ timeoutSeconds < 1 ||
460
+ timeoutSeconds > 3600) {
461
+ throw new CliError("invalid_arguments", "--timeout must be an integer number of seconds from 1 through 3600.");
462
+ }
463
+ return pollDeviceAuth(config, { timeoutMs: timeoutSeconds * 1000 });
464
+ }
465
+ if (verb === "status")
466
+ return sessionStatus(config);
467
+ if (verb === "logout") {
468
+ await clearStoredSession();
469
+ return { loggedOut: true };
470
+ }
471
+ throw new CliError("invalid_arguments", "Use auth login, request, poll, status, or logout.");
472
+ }
473
+ const api = new RecessAdminApi(config);
474
+ api.requireAuth();
475
+ if (noun === "village") {
476
+ const targetWorldId = flagString(parsed, "world") ?? "village-1";
477
+ const numberFlag = (name, fallback) => {
478
+ const value = flagNumber(parsed, name) ?? fallback;
479
+ if (value === undefined) {
480
+ throw new CliError("invalid_arguments", `Missing required --${name}.`);
481
+ }
482
+ return value;
483
+ };
484
+ const quarterRotation = () => {
485
+ const value = numberFlag("rotation", 0);
486
+ if (!Number.isInteger(value) || value < 0 || value > 3) {
487
+ throw new CliError("invalid_arguments", "--rotation must be 0, 1, 2, or 3.");
488
+ }
489
+ return value;
490
+ };
491
+ if (verb === "render") {
492
+ const params = new URLSearchParams({
493
+ worldId: targetWorldId,
494
+ minX: String(numberFlag("min-x")),
495
+ minZ: String(numberFlag("min-z")),
496
+ maxX: String(numberFlag("max-x")),
497
+ maxZ: String(numberFlag("max-z")),
498
+ });
499
+ return api.villageRequest(`/admin/village/render?${params}`);
500
+ }
501
+ if (verb !== "models") {
502
+ throw new CliError("invalid_arguments", "Use village models … or village render …");
503
+ }
504
+ const action = positional(parsed, 2, "Village model action");
505
+ if (action === "list") {
506
+ const params = new URLSearchParams({ worldId: targetWorldId });
507
+ const query = flagString(parsed, "query");
508
+ if (query)
509
+ params.set("q", query);
510
+ if (hasFlag(parsed, "archived"))
511
+ params.set("archived", "true");
512
+ return api.villageRequest(`/admin/village/models?${params}`);
513
+ }
514
+ if (action === "upload") {
515
+ const absolutePath = path.resolve(flagString(parsed, "file", { required: true }));
516
+ if (path.extname(absolutePath).toLowerCase() !== ".glb") {
517
+ throw new CliError("invalid_arguments", "--file must point to a .glb file.");
518
+ }
519
+ let bytes;
520
+ try {
521
+ bytes = await fs.readFile(absolutePath);
522
+ }
523
+ catch (error) {
524
+ throw new CliError("invalid_arguments", `Could not read GLB ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`);
525
+ }
526
+ if (bytes.byteLength === 0 || bytes.byteLength > 5 * 1024 * 1024) {
527
+ throw new CliError("invalid_arguments", "GLB must be between 1 byte and 5 MB.");
528
+ }
529
+ const metadata = {
530
+ id: flagString(parsed, "id"),
531
+ name: flagString(parsed, "name"),
532
+ description: flagString(parsed, "description"),
533
+ tags: flagString(parsed, "tags"),
534
+ visualOnly: hasFlag(parsed, "visual-only") || undefined,
535
+ };
536
+ return writeCommand(parsed, {
537
+ action: "upload and validate a reusable Village GLB draft",
538
+ target: { worldId: targetWorldId },
539
+ request: {
540
+ file: absolutePath,
541
+ fileName: path.basename(absolutePath),
542
+ sizeBytes: bytes.byteLength,
543
+ sha256: createHash("sha256").update(bytes).digest("hex"),
544
+ ...metadata,
545
+ },
546
+ }, () => api.uploadVillageModel(targetWorldId, bytes, path.basename(absolutePath), metadata));
547
+ }
548
+ if (action === "publish" || action === "archive") {
549
+ const modelId = positional(parsed, 3, "model ID");
550
+ return writeCommand(parsed, {
551
+ action: `${action} a reusable Village model`,
552
+ target: { worldId: targetWorldId, modelId },
553
+ request: { action },
554
+ }, () => api.villageRequest(`/admin/village/models/${encodeURIComponent(modelId)}/${action}`, { method: "POST", body: { worldId: targetWorldId } }));
555
+ }
556
+ if (action === "place") {
557
+ const modelId = positional(parsed, 3, "model ID");
558
+ const body = {
559
+ worldId: targetWorldId,
560
+ assetId: modelId,
561
+ position: {
562
+ x: numberFlag("x"),
563
+ y: numberFlag("y", 0),
564
+ z: numberFlag("z"),
565
+ },
566
+ rotation: quarterRotation(),
567
+ mirrored: hasFlag(parsed, "mirrored"),
568
+ collides: !hasFlag(parsed, "no-collision"),
569
+ ...(flagString(parsed, "batch")
570
+ ? { batchId: flagString(parsed, "batch") }
571
+ : {}),
572
+ };
573
+ return writeCommand(parsed, {
574
+ action: "place a reusable Village model on the 0.5m grid",
575
+ target: { worldId: targetWorldId, modelId },
576
+ request: body,
577
+ }, () => api.villageRequest("/admin/village/models/place", {
578
+ method: "POST",
579
+ body,
580
+ }));
581
+ }
582
+ if (action === "move") {
583
+ const placementId = positional(parsed, 3, "placement ID");
584
+ const body = {
585
+ worldId: targetWorldId,
586
+ position: {
587
+ x: numberFlag("x"),
588
+ y: numberFlag("y", 0),
589
+ z: numberFlag("z"),
590
+ },
591
+ rotation: quarterRotation(),
592
+ mirrored: hasFlag(parsed, "mirrored"),
593
+ };
594
+ return writeCommand(parsed, {
595
+ action: "move a Village model placement on the 0.5m grid",
596
+ target: { worldId: targetWorldId, placementId },
597
+ request: body,
598
+ }, () => api.villageRequest(`/admin/village/models/placements/${encodeURIComponent(placementId)}`, { method: "PUT", body }));
599
+ }
600
+ if (action === "remove") {
601
+ const placementId = positional(parsed, 3, "placement ID");
602
+ return writeCommand(parsed, {
603
+ action: "remove a Village model placement",
604
+ target: { worldId: targetWorldId, placementId },
605
+ request: {},
606
+ }, () => api.villageRequest(`/admin/village/models/placements/${encodeURIComponent(placementId)}?worldId=${encodeURIComponent(targetWorldId)}`, { method: "DELETE" }));
607
+ }
608
+ throw new CliError("invalid_arguments", "Use village models list|upload|publish|archive|place|move|remove.");
609
+ }
610
+ if (noun === "users" && verb === "search") {
611
+ const query = parsed.positionals.slice(2).join(" ").trim();
612
+ if (!query)
613
+ throw new CliError("invalid_arguments", "Missing search query.");
614
+ const limit = flagNumber(parsed, "limit") ?? 10;
615
+ return unwrap(await api.client.GET("/admin/users/operations-search", {
616
+ params: { query: { search: query, limit } },
617
+ }));
618
+ }
619
+ if (noun === "users" && verb === "tier") {
620
+ const action = positional(parsed, 2, "users tier action");
621
+ if (action === "list-tiers") {
622
+ return { tiers: SCHOOL_TIER_OPTIONS };
623
+ }
624
+ if (!["get", "preview", "set"].includes(action)) {
625
+ throw new CliError("invalid_arguments", "Use users tier list-tiers|get|preview|set.");
626
+ }
627
+ const kidId = positional(parsed, 3, "kid ID");
628
+ if (action === "get") {
629
+ const context = await resolveSchoolKidContext(api, kidId);
630
+ const state = unwrap(await api.client.GET("/admin/partner/{slug}/kids/{kidId}/tier", {
631
+ params: {
632
+ path: { slug: context.institutionSlug, kidId },
633
+ query: {},
634
+ },
635
+ }));
636
+ return { kidId, ...context, ...state };
637
+ }
638
+ const tierId = assertChoice(flagString(parsed, "tier", { required: true }), SCHOOL_TIER_IDS, "--tier");
639
+ const proposedSlots = tierSlots(parsed);
640
+ const expectedUpdatedAt = action === "set"
641
+ ? flagString(parsed, "expected-updated-at", { required: true })
642
+ : undefined;
643
+ if (expectedUpdatedAt !== undefined &&
644
+ Number.isNaN(Date.parse(expectedUpdatedAt))) {
645
+ throw new CliError("invalid_arguments", "--expected-updated-at must be an ISO-8601 timestamp from users tier get.");
646
+ }
647
+ const context = await resolveSchoolKidContext(api, kidId);
648
+ const state = unwrap(await api.client.GET("/admin/partner/{slug}/kids/{kidId}/tier", {
649
+ params: {
650
+ path: { slug: context.institutionSlug, kidId },
651
+ query: {
652
+ proposedTierId: tierId,
653
+ ...(proposedSlots === undefined ? {} : { proposedSlots }),
654
+ },
655
+ },
656
+ }));
657
+ const preview = {
658
+ kidId,
659
+ ...context,
660
+ tierId: state.tierId,
661
+ concurrentClassSlots: state.concurrentClassSlots,
662
+ slotsUsed: state.slotsUsed,
663
+ enforcementOn: state.enforcementOn,
664
+ updatedAt: state.updatedAt,
665
+ proposedTierId: state.proposedTierId,
666
+ proposedConcurrentClassSlots: state.proposedConcurrentClassSlots,
667
+ capabilitiesLockedNow: state.capabilitiesLocked,
668
+ capabilitiesLockedAfter: state.capabilitiesLockedAfter,
669
+ wouldStrandRegistrations: state.wouldStrandRegistrations,
670
+ };
671
+ if (action === "preview")
672
+ return preview;
673
+ const allowStrand = hasFlag(parsed, "allow-strand");
674
+ if (state.wouldStrandRegistrations && !allowStrand) {
675
+ throw new CliError("would_strand_registrations", `The proposed ${state.proposedConcurrentClassSlots} slots are below ${state.slotsUsed} active registrations. Rerun with --allow-strand only after explicitly approving the over-allocation.`, 1, { preview });
676
+ }
677
+ const body = {
678
+ tierId,
679
+ ...(proposedSlots === undefined ? {} : { slotsPerKid: proposedSlots }),
680
+ expectedUpdatedAt: expectedUpdatedAt,
681
+ allowStrand,
682
+ };
683
+ return writeCommand(parsed, {
684
+ action: state.enforcementOn
685
+ ? `set the kid's school tier to ${tierId} with the displayed capability and class-slot consequences`
686
+ : `set the kid's school tier to ${tierId}; enforcement is OFF, so capability locks will not be observable yet`,
687
+ target: { kidId, institutionSlug: context.institutionSlug },
688
+ request: body,
689
+ details: preview,
690
+ }, async () => unwrap(await api.client.PATCH("/admin/partner/{slug}/kids/{kidId}/tier", {
691
+ params: {
692
+ path: { slug: context.institutionSlug, kidId },
693
+ },
694
+ body,
695
+ })));
696
+ }
697
+ if (noun === "users" && verb === "get") {
698
+ const userId = positional(parsed, 2, "user ID");
699
+ return unwrap(await api.client.GET("/admin/users/{userId}", {
700
+ params: { path: { userId } },
701
+ }));
702
+ }
703
+ if (noun === "students" && verb === "upload-map-scores") {
704
+ const studentId = flagString(parsed, "student", { required: true });
705
+ const pdf = await readMapScorePdf(flagString(parsed, "file", { required: true }));
706
+ return writeCommand(parsed, {
707
+ action: "upload a MAP Growth PDF and extract scores into the tutor dashboard",
708
+ target: { studentId },
709
+ request: {
710
+ file: pdf.absolutePath,
711
+ fileName: pdf.fileName,
712
+ sizeBytes: pdf.bytes.byteLength,
713
+ sha256: pdf.sha256,
714
+ contentType: "application/pdf",
715
+ },
716
+ }, () => api.uploadMapTestScores(studentId, pdf.bytes, pdf.fileName));
717
+ }
718
+ if (noun === "enrollments" && verb === "list") {
719
+ const userId = flagString(parsed, "user", { required: true });
720
+ const data = unwrap(await api.client.GET("/admin/users/operations-search", {
721
+ params: { query: { search: userId, limit: 10 } },
722
+ }));
723
+ const member = data.results
724
+ .flatMap((result) => result.members)
725
+ .find((candidate) => candidate.id === userId);
726
+ if (!member) {
727
+ throw new CliError("not_found", `No user found for ${userId}.`);
728
+ }
729
+ return { user: member, enrollments: member.enrollments };
730
+ }
731
+ if (noun === "subscriptions" && verb === "list") {
732
+ const familyId = flagString(parsed, "family", { required: true });
733
+ const kidId = flagString(parsed, "kid");
734
+ return unwrap(await api.client.GET("/admin/stripe/family-subscriptions", {
735
+ params: { query: { familyId, ...(kidId ? { kidId } : {}) } },
736
+ }));
737
+ }
738
+ if (noun === "invoices" && verb === "list") {
739
+ const subscriptionId = flagString(parsed, "subscription", {
740
+ required: true,
741
+ });
742
+ return unwrap(await api.client.GET("/admin/stripe/subscription-invoices", {
743
+ params: { query: { subscriptionId } },
744
+ }));
745
+ }
746
+ if (noun === "cohorts" && verb === "search") {
747
+ const search = parsed.positionals.slice(2).join(" ").trim();
748
+ if (!search)
749
+ throw new CliError("invalid_arguments", "Missing cohort search query.");
750
+ return unwrap(await api.client.GET("/admin/cohorts/", {
751
+ params: { query: { search } },
752
+ }));
753
+ }
754
+ if (noun === "billing" && (verb === "pause" || verb === "resume")) {
755
+ const subscriptionId = flagString(parsed, "subscription", {
756
+ required: true,
757
+ });
758
+ const until = flagString(parsed, "until");
759
+ let pauseOption = verb === "resume" ? "resume" : until ? "fixed" : "indefinite";
760
+ let resumeAt;
761
+ if (until) {
762
+ resumeAt = Math.floor(new Date(until).getTime() / 1000);
763
+ if (!Number.isFinite(resumeAt) || resumeAt <= Date.now() / 1000) {
764
+ throw new CliError("invalid_arguments", "--until must be a valid future ISO date.");
765
+ }
766
+ }
767
+ const body = {
768
+ subscriptionId,
769
+ pauseOption,
770
+ ...(resumeAt ? { resumeAt } : {}),
771
+ };
772
+ return writeCommand(parsed, {
773
+ action: verb === "resume"
774
+ ? "resume billing collection"
775
+ : "pause billing collection",
776
+ target: { subscriptionId },
777
+ request: body,
778
+ }, async () => unwrap(await api.client.POST("/admin/stripe/pause-collection", { body })));
779
+ }
780
+ if (noun === "invoices" && verb === "refund") {
781
+ const invoiceId = flagString(parsed, "invoice", { required: true });
782
+ const lineItem = flagString(parsed, "line-item", { required: true });
783
+ const method = assertChoice(flagString(parsed, "method", { required: true }), ["refund", "credit", "tokens"], "--method");
784
+ const whoPays = assertChoice(flagString(parsed, "who-pays") ?? "guide", ["guide", "recess"], "--who-pays");
785
+ const reason = flagString(parsed, "reason");
786
+ const amount = flagNumber(parsed, "amount-cents");
787
+ const fullRefund = hasFlag(parsed, "full");
788
+ if (method === "tokens" && (!amount || fullRefund)) {
789
+ throw new CliError("invalid_arguments", "Token refunds require --amount-cents and do not accept --full.");
790
+ }
791
+ if (method !== "tokens" && !fullRefund && !amount) {
792
+ throw new CliError("invalid_arguments", "Pass --full or a positive --amount-cents.");
793
+ }
794
+ if (amount !== undefined && (!Number.isInteger(amount) || amount <= 0)) {
795
+ throw new CliError("invalid_arguments", "--amount-cents must be a positive integer.");
796
+ }
797
+ if (method === "tokens") {
798
+ const body = { invoiceId, lineItem, amount: amount, whoPays, reason };
799
+ return writeCommand(parsed, {
800
+ action: "refund invoice item as tokens",
801
+ target: { invoiceId, lineItem },
802
+ request: body,
803
+ }, async () => unwrap(await api.client.POST("/admin/stripe/refund-as-tokens", {
804
+ body,
805
+ })));
806
+ }
807
+ const body = {
808
+ invoiceId,
809
+ lineItem,
810
+ refundOrCredit: method,
811
+ fullRefund,
812
+ ...(amount ? { amount } : {}),
813
+ whoPays,
814
+ reason,
815
+ };
816
+ return writeCommand(parsed, {
817
+ action: `issue invoice ${method}`,
818
+ target: { invoiceId, lineItem },
819
+ request: body,
820
+ }, async () => unwrap(await api.client.POST("/admin/stripe/issue-credit-note", {
821
+ body,
822
+ })));
823
+ }
824
+ if (noun === "enrollments" && verb === "create") {
825
+ const userId = flagString(parsed, "user", { required: true });
826
+ const cohortId = flagString(parsed, "cohort", { required: true });
827
+ const firstChargeAt = flagString(parsed, "first-charge-at");
828
+ if (firstChargeAt && Number.isNaN(Date.parse(firstChargeAt))) {
829
+ throw new CliError("invalid_arguments", "--first-charge-at must be an ISO datetime, e.g. 2026-08-02T00:00:00Z.");
830
+ }
831
+ const body = {
832
+ userId,
833
+ cohortId,
834
+ sendEmail: hasFlag(parsed, "send-email"),
835
+ force: hasFlag(parsed, "force"),
836
+ ...(firstChargeAt
837
+ ? { firstChargeAt: new Date(firstChargeAt).toISOString() }
838
+ : {}),
839
+ };
840
+ // Unlike other writes, the offline preview here would hide the only facts
841
+ // that matter — the amount charged, whether anything is charged at all, and
842
+ // whether the kid is over capacity or out of school slots. Ask the backend
843
+ // for its own dry run (read-only, writes nothing) and put that in front of
844
+ // the human before the confirmation gate.
845
+ if (!hasFlag(parsed, "confirm")) {
846
+ const preview = unwrap(await api.client.POST("/admin/cohorts/enroll/", {
847
+ body: { ...body, dryRun: true },
848
+ }));
849
+ const b = preview.plan.billing;
850
+ const usd = (cents) => `${(cents / 100).toFixed(2)} USD`;
851
+ const recurrence = b.isRecurring ? `, recurring per ${b.interval}` : "";
852
+ // effectivePriceCents already has the course discount applied; credits are
853
+ // deducted later as an invoice line, so this is an upper bound.
854
+ const amount = b.discountKind || b.creditBalanceCents > 0
855
+ ? `${usd(b.effectivePriceCents)} (list ${usd(b.listPriceCents)}${b.discountKind ? `, ${b.discountKind} discount applied` : ""}${b.creditBalanceCents > 0 ? `; up to ${usd(b.creditBalanceCents)} of Recess credits may reduce this further` : ""})`
856
+ : usd(b.effectivePriceCents);
857
+ requireConfirmation(false, {
858
+ action: preview.plan.reusedEnrollmentId
859
+ ? "enroll kid into cohort by reusing an existing enrollment (no charge)"
860
+ : b.chargesImmediately
861
+ ? `enroll kid into cohort and CHARGE ${amount} NOW${recurrence}`
862
+ : `enroll kid into cohort with first charge of ${amount} on ${b.firstChargeAt}${recurrence}`,
863
+ target: {
864
+ kid: `${preview.plan.kid.firstName ?? ""} ${preview.plan.kid.lastName ?? ""}`.trim(),
865
+ userId,
866
+ cohort: preview.plan.cohort.name,
867
+ cohortId,
868
+ course: preview.plan.cohort.courseName,
869
+ },
870
+ request: { ...body, dryRun: false },
871
+ details: {
872
+ billing: b,
873
+ reusedEnrollmentId: preview.plan.reusedEnrollmentId,
874
+ warnings: preview.warnings,
875
+ ...(preview.warnings.length > 0 && !body.force
876
+ ? {
877
+ blocked: "These warnings will reject the write unless you also pass --force.",
878
+ }
879
+ : {}),
880
+ },
881
+ });
882
+ }
883
+ return unwrap(await api.client.POST("/admin/cohorts/enroll/", {
884
+ body: { ...body, dryRun: false },
885
+ }));
886
+ }
887
+ if (noun === "enrollments" && verb === "register-cohort") {
888
+ const enrollmentId = flagString(parsed, "enrollment", { required: true });
889
+ const userId = flagString(parsed, "user", { required: true });
890
+ const cohortId = flagString(parsed, "cohort", { required: true });
891
+ const body = {
892
+ cohortId,
893
+ userIds: [userId],
894
+ sendEmail: false,
895
+ enforcePendingEnrollmentCheck: false,
896
+ enrollmentId,
897
+ };
898
+ return writeCommand(parsed, {
899
+ action: "register cohort against enrollment",
900
+ target: { enrollmentId, userId, cohortId },
901
+ request: body,
902
+ }, async () => unwrap(await api.client.POST("/admin/cohorts/register/", { body })));
903
+ }
904
+ if (noun === "enrollments" && verb === "unregister-cohort") {
905
+ const userId = flagString(parsed, "user", { required: true });
906
+ const cohortId = flagString(parsed, "cohort", { required: true });
907
+ const body = { cohortId, userIds: [userId], cancelEnrollment: false };
908
+ return writeCommand(parsed, {
909
+ action: "unregister cohort without canceling enrollment",
910
+ target: { userId, cohortId },
911
+ request: body,
912
+ }, async () => unwrap(await api.client.POST("/admin/cohorts/unregister/", { body })));
913
+ }
914
+ if (noun === "enrollments" && verb === "get-for-subscription") {
915
+ const subscriptionId = flagString(parsed, "subscription", {
916
+ required: true,
917
+ });
918
+ return unwrap(await api.client.GET("/admin/stripe/enrollment-for-subscription", {
919
+ params: { query: { subscriptionId } },
920
+ }));
921
+ }
922
+ if (noun === "billing" && verb === "extend-trial") {
923
+ const subscriptionId = flagString(parsed, "subscription", {
924
+ required: true,
925
+ });
926
+ const trialEndRaw = flagString(parsed, "trial-end", { required: true });
927
+ const trialEnd = Math.floor(new Date(trialEndRaw).getTime() / 1000);
928
+ if (!Number.isFinite(trialEnd) || trialEnd <= Date.now() / 1000) {
929
+ throw new CliError("invalid_arguments", "--trial-end must be a valid future ISO date.");
930
+ }
931
+ const body = { subscriptionId, trialEnd };
932
+ return writeCommand(parsed, {
933
+ action: `extend subscription trial to ${trialEndRaw}`,
934
+ target: { subscriptionId },
935
+ request: body,
936
+ }, async () => unwrap(await api.client.POST("/admin/stripe/extend-trial", { body })));
937
+ }
938
+ if (noun === "billing" && verb === "cancel-subscription") {
939
+ const subscriptionId = flagString(parsed, "subscription", {
940
+ required: true,
941
+ });
942
+ const immediate = hasFlag(parsed, "immediate");
943
+ const restore = hasFlag(parsed, "restore");
944
+ const reason = flagString(parsed, "reason");
945
+ const body = {
946
+ immediate,
947
+ subscriptionId,
948
+ restore,
949
+ ...(reason ? { reason } : {}),
950
+ };
951
+ return writeCommand(parsed, {
952
+ action: restore
953
+ ? "restore a previously canceled subscription"
954
+ : immediate
955
+ ? "cancel subscription IMMEDIATELY (no period-end grace)"
956
+ : "cancel subscription at period end",
957
+ target: { subscriptionId },
958
+ request: body,
959
+ }, async () => unwrap(await api.client.POST("/admin/stripe/cancel-subscription", { body })));
960
+ }
961
+ if (noun === "payout") {
962
+ const area = verb;
963
+ const action = parsed.positionals[2];
964
+ if (area === "payruns" && action === "list") {
965
+ const status = flagStatusList(parsed, "status", PAYRUN_STATUSES);
966
+ const scheduleId = flagString(parsed, "schedule");
967
+ return unwrap(await api.client.GET("/admin/payout/payrun/payrun/", {
968
+ params: {
969
+ query: {
970
+ ...(status ? { status } : {}),
971
+ ...(scheduleId ? { scheduleId } : {}),
972
+ },
973
+ },
974
+ }));
975
+ }
976
+ if (area === "recipients" && action === "list") {
977
+ const search = flagString(parsed, "search");
978
+ const userId = flagString(parsed, "user");
979
+ const id = flagString(parsed, "id");
980
+ return unwrap(await api.client.GET("/admin/payout/recipient/", {
981
+ params: {
982
+ query: {
983
+ ...(search ? { search } : {}),
984
+ ...(userId ? { userId } : {}),
985
+ ...(id ? { id } : {}),
986
+ },
987
+ },
988
+ }));
989
+ }
990
+ if (area === "invoices" && action === "list") {
991
+ const payRunId = flagString(parsed, "payrun");
992
+ const accountId = flagString(parsed, "recipient");
993
+ const userId = flagString(parsed, "user");
994
+ const status = flagStatusList(parsed, "status", PAYOUT_INVOICE_STATUSES);
995
+ if (!payRunId && !accountId && !userId && !status) {
996
+ throw new CliError("invalid_arguments", "Pass at least one filter: --payrun, --recipient, --user, or --status.");
997
+ }
998
+ return unwrap(await api.client.GET("/admin/payout/invoices/", {
999
+ params: {
1000
+ query: {
1001
+ ...(payRunId ? { payRunId } : {}),
1002
+ ...(accountId ? { accountId } : {}),
1003
+ ...(userId ? { userId } : {}),
1004
+ ...(status ? { status } : {}),
1005
+ },
1006
+ },
1007
+ }));
1008
+ }
1009
+ if (area === "invoices" && action === "get") {
1010
+ const invoiceId = positional(parsed, 3, "invoice ID");
1011
+ return unwrap(await api.client.GET("/admin/payout/invoice/{id}", {
1012
+ params: { path: { id: invoiceId } },
1013
+ }));
1014
+ }
1015
+ if (area === "invoices" && action === "set-status") {
1016
+ const invoiceId = positional(parsed, 3, "invoice ID");
1017
+ const status = assertChoice(flagString(parsed, "status", { required: true }), ["IN_REVIEW", "OPEN", "PAID", "CANCELED"], "--status");
1018
+ const sendEmail = hasFlag(parsed, "send-email");
1019
+ const emailEffect = status === "IN_REVIEW" || status === "OPEN"
1020
+ ? sendEmail
1021
+ ? " and emails the guide"
1022
+ : " WITHOUT emailing the guide (pass --send-email to notify)"
1023
+ : "";
1024
+ const body = { status, sendEmail };
1025
+ return writeCommand(parsed, {
1026
+ action: `set payout invoice status to ${status} — ${PAYOUT_STATUS_SIDE_EFFECTS[status]}${emailEffect}`,
1027
+ target: { invoiceId },
1028
+ request: body,
1029
+ }, async () => unwrap(await api.client.PATCH("/admin/payout/invoice-status/{id}", {
1030
+ params: { path: { id: invoiceId } },
1031
+ body,
1032
+ })));
1033
+ }
1034
+ if (area === "items" && action === "add") {
1035
+ const invoiceId = flagString(parsed, "invoice", { required: true });
1036
+ const totalAmountCents = flagCents(parsed, "amount-cents", {
1037
+ required: true,
1038
+ });
1039
+ const description = flagString(parsed, "description", { required: true }).trim();
1040
+ if (!description) {
1041
+ throw new CliError("invalid_arguments", "--description must not be empty.");
1042
+ }
1043
+ let itemTimestampMs = flagItemDateMs(parsed);
1044
+ let itemDateSource = "--date flag";
1045
+ if (itemTimestampMs === undefined) {
1046
+ const detail = unwrap(await api.client.GET("/admin/payout/invoice/{id}", {
1047
+ params: { path: { id: invoiceId } },
1048
+ }));
1049
+ itemTimestampMs = penultimateCycleDayMs(detail.invoice.endDate);
1050
+ itemDateSource =
1051
+ "default: penultimate day of the invoice cycle (endDate - 1 day)";
1052
+ }
1053
+ const body = {
1054
+ invoiceId,
1055
+ totalAmountCents,
1056
+ description,
1057
+ itemTimestampMs,
1058
+ };
1059
+ return writeCommand(parsed, {
1060
+ action: "add custom payout invoice line item",
1061
+ target: { invoiceId },
1062
+ request: {
1063
+ ...body,
1064
+ itemDate: new Date(itemTimestampMs).toISOString(),
1065
+ itemDateSource,
1066
+ },
1067
+ }, async () => unwrap(await api.client.POST("/admin/payout/custom-invoice-item/", {
1068
+ body,
1069
+ })));
1070
+ }
1071
+ if (area === "items" && action === "edit") {
1072
+ const itemId = positional(parsed, 3, "line item ID");
1073
+ const amountCents = flagCents(parsed, "amount-cents");
1074
+ const description = flagString(parsed, "description");
1075
+ const itemDateMs = flagItemDateMs(parsed);
1076
+ if (amountCents === undefined &&
1077
+ description === undefined &&
1078
+ itemDateMs === undefined) {
1079
+ throw new CliError("invalid_arguments", "Pass at least one of --amount-cents, --description, or --date.");
1080
+ }
1081
+ // Custom line items carry no platform fee, so net always equals total.
1082
+ const body = {
1083
+ ...(amountCents !== undefined
1084
+ ? { totalAmountCents: amountCents, netAmountCents: amountCents }
1085
+ : {}),
1086
+ ...(description !== undefined ? { description } : {}),
1087
+ ...(itemDateMs !== undefined
1088
+ ? { itemDate: new Date(itemDateMs).toISOString() }
1089
+ : {}),
1090
+ };
1091
+ return writeCommand(parsed, {
1092
+ action: "edit payout invoice line item",
1093
+ target: { itemId },
1094
+ request: body,
1095
+ }, async () => unwrap(await api.client.PATCH("/admin/payout/invoice-item/{id}", {
1096
+ params: { path: { id: itemId } },
1097
+ body,
1098
+ })));
1099
+ }
1100
+ if (area === "items" && action === "delete") {
1101
+ const itemId = positional(parsed, 3, "line item ID");
1102
+ return writeCommand(parsed, {
1103
+ action: "soft-delete payout invoice line item",
1104
+ target: { itemId },
1105
+ request: {},
1106
+ }, async () => unwrap(await api.client.DELETE("/admin/payout/invoice-item/{id}", {
1107
+ params: { path: { id: itemId } },
1108
+ })));
1109
+ }
1110
+ throw new CliError("invalid_arguments", "Use payout payruns list, payout recipients list, payout invoices list|get|set-status, or payout items add|edit|delete.");
1111
+ }
1112
+ if (noun === "cohorts" && verb === "get") {
1113
+ const cohortId = positional(parsed, 2, "cohort ID");
1114
+ const eventsTab = flagString(parsed, "events-tab");
1115
+ return unwrap(await api.client.GET("/admin/cohorts/{id}/details/", {
1116
+ params: {
1117
+ path: { id: cohortId },
1118
+ query: eventsTab
1119
+ ? {
1120
+ eventsTab: assertChoice(eventsTab, ["ACTIVE", "ENDED", "CANCELED", "ARCHIVED"], "--events-tab"),
1121
+ }
1122
+ : {},
1123
+ },
1124
+ }));
1125
+ }
1126
+ if (noun === "cohorts" && verb === "parent-emails") {
1127
+ const cohortId = positional(parsed, 2, "cohort ID");
1128
+ return unwrap(await api.client.GET("/admin/cohorts/{id}/parent-emails/", {
1129
+ params: { path: { id: cohortId } },
1130
+ }));
1131
+ }
1132
+ if (noun === "cohorts" && verb === "end") {
1133
+ const cohortId = positional(parsed, 2, "cohort ID");
1134
+ const cancelSubscriptions = hasFlag(parsed, "cancel-subscriptions");
1135
+ return writeCommand(parsed, {
1136
+ action: cancelSubscriptions
1137
+ ? "end cohort AND CANCEL EVERY ACTIVE STRIPE SUBSCRIPTION at period end (exceptional)"
1138
+ : "end cohort (archives channels and future events; subscriptions untouched)",
1139
+ target: { cohortId },
1140
+ request: { cancelSubscriptions },
1141
+ }, async () => unwrap(await api.client.POST("/admin/cohorts/{id}/end/", {
1142
+ params: {
1143
+ path: { id: cohortId },
1144
+ query: cancelSubscriptions ? { cancelSubscriptions: "true" } : {},
1145
+ },
1146
+ })));
1147
+ }
1148
+ if (noun === "cohorts" &&
1149
+ (verb === "pause-billing" || verb === "resume-billing")) {
1150
+ const cohortId = positional(parsed, 2, "cohort ID");
1151
+ const pausing = verb === "pause-billing";
1152
+ const weeks = flagNumber(parsed, "weeks");
1153
+ if (pausing) {
1154
+ if (weeks === undefined ||
1155
+ !Number.isInteger(weeks) ||
1156
+ weeks < 1 ||
1157
+ weeks > 6) {
1158
+ throw new CliError("invalid_arguments", "--weeks must be an integer from 1 through 6.");
1159
+ }
1160
+ }
1161
+ else if (weeks !== undefined) {
1162
+ throw new CliError("invalid_arguments", "resume-billing does not accept --weeks.");
1163
+ }
1164
+ const body = pausing
1165
+ ? { action: "pause", pauseWeeks: weeks }
1166
+ : { action: "resume" };
1167
+ return writeCommand(parsed, {
1168
+ action: pausing
1169
+ ? `pause Stripe collection for every active enrollment in the cohort for ${weeks} week(s)`
1170
+ : "resume Stripe collection for the cohort's paused enrollments",
1171
+ target: { cohortId },
1172
+ request: body,
1173
+ }, async () => unwrap(await api.client.POST("/admin/cohorts/{id}/pause-billing/", {
1174
+ params: { path: { id: cohortId } },
1175
+ body,
1176
+ })));
1177
+ }
1178
+ if (noun === "cohorts" && verb === "email") {
1179
+ const cohortId = positional(parsed, 2, "cohort ID");
1180
+ const target = assertChoice(flagString(parsed, "target", { required: true }), ["ALL_PARENTS", "ALL_PARENTS_GUIDES"], "--target");
1181
+ const content = flagString(parsed, "content", { required: true }).trim();
1182
+ if (!content) {
1183
+ throw new CliError("invalid_arguments", "--content must not be empty.");
1184
+ }
1185
+ const body = { target, content };
1186
+ return writeCommand(parsed, {
1187
+ action: `send an email blast to ${target === "ALL_PARENTS" ? "ALL PARENTS" : "ALL PARENTS AND GUIDES"} of the cohort (exceptional outward email)`,
1188
+ target: { cohortId },
1189
+ request: body,
1190
+ }, async () => unwrap(await api.client.POST("/admin/cohorts/{id}/email/", {
1191
+ params: { path: { id: cohortId } },
1192
+ body,
1193
+ })));
1194
+ }
1195
+ if (noun === "events" && verb === "get") {
1196
+ const eventId = positional(parsed, 2, "event ID");
1197
+ return unwrap(await api.client.GET("/admin/events/{eventId}/", {
1198
+ params: { path: { eventId } },
1199
+ }));
1200
+ }
1201
+ if (noun === "events" && verb === "take-attendance") {
1202
+ const eventId = positional(parsed, 2, "event ID");
1203
+ const attended = flagIdList(parsed, "attended");
1204
+ const absent = flagIdList(parsed, "absent");
1205
+ const excused = flagIdList(parsed, "excused");
1206
+ if (attended.length === 0 && absent.length === 0 && excused.length === 0) {
1207
+ throw new CliError("invalid_arguments", "Pass at least one of --attended, --absent, or --excused.");
1208
+ }
1209
+ const seen = new Set();
1210
+ for (const id of [...attended, ...absent, ...excused]) {
1211
+ if (seen.has(id)) {
1212
+ throw new CliError("invalid_arguments", `User ${id} appears in more than one of --attended/--absent/--excused.`);
1213
+ }
1214
+ seen.add(id);
1215
+ }
1216
+ const attendance = [
1217
+ ...attended.map((userId) => ({ userId, attended: true })),
1218
+ ...absent.map((userId) => ({ userId, attended: false })),
1219
+ ...excused.map((userId) => ({
1220
+ userId,
1221
+ attended: false,
1222
+ excused: true,
1223
+ })),
1224
+ ];
1225
+ const body = { attendanceTakenAt: new Date().toISOString(), attendance };
1226
+ return writeCommand(parsed, {
1227
+ action: "take attendance (non-excused absentees may trigger automatic 'we missed you' emails to parents)",
1228
+ target: { eventId },
1229
+ request: body,
1230
+ }, async () => unwrap(await api.client.PATCH("/admin/events/{eventId}", {
1231
+ params: { path: { eventId } },
1232
+ body,
1233
+ })));
1234
+ }
1235
+ if (noun === "events" && verb === "cancel") {
1236
+ const eventId = positional(parsed, 2, "event ID");
1237
+ const reason = flagString(parsed, "reason", { required: true }).trim();
1238
+ if (!reason) {
1239
+ throw new CliError("invalid_arguments", "--reason must not be empty.");
1240
+ }
1241
+ const body = { reason };
1242
+ return writeCommand(parsed, {
1243
+ action: "cancel event AND NOTIFY: messages kid + parent channels, emails every registered parent, files credit-owed notes, posts to Slack",
1244
+ target: { eventId },
1245
+ request: body,
1246
+ }, async () => unwrap(await api.client.POST("/admin/events/{eventId}/cancel", {
1247
+ params: { path: { eventId } },
1248
+ body,
1249
+ })));
1250
+ }
1251
+ if (noun === "events" && verb === "set-status") {
1252
+ const eventId = positional(parsed, 2, "event ID");
1253
+ const status = assertChoice(flagString(parsed, "status", { required: true }), ["ACTIVE", "ENDED", "CANCELED"], "--status");
1254
+ const body = { status };
1255
+ return writeCommand(parsed, {
1256
+ action: `set event status to ${status} (silent administrative change — sends NO notifications; use "events cancel" to notify families)`,
1257
+ target: { eventId },
1258
+ request: body,
1259
+ }, async () => unwrap(await api.client.PATCH("/admin/events/{eventId}", {
1260
+ params: { path: { eventId } },
1261
+ body,
1262
+ })));
1263
+ }
1264
+ if (noun === "events" && (verb === "reschedule" || verb === "add")) {
1265
+ const cohortId = flagString(parsed, "cohort", { required: true });
1266
+ const startsAt = flagLocalDateTime(parsed, "starts-at", {
1267
+ required: true,
1268
+ });
1269
+ const timezone = flagString(parsed, "timezone");
1270
+ const lengthMins = flagNumber(parsed, "length-mins");
1271
+ if (lengthMins !== undefined &&
1272
+ (!Number.isInteger(lengthMins) || lengthMins <= 0)) {
1273
+ throw new CliError("invalid_arguments", "--length-mins must be a positive integer.");
1274
+ }
1275
+ const body = {
1276
+ startsAt,
1277
+ ...(timezone ? { timezone } : {}),
1278
+ ...(lengthMins !== undefined ? { eventLength: lengthMins } : {}),
1279
+ };
1280
+ if (verb === "reschedule") {
1281
+ const eventId = flagString(parsed, "event", { required: true });
1282
+ return writeCommand(parsed, {
1283
+ action: "reschedule event (cohort-local wall-clock time; tombstone-safe move)",
1284
+ target: { cohortId, eventId },
1285
+ request: body,
1286
+ }, async () => unwrap(await api.client.PATCH("/admin/cohorts/{id}/events/{eventId}", {
1287
+ params: { path: { id: cohortId, eventId } },
1288
+ body,
1289
+ })));
1290
+ }
1291
+ return writeCommand(parsed, {
1292
+ action: "add a one-off meeting (cohort-local wall-clock time)",
1293
+ target: { cohortId },
1294
+ request: body,
1295
+ }, async () => unwrap(await api.client.POST("/admin/cohorts/{id}/events/", {
1296
+ params: { path: { id: cohortId } },
1297
+ body,
1298
+ })));
1299
+ }
1300
+ if (noun === "registrations" && verb === "approve") {
1301
+ const registrationId = flagString(parsed, "registration", {
1302
+ required: true,
1303
+ });
1304
+ const body = { registrationId };
1305
+ return writeCommand(parsed, {
1306
+ action: "approve pending cohort registration (sends the welcome email)",
1307
+ target: { registrationId },
1308
+ request: body,
1309
+ }, async () => unwrap(await api.client.POST("/admin/cohorts/register/approve/", { body })));
1310
+ }
1311
+ if (noun === "registrations" && verb === "deny") {
1312
+ const cohortId = flagString(parsed, "cohort", { required: true });
1313
+ const userId = flagString(parsed, "user", { required: true });
1314
+ const body = { cohortId, userIds: [userId], cancelEnrollment: true };
1315
+ return writeCommand(parsed, {
1316
+ action: "deny registration: unregister from the cohort AND cancel the enrollment",
1317
+ target: { cohortId, userId },
1318
+ request: body,
1319
+ }, async () => unwrap(await api.client.POST("/admin/cohorts/unregister/", { body })));
1320
+ }
1321
+ if (noun === "onboarding") {
1322
+ if (verb === "status") {
1323
+ const familyId = positional(parsed, 2, "family ID");
1324
+ return unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/status", {
1325
+ params: { path: { familyId } },
1326
+ }));
1327
+ }
1328
+ if (verb === "kids") {
1329
+ const timePeriodDays = flagNumber(parsed, "time-period-days");
1330
+ const cohortId = flagString(parsed, "cohort");
1331
+ const limit = flagNumber(parsed, "limit");
1332
+ const stageFilterRaw = flagString(parsed, "stage-filter");
1333
+ const stageFilter = stageFilterRaw
1334
+ ? assertChoice(stageFilterRaw, ["all", "scheduled", "oriented", "course", "converted", "lost"], "--stage-filter")
1335
+ : undefined;
1336
+ return unwrap(await api.client.GET("/admin/onboarding/kids/", {
1337
+ params: {
1338
+ query: {
1339
+ ...(timePeriodDays !== undefined ? { timePeriodDays } : {}),
1340
+ ...(cohortId ? { cohortId } : {}),
1341
+ ...(limit !== undefined ? { limit } : {}),
1342
+ ...(stageFilter ? { stageFilter } : {}),
1343
+ },
1344
+ },
1345
+ }));
1346
+ }
1347
+ if (verb === "intake-session") {
1348
+ const familyId = positional(parsed, 2, "family ID");
1349
+ // A true read: look up the family's current IN_PROGRESS intake session
1350
+ // WITHOUT minting one (get.family-intake-session.ts). Merely viewing a
1351
+ // family must not create a blank session — use intake-session-create to
1352
+ // mint one. A 404 means "none yet" and is a clean result, not an error.
1353
+ try {
1354
+ return await api.rawGet(`/admin/onboarding/families/${encodeURIComponent(familyId)}/intake-session`);
1355
+ }
1356
+ catch (error) {
1357
+ if (error instanceof CliError &&
1358
+ typeof error.details === "object" &&
1359
+ error.details !== null &&
1360
+ error.details.status === 404) {
1361
+ return {
1362
+ intakeSession: null,
1363
+ familyId,
1364
+ message: "No intake session for this family yet.",
1365
+ };
1366
+ }
1367
+ throw error;
1368
+ }
1369
+ }
1370
+ if (verb === "intake-session-create") {
1371
+ const familyId = positional(parsed, 2, "family ID");
1372
+ // The explicit create: get-or-create the intake session. Mints a blank
1373
+ // IN_PROGRESS session when none exists (post.family-intake-session.ts,
1374
+ // freshIfFinished), so it is a write and goes through the confirmation
1375
+ // gate like every other mutating command.
1376
+ return writeCommand(parsed, {
1377
+ action: "create the family's parent intake session (mints a blank IN_PROGRESS session if none exists)",
1378
+ target: { familyId },
1379
+ request: {},
1380
+ }, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/intake-session", { params: { path: { familyId } } })));
1381
+ }
1382
+ if (verb === "set-stage") {
1383
+ const familyId = positional(parsed, 2, "family ID");
1384
+ const stage = assertChoice(flagString(parsed, "stage", { required: true }), ONBOARDING_STAGES, "--stage");
1385
+ // Read the current stage first so the preview can name the transition and
1386
+ // warn when it moves progress backward (re-locks capabilities).
1387
+ const current = unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/status", {
1388
+ params: { path: { familyId } },
1389
+ }));
1390
+ const from = current.onboardingStage;
1391
+ const backward = ONBOARDING_STAGES.indexOf(stage) < ONBOARDING_STAGES.indexOf(from);
1392
+ const body = { stage };
1393
+ return writeCommand(parsed, {
1394
+ action: `${backward ? "MOVE BACKWARD — re-locks progress: " : ""}set onboarding stage to ${stage} — ${ONBOARDING_STAGE_SIDE_EFFECTS[stage]}`,
1395
+ target: { familyId, from },
1396
+ request: body,
1397
+ }, async () => unwrap(await api.client.PATCH("/admin/onboarding/families/{familyId}/stage", { params: { path: { familyId } }, body })));
1398
+ }
1399
+ if (verb === "set-account-state") {
1400
+ const familyId = positional(parsed, 2, "family ID");
1401
+ const state = assertChoice(flagString(parsed, "state", { required: true }), ONBOARDING_ACCOUNT_STATES, "--state");
1402
+ const note = flagString(parsed, "note");
1403
+ const lockout = state === "PAUSED" || state === "BOOTED"
1404
+ ? " — locks the family out of paid capabilities"
1405
+ : "";
1406
+ const body = { state, ...(note !== undefined ? { note } : {}) };
1407
+ return writeCommand(parsed, {
1408
+ action: `set account state to ${state}${lockout}`,
1409
+ target: { familyId },
1410
+ request: body,
1411
+ }, async () => unwrap(await api.client.PATCH("/admin/onboarding/families/{familyId}/account-state", { params: { path: { familyId } }, body })));
1412
+ }
1413
+ if (verb === "attest") {
1414
+ const familyId = positional(parsed, 2, "family ID");
1415
+ const condition = assertChoice(flagString(parsed, "condition", { required: true }), ONBOARDING_CONDITIONS, "--condition");
1416
+ const revoke = hasFlag(parsed, "revoke");
1417
+ const note = flagString(parsed, "note");
1418
+ const body = {
1419
+ condition,
1420
+ attested: !revoke,
1421
+ ...(note !== undefined ? { note } : {}),
1422
+ };
1423
+ return writeCommand(parsed, {
1424
+ action: revoke
1425
+ ? `REVOKE attestation ${condition}`
1426
+ : `attest ${condition}`,
1427
+ target: { familyId },
1428
+ request: body,
1429
+ }, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/attest", { params: { path: { familyId } }, body })));
1430
+ }
1431
+ if (verb === "set-intake") {
1432
+ const familyId = positional(parsed, 2, "family ID");
1433
+ const sessionId = flagString(parsed, "session", { required: true });
1434
+ const dataRaw = flagString(parsed, "data", { required: true });
1435
+ const expectedUpdatedAt = flagString(parsed, "expected-updated-at");
1436
+ let collectedData;
1437
+ try {
1438
+ collectedData = JSON.parse(dataRaw);
1439
+ }
1440
+ catch {
1441
+ throw new CliError("invalid_arguments", "--data must be valid JSON for the intake collectedData object.");
1442
+ }
1443
+ if (expectedUpdatedAt !== undefined &&
1444
+ Number.isNaN(Date.parse(expectedUpdatedAt))) {
1445
+ throw new CliError("invalid_arguments", "--expected-updated-at must be an ISO-8601 timestamp (the intake-session read's updatedAt).");
1446
+ }
1447
+ const collected = collectedData;
1448
+ // The preview must do ZERO network so an offline preview stays honest
1449
+ // (admin-cli offline-preview contract). The optimistic-concurrency token
1450
+ // is resolved only on --confirm, inside the execute closure below — so a
1451
+ // plain preview never reaches out. Strict mode (--expected-updated-at)
1452
+ // shows the pinned token in the preview; the default fetches it at confirm.
1453
+ const previewRequest = {
1454
+ sessionId,
1455
+ collectedData: collected,
1456
+ ...(expectedUpdatedAt !== undefined ? { expectedUpdatedAt } : {}),
1457
+ };
1458
+ return writeCommand(parsed, {
1459
+ action: "write structured parent intake data for the family",
1460
+ target: { familyId, sessionId },
1461
+ request: previewRequest,
1462
+ }, async () => {
1463
+ // Resolve the CAS token now (only reached on --confirm):
1464
+ // - strict mode: pin the operator-supplied token. A stale one 409s
1465
+ // (unwrap → CliError → non-zero exit), protecting a parent's
1466
+ // concurrent confirm-flow autosave.
1467
+ // - default: fetch the CURRENT token and warn that edits made since
1468
+ // the preview are unprotected. The CLI is a last-resort admin tool;
1469
+ // pragmatic default + honest warning + strict opt-in.
1470
+ let token = expectedUpdatedAt;
1471
+ if (token === undefined) {
1472
+ const current = unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/intake-session", { params: { path: { familyId } } }));
1473
+ token = current.updatedAt;
1474
+ process.stderr.write("Warning: using current session token; edits made since your preview are not protected — pass --expected-updated-at for strict CAS.\n");
1475
+ }
1476
+ const body = {
1477
+ sessionId,
1478
+ collectedData: collected,
1479
+ expectedUpdatedAt: token,
1480
+ };
1481
+ return unwrap(await api.client.PUT("/admin/onboarding/families/{familyId}/intake", { params: { path: { familyId } }, body }));
1482
+ });
1483
+ }
1484
+ if (verb === "extract") {
1485
+ const familyId = positional(parsed, 2, "family ID");
1486
+ const sessionId = flagString(parsed, "session", { required: true });
1487
+ const transcriptFile = flagString(parsed, "transcript-file");
1488
+ const granolaRef = flagString(parsed, "granola");
1489
+ if ((transcriptFile && granolaRef) || (!transcriptFile && !granolaRef)) {
1490
+ throw new CliError("invalid_arguments", "Pass exactly one source: --transcript-file <path> or --granola <ref>.");
1491
+ }
1492
+ let transcript;
1493
+ if (transcriptFile) {
1494
+ try {
1495
+ transcript = await fs.readFile(path.resolve(transcriptFile), "utf8");
1496
+ }
1497
+ catch (error) {
1498
+ throw new CliError("invalid_arguments", `Could not read transcript file ${transcriptFile}: ${error instanceof Error ? error.message : String(error)}`);
1499
+ }
1500
+ }
1501
+ const body = {
1502
+ sessionId,
1503
+ ...(transcript !== undefined ? { transcript } : {}),
1504
+ ...(granolaRef ? { granolaRef } : {}),
1505
+ };
1506
+ // Keep raw transcript PII out of the confirmation preview (it prints to
1507
+ // stderr, agent logs, and approval records); the full body still goes to
1508
+ // the API on --confirm.
1509
+ const previewRequest = transcript !== undefined && transcriptFile
1510
+ ? {
1511
+ sessionId,
1512
+ path: path.resolve(transcriptFile),
1513
+ byteCount: Buffer.byteLength(transcript, "utf8"),
1514
+ sha256Prefix: createHash("sha256")
1515
+ .update(transcript)
1516
+ .digest("hex")
1517
+ .slice(0, 12),
1518
+ }
1519
+ : { sessionId, ...(granolaRef ? { granolaRef } : {}) };
1520
+ return writeCommand(parsed, {
1521
+ action: "extract intake from transcript via Claude and merge into session",
1522
+ target: {
1523
+ familyId,
1524
+ sessionId,
1525
+ source: transcriptFile ? "transcript" : "granola",
1526
+ },
1527
+ request: previewRequest,
1528
+ }, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/intake-extract", { params: { path: { familyId } }, body })));
1529
+ }
1530
+ throw new CliError("invalid_arguments", "Use onboarding status|kids|intake-session|intake-session-create|set-stage|set-account-state|attest|set-intake|extract.");
1531
+ }
1532
+ if (noun === "request" && verb === "get") {
1533
+ return api.rawGet(positional(parsed, 2, "request path"));
1534
+ }
1535
+ throw new CliError("unknown_command", `Unknown command: ${parsed.positionals.join(" ")}\n\n${HELP}`);
1536
+ }
1537
+ //# sourceMappingURL=cli.js.map