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/README.md +13 -2
- package/dist/api.js +80 -0
- package/dist/cli.js +175 -659
- package/dist/command-schema.js +4 -0
- package/dist/commands/applications.js +336 -0
- package/dist/commands/onboarding.js +623 -0
- package/dist/commands/shared.js +100 -0
- package/dist/help.js +376 -0
- package/package.json +3 -3
- package/skill/recess-cli/SKILL.md +2 -0
- package/skill/recess-cli/agents/version.json +2 -2
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 {
|
|
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.
|
|
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
|
|
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"
|
|
2486
|
-
|
|
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
|
-
|
|
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
|