recess-cli 2.3.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,413 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { unwrap } from "../api.js";
4
+ import { flagString, hasFlag } from "../args.js";
5
+ import { CliError } from "../errors.js";
6
+ import { positional } from "./shared.js";
7
+ const PROJECT_FILE = ".recess/app.json";
8
+ const CONTRACT_FILE = "contract.json";
9
+ const MANIFEST_FILE = "manifest.json";
10
+ const BRIEF_FILE = "brief.md";
11
+ const SKIP_DIRS = new Set([".recess", ".git", "node_modules"]);
12
+ const MAX_FILE_BYTES = 512 * 1024;
13
+ const TERMINAL = new Set(["COMPLETE", "FAILED", "REJECTED"]);
14
+ async function readAppLink(dir) {
15
+ try {
16
+ const raw = await fs.readFile(path.join(dir, PROJECT_FILE), "utf8");
17
+ const parsed = JSON.parse(raw);
18
+ return parsed && typeof parsed === "object" ? parsed : null;
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ async function writeAppLink(dir, link) {
25
+ await fs.mkdir(path.join(dir, ".recess"), { recursive: true });
26
+ await fs.writeFile(path.join(dir, PROJECT_FILE), JSON.stringify(link, null, 2) + "\n");
27
+ }
28
+ /** Every text file under dir (relative, forward-slash paths); the CLI's own state stays out. */
29
+ async function collectFiles(dir) {
30
+ const files = {};
31
+ async function walk(current, prefix) {
32
+ const entries = await fs.readdir(current, { withFileTypes: true });
33
+ for (const entry of entries) {
34
+ if (SKIP_DIRS.has(entry.name))
35
+ continue;
36
+ const full = path.join(current, entry.name);
37
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
38
+ if (entry.isDirectory()) {
39
+ await walk(full, rel);
40
+ continue;
41
+ }
42
+ if (!entry.isFile())
43
+ continue;
44
+ const stat = await fs.stat(full);
45
+ if (stat.size > MAX_FILE_BYTES) {
46
+ throw new CliError("invalid_arguments", `${rel} is larger than ${MAX_FILE_BYTES / 1024} KB; Studio apps are small text files.`);
47
+ }
48
+ files[rel] = await fs.readFile(full, "utf8");
49
+ }
50
+ }
51
+ await walk(dir, "");
52
+ if (!files["index.html"]) {
53
+ throw new CliError("invalid_arguments", `${dir} has no index.html. Run \`recess apps init\` first, or point --dir at the app.`);
54
+ }
55
+ return files;
56
+ }
57
+ async function writeFiles(dir, files) {
58
+ let count = 0;
59
+ for (const [rel, content] of Object.entries(files)) {
60
+ if (rel.includes("..") || path.isAbsolute(rel))
61
+ continue;
62
+ const full = path.join(dir, rel);
63
+ await fs.mkdir(path.dirname(full), { recursive: true });
64
+ await fs.writeFile(full, content);
65
+ count += 1;
66
+ }
67
+ return count;
68
+ }
69
+ function resolveDir(ctx, positionalIndex) {
70
+ const flag = flagString(ctx.parsed, "dir");
71
+ const fromPositional = ctx.parsed.positionals[positionalIndex];
72
+ return path.resolve(flag ?? fromPositional ?? ".");
73
+ }
74
+ async function readContract(dir) {
75
+ const raw = await fs
76
+ .readFile(path.join(dir, CONTRACT_FILE), "utf8")
77
+ .catch(() => null);
78
+ if (!raw) {
79
+ throw new CliError("invalid_arguments", `${CONTRACT_FILE} is missing. Your agent writes the BuildContract there (see AGENTS.md in the app folder).`);
80
+ }
81
+ let parsed;
82
+ try {
83
+ parsed = JSON.parse(raw);
84
+ }
85
+ catch {
86
+ throw new CliError("invalid_arguments", `${CONTRACT_FILE} is not valid JSON.`);
87
+ }
88
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
89
+ throw new CliError("invalid_arguments", `${CONTRACT_FILE} must be a JSON object (the BuildContract).`);
90
+ }
91
+ return parsed;
92
+ }
93
+ async function readManifest(dir) {
94
+ const raw = await fs
95
+ .readFile(path.join(dir, MANIFEST_FILE), "utf8")
96
+ .catch(() => null);
97
+ if (!raw)
98
+ return {};
99
+ try {
100
+ return JSON.parse(raw);
101
+ }
102
+ catch {
103
+ throw new CliError("invalid_arguments", `${MANIFEST_FILE} is not valid JSON.`);
104
+ }
105
+ }
106
+ function slug(value) {
107
+ return value
108
+ .toLowerCase()
109
+ .replace(/[^a-z0-9]+/g, "-")
110
+ .replace(/^-+|-+$/g, "")
111
+ .slice(0, 48);
112
+ }
113
+ function briefFor(data) {
114
+ const { todo, analysis } = data;
115
+ const misconceptions = analysis.patterns.map((p) => p.instanceKey ?? slug(p.name));
116
+ const lines = [
117
+ "# Build brief — remediation for a kid's todo",
118
+ "",
119
+ `Source todo: "${todo.title}" (${todo.id})${todo.completedAt ? `, completed ${todo.completedAt.slice(0, 10)}` : ""}`,
120
+ ...(todo.description ? [`Todo description: ${todo.description}`] : []),
121
+ ...(data.standards.length
122
+ ? [
123
+ "",
124
+ "## Standard(s)",
125
+ ...data.standards.map((s) => `- ${s.notation} — ${s.description}`),
126
+ ]
127
+ : [
128
+ "",
129
+ "## Standard(s)",
130
+ "- none mapped; pick one with `recess apps standards <words>`",
131
+ ]),
132
+ ...(data.gradeBand ? ["", `Grade band: ${data.gradeBand}`] : []),
133
+ ...(data.interests.length
134
+ ? [`Interests: ${data.interests.join(", ")}`]
135
+ : []),
136
+ "",
137
+ "## What went wrong (forensic analysis of the session)",
138
+ "",
139
+ ...(analysis.overallPerformance
140
+ ? [`Overall: ${analysis.overallPerformance}`, ""]
141
+ : []),
142
+ ...(analysis.rootCause ? [`Root cause: ${analysis.rootCause}`, ""] : []),
143
+ ...(analysis.summary ? [`Summary: ${analysis.summary}`, ""] : []),
144
+ "### Patterns",
145
+ ...(analysis.patterns.length
146
+ ? analysis.patterns.flatMap((p) => [
147
+ `- **${p.name}**${p.instanceKey ? ` (\`${p.instanceKey}\`)` : ""}: ${p.issue}`,
148
+ ` ${p.analysis}`,
149
+ ...(p.behavioralGap
150
+ ? [
151
+ ` Observed: ${p.behavioralGap.observed}`,
152
+ ` Expected: ${p.behavioralGap.expected}`,
153
+ ]
154
+ : []),
155
+ ])
156
+ : ["- none recorded"]),
157
+ "",
158
+ "### Prescriptions",
159
+ ...(analysis.prescriptions.length
160
+ ? analysis.prescriptions.map((p) => `- ${p.type}: ${p.recommendation}`)
161
+ : ["- none recorded"]),
162
+ "",
163
+ "## Build against this",
164
+ "",
165
+ "- Mode: remediate. The app exists to close the gap above — every practice item must be one the kid",
166
+ " would get wrong by the observed behaviour, and the fork must confront observed vs expected.",
167
+ `- In manifest.json declare template.misconceptions as ${JSON.stringify(misconceptions)} (match the`,
168
+ " keys above; add more only if the model teaches them) and template.ccss as the standard.",
169
+ "- Theme items with the interests listed; never use the kid's name or anything from the session.",
170
+ "- The kid must never learn this app exists because of a mistake: no 'last time', 'you got',",
171
+ " 'let's fix your'. The confront intro shows a stranger's attempt ('Someone tried this'); the",
172
+ " title and every line read like any other practice.",
173
+ "- Follow AGENTS.md for everything else (contract, no reveals, one reading per step).",
174
+ "",
175
+ ];
176
+ return lines.join("\n");
177
+ }
178
+ function agentsGuide(docs) {
179
+ return [
180
+ "# Building this Recess app",
181
+ "",
182
+ "If a brief.md sits next to this file, it is the assignment: build for that kid's gap first —",
183
+ "but the kid never hears about the gap: no copy that refers to their past work or a mistake.",
184
+ "",
185
+ "This folder is a Recess Studio app on the locked scaffold. Edit ONLY: skill.js, model.js,",
186
+ "model.css, params.schema.json, params.json. Never touch index.html, styles.css, shell.js,",
187
+ "widgets.js, engine.js, key.js — a modified locked file fails validation.",
188
+ "",
189
+ "Before publishing, write two files at the root:",
190
+ "- contract.json — the BuildContract described below (copy the shape in the template contract).",
191
+ '- manifest.json — { "title": "...", "description": "...", "template": { "ccss": "5.NF.A.1", "modes": ["prepare","remediate"], "misconceptions": ["..."] } }',
192
+ "",
193
+ 'Then: `recess apps validate --reason "..."` (reports without publishing) and',
194
+ '`recess apps publish --reason "..."` (validation + independent review; on pass the app is live).',
195
+ "",
196
+ "---",
197
+ "",
198
+ docs.runtime ?? "",
199
+ "",
200
+ "---",
201
+ "",
202
+ docs.template ?? "",
203
+ "",
204
+ "---",
205
+ "",
206
+ docs.pedagogy ? `# Pedagogy\n\n${docs.pedagogy}` : "",
207
+ "",
208
+ "---",
209
+ "",
210
+ docs.design ? `# Design\n\n${docs.design}` : "",
211
+ "",
212
+ ].join("\n");
213
+ }
214
+ async function pollBuild(ctx, buildId, timeoutMs) {
215
+ const started = Date.now();
216
+ let delay = 3_000;
217
+ for (;;) {
218
+ const status = unwrap(await ctx.api.client.GET("/studio/builds/{buildId}", {
219
+ params: { path: { buildId } },
220
+ }));
221
+ if (TERMINAL.has(status.status))
222
+ return status;
223
+ if (Date.now() - started > timeoutMs) {
224
+ throw new CliError("timeout", `Build ${buildId} is still ${status.status} after ${Math.round(timeoutMs / 1000)}s; check later with \`recess apps status ${buildId}\`.`);
225
+ }
226
+ await new Promise((r) => setTimeout(r, delay));
227
+ delay = Math.min(delay * 1.5, 15_000);
228
+ }
229
+ }
230
+ async function submit(ctx, dir, dryRun) {
231
+ const files = await collectFiles(dir);
232
+ const contract = await readContract(dir);
233
+ const manifest = await readManifest(dir);
234
+ const link = await readAppLink(dir);
235
+ const name = flagString(ctx.parsed, "name") ?? manifest.title ?? path.basename(dir);
236
+ const body = {
237
+ ...(link?.projectId ? { projectId: link.projectId } : {}),
238
+ name,
239
+ ...(manifest.description ? { description: manifest.description } : {}),
240
+ files,
241
+ contract,
242
+ ...(manifest.template ? { template: manifest.template } : {}),
243
+ dryRun,
244
+ };
245
+ const assignTo = flagString(ctx.parsed, "assign");
246
+ const due = flagString(ctx.parsed, "due");
247
+ if (assignTo && dryRun) {
248
+ throw new CliError("invalid_arguments", "--assign publishes; use `recess apps publish --assign <student-id>`.");
249
+ }
250
+ if (due && !/^\d{4}-\d{2}-\d{2}$/.test(due)) {
251
+ throw new CliError("invalid_arguments", "--due must be YYYY-MM-DD.");
252
+ }
253
+ const created = unwrap(await ctx.api.client.POST("/studio/imports", { body }));
254
+ await writeAppLink(dir, {
255
+ ...link,
256
+ projectId: created.projectId,
257
+ appUrl: created.appUrl,
258
+ });
259
+ const wait = !hasFlag(ctx.parsed, "no-wait");
260
+ if (!wait) {
261
+ if (assignTo) {
262
+ throw new CliError("invalid_arguments", "--assign needs the build result; drop --no-wait.");
263
+ }
264
+ return { ...created, dryRun };
265
+ }
266
+ const status = await pollBuild(ctx, created.buildId, 30 * 60 * 1000);
267
+ const result = {
268
+ projectId: created.projectId,
269
+ buildId: created.buildId,
270
+ appUrl: created.appUrl,
271
+ dryRun,
272
+ status: status.status,
273
+ report: status.report ?? null,
274
+ error: status.error ?? null,
275
+ };
276
+ if (!assignTo)
277
+ return result;
278
+ if (status.status !== "COMPLETE") {
279
+ return {
280
+ ...result,
281
+ assigned: null,
282
+ assignError: `Not assigned: the build ended ${status.status}. Fix the report and publish again.`,
283
+ };
284
+ }
285
+ const assigned = await assign(ctx, created.projectId, {
286
+ studentId: assignTo,
287
+ ...(due ? { dueDate: due } : {}),
288
+ ...(link?.forTodoId ? { sourceTodoId: link.forTodoId } : {}),
289
+ });
290
+ return { ...result, assigned };
291
+ }
292
+ async function assign(ctx, projectId, body) {
293
+ return unwrap(await ctx.api.client.POST("/studio/projects/{projectId}/assign", {
294
+ params: { path: { projectId } },
295
+ body,
296
+ }));
297
+ }
298
+ export async function runAppsCommand(ctx) {
299
+ const { parsed, api } = ctx;
300
+ const verb = parsed.positionals[1];
301
+ if (verb === "init") {
302
+ const dir = path.resolve(positional(parsed, 2, "app folder"));
303
+ await fs.mkdir(dir, { recursive: true });
304
+ const existing = await fs.readdir(dir);
305
+ if (existing.length > 0 && !hasFlag(parsed, "force")) {
306
+ throw new CliError("invalid_arguments", `${dir} is not empty. Use --force to write the scaffold over it.`);
307
+ }
308
+ const forTodo = flagString(parsed, "for-todo");
309
+ const analysis = forTodo
310
+ ? unwrap(await api.client.GET("/studio/todos/{todoId}/analysis", {
311
+ params: { path: { todoId: forTodo } },
312
+ }))
313
+ : null;
314
+ const scaffold = unwrap(await api.client.GET("/studio/scaffold"));
315
+ const written = await writeFiles(dir, scaffold.files);
316
+ await fs.writeFile(path.join(dir, "AGENTS.md"), agentsGuide(scaffold.docs));
317
+ if (analysis) {
318
+ await fs.writeFile(path.join(dir, BRIEF_FILE), briefFor(analysis));
319
+ await writeAppLink(dir, {
320
+ forTodoId: analysis.todo.id,
321
+ studentId: analysis.todo.studentId,
322
+ });
323
+ }
324
+ return {
325
+ dir,
326
+ filesWritten: written + (analysis ? 2 : 1),
327
+ ...(analysis
328
+ ? {
329
+ brief: BRIEF_FILE,
330
+ studentId: analysis.todo.studentId,
331
+ standards: analysis.standards.map((s) => s.notation),
332
+ misconceptions: analysis.analysis.patterns.map((p) => p.instanceKey ?? slug(p.name)),
333
+ }
334
+ : {}),
335
+ next: [
336
+ `cd ${dir}`,
337
+ analysis
338
+ ? "Open the folder with your agent: brief.md is the assignment, AGENTS.md the contract."
339
+ : "Open the folder with your agent and describe what the app should teach (AGENTS.md has the contract).",
340
+ 'recess apps validate --reason "Check my app"',
341
+ analysis
342
+ ? `recess apps publish --assign ${analysis.todo.studentId} --reason "Ship it to the kid"`
343
+ : 'recess apps publish --reason "Ship my app"',
344
+ ],
345
+ };
346
+ }
347
+ if (verb === "standards") {
348
+ const q = parsed.positionals.slice(2).join(" ").trim();
349
+ if (!q) {
350
+ throw new CliError("invalid_arguments", 'Give words or a notation: recess apps standards "grade 4 adding fractions".');
351
+ }
352
+ return unwrap(await api.client.GET("/studio/standards", {
353
+ params: { query: { q } },
354
+ }));
355
+ }
356
+ if (verb === "assign") {
357
+ const dir = resolveDir(ctx, 2);
358
+ const link = await readAppLink(dir);
359
+ const studentId = flagString(parsed, "student") ?? link?.studentId;
360
+ if (!link?.projectId) {
361
+ throw new CliError("invalid_arguments", `${dir} has no published app yet; run \`recess apps publish\` first.`);
362
+ }
363
+ if (!studentId) {
364
+ throw new CliError("invalid_arguments", "--student <student-id> is required (or init the app with --for-todo).");
365
+ }
366
+ const due = flagString(parsed, "due");
367
+ if (due && !/^\d{4}-\d{2}-\d{2}$/.test(due)) {
368
+ throw new CliError("invalid_arguments", "--due must be YYYY-MM-DD.");
369
+ }
370
+ return assign(ctx, link.projectId, {
371
+ studentId,
372
+ ...(due ? { dueDate: due } : {}),
373
+ ...(link.forTodoId ? { sourceTodoId: link.forTodoId } : {}),
374
+ });
375
+ }
376
+ if (verb === "list") {
377
+ return unwrap(await api.client.GET("/studio/projects"));
378
+ }
379
+ if (verb === "pull") {
380
+ const projectId = positional(parsed, 2, "project id");
381
+ const dir = resolveDir(ctx, 3);
382
+ const project = unwrap(await api.client.GET("/studio/projects/{projectId}/files", {
383
+ params: { path: { projectId } },
384
+ }));
385
+ await fs.mkdir(dir, { recursive: true });
386
+ const written = await writeFiles(dir, project.files);
387
+ if (project.contract)
388
+ await fs.writeFile(path.join(dir, CONTRACT_FILE), JSON.stringify(project.contract, null, 2) + "\n");
389
+ await fs.writeFile(path.join(dir, MANIFEST_FILE), JSON.stringify({ title: project.name }, null, 2) + "\n");
390
+ await writeAppLink(dir, { projectId });
391
+ return {
392
+ dir,
393
+ projectId,
394
+ name: project.name,
395
+ versionNumber: project.versionNumber,
396
+ filesWritten: written,
397
+ };
398
+ }
399
+ if (verb === "validate") {
400
+ return submit(ctx, resolveDir(ctx, 2), true);
401
+ }
402
+ if (verb === "publish") {
403
+ return submit(ctx, resolveDir(ctx, 2), false);
404
+ }
405
+ if (verb === "status") {
406
+ const buildId = positional(parsed, 2, "build id");
407
+ return unwrap(await api.client.GET("/studio/builds/{buildId}", {
408
+ params: { path: { buildId } },
409
+ }));
410
+ }
411
+ throw new CliError("invalid_arguments", "Unknown apps command. Use: apps init <dir> [--for-todo <todo-id>] | apps standards <words> | apps list | apps pull <project-id> [dir] | apps validate [dir] | apps publish [dir] [--assign <student-id>] | apps assign [dir] --student <id> | apps status <build-id>.");
412
+ }
413
+ //# sourceMappingURL=apps.js.map
@@ -503,6 +503,21 @@ export async function runOnboardingCommand({ parsed, api, writeCommand, }) {
503
503
  request: {},
504
504
  }, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/intake/generate-summaries", { params: { path: { familyId } } })));
505
505
  }
506
+ if (verb === "link-math-academy") {
507
+ const familyId = positional(parsed, 2, "family ID");
508
+ const kidUserId = flagString(parsed, "kid", { required: true });
509
+ const mathAcademyStudentId = flagInteger(parsed, "student-id", {
510
+ min: 1,
511
+ });
512
+ const body = mathAcademyStudentId === undefined ? {} : { mathAcademyStudentId };
513
+ return writeCommand(parsed, {
514
+ action: mathAcademyStudentId === undefined
515
+ ? "link the kid to an existing Math Academy account using the saved login or a unique name match"
516
+ : `link the kid to existing Math Academy student ${mathAcademyStudentId} after verifying that student exists`,
517
+ target: { familyId, kidUserId },
518
+ request: body,
519
+ }, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/kids/{kidUserId}/link/math-academy", { params: { path: { familyId, kidUserId } }, body })));
520
+ }
506
521
  if (verb === "provision-math-academy") {
507
522
  const familyId = positional(parsed, 2, "family ID");
508
523
  const kidUserId = flagString(parsed, "kid", { required: true });
@@ -0,0 +1,150 @@
1
+ import { unwrap } from "../api.js";
2
+ import { flagString, hasFlag } from "../args.js";
3
+ import { apiError, CliError } from "../errors.js";
4
+ const NONE = "—";
5
+ async function fetchTemplatesAndRooms(ctx) {
6
+ const [templatesResponse, rooms] = await Promise.all([
7
+ ctx.api.client.GET("/recess/event-templates-editable/"),
8
+ ctx.api.client.GET("/recess/village-rooms/"),
9
+ ]);
10
+ return {
11
+ templates: unwrap(templatesResponse).eventTemplates,
12
+ rooms: unwrap(rooms),
13
+ };
14
+ }
15
+ function schedule(template) {
16
+ if (template.singleEventDate)
17
+ return template.singleEventDate.slice(0, 10);
18
+ return template.rrule || NONE;
19
+ }
20
+ function renderColumns(rows) {
21
+ const widths = rows[0].map((_, column) => Math.max(...rows.map((row) => row[column].length)));
22
+ return rows
23
+ .map((row) => row
24
+ .map((cell, column) => column === row.length - 1 ? cell : cell.padEnd(widths[column]))
25
+ .join(" ")
26
+ .trimEnd())
27
+ .join("\n");
28
+ }
29
+ /** `village events` — every editable Recess Hour template and its Town Center zone. */
30
+ async function listEvents(ctx) {
31
+ const { templates, rooms } = await fetchTemplatesAndRooms(ctx);
32
+ const roomsById = new Map(rooms.map((room) => [room.id, room]));
33
+ const templateRows = templates.map((template) => {
34
+ const room = template.villageRoomId
35
+ ? roomsById.get(template.villageRoomId)
36
+ : undefined;
37
+ return {
38
+ id: template.id,
39
+ name: template.name,
40
+ status: template.status,
41
+ schedule: schedule(template),
42
+ villageRoomId: template.villageRoomId,
43
+ villageRoomName: room?.name ?? null,
44
+ joinUrl: template.joinUrl ?? null,
45
+ };
46
+ });
47
+ const unlinkedRooms = rooms
48
+ .filter((room) => !room.linkedTemplateId)
49
+ .map((room) => ({ id: room.id, name: room.name }));
50
+ if (hasFlag(ctx.parsed, "json")) {
51
+ return { templates: templateRows, unlinkedRooms };
52
+ }
53
+ const table = renderColumns([
54
+ ["TEMPLATE", "NAME", "STATUS", "SCHEDULE", "ZONE", "ZONE NAME", "URL"],
55
+ ...templateRows.map((row) => [
56
+ row.id,
57
+ row.name,
58
+ row.status,
59
+ row.schedule,
60
+ row.villageRoomId ?? NONE,
61
+ row.villageRoomName ?? (row.villageRoomId ? "(unknown zone)" : NONE),
62
+ row.joinUrl ?? NONE,
63
+ ]),
64
+ ]);
65
+ const unlinked = unlinkedRooms.length === 0
66
+ ? " (none)"
67
+ : renderColumns(unlinkedRooms.map((room) => [` ${room.id}`, room.name]));
68
+ return {
69
+ help: `${templates.length === 0 ? "No editable event templates." : table}\n\nUnlinked Town Center zones:\n${unlinked}`,
70
+ };
71
+ }
72
+ function isHttpUrl(value) {
73
+ try {
74
+ const url = new URL(value);
75
+ return url.protocol === "http:" || url.protocol === "https:";
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ }
81
+ /** `village link-event --template <id> (--room <zoneId> | --unlink) [--url <https://…>]` */
82
+ async function linkEvent(ctx) {
83
+ const { parsed, api, writeCommand } = ctx;
84
+ const templateId = flagString(parsed, "template", { required: true });
85
+ const roomId = flagString(parsed, "room");
86
+ const unlink = hasFlag(parsed, "unlink");
87
+ const url = flagString(parsed, "url");
88
+ if (url !== undefined && !isHttpUrl(url)) {
89
+ throw new CliError("invalid_arguments", "--url must be an http(s) URL (e.g. https://example.com/event).");
90
+ }
91
+ if (unlink === Boolean(roomId)) {
92
+ throw new CliError("invalid_arguments", "Pass exactly one of --room <zoneId> or --unlink.");
93
+ }
94
+ const { templates, rooms } = await fetchTemplatesAndRooms(ctx);
95
+ const template = templates.find((row) => row.id === templateId);
96
+ if (!template) {
97
+ throw new CliError("not_found", `No editable event template found for ${templateId}. Run \`recess village events\` to list them.`);
98
+ }
99
+ let room;
100
+ if (roomId) {
101
+ room = rooms.find((row) => row.id === roomId);
102
+ if (!room) {
103
+ throw new CliError("not_found", `No Village Town Center zone found for ${roomId}. Run \`recess village events\` to list linkable zones.`);
104
+ }
105
+ if (room.linkedTemplateId && room.linkedTemplateId !== templateId) {
106
+ throw new CliError("invalid_arguments", `Zone ${room.id} (${room.name}) is already linked to template ${room.linkedTemplateId} (${room.linkedTemplateName ?? "unnamed"}). Unlink that template first.`);
107
+ }
108
+ }
109
+ const previousRoom = template.villageRoomId
110
+ ? rooms.find((row) => row.id === template.villageRoomId)
111
+ : undefined;
112
+ const body = {
113
+ villageRoomId: room ? room.id : null,
114
+ ...(url !== undefined ? { joinUrl: url } : {}),
115
+ };
116
+ return writeCommand(parsed, {
117
+ action: room
118
+ ? `Link template ${template.id} (${template.name}) → zone ${room.id} (${room.name}); zone will be renamed to the event title${url ? ` and its link set to ${url}` : ""}`
119
+ : `Unlink template ${template.id} (${template.name}) from zone ${template.villageRoomId ?? NONE}${previousRoom ? ` (${previousRoom.name})` : ""}`,
120
+ target: {
121
+ templateId: template.id,
122
+ templateName: template.name,
123
+ currentVillageRoomId: template.villageRoomId,
124
+ },
125
+ request: body,
126
+ }, async () => {
127
+ const result = await api.client.PATCH("/recess/event-templates/{id}/", {
128
+ params: { path: { id: template.id } },
129
+ body,
130
+ });
131
+ if (!result.response.ok) {
132
+ throw apiError(result.response.status, result.error);
133
+ }
134
+ return {
135
+ templateId: template.id,
136
+ templateName: template.name,
137
+ villageRoomId: body.villageRoomId,
138
+ villageRoomName: room?.name ?? null,
139
+ ...(url !== undefined ? { joinUrl: url } : {}),
140
+ };
141
+ });
142
+ }
143
+ export async function runVillageEventsCommand(ctx, verb) {
144
+ if (verb === "events")
145
+ return listEvents(ctx);
146
+ if (verb === "link-event")
147
+ return linkEvent(ctx);
148
+ throw new CliError("invalid_arguments", "Use village events or link-event.");
149
+ }
150
+ //# sourceMappingURL=village-events.js.map
package/dist/help.js CHANGED
@@ -22,6 +22,10 @@ Usage:
22
22
  recess [--json] auth status|logout
23
23
  recess [--json] users search <name-or-id> [--limit 10]
24
24
  recess [--json] users get <user-id>
25
+ recess [--json] users lock <user-id> [--confirm]
26
+ recess [--json] users unlock <user-id> [--confirm]
27
+ recess [--json] users disable <user-id> [--whole-family] [--allow-billing] [--confirm]
28
+ recess [--json] users restore <user-id> --role kid|guardian|guide|program [--confirm]
25
29
  recess [--json] users tier list-tiers
26
30
  recess [--json] users tier get <kid-id>
27
31
  recess [--json] users tier preview <kid-id> --tier social|academics|lite|complete|platform
@@ -38,6 +42,8 @@ Usage:
38
42
  recess [--json] students list
39
43
  recess [--json] students today --student <kid-id> [--date YYYY-MM-DD]
40
44
  recess [--json] students schedule --student <kid-id> [--days 14]
45
+ recess [--json] students todos --student <kid-id> [--limit 30] [--analyzed]
46
+ recess [--json] students analysis --todo <todo-id>
41
47
  recess [--json] students xp-history --student <kid-id>
42
48
  [--range week|month|quarter|year]
43
49
  recess [--json] enrollments list --user <user-id>
@@ -48,6 +54,14 @@ Usage:
48
54
  recess [--json] invoices refund --invoice <id> --line-item <id>
49
55
  --method refund|credit|tokens [--full | --amount-cents N]
50
56
  [--who-pays guide|recess] [--reason TEXT] [--confirm]
57
+ recess [--json] apps init <dir> [--for-todo <todo-id>] [--force]
58
+ recess [--json] apps standards <query>
59
+ recess [--json] apps list
60
+ recess [--json] apps pull <project-id> [dir]
61
+ recess [--json] apps validate [dir] [--name TEXT] [--no-wait]
62
+ recess [--json] apps publish [dir] [--name TEXT] [--assign <kid-id>] [--due YYYY-MM-DD] [--no-wait]
63
+ recess [--json] apps assign [dir] --student <kid-id> [--due YYYY-MM-DD]
64
+ recess [--json] apps status <build-id>
51
65
  recess [--json] applications list [--status SUBMITTED|CLAIMED|ENROLLED|CLOSED]
52
66
  [--status-scope active|closed] [--disposition READY_TO_ENROLL|TRIAL|PENDING_FUNDING|NO]
53
67
  [--dispositioned true|false] [--quality QUALIFIED|NEEDS_NURTURE|UNKNOWN]
@@ -168,6 +182,8 @@ Usage:
168
182
  recess [--json] onboarding generate-summaries <family-id> [--confirm]
169
183
  recess [--json] onboarding provision-math-academy <family-id>
170
184
  --kid <kid-id> [--grade 1..12] [--confirm]
185
+ recess [--json] onboarding link-math-academy <family-id>
186
+ --kid <kid-id> [--student-id <math-academy-student-id>] [--confirm]
171
187
  recess [--json] onboarding provision-ixl <family-id>
172
188
  --kid <kid-id> [--credentials-file <credentials.json>] [--confirm]
173
189
  recess [--json] onboarding remove-ixl <family-id> --kid <kid-id> [--confirm]
@@ -270,6 +286,9 @@ Usage:
270
286
  recess [--json] village worlds export <world-id> [--out <bundle.json>] [--confirm]
271
287
  recess [--json] village worlds import <world-id> --file <bundle.json> [--confirm]
272
288
  recess [--json] village worlds promote <mirror-or-archive-id> [--confirm]
289
+ recess [--json] village events
290
+ recess [--json] village link-event --template <template-id>
291
+ [--room <zone-id>] [--unlink] [--url <https://…>] [--confirm]
273
292
  recess [--json] store-items list [--search TEXT]
274
293
  [--status ACTIVE|INACTIVE|COMING_SOON] [--item-type TYPE]
275
294
  [--page 0] [--limit 20] [--sort-by name|price|createdAt|updatedAt|order]
@@ -344,6 +363,8 @@ Usage:
344
363
  [--due-date YYYY-MM-DD] [--estimated-minutes N] [--url URL] [--confirm]
345
364
  recess [--json] todos edit <todo-id> --patch-file <path/patch.json>
346
365
  [--confirm --approval-token TOKEN]
366
+ recess [--json] todos complete <todo-id> --xp N
367
+ [--confirm --approval-token TOKEN]
347
368
  recess [--json] todos delete <todo-id>
348
369
  [--confirm --approval-token TOKEN]
349
370
  recess [--json] todos generate-applet <todo-id> --student <kid-id>
@@ -413,6 +434,33 @@ monthly top-up cron and are exact-ADMIN on update); descriptive edits
413
434
  (name/slug/logo) are ordinary staff writes. "school kid-slots" is the raw
414
435
  slot override; "users tier set" is the tier-driven path.
415
436
 
437
+ "users lock"/"users unlock" are the CLI twin of the Lock/Unlock buttons on
438
+ recess.gg/ai/students/<kid-id>/admin. They act on the whole account, not one
439
+ person: a KID target is locked or unlocked together with every GUARDIAN in
440
+ their family, because an unlocked kid under a locked guardian is not actually
441
+ unlocked. Unlock grants the kid and guardian access roles, restores the kid's
442
+ Recess channel memberships, sets FLAG_ACCOUNT_UNLOCKED, and notifies the kid's
443
+ live clients; lock is the exact inverse. Rerunning "unlock" on an already
444
+ unlocked account is the "Refresh Unlock" button and is safe. The preview shows
445
+ each affected person's current flag; "users get" is the read.
446
+
447
+ "users disable" is the harder stop, and a different mechanism: it sets
448
+ User.role to DISABLED, so the account stops resolving in every dashboard,
449
+ search, chat, and todo query at the database layer, and its coherent-feed
450
+ personal data is purged. "users lock" only strips access roles. Disabling is
451
+ SOFT and reversible — "users restore <id> --role ..." puts the account back —
452
+ and it is NOT a GDPR erasure; nothing is deleted. A read-only preflight resolves
453
+ the whole family (including members already DISABLED, which ordinary reads hide)
454
+ and each person's billable enrollments. "--whole-family" extends the disable to
455
+ every member of the target's family; ADMIN/MODERATOR members, your own account,
456
+ and anyone already in the destination role are listed under preview.skipped
457
+ rather than silently dropped. A target holding an ACTIVE or PAST_DUE enrollment
458
+ is refused until billing is cancelled or "--allow-billing" deliberately orphans
459
+ the seat. Restore names the role explicitly because DISABLED does not record
460
+ what the account was; ADMIN and MODERATOR are not restorable from the CLI.
461
+ Note that after a disable "users get" and "users search" no longer resolve the
462
+ account — the confirmed write's own response is the record of what changed.
463
+
416
464
  Onboarding notes: "status" and "intake-session" are reads — "intake-session"
417
465
  looks up the current IN_PROGRESS session without creating one (prints a "none
418
466
  yet" result when absent). "intake-session-create" is the explicit write that