recess-cli 1.9.2 → 2.1.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.
@@ -0,0 +1,623 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { unwrap } from "../api.js";
5
+ import { flagNumber, flagString, hasFlag } from "../args.js";
6
+ import { CliError } from "../errors.js";
7
+ import { assertChoice, flagBooleanValue, flagIdList, flagInteger, flagIsoInstant, positional, readJsonFile, } from "./shared.js";
8
+ const ONBOARDING_STAGES = [
9
+ "LEGACY",
10
+ "PROVISIONED",
11
+ "PARENT_CONFIRMED",
12
+ "CLEARED_FOR_COHORT",
13
+ "COMPLETE",
14
+ ];
15
+ const ONBOARDING_STAGE_SIDE_EFFECTS = {
16
+ LEGACY: "the pre-onboarding baseline",
17
+ PROVISIONED: "kid accounts provisioned, awaiting parent confirmation",
18
+ PARENT_CONFIRMED: "parent has confirmed setup",
19
+ CLEARED_FOR_COHORT: "unlocks cohort registration for school families",
20
+ COMPLETE: "onboarding finished",
21
+ };
22
+ const ONBOARDING_ACCOUNT_STATES = [
23
+ "ACTIVE",
24
+ "PENDING_PAYMENT",
25
+ "PAUSED",
26
+ "BOOTED",
27
+ ];
28
+ const ONBOARDING_CONDITIONS = [
29
+ "app_downloaded",
30
+ "tutor_met",
31
+ "goals_loaded",
32
+ "ma_diagnostic",
33
+ ];
34
+ const ONBOARDING_COMMS_STATUSES = ["RESERVED", "SENT", "FAILED"];
35
+ const LIFECYCLE_PROMPT_KINDS = [
36
+ "TWO_WEEK_REVIEW",
37
+ "TWO_WEEK_CHECK_IN",
38
+ "TWO_MONTH_TESTIMONIAL",
39
+ ];
40
+ export async function runOnboardingCommand({ parsed, api, writeCommand, }) {
41
+ const noun = parsed.positionals[0] ?? "";
42
+ const verb = parsed.positionals[1] ?? "";
43
+ if (noun === "onboarding") {
44
+ if (verb === "status") {
45
+ const familyId = positional(parsed, 2, "family ID");
46
+ return unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/status", {
47
+ params: { path: { familyId } },
48
+ }));
49
+ }
50
+ if (verb === "queue") {
51
+ const partnerSlug = flagString(parsed, "school");
52
+ return unwrap(await api.client.GET("/admin/onboarding/queue", {
53
+ params: { query: partnerSlug ? { partnerSlug } : {} },
54
+ }));
55
+ }
56
+ if (verb === "kids") {
57
+ const timePeriodDays = flagNumber(parsed, "time-period-days");
58
+ const cohortId = flagString(parsed, "cohort");
59
+ const limit = flagNumber(parsed, "limit");
60
+ const stageFilterRaw = flagString(parsed, "stage-filter");
61
+ const stageFilter = stageFilterRaw
62
+ ? assertChoice(stageFilterRaw, ["all", "scheduled", "oriented", "course", "converted", "lost"], "--stage-filter")
63
+ : undefined;
64
+ return unwrap(await api.client.GET("/admin/onboarding/kids/", {
65
+ params: {
66
+ query: {
67
+ ...(timePeriodDays !== undefined ? { timePeriodDays } : {}),
68
+ ...(cohortId ? { cohortId } : {}),
69
+ ...(limit !== undefined ? { limit } : {}),
70
+ ...(stageFilter ? { stageFilter } : {}),
71
+ },
72
+ },
73
+ }));
74
+ }
75
+ if (verb === "timeline") {
76
+ const familyId = positional(parsed, 2, "family ID");
77
+ return unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/timeline", {
78
+ params: { path: { familyId } },
79
+ }));
80
+ }
81
+ if (verb === "readiness") {
82
+ const familyId = positional(parsed, 2, "family ID");
83
+ return unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/readiness", { params: { path: { familyId } } }));
84
+ }
85
+ if (verb === "active-tutors") {
86
+ const familyId = positional(parsed, 2, "family ID");
87
+ return unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/active-tutors", { params: { path: { familyId } } }));
88
+ }
89
+ if (verb === "meetings") {
90
+ const familyId = positional(parsed, 2, "family ID");
91
+ return unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/meetings", {
92
+ params: { path: { familyId } },
93
+ }));
94
+ }
95
+ if (verb === "cohort-options") {
96
+ const familyId = positional(parsed, 2, "family ID");
97
+ const kidUserId = flagString(parsed, "kid");
98
+ return unwrap(await api.client.GET("/admin/onboarding/cohort-options", {
99
+ params: {
100
+ query: { familyId, ...(kidUserId ? { kidUserId } : {}) },
101
+ },
102
+ }));
103
+ }
104
+ if (verb === "comms") {
105
+ const partnerSlug = flagString(parsed, "school");
106
+ const statusRaw = flagString(parsed, "status");
107
+ const status = statusRaw
108
+ ? assertChoice(statusRaw, ONBOARDING_COMMS_STATUSES, "--status")
109
+ : undefined;
110
+ const kind = flagString(parsed, "kind");
111
+ const limit = flagInteger(parsed, "limit", { min: 1, max: 200 });
112
+ const cursor = flagString(parsed, "cursor");
113
+ return unwrap(await api.client.GET("/admin/onboarding/comms", {
114
+ params: {
115
+ query: {
116
+ ...(partnerSlug ? { partnerSlug } : {}),
117
+ ...(status ? { status } : {}),
118
+ ...(kind ? { kind } : {}),
119
+ ...(limit !== undefined ? { limit } : {}),
120
+ ...(cursor ? { cursor } : {}),
121
+ },
122
+ },
123
+ }));
124
+ }
125
+ if (verb === "lifecycle-prompts") {
126
+ return unwrap(await api.client.GET("/admin/onboarding/lifecycle-prompts"));
127
+ }
128
+ if (verb === "orientation-sessions") {
129
+ return unwrap(await api.client.GET("/admin/onboarding/orientation-sessions/"));
130
+ }
131
+ if (verb === "ixl-preview") {
132
+ const familyId = positional(parsed, 2, "family ID");
133
+ const kidUserId = flagString(parsed, "kid", { required: true });
134
+ return unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/kids/{kidUserId}/provision/ixl/preview", { params: { path: { familyId, kidUserId } } }));
135
+ }
136
+ if (verb === "intake-session") {
137
+ const familyId = positional(parsed, 2, "family ID");
138
+ // A true read: look up the family's current IN_PROGRESS intake session
139
+ // WITHOUT minting one (get.family-intake-session.ts). Merely viewing a
140
+ // family must not create a blank session — use intake-session-create to
141
+ // mint one. A 404 means "none yet" and is a clean result, not an error.
142
+ try {
143
+ return await api.rawGet(`/admin/onboarding/families/${encodeURIComponent(familyId)}/intake-session`);
144
+ }
145
+ catch (error) {
146
+ if (error instanceof CliError &&
147
+ typeof error.details === "object" &&
148
+ error.details !== null &&
149
+ error.details.status === 404) {
150
+ return {
151
+ intakeSession: null,
152
+ familyId,
153
+ message: "No intake session for this family yet.",
154
+ };
155
+ }
156
+ throw error;
157
+ }
158
+ }
159
+ if (verb === "intake-session-create") {
160
+ const familyId = positional(parsed, 2, "family ID");
161
+ // The explicit create: get-or-create the intake session. Mints a blank
162
+ // IN_PROGRESS session when none exists (post.family-intake-session.ts,
163
+ // freshIfFinished), so it is a write and goes through the confirmation
164
+ // gate like every other mutating command.
165
+ return writeCommand(parsed, {
166
+ action: "create the family's parent intake session (mints a blank IN_PROGRESS session if none exists)",
167
+ target: { familyId },
168
+ request: {},
169
+ }, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/intake-session", { params: { path: { familyId } } })));
170
+ }
171
+ if (verb === "set-stage") {
172
+ const familyId = positional(parsed, 2, "family ID");
173
+ const stage = assertChoice(flagString(parsed, "stage", { required: true }), ONBOARDING_STAGES, "--stage");
174
+ // Read the current stage first so the preview can name the transition and
175
+ // warn when it moves progress backward (re-locks capabilities).
176
+ const current = unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/status", {
177
+ params: { path: { familyId } },
178
+ }));
179
+ const from = current.onboardingStage;
180
+ const backward = ONBOARDING_STAGES.indexOf(stage) < ONBOARDING_STAGES.indexOf(from);
181
+ const body = { stage };
182
+ return writeCommand(parsed, {
183
+ action: `${backward ? "MOVE BACKWARD — re-locks progress: " : ""}set onboarding stage to ${stage} — ${ONBOARDING_STAGE_SIDE_EFFECTS[stage]}`,
184
+ target: { familyId, from },
185
+ request: body,
186
+ }, async () => unwrap(await api.client.PATCH("/admin/onboarding/families/{familyId}/stage", { params: { path: { familyId } }, body })));
187
+ }
188
+ if (verb === "set-account-state") {
189
+ const familyId = positional(parsed, 2, "family ID");
190
+ const state = assertChoice(flagString(parsed, "state", { required: true }), ONBOARDING_ACCOUNT_STATES, "--state");
191
+ const note = flagString(parsed, "note");
192
+ const lockout = state === "PAUSED" || state === "BOOTED"
193
+ ? " — locks the family out of paid capabilities"
194
+ : "";
195
+ const body = { state, ...(note !== undefined ? { note } : {}) };
196
+ return writeCommand(parsed, {
197
+ action: `set account state to ${state}${lockout}`,
198
+ target: { familyId },
199
+ request: body,
200
+ }, async () => unwrap(await api.client.PATCH("/admin/onboarding/families/{familyId}/account-state", { params: { path: { familyId } }, body })));
201
+ }
202
+ if (verb === "set-primary-tutor") {
203
+ const familyId = positional(parsed, 2, "family ID");
204
+ const tutorUserId = flagString(parsed, "tutor");
205
+ const clear = hasFlag(parsed, "clear");
206
+ if ((tutorUserId && clear) || (!tutorUserId && !clear)) {
207
+ throw new CliError("invalid_arguments", "Pass exactly one of --tutor <user-id> or --clear.");
208
+ }
209
+ const body = { tutorUserId: clear ? null : tutorUserId };
210
+ return writeCommand(parsed, {
211
+ action: clear
212
+ ? "clear the family's primary tutor"
213
+ : "set the family's primary tutor (requires an active assignment in this family)",
214
+ target: { familyId },
215
+ request: body,
216
+ }, async () => unwrap(await api.client.PATCH("/admin/onboarding/families/{familyId}/primary-tutor", { params: { path: { familyId } }, body })));
217
+ }
218
+ if (verb === "attest") {
219
+ const familyId = positional(parsed, 2, "family ID");
220
+ const condition = assertChoice(flagString(parsed, "condition", { required: true }), ONBOARDING_CONDITIONS, "--condition");
221
+ const revoke = hasFlag(parsed, "revoke");
222
+ const note = flagString(parsed, "note");
223
+ const body = {
224
+ condition,
225
+ attested: !revoke,
226
+ ...(note !== undefined ? { note } : {}),
227
+ };
228
+ return writeCommand(parsed, {
229
+ action: revoke
230
+ ? `REVOKE attestation ${condition}`
231
+ : `attest ${condition}`,
232
+ target: { familyId },
233
+ request: body,
234
+ }, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/attest", { params: { path: { familyId } }, body })));
235
+ }
236
+ if (verb === "clear-for-cohort") {
237
+ const familyId = positional(parsed, 2, "family ID");
238
+ return writeCommand(parsed, {
239
+ action: "clear the family for cohort placement after the server rechecks stage, account, kids, goals, tutor, and attestations",
240
+ target: { familyId },
241
+ request: {},
242
+ }, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/clear-for-cohort", { params: { path: { familyId } } })));
243
+ }
244
+ if (verb === "register-cohort") {
245
+ const familyId = positional(parsed, 2, "family ID");
246
+ const kidUserId = flagString(parsed, "kid", { required: true });
247
+ const eventSeriesId = flagString(parsed, "cohort", { required: true });
248
+ const body = { kidUserId, eventSeriesId };
249
+ return writeCommand(parsed, {
250
+ action: "register the kid in an eligible school cohort (staff placement may occur before the normal family-stage gate)",
251
+ target: { familyId, kidUserId, eventSeriesId },
252
+ request: body,
253
+ }, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/register-cohort", { params: { path: { familyId } }, body })));
254
+ }
255
+ if (verb === "set-kid-grade") {
256
+ const familyId = positional(parsed, 2, "family ID");
257
+ const kidUserId = flagString(parsed, "kid", { required: true });
258
+ const grade = flagInteger(parsed, "grade", { min: 1, max: 12 });
259
+ const clear = hasFlag(parsed, "clear");
260
+ if ((grade !== undefined && clear) || (grade === undefined && !clear)) {
261
+ throw new CliError("invalid_arguments", "Pass exactly one of --grade 1..12 or --clear.");
262
+ }
263
+ const body = { currentGrade: clear ? null : grade };
264
+ return writeCommand(parsed, {
265
+ action: clear
266
+ ? "clear the kid's current grade back to uncollected"
267
+ : `set the kid's current grade to ${grade}`,
268
+ target: { familyId, kidUserId },
269
+ request: body,
270
+ }, async () => unwrap(await api.client.PATCH("/admin/onboarding/families/{familyId}/kids/{kidUserId}/grade", { params: { path: { familyId, kidUserId } }, body })));
271
+ }
272
+ if (verb === "seed-feed") {
273
+ const familyId = positional(parsed, 2, "family ID");
274
+ const kidUserId = flagString(parsed, "kid", { required: true });
275
+ return writeCommand(parsed, {
276
+ action: "seed the kid's feed interests from the newest populated family intake that contains this kid",
277
+ target: { familyId, kidUserId },
278
+ request: {},
279
+ }, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/kids/{kidUserId}/seed-feed", { params: { path: { familyId, kidUserId } } })));
280
+ }
281
+ if (verb === "generate-summaries") {
282
+ const familyId = positional(parsed, 2, "family ID");
283
+ return writeCommand(parsed, {
284
+ action: "regenerate the current intake's parent narrative summaries with AI (can take about 30 seconds)",
285
+ target: { familyId },
286
+ request: {},
287
+ }, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/intake/generate-summaries", { params: { path: { familyId } } })));
288
+ }
289
+ if (verb === "provision-math-academy") {
290
+ const familyId = positional(parsed, 2, "family ID");
291
+ const kidUserId = flagString(parsed, "kid", { required: true });
292
+ const grade = flagInteger(parsed, "grade", { min: 1, max: 12 });
293
+ const body = grade === undefined ? {} : { grade };
294
+ return writeCommand(parsed, {
295
+ action: "provision or reconnect the kid's Math Academy account (vendor write; cannot be automatically undone)",
296
+ target: { familyId, kidUserId },
297
+ request: body,
298
+ }, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/kids/{kidUserId}/provision/math-academy", { params: { path: { familyId, kidUserId } }, body })));
299
+ }
300
+ if (verb === "provision-ixl") {
301
+ const familyId = positional(parsed, 2, "family ID");
302
+ const kidUserId = flagString(parsed, "kid", { required: true });
303
+ const credentialsFile = flagString(parsed, "credentials-file");
304
+ const body = credentialsFile
305
+ ? await readJsonFile(credentialsFile, "IXL credentials file")
306
+ : undefined;
307
+ const password = typeof body?.password === "string" ? body.password : undefined;
308
+ const safeRequest = body
309
+ ? {
310
+ ...body,
311
+ ...(password
312
+ ? {
313
+ password: "[redacted]",
314
+ passwordSha256: createHash("sha256")
315
+ .update(password)
316
+ .digest("hex"),
317
+ }
318
+ : {}),
319
+ }
320
+ : {};
321
+ return writeCommand(parsed, {
322
+ action: "add or restore the kid in the IXL roster and optionally store the supplied login",
323
+ target: { familyId, kidUserId },
324
+ request: safeRequest,
325
+ }, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/kids/{kidUserId}/provision/ixl", {
326
+ params: { path: { familyId, kidUserId } },
327
+ ...(body ? { body } : {}),
328
+ })));
329
+ }
330
+ if (verb === "remove-ixl") {
331
+ const familyId = positional(parsed, 2, "family ID");
332
+ const kidUserId = flagString(parsed, "kid", { required: true });
333
+ return writeCommand(parsed, {
334
+ action: "remove the kid from the next IXL roster snapshot (stored login is retained)",
335
+ target: { familyId, kidUserId },
336
+ request: {},
337
+ }, async () => unwrap(await api.client.DELETE("/admin/onboarding/families/{familyId}/kids/{kidUserId}/provision/ixl", { params: { path: { familyId, kidUserId } } })));
338
+ }
339
+ if (verb === "ixl-sync") {
340
+ const apply = hasFlag(parsed, "apply");
341
+ if (!apply) {
342
+ return unwrap(await api.client.POST("/admin/onboarding/ixl/sync", {
343
+ body: { dryRun: true },
344
+ }));
345
+ }
346
+ return writeCommand(parsed, {
347
+ action: "UPLOAD the complete current IXL roster snapshot (learners omitted from it are deactivated by the vendor)",
348
+ target: { vendor: "IXL" },
349
+ request: { dryRun: false },
350
+ }, async () => unwrap(await api.client.POST("/admin/onboarding/ixl/sync", {
351
+ body: { dryRun: false },
352
+ })));
353
+ }
354
+ if (verb === "send-comms") {
355
+ const subjectKey = flagString(parsed, "subject", { required: true });
356
+ const kind = flagString(parsed, "kind", { required: true });
357
+ const body = { subjectKey, kind };
358
+ return writeCommand(parsed, {
359
+ action: "send the selected onboarding lifecycle communication now (timing bypass only; rollout and preference gates still apply)",
360
+ target: { subjectKey },
361
+ request: body,
362
+ }, async () => unwrap(await api.client.POST("/admin/onboarding/comms/send-now", {
363
+ body,
364
+ })));
365
+ }
366
+ if (verb === "send-welcome" || verb === "send-payment-email") {
367
+ const familyId = positional(parsed, 2, "family ID");
368
+ const resend = hasFlag(parsed, "resend");
369
+ const payment = verb === "send-payment-email";
370
+ return writeCommand(parsed, {
371
+ action: `${resend ? "RE-SEND" : "send"} the family's ${payment ? "enrollment payment" : "plan-ready welcome"} email`,
372
+ target: { familyId },
373
+ request: { resend },
374
+ }, async () => {
375
+ if (payment) {
376
+ return unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/send-payment-email", { params: { path: { familyId } }, body: { resend } }));
377
+ }
378
+ return unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/send-welcome", { params: { path: { familyId } }, body: { resend } }));
379
+ });
380
+ }
381
+ if (verb === "send-recommendations") {
382
+ const familyId = positional(parsed, 2, "family ID");
383
+ const courseIds = flagIdList(parsed, "courses");
384
+ if (courseIds.length < 1 || courseIds.length > 8) {
385
+ throw new CliError("invalid_arguments", "--courses must contain from 1 through 8 comma-separated course IDs.");
386
+ }
387
+ const resend = hasFlag(parsed, "resend");
388
+ const body = { courseIds, resend };
389
+ return writeCommand(parsed, {
390
+ action: `${resend ? "RE-SEND" : "send"} the family's class recommendations email and unlock Explore`,
391
+ target: { familyId },
392
+ request: body,
393
+ }, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/send-recommendations", { params: { path: { familyId } }, body })));
394
+ }
395
+ if (verb === "set-lifecycle-prompt") {
396
+ const kind = assertChoice(positional(parsed, 2, "lifecycle prompt kind"), LIFECYCLE_PROMPT_KINDS, "prompt kind");
397
+ const enabled = flagBooleanValue(parsed, "enabled");
398
+ const delayDays = flagInteger(parsed, "delay-days", {
399
+ min: 0,
400
+ max: 365,
401
+ });
402
+ const windowDays = flagInteger(parsed, "window-days", {
403
+ min: 1,
404
+ max: 730,
405
+ });
406
+ const snoozeDays = flagInteger(parsed, "snooze-days", {
407
+ min: 0,
408
+ max: 90,
409
+ });
410
+ const maxPrompts = flagInteger(parsed, "max-prompts", {
411
+ min: 1,
412
+ max: 20,
413
+ });
414
+ const body = {
415
+ ...(enabled !== undefined ? { enabled } : {}),
416
+ ...(delayDays !== undefined ? { delayDays } : {}),
417
+ ...(windowDays !== undefined ? { windowDays } : {}),
418
+ ...(snoozeDays !== undefined ? { snoozeDays } : {}),
419
+ ...(maxPrompts !== undefined ? { maxPrompts } : {}),
420
+ };
421
+ if (Object.keys(body).length === 0) {
422
+ throw new CliError("invalid_arguments", "Pass at least one lifecycle prompt field to update.");
423
+ }
424
+ return writeCommand(parsed, {
425
+ action: "change the live family lifecycle prompt schedule (exact-admin; affects future prompts)",
426
+ target: { kind },
427
+ request: body,
428
+ }, async () => unwrap(await api.client.PATCH("/admin/onboarding/lifecycle-prompts/{kind}", { params: { path: { kind } }, body })));
429
+ }
430
+ if (verb === "override-lifecycle-prompt") {
431
+ const kind = assertChoice(positional(parsed, 2, "lifecycle prompt kind"), LIFECYCLE_PROMPT_KINDS, "prompt kind");
432
+ const familyId = positional(parsed, 3, "family ID");
433
+ const action = assertChoice(flagString(parsed, "action", { required: true }), ["delay", "skip", "clear"], "--action");
434
+ const days = flagInteger(parsed, "days", { min: 1, max: 365 });
435
+ if ((action === "delay" && days === undefined) ||
436
+ (action !== "delay" && days !== undefined)) {
437
+ throw new CliError("invalid_arguments", action === "delay"
438
+ ? "--days is required when --action is delay."
439
+ : "--days is accepted only when --action is delay.");
440
+ }
441
+ const body = { action, ...(days !== undefined ? { days } : {}) };
442
+ return writeCommand(parsed, {
443
+ action: `${action} the ${kind} lifecycle prompt for this family${days === undefined ? "" : ` by ${days} day(s)`}`,
444
+ target: { kind, familyId },
445
+ request: body,
446
+ }, async () => unwrap(await api.client.POST("/admin/onboarding/lifecycle-prompts/{kind}/families/{familyId}/override", { params: { path: { kind, familyId } }, body })));
447
+ }
448
+ if (verb === "orientation-create") {
449
+ const startsAt = flagIsoInstant(parsed, "starts-at", {
450
+ required: true,
451
+ });
452
+ const endsAt = flagIsoInstant(parsed, "ends-at");
453
+ const capacity = flagInteger(parsed, "capacity", { min: 0, max: 1000 });
454
+ if (endsAt && Date.parse(endsAt) <= Date.parse(startsAt)) {
455
+ throw new CliError("invalid_arguments", "--ends-at must be later than --starts-at.");
456
+ }
457
+ const body = {
458
+ startsAt,
459
+ ...(endsAt ? { endsAt } : {}),
460
+ ...(capacity !== undefined ? { capacity } : {}),
461
+ };
462
+ return writeCommand(parsed, {
463
+ action: "schedule a new family orientation session",
464
+ target: { startsAt },
465
+ request: body,
466
+ }, async () => unwrap(await api.client.POST("/admin/onboarding/orientation-sessions/", {
467
+ body,
468
+ })));
469
+ }
470
+ if (verb === "orientation-set-status") {
471
+ const sessionId = positional(parsed, 2, "orientation session ID");
472
+ const status = assertChoice(flagString(parsed, "status", { required: true }), ["CANCELED", "COMPLETED"], "--status");
473
+ const body = { status };
474
+ return writeCommand(parsed, {
475
+ action: `${status === "CANCELED" ? "cancel" : "complete"} the orientation session (canceled sessions cannot be reopened)`,
476
+ target: { sessionId },
477
+ request: body,
478
+ }, async () => unwrap(await api.client.PATCH("/admin/onboarding/orientation-sessions/{sessionId}/", { params: { path: { sessionId } }, body })));
479
+ }
480
+ if (verb === "orientation-attendance") {
481
+ const sessionId = positional(parsed, 2, "orientation session ID");
482
+ const familyId = flagString(parsed, "family", { required: true });
483
+ const attended = flagBooleanValue(parsed, "attended");
484
+ if (attended === undefined) {
485
+ throw new CliError("invalid_arguments", "Missing required --attended true|false.");
486
+ }
487
+ const body = { familyId, attended };
488
+ return writeCommand(parsed, {
489
+ action: attended
490
+ ? "mark the family as having attended this orientation session"
491
+ : "clear the family's orientation attendance mark",
492
+ target: { sessionId, familyId },
493
+ request: body,
494
+ }, async () => unwrap(await api.client.POST("/admin/onboarding/orientation-sessions/{sessionId}/attendance/", { params: { path: { sessionId } }, body })));
495
+ }
496
+ if (verb === "meeting-reschedule") {
497
+ const meetingId = positional(parsed, 2, "meeting ID");
498
+ const start = flagIsoInstant(parsed, "starts-at", { required: true });
499
+ const reason = flagString(parsed, "note");
500
+ const body = { start, ...(reason ? { reason } : {}) };
501
+ return writeCommand(parsed, {
502
+ action: "reschedule the Cal.com booking (provisional until its webhook settles; do not blindly retry an ambiguous outcome)",
503
+ target: { meetingId },
504
+ request: body,
505
+ }, async () => unwrap(await api.client.POST("/admin/onboarding/meetings/{meetingId}/reschedule", { params: { path: { meetingId } }, body })));
506
+ }
507
+ if (verb === "meeting-cancel") {
508
+ const meetingId = positional(parsed, 2, "meeting ID");
509
+ const reason = flagString(parsed, "reason", { required: true }).trim();
510
+ if (!reason) {
511
+ throw new CliError("invalid_arguments", "--reason must not be empty.");
512
+ }
513
+ const body = { reason };
514
+ return writeCommand(parsed, {
515
+ action: "cancel the Cal.com booking and email the family the supplied reason (provisional until its webhook settles)",
516
+ target: { meetingId },
517
+ request: body,
518
+ }, async () => unwrap(await api.client.POST("/admin/onboarding/meetings/{meetingId}/cancel", { params: { path: { meetingId } }, body })));
519
+ }
520
+ if (verb === "set-intake") {
521
+ const familyId = positional(parsed, 2, "family ID");
522
+ const sessionId = flagString(parsed, "session", { required: true });
523
+ const dataRaw = flagString(parsed, "data", { required: true });
524
+ const expectedUpdatedAt = flagString(parsed, "expected-updated-at");
525
+ let collectedData;
526
+ try {
527
+ collectedData = JSON.parse(dataRaw);
528
+ }
529
+ catch {
530
+ throw new CliError("invalid_arguments", "--data must be valid JSON for the intake collectedData object.");
531
+ }
532
+ if (expectedUpdatedAt !== undefined &&
533
+ Number.isNaN(Date.parse(expectedUpdatedAt))) {
534
+ throw new CliError("invalid_arguments", "--expected-updated-at must be an ISO-8601 timestamp (the intake-session read's updatedAt).");
535
+ }
536
+ const collected = collectedData;
537
+ // The preview must do ZERO network so an offline preview stays honest
538
+ // (admin-cli offline-preview contract). The optimistic-concurrency token
539
+ // is resolved only on --confirm, inside the execute closure below — so a
540
+ // plain preview never reaches out. Strict mode (--expected-updated-at)
541
+ // shows the pinned token in the preview; the default fetches it at confirm.
542
+ const previewRequest = {
543
+ sessionId,
544
+ collectedData: collected,
545
+ ...(expectedUpdatedAt !== undefined ? { expectedUpdatedAt } : {}),
546
+ };
547
+ return writeCommand(parsed, {
548
+ action: "write structured parent intake data for the family",
549
+ target: { familyId, sessionId },
550
+ request: previewRequest,
551
+ }, async () => {
552
+ // Resolve the CAS token now (only reached on --confirm):
553
+ // - strict mode: pin the operator-supplied token. A stale one 409s
554
+ // (unwrap → CliError → non-zero exit), protecting a parent's
555
+ // concurrent confirm-flow autosave.
556
+ // - default: fetch the CURRENT token and warn that edits made since
557
+ // the preview are unprotected. The CLI is a last-resort admin tool;
558
+ // pragmatic default + honest warning + strict opt-in.
559
+ let token = expectedUpdatedAt;
560
+ if (token === undefined) {
561
+ const current = unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/intake-session", { params: { path: { familyId } } }));
562
+ token = current.updatedAt;
563
+ process.stderr.write("Warning: using current session token; edits made since your preview are not protected — pass --expected-updated-at for strict CAS.\n");
564
+ }
565
+ const body = {
566
+ sessionId,
567
+ collectedData: collected,
568
+ expectedUpdatedAt: token,
569
+ };
570
+ return unwrap(await api.client.PUT("/admin/onboarding/families/{familyId}/intake", { params: { path: { familyId } }, body }));
571
+ });
572
+ }
573
+ if (verb === "extract") {
574
+ const familyId = positional(parsed, 2, "family ID");
575
+ const sessionId = flagString(parsed, "session", { required: true });
576
+ const transcriptFile = flagString(parsed, "transcript-file");
577
+ const granolaRef = flagString(parsed, "granola");
578
+ if ((transcriptFile && granolaRef) || (!transcriptFile && !granolaRef)) {
579
+ throw new CliError("invalid_arguments", "Pass exactly one source: --transcript-file <path> or --granola <ref>.");
580
+ }
581
+ let transcript;
582
+ if (transcriptFile) {
583
+ try {
584
+ transcript = await fs.readFile(path.resolve(transcriptFile), "utf8");
585
+ }
586
+ catch (error) {
587
+ throw new CliError("invalid_arguments", `Could not read transcript file ${transcriptFile}: ${error instanceof Error ? error.message : String(error)}`);
588
+ }
589
+ }
590
+ const body = {
591
+ sessionId,
592
+ ...(transcript !== undefined ? { transcript } : {}),
593
+ ...(granolaRef ? { granolaRef } : {}),
594
+ };
595
+ // Keep raw transcript PII out of the confirmation preview (it prints to
596
+ // stderr, agent logs, and approval records); the full body still goes to
597
+ // the API on --confirm.
598
+ const previewRequest = transcript !== undefined && transcriptFile
599
+ ? {
600
+ sessionId,
601
+ path: path.resolve(transcriptFile),
602
+ byteCount: Buffer.byteLength(transcript, "utf8"),
603
+ sha256Prefix: createHash("sha256")
604
+ .update(transcript)
605
+ .digest("hex")
606
+ .slice(0, 12),
607
+ }
608
+ : { sessionId, ...(granolaRef ? { granolaRef } : {}) };
609
+ return writeCommand(parsed, {
610
+ action: "extract intake from transcript via Claude and merge into session",
611
+ target: {
612
+ familyId,
613
+ sessionId,
614
+ source: transcriptFile ? "transcript" : "granola",
615
+ },
616
+ request: previewRequest,
617
+ }, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/intake-extract", { params: { path: { familyId } }, body })));
618
+ }
619
+ throw new CliError("invalid_arguments", "Unknown onboarding command. Run `recess onboarding --help` for the current command list.");
620
+ }
621
+ throw new CliError("invalid_arguments", "Unknown onboarding command.");
622
+ }
623
+ //# sourceMappingURL=onboarding.js.map