recess-cli 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -9,6 +9,7 @@ import { clearStoredSession, deleteProfile, listProfiles, resolveConfig, savePro
9
9
  import { agentContext, buildCommandSchema, scopedHelp, validateInvocation, } from "./command-schema.js";
10
10
  import { runApplicationsCommand } from "./commands/applications.js";
11
11
  import { runOnboardingCommand } from "./commands/onboarding.js";
12
+ import { runSchoolCommand } from "./commands/school.js";
12
13
  import { assertChoice, flagIdList, positional, readJsonFile, readJsonValue, } from "./commands/shared.js";
13
14
  import { CliError } from "./errors.js";
14
15
  import { listFeedback, submitFeedback } from "./feedback.js";
@@ -1156,6 +1157,14 @@ function parseGoalTemplateDocument(doc) {
1156
1157
  (typeof rawSortOrder !== "number" || !Number.isInteger(rawSortOrder))) {
1157
1158
  throw new CliError("invalid_arguments", 'Template file field "sortOrder" must be an integer.');
1158
1159
  }
1160
+ const rawCoinAmount = doc.coinAmount;
1161
+ if (rawCoinAmount !== undefined &&
1162
+ rawCoinAmount !== null &&
1163
+ (typeof rawCoinAmount !== "number" ||
1164
+ !Number.isInteger(rawCoinAmount) ||
1165
+ rawCoinAmount < 1)) {
1166
+ throw new CliError("invalid_arguments", 'Template file field "coinAmount" must be a positive integer.');
1167
+ }
1159
1168
  const kind = assertChoice(optionalDocString(doc, "kind") ?? "SIMPLE", GOAL_TEMPLATE_KINDS, "kind");
1160
1169
  const setupAudience = assertChoice(optionalDocString(doc, "setupAudience") ?? "KID_FRIENDLY", GOAL_TEMPLATE_SETUP_AUDIENCES, "setupAudience");
1161
1170
  const starterTierRaw = optionalDocString(doc, "starterTier");
@@ -1175,6 +1184,9 @@ function parseGoalTemplateDocument(doc) {
1175
1184
  ...(optionalDocString(doc, "imageUrl") === undefined
1176
1185
  ? {}
1177
1186
  : { imageUrl: optionalDocString(doc, "imageUrl") }),
1187
+ ...(rawCoinAmount === undefined || rawCoinAmount === null
1188
+ ? {}
1189
+ : { coinAmount: rawCoinAmount }),
1178
1190
  ...(optionalDocString(doc, "category") === undefined
1179
1191
  ? {}
1180
1192
  : { category: optionalDocString(doc, "category") }),
@@ -2248,6 +2260,9 @@ export async function runCommand(argv) {
2248
2260
  if (noun === "applications" || noun === "quotes") {
2249
2261
  return runApplicationsCommand({ parsed, api, writeCommand });
2250
2262
  }
2263
+ if (noun === "school") {
2264
+ return runSchoolCommand({ parsed, api, writeCommand });
2265
+ }
2251
2266
  if (noun === "cohorts" && verb === "search") {
2252
2267
  const search = parsed.positionals.slice(2).join(" ").trim();
2253
2268
  if (!search)
@@ -3084,7 +3099,18 @@ export async function runCommand(argv) {
3084
3099
  }
3085
3100
  if (verb === "create") {
3086
3101
  const filePath = flagString(parsed, "file", { required: true });
3087
- const document = parseGoalTemplateDocument(await readJsonFile(filePath, "Template file"));
3102
+ const authoredDocument = parseGoalTemplateDocument(await readJsonFile(filePath, "Template file"));
3103
+ const coinAmountOverride = flagNumber(parsed, "coin-amount");
3104
+ if (coinAmountOverride !== undefined &&
3105
+ (!Number.isInteger(coinAmountOverride) || coinAmountOverride < 1)) {
3106
+ throw new CliError("invalid_arguments", "--coin-amount must be a positive integer.");
3107
+ }
3108
+ const document = {
3109
+ ...authoredDocument,
3110
+ ...(coinAmountOverride === undefined
3111
+ ? {}
3112
+ : { coinAmount: coinAmountOverride }),
3113
+ };
3088
3114
  // C3 read-only preflight, for the same reason `enrollments create` has
3089
3115
  // one: the consequences an approver must weigh are resolved SERVER-side.
3090
3116
  // Which setupHandler runs, what shape of goal students get, and which
@@ -3110,6 +3136,7 @@ export async function runCommand(argv) {
3110
3136
  category: document.category ?? null,
3111
3137
  tags: document.tags,
3112
3138
  isStarter: document.isStarter ?? false,
3139
+ coinAmount: document.coinAmount ?? null,
3113
3140
  },
3114
3141
  details: {
3115
3142
  resolvedSetupHandler: validation.setupHandler,
@@ -3206,6 +3233,11 @@ export async function runCommand(argv) {
3206
3233
  const tags = flagString(parsed, "tags");
3207
3234
  const kind = flagString(parsed, "kind");
3208
3235
  const sortOrder = flagNumber(parsed, "sort-order");
3236
+ const coinAmount = flagNumber(parsed, "coin-amount");
3237
+ if (coinAmount !== undefined &&
3238
+ (!Number.isInteger(coinAmount) || coinAmount < 1)) {
3239
+ throw new CliError("invalid_arguments", "--coin-amount must be a positive integer.");
3240
+ }
3209
3241
  const isStarterRaw = flagString(parsed, "is-starter");
3210
3242
  const setupAudienceRaw = flagString(parsed, "setup-audience");
3211
3243
  const isStarter = isStarterRaw === undefined
@@ -3222,6 +3254,10 @@ export async function runCommand(argv) {
3222
3254
  ...(flagString(parsed, "emoji")
3223
3255
  ? { emoji: flagString(parsed, "emoji") }
3224
3256
  : {}),
3257
+ ...(flagString(parsed, "image-url")
3258
+ ? { imageUrl: flagString(parsed, "image-url") }
3259
+ : {}),
3260
+ ...(coinAmount === undefined ? {} : { coinAmount }),
3225
3261
  ...(flagString(parsed, "category")
3226
3262
  ? { category: flagString(parsed, "category") }
3227
3263
  : {}),
@@ -3259,7 +3295,7 @@ export async function runCommand(argv) {
3259
3295
  // shape that caused the template incident; editing an existing spec goes
3260
3296
  // through the guarded /ai patch path with its destructive-change token.
3261
3297
  if (Object.keys(body).length === 1) {
3262
- throw new CliError("invalid_arguments", "Pass at least one field to change (--title, --description, --emoji, --category, --tags, --sort-order, --is-starter, --setup-audience, --kind, --agent-instructions-file, --output-template-file).");
3298
+ throw new CliError("invalid_arguments", "Pass at least one field to change (--title, --description, --emoji, --image-url, --coin-amount, --category, --tags, --sort-order, --is-starter, --setup-audience, --kind, --agent-instructions-file, --output-template-file).");
3263
3299
  }
3264
3300
  return writeCommand(parsed, {
3265
3301
  action: "update goal template metadata (never its setupWorkflowSpec)",
@@ -3270,6 +3306,28 @@ export async function runCommand(argv) {
3270
3306
  body,
3271
3307
  })));
3272
3308
  }
3309
+ if (verb === "generate-image") {
3310
+ const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
3311
+ const current = unwrap(await api.client.GET("/ai/goal-templates/{id}", {
3312
+ params: { path: { id } },
3313
+ }));
3314
+ const prompt = flagString(parsed, "prompt");
3315
+ return writeCommand(parsed, {
3316
+ action: "queue paid GPT-Image-2 art regeneration",
3317
+ target: {
3318
+ templateId: id,
3319
+ slug: current.slug,
3320
+ title: current.title,
3321
+ },
3322
+ request: { prompt: prompt ?? null },
3323
+ details: {
3324
+ costNote: "This queues one paid 1024×1024 GPT-Image-2 generation and replaces the template image when it finishes. New templates already generate art automatically.",
3325
+ },
3326
+ }, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/generate-image", {
3327
+ params: { path: { id } },
3328
+ body: prompt ? { prompt } : {},
3329
+ })));
3330
+ }
3273
3331
  if (verb === "delete") {
3274
3332
  const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
3275
3333
  const expectedVersion = requiredExpectedVersion(parsed);
@@ -3476,9 +3534,31 @@ export async function runCommand(argv) {
3476
3534
  body: { studentUserId, answers },
3477
3535
  })));
3478
3536
  }
3479
- throw new CliError("invalid_arguments", "Use goal-templates list|get|versions|validate-spec|create|patch-spec|set-metadata|delete|snapshot-files|capture-snapshot|apply|apply-starter.");
3537
+ throw new CliError("invalid_arguments", "Use goal-templates list|get|versions|validate-spec|create|patch-spec|set-metadata|generate-image|delete|snapshot-files|capture-snapshot|apply|apply-starter.");
3480
3538
  }
3481
3539
  if (noun === "goals" && verb !== "files" && verb !== "pdf") {
3540
+ if (verb === "complete" || verb === "undo-completion") {
3541
+ const goalId = positional(parsed, 2, "goal ID");
3542
+ const undo = verb === "undo-completion";
3543
+ return writeCommand(parsed, {
3544
+ action: undo
3545
+ ? "undo goal completion and reverse its coin reward"
3546
+ : "complete a goal and grant its one-time coin reward",
3547
+ target: { goalId },
3548
+ request: { source: "STAFF" },
3549
+ details: undo
3550
+ ? {
3551
+ note: "Undo is refused if the student has already spent enough coins that the reward cannot be reversed.",
3552
+ }
3553
+ : undefined,
3554
+ }, async () => unwrap(undo
3555
+ ? await api.client.POST("/ai/goals/{goalId}/undo-completion", {
3556
+ params: { path: { goalId } },
3557
+ })
3558
+ : await api.client.POST("/ai/goals/{goalId}/complete", {
3559
+ params: { path: { goalId } },
3560
+ })));
3561
+ }
3482
3562
  if (verb === "list") {
3483
3563
  const userId = flagString(parsed, "student", { required: true });
3484
3564
  return unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
@@ -3789,7 +3869,7 @@ export async function runCommand(argv) {
3789
3869
  }
3790
3870
  throw new CliError("invalid_arguments", "Use goals queue get|set.");
3791
3871
  }
3792
- throw new CliError("invalid_arguments", "Use goals list|create|edit|delete|queue|files|pdf.");
3872
+ throw new CliError("invalid_arguments", "Use goals list|create|edit|delete|complete|undo-completion|queue|files|pdf.");
3793
3873
  }
3794
3874
  if (noun === "students") {
3795
3875
  if (verb === "list") {
@@ -15,6 +15,7 @@ const BOOLEAN_FLAGS = new Set([
15
15
  "full",
16
16
  "help",
17
17
  "immediate",
18
+ "keep-tokens",
18
19
  "include-deleted",
19
20
  "json",
20
21
  "mirrored",
@@ -37,6 +37,17 @@ const LIFECYCLE_PROMPT_KINDS = [
37
37
  "TWO_WEEK_CHECK_IN",
38
38
  "TWO_MONTH_TESTIMONIAL",
39
39
  ];
40
+ // device-pairing.ts PAIRING_TTL_MS — the code dies 5 minutes after it is
41
+ // minted, so the confirm step is what starts the clock, not the preview.
42
+ const PAIRING_CODE_TTL_MINUTES = 5;
43
+ function personName(firstName, lastName, fallback) {
44
+ return [firstName, lastName].filter(Boolean).join(" ") || fallback;
45
+ }
46
+ function summaryPreview(text, max = 600) {
47
+ if (!text)
48
+ return null;
49
+ return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
50
+ }
40
51
  export async function runOnboardingCommand({ parsed, api, writeCommand, }) {
41
52
  const noun = parsed.positionals[0] ?? "";
42
53
  const verb = parsed.positionals[1] ?? "";
@@ -156,6 +167,26 @@ export async function runOnboardingCommand({ parsed, api, writeCommand, }) {
156
167
  throw error;
157
168
  }
158
169
  }
170
+ if (verb === "reviews") {
171
+ const familyId = positional(parsed, 2, "family ID");
172
+ // The family workspace's review card: the handoff session(s) for this
173
+ // family. get.reviews.ts binds the family scope to the canonical (oldest)
174
+ // guardian — the same one the intake commands operate on — so a
175
+ // two-guardian family never surfaces the other guardian's session.
176
+ // `guardianMissing: true` with an empty list means the family has no
177
+ // guardian at all, not that no session was ever started.
178
+ return unwrap(await api.client.GET("/ai/onboarding/reviews", {
179
+ params: { query: { familyId } },
180
+ }));
181
+ }
182
+ if (verb === "review") {
183
+ const sessionId = positional(parsed, 2, "onboarding session ID");
184
+ // The full handoff record behind the workspace's handoff page: summary,
185
+ // collected data, artifacts, progress. Read this before mark-reviewed.
186
+ return unwrap(await api.client.GET("/ai/onboarding/reviews/{id}", {
187
+ params: { path: { id: sessionId } },
188
+ }));
189
+ }
159
190
  if (verb === "intake-session-create") {
160
191
  const familyId = positional(parsed, 2, "family ID");
161
192
  // The explicit create: get-or-create the intake session. Mints a blank
@@ -616,6 +647,149 @@ export async function runOnboardingCommand({ parsed, api, writeCommand, }) {
616
647
  request: previewRequest,
617
648
  }, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/intake-extract", { params: { path: { familyId } }, body })));
618
649
  }
650
+ if (verb === "contracts") {
651
+ const kidUserId = flagString(parsed, "kid");
652
+ // The route takes no filter: it returns the newest 100 contracts across
653
+ // ALL families (get.contracts.ts), so --kid narrows client-side and a
654
+ // contract older than that window is simply not in the window.
655
+ const result = unwrap(await api.client.GET("/admin/enrollment-contracts/"));
656
+ if (!kidUserId)
657
+ return result;
658
+ return {
659
+ ...result,
660
+ contracts: result.contracts.filter((contract) => contract.studentUserId === kidUserId),
661
+ scope: "newest 100 contracts across all families, filtered by --kid",
662
+ };
663
+ }
664
+ if (verb === "send-contract") {
665
+ const kidUserId = positional(parsed, 2, "kid user ID");
666
+ const tuitionCents = flagInteger(parsed, "tuition-cents", { min: 0 });
667
+ const parentEmail = flagString(parsed, "parent-email");
668
+ const partnerName = flagString(parsed, "partner-name");
669
+ const partnerEmail = flagString(parsed, "partner-email");
670
+ // Read-only preflight. Three things the approver must see before a legal
671
+ // document goes out under someone's name: that this id is the intended
672
+ // KID, that a live contract is not already out with the parent (the
673
+ // workspace hides its button in that case — here --resend is the explicit
674
+ // override), and who the signature request will actually reach.
675
+ const { user } = unwrap(await api.client.GET("/admin/users/{userId}", {
676
+ params: { path: { userId: kidUserId } },
677
+ }));
678
+ if (user.role !== "KID") {
679
+ throw new CliError("invalid_arguments", `User ${kidUserId} has role ${user.role}; an enrollment contract is sent for a kid.`);
680
+ }
681
+ if (!user.familyId) {
682
+ throw new CliError("invalid_arguments", `Kid ${kidUserId} has no family, so there is no guardian to sign.`);
683
+ }
684
+ const familyId = user.familyId;
685
+ const [contracts, status] = await Promise.all([
686
+ unwrap(await api.client.GET("/admin/enrollment-contracts/")),
687
+ unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/status", {
688
+ params: { path: { familyId } },
689
+ })),
690
+ ]);
691
+ const live = contracts.contracts.find((contract) => contract.studentUserId === kidUserId &&
692
+ contract.status !== "DECLINED" &&
693
+ contract.status !== "VOIDED");
694
+ if (live && !hasFlag(parsed, "resend")) {
695
+ throw new CliError("invalid_arguments", `Contract ${live.id} for this kid is already ${live.status} (sent ${live.sentAt}). Sending again originates a SECOND Documenso document to the parent — pass --resend if that is what you mean.`);
696
+ }
697
+ const body = {
698
+ studentUserId: kidUserId,
699
+ ...(tuitionCents !== undefined ? { tuitionCents } : {}),
700
+ ...(parentEmail ? { parentEmail } : {}),
701
+ ...(partnerName ? { partnerName } : {}),
702
+ ...(partnerEmail ? { partnerEmail } : {}),
703
+ };
704
+ return writeCommand(parsed, {
705
+ action: "originate the two-signer WonderED enrollment contract (partner signs first, then the parent) and email the signature request",
706
+ target: { kidUserId, familyId, familyName: status.familyName },
707
+ request: body,
708
+ details: {
709
+ student: personName(user.firstName, user.lastName, kidUserId),
710
+ // The send resolves the family's OLDEST guardian; this is the
711
+ // family's predicted parent email (the oldest EMAIL-BEARING
712
+ // guardian), so the two can differ when the oldest guardian has no
713
+ // email — which is the case the route 400s on. --parent-email pins
714
+ // the signer either way.
715
+ parentEmailPredicted: status.welcome.recipientEmail,
716
+ ...(parentEmail ? { parentEmailOverride: parentEmail } : {}),
717
+ tuition: tuitionCents === undefined
718
+ ? "resolved server-side from the family's newest ACCEPTED quote (the send 400s when no accepted quote covers this kid) — pass --tuition-cents to pin the amount in this approval"
719
+ : `$${(tuitionCents / 100).toFixed(2)}`,
720
+ partnerSigner: partnerName || partnerEmail
721
+ ? { partnerName, partnerEmail }
722
+ : "the WONDERED_SIGNATORY_* env pair",
723
+ ...(live
724
+ ? {
725
+ resend: `Overriding live contract ${live.id} (${live.status}); this creates a SECOND document, it does not replace the first.`,
726
+ }
727
+ : {}),
728
+ note: "Contracts normally auto-send when onboarding finalizes (enrollment-contract-autosend.ts). This command is for families priced after the fact, or whose auto-send was skipped for want of an accepted quote.",
729
+ },
730
+ }, async () => unwrap(await api.client.POST("/admin/enrollment-contracts/send/", { body })));
731
+ }
732
+ if (verb === "mark-reviewed") {
733
+ const sessionId = positional(parsed, 2, "onboarding session ID");
734
+ // Read-only preflight: the human approving this signs their name to a
735
+ // specific family's handoff, so the preview must carry the summary they
736
+ // are attesting to — and post.review.ts accepts ONLY HANDED_OFF, so
737
+ // refuse the states it would reject before the gate, not after approval.
738
+ const session = unwrap(await api.client.GET("/ai/onboarding/reviews/{id}", {
739
+ params: { path: { id: sessionId } },
740
+ }));
741
+ if (session.status !== "HANDED_OFF" && session.status !== "REVIEWED") {
742
+ throw new CliError("invalid_arguments", `Onboarding session ${sessionId} is ${session.status}; only a HANDED_OFF session can be marked reviewed. A COMPLETED session still needs the parent confirmation that creates the kid accounts.`);
743
+ }
744
+ const reviewer = session.reviewer
745
+ ? personName(session.reviewer.firstName, session.reviewer.lastName, session.reviewer.id)
746
+ : null;
747
+ return writeCommand(parsed, {
748
+ action: "mark the parent onboarding handoff as reviewed by you",
749
+ target: { sessionId, familyId: session.familyId },
750
+ request: {},
751
+ details: {
752
+ status: session.status,
753
+ parent: personName(session.parent.firstName, session.parent.lastName, session.parent.email ?? session.parent.id),
754
+ handoffSentAt: session.handoffSentAt,
755
+ // The stamp attests to THIS text; show it with the approval ask.
756
+ handoffSummary: summaryPreview(session.handoffSummary),
757
+ fullRecord: `recess --json onboarding review ${sessionId}`,
758
+ ...(session.status === "REVIEWED"
759
+ ? {
760
+ note: `Already reviewed by ${reviewer ?? "another admin"} at ${session.handoffReviewedAt ?? "an unrecorded time"}. Confirming is a first-reviewer-wins no-op that preserves that attribution.`,
761
+ }
762
+ : {}),
763
+ },
764
+ }, async () => unwrap(await api.client.POST("/ai/onboarding/reviews/{id}", {
765
+ params: { path: { id: sessionId } },
766
+ })));
767
+ }
768
+ if (verb === "pairing-code") {
769
+ const kidUserId = positional(parsed, 2, "kid user ID");
770
+ // Read-only preflight: name the exact kid whose account this code signs
771
+ // in, and refuse a non-KID before the gate (the backend enforces the
772
+ // same rule, and an admin can pair ANY kid — there is no family fence to
773
+ // catch a mistyped id).
774
+ const { user } = unwrap(await api.client.GET("/admin/users/{userId}", {
775
+ params: { path: { userId: kidUserId } },
776
+ }));
777
+ if (user.role !== "KID") {
778
+ throw new CliError("invalid_arguments", `User ${kidUserId} has role ${user.role}; pairing codes can only be issued for kids.`);
779
+ }
780
+ return writeCommand(parsed, {
781
+ action: "issue a single-use device-pairing code that signs this kid into a new device",
782
+ target: { kidUserId, familyId: user.familyId },
783
+ request: { kidUserId },
784
+ details: {
785
+ kid: personName(user.firstName, user.lastName, kidUserId),
786
+ expiresInMinutes: PAIRING_CODE_TTL_MINUTES,
787
+ credential: "The result is a live sign-in credential (token, shortCode, pairingUrl). Read it to the family for the device in front of them; never paste it into a ticket, log, or shared channel.",
788
+ },
789
+ }, async () => unwrap(await api.client.POST("/auth/device-pairing/generate/", {
790
+ body: { kidUserId },
791
+ })));
792
+ }
619
793
  throw new CliError("invalid_arguments", "Unknown onboarding command. Run `recess onboarding --help` for the current command list.");
620
794
  }
621
795
  throw new CliError("invalid_arguments", "Unknown onboarding command.");
@@ -0,0 +1,465 @@
1
+ import { unwrap } from "../api.js";
2
+ import { flagList, flagString, hasFlag } from "../args.js";
3
+ import { CliError } from "../errors.js";
4
+ import { assertChoice, flagInteger, flagIsoInstant, positional, readJsonFile, } from "./shared.js";
5
+ const SCHOOL_TIERS = [
6
+ "social",
7
+ "academics",
8
+ "lite",
9
+ "complete",
10
+ "platform",
11
+ ];
12
+ const RESOLVE_ACTIONS = ["waive", "cancel"];
13
+ const RECONCILIATION_OUTCOMES = ["refunded", "kept", "written_off"];
14
+ /** post.start-memberships.ts caps the body at ten kids per call. */
15
+ const MEMBERSHIP_KID_LIMIT = 10;
16
+ function dollars(cents) {
17
+ return `$${(cents / 100).toFixed(2)}`;
18
+ }
19
+ function kidName(firstName, lastName, fallback) {
20
+ return [firstName, lastName].filter(Boolean).join(" ") || fallback;
21
+ }
22
+ /**
23
+ * `"<kid-id>:<tier>[:<slots>[:<enrollment-cents>]]"` — the same
24
+ * colon-delimited per-kid spec `applications enroll --kid` uses. The 4th field
25
+ * is the child's own first-month charge (":0" = free); OMITTING it inherits the
26
+ * family-level default, and when neither is given the server refuses the whole
27
+ * conversion as PRICE_UNDECIDED rather than guessing a price.
28
+ */
29
+ function parseKidTierSpec(spec) {
30
+ const [kidId, tier, slots, cents, ...rest] = spec.split(":");
31
+ if (!kidId || !tier || rest.length > 0) {
32
+ throw new CliError("invalid_arguments", `--kid "${spec}" must be "<kid-id>:<tier>[:<slots>[:<enrollment-cents>]]".`);
33
+ }
34
+ const number = (value, label) => {
35
+ const parsed = Number(value);
36
+ if (!Number.isInteger(parsed) || parsed < 0) {
37
+ throw new CliError("invalid_arguments", `--kid "${spec}": ${label} must be a nonnegative integer.`);
38
+ }
39
+ return parsed;
40
+ };
41
+ return {
42
+ kidId,
43
+ tierId: assertChoice(tier, SCHOOL_TIERS, `--kid "${spec}" tier`),
44
+ ...(slots ? { slotsPerKid: number(slots, "slots") } : {}),
45
+ ...(cents
46
+ ? { enrollmentPaymentCents: number(cents, "enrollment cents") }
47
+ : {}),
48
+ };
49
+ }
50
+ export async function runSchoolCommand({ parsed, api, writeCommand, }) {
51
+ const verb = parsed.positionals[1] ?? "";
52
+ /**
53
+ * The school roster is the ONE read behind every command here: it carries the
54
+ * institution, each family's stage/account state and kids, the live
55
+ * enrollment payment, and the open reconciliation items. Every write below
56
+ * previews from this same response, so the approval names the same facts the
57
+ * dashboard shows rather than a second, differently-shaped guess.
58
+ */
59
+ const roster = async (slug) => unwrap(await api.client.GET("/admin/partner/{slug}/families", {
60
+ params: { path: { slug } },
61
+ }));
62
+ const requireSlug = () => flagString(parsed, "school", { required: true });
63
+ if (verb === "families") {
64
+ return roster(requireSlug());
65
+ }
66
+ if (verb === "list") {
67
+ return unwrap(await api.client.GET("/admin/partner/"));
68
+ }
69
+ if (verb === "family-search") {
70
+ const search = flagString(parsed, "query", { required: true });
71
+ const limit = flagInteger(parsed, "limit", { min: 1, max: 100 });
72
+ return unwrap(await api.client.GET("/admin/partner/{slug}/family-search", {
73
+ params: {
74
+ path: { slug: requireSlug() },
75
+ query: { search, ...(limit !== undefined ? { limit } : {}) },
76
+ },
77
+ }));
78
+ }
79
+ if (verb === "convert") {
80
+ const familyId = positional(parsed, 2, "family ID");
81
+ const slug = requireSlug();
82
+ const kids = flagList(parsed, "kid").map(parseKidTierSpec);
83
+ if (kids.length === 0) {
84
+ throw new CliError("invalid_arguments", '--kid is required, once per kid: "<kid-id>:<tier>[:<slots>[:<enrollment-cents>]]". Every kid in the family must be assigned a tier, same rule as enrollment.');
85
+ }
86
+ const creditAmountCents = flagInteger(parsed, "credit-cents", {
87
+ min: 0,
88
+ max: 1_000_000,
89
+ });
90
+ const enrollmentPaymentCentsPerKid = flagInteger(parsed, "enrollment-payment-cents", { min: 0 });
91
+ const note = flagString(parsed, "note");
92
+ const body = {
93
+ familyId,
94
+ kids,
95
+ ...(creditAmountCents !== undefined ? { creditAmountCents } : {}),
96
+ ...(enrollmentPaymentCentsPerKid !== undefined
97
+ ? { enrollmentPaymentCentsPerKid }
98
+ : {}),
99
+ ...(note ? { note } : {}),
100
+ };
101
+ const unpriced = kids.filter((kid) => kid.enrollmentPaymentCents === undefined &&
102
+ enrollmentPaymentCentsPerKid === undefined);
103
+ if (unpriced.length > 0) {
104
+ throw new CliError("invalid_arguments", `No first-month price decided for ${unpriced
105
+ .map((kid) => kid.kidId)
106
+ .join(", ")}. Give each kid a 4th spec field (":0" for free) or pass --enrollment-payment-cents for the family; the server refuses an undecided price (PRICE_UNDECIDED).`);
107
+ }
108
+ // Read-only preflight on the family's own state — the roster cannot help
109
+ // here, because a family being converted INTO the program is not on it yet.
110
+ const status = unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/status", {
111
+ params: { path: { familyId } },
112
+ }));
113
+ const familyKidIds = new Set(status.kids.map((kid) => kid.id));
114
+ const foreign = kids
115
+ .map((kid) => kid.kidId)
116
+ .filter((id) => !familyKidIds.has(id));
117
+ if (foreign.length > 0) {
118
+ throw new CliError("invalid_arguments", `Not kids in this family: ${foreign.join(", ")}.`);
119
+ }
120
+ const missing = status.kids.filter((kid) => !kids.some((k) => k.kidId === kid.id));
121
+ return writeCommand(parsed, {
122
+ action: "convert this marketplace family INTO the school program (links the institution, funds tokens, cancels live memberships)",
123
+ target: { familyId, slug, familyName: status.familyName },
124
+ request: body,
125
+ details: {
126
+ kids: kids.map((kid) => ({
127
+ kid: status.kids.find((row) => row.id === kid.kidId)?.firstName ??
128
+ kid.kidId,
129
+ tier: kid.tierId,
130
+ slots: kid.slotsPerKid ?? "the tier default",
131
+ firstMonth: kid.enrollmentPaymentCents === undefined
132
+ ? enrollmentPaymentCentsPerKid === undefined
133
+ ? "undecided"
134
+ : `${dollars(enrollmentPaymentCentsPerKid)} (family default)`
135
+ : dollars(kid.enrollmentPaymentCents),
136
+ })),
137
+ ...(missing.length > 0
138
+ ? {
139
+ unassignedKids: missing.map((kid) => kid.firstName ?? kid.id),
140
+ unassignedWarning: "These kids of the family were NOT given a tier here. They come out unassigned with 0 slots until an admin picks one.",
141
+ }
142
+ : {}),
143
+ consequences: [
144
+ "Cancels live membership subscriptions at period end and turns off membership credits.",
145
+ "Funds each kid top-up-to-target for the month; a lower --credit-cents is NOT topped back up until the next UTC month (it shares the cron's idempotency key).",
146
+ "Existing cohort-enrollment subscriptions are deliberately left running — they migrate to token rails at their next renewal.",
147
+ "Any nonzero total holds the family at PENDING_PAYMENT and gates them out of the parent app until they pay at /school/pay.",
148
+ "ADMIN-only (exact-admin): a GUIDE or PROGRAM session gets 403.",
149
+ ],
150
+ reverse: `The inverse is \`school revert ${familyId} --school ${slug}\`, which is fenced off in production.`,
151
+ },
152
+ }, async () => unwrap(await api.client.POST("/admin/partner/{slug}/convert-family", {
153
+ params: { path: { slug } },
154
+ body,
155
+ })));
156
+ }
157
+ if (verb === "revert") {
158
+ const familyId = positional(parsed, 2, "family ID");
159
+ const slug = requireSlug();
160
+ const revokeTokens = !hasFlag(parsed, "keep-tokens");
161
+ const note = flagString(parsed, "note");
162
+ const { institution, families } = await roster(slug);
163
+ const family = families.find((row) => row.id === familyId);
164
+ if (!family) {
165
+ throw new CliError("invalid_arguments", `Family ${familyId} is not in ${institution.name} (${slug}). Revert only applies to a family currently in that school program.`);
166
+ }
167
+ return writeCommand(parsed, {
168
+ action: "convert this family OUT of the school program and back to marketplace billing",
169
+ target: { familyId, slug, familyName: family.name },
170
+ request: { familyId, revokeTokens, ...(note ? { note } : {}) },
171
+ details: {
172
+ institution: institution.name,
173
+ accountState: family.accountState,
174
+ onboardingStage: family.onboardingStage,
175
+ kids: family.kids.map((kid) => ({
176
+ id: kid.id,
177
+ name: kidName(kid.firstName, kid.lastName, kid.id),
178
+ creditBalance: kid.creditBalance,
179
+ })),
180
+ revokeTokens,
181
+ liveEnrollmentPayment: family.liveEnrollmentPayment
182
+ ? {
183
+ id: family.liveEnrollmentPayment.id,
184
+ status: family.liveEnrollmentPayment.status,
185
+ amountDue: dollars(family.liveEnrollmentPayment.amountDueCents),
186
+ }
187
+ : null,
188
+ consequences: [
189
+ revokeTokens
190
+ ? "Revokes each kid's remaining school token balance (shown above)."
191
+ : "LEAVES each kid's school token balance in place (--keep-tokens).",
192
+ "Cancels the family's program membership; kids come out with NO membership. The follow-up is `school start-memberships`, which charges their card.",
193
+ "Class registrations keep running on purpose — they start billing the family's own card at renewal.",
194
+ "ADMIN-only (exact-admin): a GUIDE or PROGRAM session gets 403.",
195
+ ],
196
+ fence: "Gated on SCHOOL_REVERT_ENABLED, which is fail-closed and unset in every deployed environment — production refuses until settlement ships. A 409 here is that door, not a bad request.",
197
+ retry: "A 409 `revert_in_flight` means an earlier attempt is still settling; rerun the unchanged command with the SAME operation key rather than minting a new one.",
198
+ },
199
+ }, async () => unwrap(await api.client.POST("/admin/partner/{slug}/revert-family", {
200
+ params: { path: { slug } },
201
+ // Announce that this client reads the two-phase union; without the
202
+ // header the server sends the pre-union façade, which reports a
203
+ // still-settling revert as finished (revert-wire.ts).
204
+ headers: { "x-recess-revert-union": "1" },
205
+ body: { familyId, revokeTokens, ...(note ? { note } : {}) },
206
+ })));
207
+ }
208
+ if (verb === "start-memberships") {
209
+ const familyId = positional(parsed, 2, "family ID");
210
+ // --kid is REPEATABLE: read every occurrence, not just the last one.
211
+ const kidIds = flagList(parsed, "kid");
212
+ if (kidIds.length === 0) {
213
+ throw new CliError("invalid_arguments", "--kid is required: name each kid whose membership should start.");
214
+ }
215
+ if (kidIds.length > MEMBERSHIP_KID_LIMIT) {
216
+ throw new CliError("invalid_arguments", `--kid accepts at most ${MEMBERSHIP_KID_LIMIT} kids per call.`);
217
+ }
218
+ // Family-scoped, not slug-scoped: by the time this runs the family is no
219
+ // longer linked to a partner institution, so the roster read above cannot
220
+ // see it. The onboarding status is the read that still resolves.
221
+ const status = unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/status", {
222
+ params: { path: { familyId } },
223
+ }));
224
+ const known = new Map(status.kids.map((kid) => [kid.id, kid]));
225
+ const unknown = kidIds.filter((id) => !known.has(id));
226
+ if (unknown.length > 0) {
227
+ throw new CliError("invalid_arguments", `Not kids in this family: ${unknown.join(", ")}. The server refuses the whole call when one id is foreign.`);
228
+ }
229
+ return writeCommand(parsed, {
230
+ action: "start a paid membership subscription for these kids, CHARGING the family's card on file",
231
+ target: { familyId, familyName: status.familyName },
232
+ request: { familyId, kidIds },
233
+ details: {
234
+ kids: kidIds.map((id) => kidName(known.get(id)?.firstName ?? null, null, id)),
235
+ billing: "Default membership price unless the family is grandfathered onto one, with a 1-day trial, confirmed against the customer's saved default payment method.",
236
+ note: "A kid whose membership is already ACTIVE comes back `skipped_active` rather than being charged twice; per-kid failures are reported in `results`, not thrown.",
237
+ },
238
+ }, async () => unwrap(await api.client.POST("/admin/memberships/start", {
239
+ body: { familyId, kidIds },
240
+ })));
241
+ }
242
+ if (verb === "resolve-payment") {
243
+ const paymentId = positional(parsed, 2, "enrollment payment ID");
244
+ const slug = requireSlug();
245
+ const action = assertChoice(flagString(parsed, "action", { required: true }), RESOLVE_ACTIONS, "--action");
246
+ const note = flagString(parsed, "note");
247
+ const { institution, families } = await roster(slug);
248
+ const family = families.find((row) => row.liveEnrollmentPayment?.id === paymentId);
249
+ const payment = family?.liveEnrollmentPayment;
250
+ if (!family || !payment) {
251
+ throw new CliError("invalid_arguments", `No live enrollment payment ${paymentId} under ${institution.name} (${slug}). Run \`school families --school ${slug}\` and read \`liveEnrollmentPayment\`; a payment already resolved is not resolvable again.`);
252
+ }
253
+ return writeCommand(parsed, {
254
+ action: action === "waive"
255
+ ? "WAIVE this enrollment charge — record it as collected off-platform and lift the paywall"
256
+ : "CANCEL this enrollment charge — drop the requirement entirely and lift the paywall",
257
+ target: {
258
+ paymentId,
259
+ slug,
260
+ familyId: family.id,
261
+ familyName: family.name,
262
+ },
263
+ request: { action, ...(note ? { note } : {}) },
264
+ details: {
265
+ institution: institution.name,
266
+ status: payment.status,
267
+ amountDue: dollars(payment.amountDueCents),
268
+ lines: payment.lines.map((line) => ({
269
+ kid: line.firstName ?? line.kidId ?? "unassigned",
270
+ amount: dollars(line.amountCents),
271
+ })),
272
+ stripeInvoiceId: payment.stripeInvoiceId,
273
+ effect: "The family stops being redirected to /school/pay and class registration reopens — but ONLY if no other PENDING enrollment payment remains on the family.",
274
+ accountState: "PENDING_PAYMENT is walked back; a family separately set to PAUSED or BOOTED keeps that state.",
275
+ ...(action === "cancel"
276
+ ? {
277
+ cancelCaveat: "If a charge already landed, compensation is unproven and the wire status comes back CANCEL_REQUESTED — the row is still live and still blocking. That is not a failed call.",
278
+ }
279
+ : {}),
280
+ },
281
+ }, async () => unwrap(await api.client.POST("/admin/partner/{slug}/enrollment-payments/{paymentId}/resolve", {
282
+ params: { path: { slug, paymentId } },
283
+ body: { action, ...(note ? { note } : {}) },
284
+ })));
285
+ }
286
+ if (verb === "close-reconciliation") {
287
+ const paymentId = positional(parsed, 2, "enrollment payment ID");
288
+ const slug = requireSlug();
289
+ const outcome = assertChoice(flagString(parsed, "outcome", { required: true }), RECONCILIATION_OUTCOMES, "--outcome");
290
+ const note = flagString(parsed, "note");
291
+ const { institution, families } = await roster(slug);
292
+ const family = families.find((row) => row.reconciliationItems.some((item) => item.paymentId === paymentId));
293
+ const item = family?.reconciliationItems.find((row) => row.paymentId === paymentId);
294
+ if (!family || !item) {
295
+ throw new CliError("invalid_arguments", `No reconciliation item for payment ${paymentId} under ${institution.name} (${slug}). Run \`school families --school ${slug}\` and read \`reconciliationItems\`.`);
296
+ }
297
+ if (item.resolution) {
298
+ throw new CliError("invalid_arguments", `That reconciliation item was already closed as ${item.resolution.outcome} at ${item.resolution.resolvedAt}. The close is conditional and irreversible — a second one 409s.`);
299
+ }
300
+ return writeCommand(parsed, {
301
+ action: `close this reconciliation item as ${outcome} (a money decision, and irreversible)`,
302
+ target: {
303
+ paymentId,
304
+ slug,
305
+ familyId: family.id,
306
+ familyName: family.name,
307
+ },
308
+ request: { outcome, ...(note ? { note } : {}) },
309
+ details: {
310
+ institution: institution.name,
311
+ amountPaid: dollars(item.amountPaidCents),
312
+ openedBecause: item.reason,
313
+ openedAt: item.openedAt,
314
+ paymentStatus: item.status,
315
+ invoiceId: item.invoiceId,
316
+ meaning: {
317
+ refunded: "the money was returned to the family",
318
+ kept: "the family genuinely owed it, so the charge stands",
319
+ written_off: "neither — the business absorbed it",
320
+ }[outcome],
321
+ scope: "Bookkeeping only: this annotates the item, never deletes it, and does NOT settle a debt, unblock a family, or move the payment's status.",
322
+ irreversible: "The write is conditional on the item still being open, so every later attempt 409s and the item drops out of triage.",
323
+ },
324
+ }, async () => unwrap(await api.client.POST("/admin/partner/{slug}/enrollment-payments/{paymentId}/close-reconciliation", {
325
+ params: { path: { slug, paymentId } },
326
+ body: { outcome, ...(note ? { note } : {}) },
327
+ })));
328
+ }
329
+ if (verb === "codes") {
330
+ const subverb = parsed.positionals[2] ?? "";
331
+ const slug = requireSlug();
332
+ const codeId = () => positional(parsed, 3, "invite code ID");
333
+ if (subverb === "list") {
334
+ return unwrap(await api.client.GET("/admin/partner/{slug}/codes", {
335
+ params: { path: { slug } },
336
+ }));
337
+ }
338
+ if (subverb === "get") {
339
+ return unwrap(await api.client.GET("/admin/partner/{slug}/codes/{id}", {
340
+ params: { path: { slug, id: codeId() } },
341
+ }));
342
+ }
343
+ if (subverb === "create") {
344
+ const count = flagInteger(parsed, "count", { min: 1 });
345
+ const expiresAt = flagIsoInstant(parsed, "expires-at");
346
+ const note = flagString(parsed, "note");
347
+ const creditAmountCents = flagInteger(parsed, "credit-cents", { min: 0 });
348
+ const tierRaw = flagString(parsed, "tier");
349
+ const slotsPerKid = flagInteger(parsed, "slots", { min: 0, max: 20 });
350
+ const enrollmentPaymentCentsPerKid = flagInteger(parsed, "enrollment-payment-cents", { min: 0 });
351
+ const dataFile = flagString(parsed, "data-file");
352
+ // The preconfigured-invite `family` block (parent identity + the kid
353
+ // roster with per-kid tier and price) is a nested object, so it arrives
354
+ // as JSON the way `quotes create` takes its body. Without it this mints
355
+ // blank codes; with it, one addressed invite that emails the parent.
356
+ const family = dataFile
357
+ ? (await readJsonFile(dataFile, "Preconfigured invite file"))
358
+ : undefined;
359
+ const body = {
360
+ ...(count !== undefined ? { count } : {}),
361
+ ...(expiresAt ? { expiresAt } : {}),
362
+ ...(note ? { note } : {}),
363
+ ...(creditAmountCents !== undefined ? { creditAmountCents } : {}),
364
+ ...(tierRaw
365
+ ? { tierId: assertChoice(tierRaw, SCHOOL_TIERS, "--tier") }
366
+ : {}),
367
+ ...(slotsPerKid !== undefined ? { slotsPerKid } : {}),
368
+ ...(enrollmentPaymentCentsPerKid !== undefined
369
+ ? { enrollmentPaymentCentsPerKid }
370
+ : {}),
371
+ ...(family ? { family } : {}),
372
+ };
373
+ return writeCommand(parsed, {
374
+ action: family
375
+ ? "mint a preconfigured school invite for this family and EMAIL the parent their claim link"
376
+ : `mint ${count ?? 1} blank school invite code(s)`,
377
+ target: { slug },
378
+ request: body,
379
+ details: {
380
+ expiresAt: expiresAt ?? "the institution default",
381
+ firstMonthPerKid: enrollmentPaymentCentsPerKid === undefined
382
+ ? "not set at the family level — each kid in --data-file must carry its own price"
383
+ : dollars(enrollmentPaymentCentsPerKid),
384
+ ...(family
385
+ ? {
386
+ duplicateGuard: "The create 409s when the parent email already has a Recess account or another live invite. That is the guard, not a transient failure.",
387
+ }
388
+ : {
389
+ blank: "No --data-file, so these are blank codes: nobody is emailed and no roster is preconfigured.",
390
+ }),
391
+ },
392
+ }, async () => unwrap(await api.client.POST("/admin/partner/{slug}/codes", {
393
+ params: { path: { slug } },
394
+ body,
395
+ })));
396
+ }
397
+ if (subverb === "set-kids") {
398
+ const id = codeId();
399
+ const body = (await readJsonFile(flagString(parsed, "data-file", { required: true }), "Invite roster file"));
400
+ return writeCommand(parsed, {
401
+ action: "edit an UNREDEEMED invite's roster in place — the parent's existing link keeps working",
402
+ target: { slug, codeId: id },
403
+ request: body,
404
+ details: {
405
+ semantics: "The body is the FULL roster, not a diff: a student absent from the list is removed. `enrollmentPaymentCents` is required per student (null or 0 = that student enrolls free) so the paywall cannot be dropped by omission.",
406
+ outOfScope: "Parent identity, note, credit and expiry are not editable here — changing WHO the invite is for is `codes replace`.",
407
+ refusal: "Editing after redemption is refused outright; the accounts and the charge already exist.",
408
+ },
409
+ }, async () => unwrap(await api.client.PATCH("/admin/partner/{slug}/codes/{id}", {
410
+ params: { path: { slug, id } },
411
+ body,
412
+ })));
413
+ }
414
+ if (subverb === "replace") {
415
+ const id = codeId();
416
+ const body = (await readJsonFile(flagString(parsed, "data-file", { required: true }), "Replacement invite file"));
417
+ return writeCommand(parsed, {
418
+ action: "REPLACE this unredeemed invite: delete the old code and mint a new one, emailing the parent a fresh link",
419
+ target: { slug, codeId: id },
420
+ request: body,
421
+ details: {
422
+ atomicity: "Delete and create run in ONE transaction, so a failure rolls back and the OLD invite survives — a failed replace is plainly retryable and never leaves the family with no invite.",
423
+ whenToUse: "Use replace when the parent's identity or email changes; use `codes set-kids` to edit the roster without invalidating the link the parent already holds.",
424
+ inherits: "Fields omitted from the body (note, credit) inherit from the old row read inside the transaction.",
425
+ },
426
+ }, async () => unwrap(await api.client.POST("/admin/partner/{slug}/codes/{id}/replace", {
427
+ params: { path: { slug, id } },
428
+ body,
429
+ })));
430
+ }
431
+ if (subverb === "resend") {
432
+ const id = codeId();
433
+ return writeCommand(parsed, {
434
+ action: "re-email this invite's claim link to the parent on file",
435
+ target: { slug, codeId: id },
436
+ request: {},
437
+ details: {
438
+ outwardEmail: "This sends mail to a real family. The code itself is unchanged — use `codes replace` to mint a new one.",
439
+ },
440
+ }, async () => unwrap(await api.client.POST("/admin/partner/{slug}/codes/{id}/resend", {
441
+ params: { path: { slug, id } },
442
+ })));
443
+ }
444
+ if (subverb === "revoke") {
445
+ const id = codeId();
446
+ const existing = unwrap(await api.client.GET("/admin/partner/{slug}/codes/{id}", {
447
+ params: { path: { slug, id } },
448
+ }));
449
+ return writeCommand(parsed, {
450
+ action: "HARD-DELETE this unredeemed invite code",
451
+ target: { slug, codeId: id },
452
+ request: {},
453
+ details: {
454
+ code: existing,
455
+ irreversible: "The row is REMOVED, not soft-deleted: there is no deletedAt on TalentSchoolInviteCode and no undo. The parent's link stops working immediately.",
456
+ },
457
+ }, async () => unwrap(await api.client.DELETE("/admin/partner/{slug}/codes/{id}", {
458
+ params: { path: { slug, id } },
459
+ })));
460
+ }
461
+ throw new CliError("invalid_arguments", "Use school codes list|get|create|set-kids|replace|resend|revoke.");
462
+ }
463
+ throw new CliError("invalid_arguments", "Unknown school command. Run `recess school --help` for the current command list.");
464
+ }
465
+ //# sourceMappingURL=school.js.map
package/dist/help.js CHANGED
@@ -142,6 +142,8 @@ Usage:
142
142
  recess [--json] onboarding orientation-sessions
143
143
  recess [--json] onboarding ixl-preview <family-id> --kid <kid-id>
144
144
  recess [--json] onboarding intake-session <family-id>
145
+ recess [--json] onboarding reviews <family-id>
146
+ recess [--json] onboarding review <session-id>
145
147
  recess [--json] onboarding intake-session-create <family-id> [--confirm]
146
148
  recess [--json] onboarding set-stage <family-id>
147
149
  --stage LEGACY|PROVISIONED|PARENT_CONFIRMED|CLEARED_FOR_COHORT|COMPLETE [--confirm]
@@ -165,6 +167,9 @@ Usage:
165
167
  --kid <kid-id> [--credentials-file <credentials.json>] [--confirm]
166
168
  recess [--json] onboarding remove-ixl <family-id> --kid <kid-id> [--confirm]
167
169
  recess [--json] onboarding ixl-sync [--apply] [--confirm]
170
+ recess [--json] onboarding contracts [--kid <kid-id>]
171
+ recess [--json] onboarding send-contract <kid-id> [--tuition-cents N]
172
+ [--parent-email <email>] [--partner-name TEXT] [--partner-email <email>] [--confirm]
168
173
  recess [--json] onboarding send-comms --subject <family:id|invite:id>
169
174
  --kind <kind> [--confirm]
170
175
  recess [--json] onboarding send-welcome <family-id> [--resend] [--confirm]
@@ -189,6 +194,35 @@ Usage:
189
194
  --data <json> [--expected-updated-at <iso>] [--confirm]
190
195
  recess [--json] onboarding extract <family-id> --session <id>
191
196
  (--transcript-file <path> | --granola <ref>) [--confirm]
197
+ recess [--json] onboarding mark-reviewed <session-id> [--confirm]
198
+ recess [--json] onboarding pairing-code <kid-id> [--confirm]
199
+ recess [--json] school list
200
+ recess [--json] school families --school <institution-slug>
201
+ recess [--json] school family-search --school <institution-slug> --query TEXT
202
+ [--limit N]
203
+ recess [--json] school convert <family-id> --school <institution-slug>
204
+ --kid "<kid-id>:<tier>[:<slots>[:<enrollment-cents>]]" [--kid "..."]
205
+ [--credit-cents N] [--enrollment-payment-cents N] [--note TEXT] [--confirm]
206
+ recess [--json] school revert <family-id> --school <institution-slug>
207
+ [--keep-tokens] [--note TEXT] [--confirm]
208
+ recess [--json] school start-memberships <family-id> --kid <kid-id> [--kid <kid-id>]
209
+ [--confirm]
210
+ recess [--json] school resolve-payment <payment-id> --school <institution-slug>
211
+ --action waive|cancel [--note TEXT] [--confirm]
212
+ recess [--json] school close-reconciliation <payment-id> --school <institution-slug>
213
+ --outcome refunded|kept|written_off [--note TEXT] [--confirm]
214
+ recess [--json] school codes list --school <institution-slug>
215
+ recess [--json] school codes get <code-id> --school <institution-slug>
216
+ recess [--json] school codes create --school <institution-slug> [--count N]
217
+ [--expires-at <iso>] [--note TEXT] [--credit-cents N]
218
+ [--tier social|academics|lite|complete|platform] [--slots N]
219
+ [--enrollment-payment-cents N] [--data-file <preconfigured-family.json>] [--confirm]
220
+ recess [--json] school codes set-kids <code-id> --school <institution-slug>
221
+ --data-file <roster.json> [--confirm]
222
+ recess [--json] school codes replace <code-id> --school <institution-slug>
223
+ --data-file <invite.json> [--confirm]
224
+ recess [--json] school codes resend <code-id> --school <institution-slug> [--confirm]
225
+ recess [--json] school codes revoke <code-id> --school <institution-slug> [--confirm]
192
226
  recess [--json] village models list [--world village-1] [--query TEXT] [--archived]
193
227
  recess [--json] village models upload --file </path/model.glb>
194
228
  [--world village-1] [--name TEXT] [--id ID] [--description TEXT] [--tags A,B]
@@ -240,17 +274,20 @@ Usage:
240
274
  recess [--json] goal-templates get <template-id|slug> [--spec-only]
241
275
  recess [--json] goal-templates versions <template-id> [--version N]
242
276
  recess [--json] goal-templates validate-spec --file <path/template.json>
243
- recess [--json] goal-templates create --file <path/template.json> [--confirm]
277
+ recess [--json] goal-templates create --file <path/template.json>
278
+ [--coin-amount N] [--confirm]
244
279
  recess [--json] goal-templates patch-spec <template-id|slug> --expected-version N
245
280
  --patches-file <path/patches.json> [--confirm --approval-token TOKEN]
246
281
  [--confirm-destructive-changes --destructive-change-token TOKEN]
247
282
  recess [--json] goal-templates set-metadata <template-id> --expected-version N
248
283
  [--title TEXT] [--description TEXT] [--emoji X] [--category TEXT] [--tags A,B]
249
- [--sort-order N] [--is-starter true|false]
284
+ [--image-url URL] [--coin-amount N] [--sort-order N] [--is-starter true|false]
250
285
  [--setup-audience KID_FRIENDLY|PARENT_SETUP] [--kind SIMPLE|BLUEPRINT]
251
286
  [--agent-instructions-file <path>]
252
287
  [--output-template-file <path>] [--confirm]
253
288
  recess [--json] goal-templates delete <template-id> --expected-version N [--confirm]
289
+ recess [--json] goal-templates generate-image <template-id|slug>
290
+ [--prompt TEXT] [--confirm]
254
291
  recess [--json] goal-templates snapshot-files <template-id> [--path P]
255
292
  recess [--json] goal-templates capture-snapshot <template-id|slug>
256
293
  (--source-goal <goal-id> | --source-draft <draft-slug> --student <goal-owner-id>
@@ -270,6 +307,8 @@ Usage:
270
307
  --delta TEXT [--confirm --approval-token TOKEN]
271
308
  recess [--json] goals delete <goal-id> --student <kid-id>
272
309
  [--confirm --approval-token TOKEN]
310
+ recess [--json] goals complete <goal-id> [--confirm]
311
+ recess [--json] goals undo-completion <goal-id> [--confirm]
273
312
  recess [--json] goals queue get <goal-id> --student <kid-id>
274
313
  recess [--json] goals queue set <goal-id> --student <kid-id>
275
314
  --entries-file <path.json> --delta TEXT [--replace-description-pointer]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "recess-cli",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Safe Recess administration and family AI tools from the command line.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {