recess-cli 2.0.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -2
- package/dist/api.js +80 -0
- package/dist/cli.js +259 -663
- package/dist/command-schema.js +5 -0
- package/dist/commands/applications.js +336 -0
- package/dist/commands/onboarding.js +797 -0
- package/dist/commands/school.js +465 -0
- package/dist/commands/shared.js +100 -0
- package/dist/help.js +415 -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,24 @@ 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 { runSchoolCommand } from "./commands/school.js";
|
|
13
|
+
import { assertChoice, flagIdList, positional, readJsonFile, readJsonValue, } from "./commands/shared.js";
|
|
10
14
|
import { CliError } from "./errors.js";
|
|
11
15
|
import { listFeedback, submitFeedback } from "./feedback.js";
|
|
16
|
+
import { HELP } from "./help.js";
|
|
17
|
+
export { HELP };
|
|
12
18
|
import { cliRequestHeaders, requireCliRequestReason } from "./http.js";
|
|
13
19
|
import { requireConfirmation } from "./safety.js";
|
|
14
20
|
import { installSkill, isEphemeralInstall, readBundledSkillVersion, readCliVersion, } from "./setup.js";
|
|
15
21
|
import { compareVersions, updateSkillFromServer } from "./skill-update.js";
|
|
16
22
|
import { readSkillCache, writeSkillCache } from "./skills-cache.js";
|
|
17
23
|
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
24
|
function requiredRequestReason(parsed) {
|
|
312
25
|
return requireCliRequestReason(flagString(parsed, "reason", { required: true }));
|
|
313
26
|
}
|
|
@@ -999,12 +712,6 @@ async function readGoalWorkspaceWriteSource(parsed) {
|
|
|
999
712
|
}
|
|
1000
713
|
return { files, source, sizeBytes, sha256: hash.digest("hex") };
|
|
1001
714
|
}
|
|
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
715
|
const PAYRUN_STATUSES = [
|
|
1009
716
|
"UPCOMING",
|
|
1010
717
|
"DRAFT",
|
|
@@ -1028,36 +735,6 @@ const PAYOUT_STATUS_SIDE_EFFECTS = {
|
|
|
1028
735
|
PAID: "marks the invoice paid and deducts its total from the recipient's account balance",
|
|
1029
736
|
CANCELED: "cancels the invoice and reverses any balance items carried on it",
|
|
1030
737
|
};
|
|
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
738
|
const SCHOOL_TIER_OPTIONS = [
|
|
1062
739
|
{ id: "social", name: "Social", defaultClassSlots: 2 },
|
|
1063
740
|
{ id: "academics", name: "Academics", defaultClassSlots: 0 },
|
|
@@ -1110,41 +787,6 @@ const CONTENT_LIBRARY_DISCOVERY_LANES = [
|
|
|
1110
787
|
"idea-games",
|
|
1111
788
|
];
|
|
1112
789
|
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
790
|
function parseGoalQueueEntries(value, label) {
|
|
1149
791
|
if (!Array.isArray(value)) {
|
|
1150
792
|
throw new CliError("invalid_arguments", `${label} must be a JSON array of queue entries.`);
|
|
@@ -1515,6 +1157,14 @@ function parseGoalTemplateDocument(doc) {
|
|
|
1515
1157
|
(typeof rawSortOrder !== "number" || !Number.isInteger(rawSortOrder))) {
|
|
1516
1158
|
throw new CliError("invalid_arguments", 'Template file field "sortOrder" must be an integer.');
|
|
1517
1159
|
}
|
|
1160
|
+
const rawCoinAmount = doc.coinAmount;
|
|
1161
|
+
if (rawCoinAmount !== undefined &&
|
|
1162
|
+
rawCoinAmount !== null &&
|
|
1163
|
+
(typeof rawCoinAmount !== "number" ||
|
|
1164
|
+
!Number.isInteger(rawCoinAmount) ||
|
|
1165
|
+
rawCoinAmount < 1)) {
|
|
1166
|
+
throw new CliError("invalid_arguments", 'Template file field "coinAmount" must be a positive integer.');
|
|
1167
|
+
}
|
|
1518
1168
|
const kind = assertChoice(optionalDocString(doc, "kind") ?? "SIMPLE", GOAL_TEMPLATE_KINDS, "kind");
|
|
1519
1169
|
const setupAudience = assertChoice(optionalDocString(doc, "setupAudience") ?? "KID_FRIENDLY", GOAL_TEMPLATE_SETUP_AUDIENCES, "setupAudience");
|
|
1520
1170
|
const starterTierRaw = optionalDocString(doc, "starterTier");
|
|
@@ -1534,6 +1184,9 @@ function parseGoalTemplateDocument(doc) {
|
|
|
1534
1184
|
...(optionalDocString(doc, "imageUrl") === undefined
|
|
1535
1185
|
? {}
|
|
1536
1186
|
: { imageUrl: optionalDocString(doc, "imageUrl") }),
|
|
1187
|
+
...(rawCoinAmount === undefined || rawCoinAmount === null
|
|
1188
|
+
? {}
|
|
1189
|
+
: { coinAmount: rawCoinAmount }),
|
|
1537
1190
|
...(optionalDocString(doc, "category") === undefined
|
|
1538
1191
|
? {}
|
|
1539
1192
|
: { category: optionalDocString(doc, "category") }),
|
|
@@ -1630,34 +1283,6 @@ function flagStatusList(parsed, name, choices) {
|
|
|
1630
1283
|
}
|
|
1631
1284
|
return values.map((value) => assertChoice(value, choices, `--${name}`));
|
|
1632
1285
|
}
|
|
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
1286
|
function flagCents(parsed, name, options = {}) {
|
|
1662
1287
|
const value = flagNumber(parsed, name);
|
|
1663
1288
|
if (value === undefined) {
|
|
@@ -1753,19 +1378,6 @@ function flagLocalDateTime(parsed, name, options = {}) {
|
|
|
1753
1378
|
}
|
|
1754
1379
|
return match[2] ? raw : `${raw}:00`;
|
|
1755
1380
|
}
|
|
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
1381
|
export function penultimateCycleDayMs(cycleEndDate) {
|
|
1770
1382
|
const endMs = Date.parse(cycleEndDate);
|
|
1771
1383
|
if (!Number.isFinite(endMs)) {
|
|
@@ -2041,17 +1653,180 @@ export async function runCommand(argv) {
|
|
|
2041
1653
|
return value;
|
|
2042
1654
|
};
|
|
2043
1655
|
if (verb === "render") {
|
|
1656
|
+
const renderWorldId = flagString(parsed, "world") ??
|
|
1657
|
+
(config.user && config.user.role !== "ADMIN"
|
|
1658
|
+
? `home-${config.user.id}`
|
|
1659
|
+
: targetWorldId);
|
|
2044
1660
|
const params = new URLSearchParams({
|
|
2045
|
-
worldId: targetWorldId,
|
|
2046
1661
|
minX: String(numberFlag("min-x")),
|
|
2047
1662
|
minZ: String(numberFlag("min-z")),
|
|
2048
1663
|
maxX: String(numberFlag("max-x")),
|
|
2049
1664
|
maxZ: String(numberFlag("max-z")),
|
|
2050
1665
|
});
|
|
2051
|
-
return api.
|
|
1666
|
+
return api.villageRead(`/api/cli/worlds/${encodeURIComponent(renderWorldId)}/render?${params}`);
|
|
1667
|
+
}
|
|
1668
|
+
const directWorldId = flagString(parsed, "world") ??
|
|
1669
|
+
(config.user?.id ? `home-${config.user.id}` : targetWorldId);
|
|
1670
|
+
if (verb === "library") {
|
|
1671
|
+
const action = positional(parsed, 2, "Village library action");
|
|
1672
|
+
const base = `/api/cli/worlds/${encodeURIComponent(directWorldId)}/library`;
|
|
1673
|
+
if (action === "search") {
|
|
1674
|
+
const limit = flagNumber(parsed, "limit") ?? 20;
|
|
1675
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
|
|
1676
|
+
throw new CliError("invalid_arguments", "--limit must be an integer from 1 through 50.");
|
|
1677
|
+
}
|
|
1678
|
+
const query = flagString(parsed, "query") ??
|
|
1679
|
+
parsed.positionals.slice(3).join(" ").trim();
|
|
1680
|
+
const params = new URLSearchParams({ limit: String(limit) });
|
|
1681
|
+
if (query)
|
|
1682
|
+
params.set("q", query);
|
|
1683
|
+
return api.villageRead(`${base}?${params}`);
|
|
1684
|
+
}
|
|
1685
|
+
if (action === "get") {
|
|
1686
|
+
const modelId = positional(parsed, 3, "model ID");
|
|
1687
|
+
return api.villageRead(`${base}/${encodeURIComponent(modelId)}`);
|
|
1688
|
+
}
|
|
1689
|
+
throw new CliError("invalid_arguments", "Use village library search|get.");
|
|
1690
|
+
}
|
|
1691
|
+
if (verb === "objects") {
|
|
1692
|
+
const action = positional(parsed, 2, "Village objects action");
|
|
1693
|
+
const base = `/api/cli/worlds/${encodeURIComponent(directWorldId)}/objects`;
|
|
1694
|
+
if (action === "list") {
|
|
1695
|
+
const region = {
|
|
1696
|
+
minX: numberFlag("min-x", -20),
|
|
1697
|
+
minZ: numberFlag("min-z", -20),
|
|
1698
|
+
maxX: numberFlag("max-x", 20),
|
|
1699
|
+
maxZ: numberFlag("max-z", 20),
|
|
1700
|
+
};
|
|
1701
|
+
if (region.minX > region.maxX ||
|
|
1702
|
+
region.minZ > region.maxZ ||
|
|
1703
|
+
region.maxX - region.minX > 128 ||
|
|
1704
|
+
region.maxZ - region.minZ > 128) {
|
|
1705
|
+
throw new CliError("invalid_arguments", "Object bounds must be ordered and no wider than 128m per side.");
|
|
1706
|
+
}
|
|
1707
|
+
const params = new URLSearchParams(Object.fromEntries(Object.entries(region).map(([key, value]) => [key, String(value)])));
|
|
1708
|
+
return api.villageRead(`${base}?${params}`);
|
|
1709
|
+
}
|
|
1710
|
+
if (action === "get") {
|
|
1711
|
+
const objectId = positional(parsed, 3, "object ID");
|
|
1712
|
+
return api.villageRead(`${base}/${encodeURIComponent(objectId)}`);
|
|
1713
|
+
}
|
|
1714
|
+
throw new CliError("invalid_arguments", "Use village objects list|get.");
|
|
1715
|
+
}
|
|
1716
|
+
if (verb === "build") {
|
|
1717
|
+
const action = positional(parsed, 2, "Village build action");
|
|
1718
|
+
if (action !== "cmd") {
|
|
1719
|
+
throw new CliError("invalid_arguments", "Use village build cmd '<command-json>' or --file <command.json>.");
|
|
1720
|
+
}
|
|
1721
|
+
const commandFile = flagString(parsed, "file");
|
|
1722
|
+
const inlineCommand = parsed.positionals[3];
|
|
1723
|
+
if (Boolean(commandFile) === Boolean(inlineCommand)) {
|
|
1724
|
+
throw new CliError("invalid_arguments", "Pass exactly one command JSON argument or --file <command.json>.");
|
|
1725
|
+
}
|
|
1726
|
+
let command;
|
|
1727
|
+
let source;
|
|
1728
|
+
if (commandFile) {
|
|
1729
|
+
const value = await readJsonValue(commandFile, "Village command file");
|
|
1730
|
+
if (value.parsed === null ||
|
|
1731
|
+
typeof value.parsed !== "object" ||
|
|
1732
|
+
Array.isArray(value.parsed)) {
|
|
1733
|
+
throw new CliError("invalid_arguments", "Village command file must contain one JSON object.");
|
|
1734
|
+
}
|
|
1735
|
+
command = value.parsed;
|
|
1736
|
+
source = {
|
|
1737
|
+
file: value.absolutePath,
|
|
1738
|
+
sha256: createHash("sha256").update(value.raw).digest("hex"),
|
|
1739
|
+
};
|
|
1740
|
+
}
|
|
1741
|
+
else {
|
|
1742
|
+
try {
|
|
1743
|
+
const value = JSON.parse(inlineCommand);
|
|
1744
|
+
if (value === null ||
|
|
1745
|
+
typeof value !== "object" ||
|
|
1746
|
+
Array.isArray(value)) {
|
|
1747
|
+
throw new Error("command must be an object");
|
|
1748
|
+
}
|
|
1749
|
+
command = value;
|
|
1750
|
+
}
|
|
1751
|
+
catch (error) {
|
|
1752
|
+
throw new CliError("invalid_arguments", `Village command is not a JSON object: ${error instanceof Error ? error.message : String(error)}`);
|
|
1753
|
+
}
|
|
1754
|
+
source = { inline: true };
|
|
1755
|
+
}
|
|
1756
|
+
const buildWorldId = flagString(parsed, "world") ??
|
|
1757
|
+
(config.user?.id ? `home-${config.user.id}` : undefined);
|
|
1758
|
+
if (!buildWorldId) {
|
|
1759
|
+
throw new CliError("invalid_arguments", "Missing --world. Login normally to default to your home world, or pass the exact world ID.");
|
|
1760
|
+
}
|
|
1761
|
+
return writeCommand(parsed, {
|
|
1762
|
+
action: "run one authenticated Village build command",
|
|
1763
|
+
target: { worldId: buildWorldId },
|
|
1764
|
+
request: { command, ...source },
|
|
1765
|
+
details: {
|
|
1766
|
+
consequence: "The Village server applies the same ownership, block-limit, and broadcast rules as an in-world wrench edit.",
|
|
1767
|
+
},
|
|
1768
|
+
}, () => api.villageCommand(buildWorldId, command));
|
|
1769
|
+
}
|
|
1770
|
+
if (verb === "worlds") {
|
|
1771
|
+
const action = positional(parsed, 2, "Village world action");
|
|
1772
|
+
const worldId = positional(parsed, 3, "world ID");
|
|
1773
|
+
const bridgePath = `/admin/village/worlds/${encodeURIComponent(worldId)}`;
|
|
1774
|
+
if (action === "export") {
|
|
1775
|
+
const output = flagString(parsed, "out");
|
|
1776
|
+
if (!output) {
|
|
1777
|
+
return api.villageRequest(`${bridgePath}/export`);
|
|
1778
|
+
}
|
|
1779
|
+
const absolutePath = path.resolve(output);
|
|
1780
|
+
return writeCommand(parsed, {
|
|
1781
|
+
action: "export a Village world bundle to a local file",
|
|
1782
|
+
target: { worldId, file: absolutePath },
|
|
1783
|
+
request: { overwrite: true },
|
|
1784
|
+
}, async () => {
|
|
1785
|
+
const bundle = await api.villageRequest(`${bridgePath}/export`);
|
|
1786
|
+
const contents = `${JSON.stringify(bundle, null, 2)}\n`;
|
|
1787
|
+
await fs.writeFile(absolutePath, contents, "utf8");
|
|
1788
|
+
return {
|
|
1789
|
+
worldId,
|
|
1790
|
+
file: absolutePath,
|
|
1791
|
+
sizeBytes: Buffer.byteLength(contents),
|
|
1792
|
+
};
|
|
1793
|
+
});
|
|
1794
|
+
}
|
|
1795
|
+
if (action === "import") {
|
|
1796
|
+
const value = await readJsonValue(flagString(parsed, "file", { required: true }), "Village world bundle");
|
|
1797
|
+
return writeCommand(parsed, {
|
|
1798
|
+
action: "replace a Village world from an imported bundle",
|
|
1799
|
+
target: { worldId },
|
|
1800
|
+
request: {
|
|
1801
|
+
file: value.absolutePath,
|
|
1802
|
+
sizeBytes: Buffer.byteLength(value.raw),
|
|
1803
|
+
sha256: createHash("sha256").update(value.raw).digest("hex"),
|
|
1804
|
+
},
|
|
1805
|
+
details: {
|
|
1806
|
+
consequence: "The imported rows replace this world's current data and broadcast a live world reset.",
|
|
1807
|
+
},
|
|
1808
|
+
}, () => api.villageRequest(`${bridgePath}/import`, {
|
|
1809
|
+
method: "POST",
|
|
1810
|
+
body: { bundle: value.parsed, confirm: "import" },
|
|
1811
|
+
}));
|
|
1812
|
+
}
|
|
1813
|
+
if (action === "promote") {
|
|
1814
|
+
return writeCommand(parsed, {
|
|
1815
|
+
action: "promote a Village mirror or archive into the live hub",
|
|
1816
|
+
target: { worldId },
|
|
1817
|
+
request: { confirm: "promote" },
|
|
1818
|
+
details: {
|
|
1819
|
+
consequence: "The current hub is archived, this world replaces it atomically, and connected players receive a live world reset.",
|
|
1820
|
+
},
|
|
1821
|
+
}, () => api.villageRequest(`${bridgePath}/promote`, {
|
|
1822
|
+
method: "POST",
|
|
1823
|
+
body: { confirm: "promote" },
|
|
1824
|
+
}));
|
|
1825
|
+
}
|
|
1826
|
+
throw new CliError("invalid_arguments", "Use village worlds export|import|promote.");
|
|
2052
1827
|
}
|
|
2053
1828
|
if (verb !== "models") {
|
|
2054
|
-
throw new CliError("invalid_arguments", "Use village models
|
|
1829
|
+
throw new CliError("invalid_arguments", "Use village build …, village library …, village objects …, village worlds …, village models …, or village render …");
|
|
2055
1830
|
}
|
|
2056
1831
|
const action = positional(parsed, 2, "Village model action");
|
|
2057
1832
|
if (action === "list") {
|
|
@@ -2482,47 +2257,11 @@ export async function runCommand(argv) {
|
|
|
2482
2257
|
params: { query: { subscriptionId } },
|
|
2483
2258
|
}));
|
|
2484
2259
|
}
|
|
2485
|
-
if (noun === "applications"
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
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
|
-
})));
|
|
2260
|
+
if (noun === "applications" || noun === "quotes") {
|
|
2261
|
+
return runApplicationsCommand({ parsed, api, writeCommand });
|
|
2262
|
+
}
|
|
2263
|
+
if (noun === "school") {
|
|
2264
|
+
return runSchoolCommand({ parsed, api, writeCommand });
|
|
2526
2265
|
}
|
|
2527
2266
|
if (noun === "cohorts" && verb === "search") {
|
|
2528
2267
|
const search = parsed.positionals.slice(2).join(" ").trim();
|
|
@@ -3103,215 +2842,7 @@ export async function runCommand(argv) {
|
|
|
3103
2842
|
}, async () => unwrap(await api.client.POST("/admin/cohorts/unregister/", { body })));
|
|
3104
2843
|
}
|
|
3105
2844
|
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.");
|
|
2845
|
+
return runOnboardingCommand({ parsed, api, writeCommand });
|
|
3315
2846
|
}
|
|
3316
2847
|
if (noun === "skills") {
|
|
3317
2848
|
// Reads only — no confirmation gate. Audience is explicit so an agent never
|
|
@@ -3568,7 +3099,18 @@ export async function runCommand(argv) {
|
|
|
3568
3099
|
}
|
|
3569
3100
|
if (verb === "create") {
|
|
3570
3101
|
const filePath = flagString(parsed, "file", { required: true });
|
|
3571
|
-
const
|
|
3102
|
+
const authoredDocument = parseGoalTemplateDocument(await readJsonFile(filePath, "Template file"));
|
|
3103
|
+
const coinAmountOverride = flagNumber(parsed, "coin-amount");
|
|
3104
|
+
if (coinAmountOverride !== undefined &&
|
|
3105
|
+
(!Number.isInteger(coinAmountOverride) || coinAmountOverride < 1)) {
|
|
3106
|
+
throw new CliError("invalid_arguments", "--coin-amount must be a positive integer.");
|
|
3107
|
+
}
|
|
3108
|
+
const document = {
|
|
3109
|
+
...authoredDocument,
|
|
3110
|
+
...(coinAmountOverride === undefined
|
|
3111
|
+
? {}
|
|
3112
|
+
: { coinAmount: coinAmountOverride }),
|
|
3113
|
+
};
|
|
3572
3114
|
// C3 read-only preflight, for the same reason `enrollments create` has
|
|
3573
3115
|
// one: the consequences an approver must weigh are resolved SERVER-side.
|
|
3574
3116
|
// Which setupHandler runs, what shape of goal students get, and which
|
|
@@ -3594,6 +3136,7 @@ export async function runCommand(argv) {
|
|
|
3594
3136
|
category: document.category ?? null,
|
|
3595
3137
|
tags: document.tags,
|
|
3596
3138
|
isStarter: document.isStarter ?? false,
|
|
3139
|
+
coinAmount: document.coinAmount ?? null,
|
|
3597
3140
|
},
|
|
3598
3141
|
details: {
|
|
3599
3142
|
resolvedSetupHandler: validation.setupHandler,
|
|
@@ -3690,6 +3233,11 @@ export async function runCommand(argv) {
|
|
|
3690
3233
|
const tags = flagString(parsed, "tags");
|
|
3691
3234
|
const kind = flagString(parsed, "kind");
|
|
3692
3235
|
const sortOrder = flagNumber(parsed, "sort-order");
|
|
3236
|
+
const coinAmount = flagNumber(parsed, "coin-amount");
|
|
3237
|
+
if (coinAmount !== undefined &&
|
|
3238
|
+
(!Number.isInteger(coinAmount) || coinAmount < 1)) {
|
|
3239
|
+
throw new CliError("invalid_arguments", "--coin-amount must be a positive integer.");
|
|
3240
|
+
}
|
|
3693
3241
|
const isStarterRaw = flagString(parsed, "is-starter");
|
|
3694
3242
|
const setupAudienceRaw = flagString(parsed, "setup-audience");
|
|
3695
3243
|
const isStarter = isStarterRaw === undefined
|
|
@@ -3706,6 +3254,10 @@ export async function runCommand(argv) {
|
|
|
3706
3254
|
...(flagString(parsed, "emoji")
|
|
3707
3255
|
? { emoji: flagString(parsed, "emoji") }
|
|
3708
3256
|
: {}),
|
|
3257
|
+
...(flagString(parsed, "image-url")
|
|
3258
|
+
? { imageUrl: flagString(parsed, "image-url") }
|
|
3259
|
+
: {}),
|
|
3260
|
+
...(coinAmount === undefined ? {} : { coinAmount }),
|
|
3709
3261
|
...(flagString(parsed, "category")
|
|
3710
3262
|
? { category: flagString(parsed, "category") }
|
|
3711
3263
|
: {}),
|
|
@@ -3743,7 +3295,7 @@ export async function runCommand(argv) {
|
|
|
3743
3295
|
// shape that caused the template incident; editing an existing spec goes
|
|
3744
3296
|
// through the guarded /ai patch path with its destructive-change token.
|
|
3745
3297
|
if (Object.keys(body).length === 1) {
|
|
3746
|
-
throw new CliError("invalid_arguments", "Pass at least one field to change (--title, --description, --emoji, --category, --tags, --sort-order, --is-starter, --setup-audience, --kind, --agent-instructions-file, --output-template-file).");
|
|
3298
|
+
throw new CliError("invalid_arguments", "Pass at least one field to change (--title, --description, --emoji, --image-url, --coin-amount, --category, --tags, --sort-order, --is-starter, --setup-audience, --kind, --agent-instructions-file, --output-template-file).");
|
|
3747
3299
|
}
|
|
3748
3300
|
return writeCommand(parsed, {
|
|
3749
3301
|
action: "update goal template metadata (never its setupWorkflowSpec)",
|
|
@@ -3754,6 +3306,28 @@ export async function runCommand(argv) {
|
|
|
3754
3306
|
body,
|
|
3755
3307
|
})));
|
|
3756
3308
|
}
|
|
3309
|
+
if (verb === "generate-image") {
|
|
3310
|
+
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
3311
|
+
const current = unwrap(await api.client.GET("/ai/goal-templates/{id}", {
|
|
3312
|
+
params: { path: { id } },
|
|
3313
|
+
}));
|
|
3314
|
+
const prompt = flagString(parsed, "prompt");
|
|
3315
|
+
return writeCommand(parsed, {
|
|
3316
|
+
action: "queue paid GPT-Image-2 art regeneration",
|
|
3317
|
+
target: {
|
|
3318
|
+
templateId: id,
|
|
3319
|
+
slug: current.slug,
|
|
3320
|
+
title: current.title,
|
|
3321
|
+
},
|
|
3322
|
+
request: { prompt: prompt ?? null },
|
|
3323
|
+
details: {
|
|
3324
|
+
costNote: "This queues one paid 1024×1024 GPT-Image-2 generation and replaces the template image when it finishes. New templates already generate art automatically.",
|
|
3325
|
+
},
|
|
3326
|
+
}, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/generate-image", {
|
|
3327
|
+
params: { path: { id } },
|
|
3328
|
+
body: prompt ? { prompt } : {},
|
|
3329
|
+
})));
|
|
3330
|
+
}
|
|
3757
3331
|
if (verb === "delete") {
|
|
3758
3332
|
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
3759
3333
|
const expectedVersion = requiredExpectedVersion(parsed);
|
|
@@ -3960,9 +3534,31 @@ export async function runCommand(argv) {
|
|
|
3960
3534
|
body: { studentUserId, answers },
|
|
3961
3535
|
})));
|
|
3962
3536
|
}
|
|
3963
|
-
throw new CliError("invalid_arguments", "Use goal-templates list|get|versions|validate-spec|create|patch-spec|set-metadata|delete|snapshot-files|capture-snapshot|apply|apply-starter.");
|
|
3537
|
+
throw new CliError("invalid_arguments", "Use goal-templates list|get|versions|validate-spec|create|patch-spec|set-metadata|generate-image|delete|snapshot-files|capture-snapshot|apply|apply-starter.");
|
|
3964
3538
|
}
|
|
3965
3539
|
if (noun === "goals" && verb !== "files" && verb !== "pdf") {
|
|
3540
|
+
if (verb === "complete" || verb === "undo-completion") {
|
|
3541
|
+
const goalId = positional(parsed, 2, "goal ID");
|
|
3542
|
+
const undo = verb === "undo-completion";
|
|
3543
|
+
return writeCommand(parsed, {
|
|
3544
|
+
action: undo
|
|
3545
|
+
? "undo goal completion and reverse its coin reward"
|
|
3546
|
+
: "complete a goal and grant its one-time coin reward",
|
|
3547
|
+
target: { goalId },
|
|
3548
|
+
request: { source: "STAFF" },
|
|
3549
|
+
details: undo
|
|
3550
|
+
? {
|
|
3551
|
+
note: "Undo is refused if the student has already spent enough coins that the reward cannot be reversed.",
|
|
3552
|
+
}
|
|
3553
|
+
: undefined,
|
|
3554
|
+
}, async () => unwrap(undo
|
|
3555
|
+
? await api.client.POST("/ai/goals/{goalId}/undo-completion", {
|
|
3556
|
+
params: { path: { goalId } },
|
|
3557
|
+
})
|
|
3558
|
+
: await api.client.POST("/ai/goals/{goalId}/complete", {
|
|
3559
|
+
params: { path: { goalId } },
|
|
3560
|
+
})));
|
|
3561
|
+
}
|
|
3966
3562
|
if (verb === "list") {
|
|
3967
3563
|
const userId = flagString(parsed, "student", { required: true });
|
|
3968
3564
|
return unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
|
|
@@ -4273,7 +3869,7 @@ export async function runCommand(argv) {
|
|
|
4273
3869
|
}
|
|
4274
3870
|
throw new CliError("invalid_arguments", "Use goals queue get|set.");
|
|
4275
3871
|
}
|
|
4276
|
-
throw new CliError("invalid_arguments", "Use goals list|create|edit|delete|queue|files|pdf.");
|
|
3872
|
+
throw new CliError("invalid_arguments", "Use goals list|create|edit|delete|complete|undo-completion|queue|files|pdf.");
|
|
4277
3873
|
}
|
|
4278
3874
|
if (noun === "students") {
|
|
4279
3875
|
if (verb === "list") {
|