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