recess-cli 3.4.0 → 3.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.
package/dist/api.js CHANGED
@@ -167,6 +167,23 @@ export class RecessAdminApi {
167
167
  throw apiError(response.status, body);
168
168
  return body;
169
169
  }
170
+ async uploadChannelImage(channelId, image, fileName, mimeType) {
171
+ this.requireAuth();
172
+ const formData = new FormData();
173
+ const arrayBuffer = image.buffer.slice(image.byteOffset, image.byteOffset + image.byteLength);
174
+ formData.append("file", new Blob([arrayBuffer], { type: mimeType }), fileName);
175
+ const headers = cliRequestHeaders({ cookie: this.config.sessionCookie }, this.reason, this.clientTag);
176
+ applyIdempotencyHeaders(headers);
177
+ const response = await fetch(new URL(`/admin/channels/${encodeURIComponent(channelId)}/image`, this.config.apiOrigin), {
178
+ method: "POST",
179
+ headers,
180
+ body: formData,
181
+ });
182
+ const body = await readResponseBody(response);
183
+ if (!response.ok)
184
+ throw apiError(response.status, body);
185
+ return body;
186
+ }
170
187
  async uploadMapTestScores(studentId, pdf, fileName) {
171
188
  this.requireAuth();
172
189
  const formData = new FormData();
package/dist/cli.js CHANGED
@@ -10,7 +10,10 @@ import { agentContext, buildCommandSchema, findCommandSchema, remoteCommands, sc
10
10
  import { runApplicationsCommand } from "./commands/applications.js";
11
11
  import { runAppsCommand } from "./commands/apps.js";
12
12
  import { readAssignmentSelection, runGoalCurriculumCommand, } from "./commands/goal-curriculum.js";
13
+ import { runChannelsCommand } from "./commands/channels.js";
13
14
  import { runChatLogsCommand } from "./commands/chat-logs.js";
15
+ import { runRoomsCommand } from "./commands/rooms.js";
16
+ import { runFamiliesCommand } from "./commands/families.js";
14
17
  import { runMasteryCommand } from "./commands/mastery.js";
15
18
  import { runOnboardingCommand } from "./commands/onboarding.js";
16
19
  import { runScheduleCommand, runMemoryFilesCommand, } from "./commands/safe-staff-operations.js";
@@ -308,6 +311,7 @@ async function localWriteCommand(parsed, preview, execute) {
308
311
  const GOAL_WORKSPACE_WRITE_MAX_FILES = 1_000;
309
312
  const GOAL_WORKSPACE_WRITE_MAX_TOTAL_BYTES = 20 * 1024 * 1024;
310
313
  const GOAL_PDF_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024;
314
+ const GOAL_VIDEO_UPLOAD_MAX_BYTES = 500 * 1024 * 1024;
311
315
  const GOAL_QUEUE_MAX_ENTRIES = 500;
312
316
  const URL_QUEUE_DESCRIPTION_POINTER = "Skill queue managed by the system.";
313
317
  const RECESS_GIT_METADATA = "recess-workspace.json";
@@ -4259,6 +4263,7 @@ export async function executeRecessCommand(argv, options = {}) {
4259
4263
  (!Number.isInteger(coinAmount) || coinAmount < 1)) {
4260
4264
  throw new CliError("invalid_arguments", "--coin-amount must be a positive integer.");
4261
4265
  }
4266
+ const visibility = flagString(parsed, "visibility");
4262
4267
  const isStarterRaw = flagString(parsed, "is-starter");
4263
4268
  const setupAudienceRaw = flagString(parsed, "setup-audience");
4264
4269
  const isStarter = isStarterRaw === undefined
@@ -4300,6 +4305,11 @@ export async function executeRecessCommand(argv, options = {}) {
4300
4305
  ...(kind
4301
4306
  ? { kind: assertChoice(kind, GOAL_TEMPLATE_KINDS, "--kind") }
4302
4307
  : {}),
4308
+ ...(visibility
4309
+ ? {
4310
+ visibility: assertChoice(visibility, ["PUBLIC", "PRIVATE", "STAFF_ONLY"], "--visibility"),
4311
+ }
4312
+ : {}),
4303
4313
  ...(agentInstructionsFile
4304
4314
  ? {
4305
4315
  agentInstructions: await fs.readFile(path.resolve(agentInstructionsFile), "utf8"),
@@ -4316,7 +4326,7 @@ export async function executeRecessCommand(argv, options = {}) {
4316
4326
  // shape that caused the template incident; editing an existing spec goes
4317
4327
  // through the guarded /ai patch path with its destructive-change token.
4318
4328
  if (Object.keys(body).length === 1) {
4319
- 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).");
4329
+ 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, --visibility, --agent-instructions-file, --output-template-file).");
4320
4330
  }
4321
4331
  return writeCommand(parsed, {
4322
4332
  action: "update goal template metadata (never its setupWorkflowSpec)",
@@ -4334,7 +4344,7 @@ export async function executeRecessCommand(argv, options = {}) {
4334
4344
  }));
4335
4345
  const prompt = flagString(parsed, "prompt");
4336
4346
  return writeCommand(parsed, {
4337
- action: "queue paid GPT-Image-2 art regeneration",
4347
+ action: "queue paid challenge-art regeneration",
4338
4348
  target: {
4339
4349
  templateId: id,
4340
4350
  slug: current.slug,
@@ -4342,7 +4352,7 @@ export async function executeRecessCommand(argv, options = {}) {
4342
4352
  },
4343
4353
  request: { prompt: prompt ?? null },
4344
4354
  details: {
4345
- 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.",
4355
+ costNote: "This queues one paid 1024×1024 challenge-art painting (the shared lane behind /challenges-v2, from the category's style reference; ~$0.02) and replaces the template image when it finishes. New templates already generate art automatically.",
4346
4356
  },
4347
4357
  }, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/generate-image", {
4348
4358
  params: { path: { id } },
@@ -4557,7 +4567,29 @@ export async function executeRecessCommand(argv, options = {}) {
4557
4567
  }
4558
4568
  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.");
4559
4569
  }
4560
- if (noun === "goals" && verb !== "files" && verb !== "pdf") {
4570
+ if (noun === "goals" && verb === "adaptations") {
4571
+ const action = positional(parsed, 2, "goals adaptations action");
4572
+ const studentId = flagString(parsed, "student", { required: true });
4573
+ const goalId = flagString(parsed, "goal", { required: true });
4574
+ if (action === "list") {
4575
+ return unwrap(await api.client.GET("/tutor/students/{studentId}/goals/{goalId}/adaptations", { params: { path: { studentId, goalId } } }));
4576
+ }
4577
+ if (action === "diff") {
4578
+ const runId = positional(parsed, 3, "adaptation run ID");
4579
+ const path = flagString(parsed, "path");
4580
+ return unwrap(await api.client.GET("/tutor/students/{studentId}/goals/{goalId}/adaptations/{runId}/diff", {
4581
+ params: {
4582
+ path: { studentId, goalId, runId },
4583
+ query: path ? { path } : {},
4584
+ },
4585
+ }));
4586
+ }
4587
+ throw new CliError("invalid_arguments", "Use goals adaptations list|diff.");
4588
+ }
4589
+ if (noun === "goals" &&
4590
+ verb !== "files" &&
4591
+ verb !== "pdf" &&
4592
+ verb !== "video") {
4561
4593
  if (verb === "complete" || verb === "undo-completion") {
4562
4594
  const goalId = positional(parsed, 2, "goal ID");
4563
4595
  const undo = verb === "undo-completion";
@@ -5049,11 +5081,20 @@ export async function executeRecessCommand(argv, options = {}) {
5049
5081
  }
5050
5082
  throw new CliError("invalid_arguments", "Use goals queue get|set.");
5051
5083
  }
5052
- throw new CliError("invalid_arguments", "Use goals list|create|edit|delete|restore|archive|unarchive|complete|undo-completion|move-module|queue|files|pdf.");
5084
+ throw new CliError("invalid_arguments", "Use goals list|create|edit|delete|restore|archive|unarchive|complete|undo-completion|move-module|queue|files|pdf|video.");
5085
+ }
5086
+ if (noun === "channels") {
5087
+ return runChannelsCommand({ parsed, api, writeCommand });
5088
+ }
5089
+ if (noun === "families") {
5090
+ return runFamiliesCommand({ parsed, api, writeCommand });
5053
5091
  }
5054
5092
  if (noun === "chat-logs") {
5055
5093
  return runChatLogsCommand({ parsed, api, writeCommand });
5056
5094
  }
5095
+ if (noun === "rooms") {
5096
+ return runRoomsCommand({ parsed, api, writeCommand });
5097
+ }
5057
5098
  if (noun === "students") {
5058
5099
  if (verb === "list") {
5059
5100
  const requestedScope = flagString(parsed, "scope");
@@ -5626,6 +5667,64 @@ export async function executeRecessCommand(argv, options = {}) {
5626
5667
  }
5627
5668
  throw new CliError("invalid_arguments", "Use tutor-templates list|get|create|update|delete|assign|unassign.");
5628
5669
  }
5670
+ if (noun === "goals" && verb === "video") {
5671
+ const action = positional(parsed, 2, "goals video action");
5672
+ if (action !== "upload") {
5673
+ throw new CliError("invalid_arguments", "Use goals video upload.");
5674
+ }
5675
+ const sourceFile = flagString(parsed, "source-file", { required: true });
5676
+ const absolutePath = path.resolve(sourceFile);
5677
+ const fileStat = await fs.lstat(absolutePath).catch(() => null);
5678
+ if (!fileStat?.isFile() || fileStat.isSymbolicLink()) {
5679
+ throw new CliError("invalid_arguments", `--source-file is not a file: ${absolutePath}`);
5680
+ }
5681
+ if (!absolutePath.toLowerCase().endsWith(".mp4")) {
5682
+ throw new CliError("invalid_arguments", "goals video upload accepts .mp4 files only (H.264 plays everywhere).");
5683
+ }
5684
+ if (fileStat.size <= 0 || fileStat.size > GOAL_VIDEO_UPLOAD_MAX_BYTES) {
5685
+ throw new CliError("invalid_arguments", `Video size must be between 1 byte and ${GOAL_VIDEO_UPLOAD_MAX_BYTES} bytes.`);
5686
+ }
5687
+ const fileName = path.basename(absolutePath);
5688
+ const sourceSha256 = await sha256File(absolutePath);
5689
+ const preview = {
5690
+ action: "publish a video at a public, unlisted URL",
5691
+ target: { fileName },
5692
+ request: {
5693
+ sourceFile: absolutePath,
5694
+ sourceSha256,
5695
+ sizeBytes: fileStat.size,
5696
+ },
5697
+ details: {
5698
+ note: "Anyone with the returned URL can watch it. Paste the URL into a goal module or template (with a short transcript so Rocky knows what it covers); Rocky opens it in the browser.",
5699
+ },
5700
+ };
5701
+ return previewBoundWrite(parsed, preview, async () => {
5702
+ const signed = unwrap(await api.client.POST("/os-v2-mesa/goal-videos/sign", {
5703
+ body: { fileName, sizeBytes: fileStat.size },
5704
+ }));
5705
+ const bytes = await fs.readFile(absolutePath);
5706
+ if (createHash("sha256").update(bytes).digest("hex") !== sourceSha256) {
5707
+ throw new CliError("source_changed", "The video changed after approval; preview the current file again.");
5708
+ }
5709
+ const response = await fetch(signed.uploadUrl, {
5710
+ method: "PUT",
5711
+ headers: signed.uploadHeaders,
5712
+ body: uploadBody(bytes),
5713
+ });
5714
+ if (!response.ok) {
5715
+ throw new CliError("upload_failed", `Video upload failed with HTTP ${response.status}.`);
5716
+ }
5717
+ const done = unwrap(await api.client.POST("/os-v2-mesa/goal-videos/complete", {
5718
+ body: { assetId: signed.assetId },
5719
+ }));
5720
+ return {
5721
+ action: "upload_goal_video",
5722
+ url: done.url,
5723
+ fileName,
5724
+ sizeBytes: fileStat.size,
5725
+ };
5726
+ });
5727
+ }
5629
5728
  if (noun === "goals" && verb === "pdf") {
5630
5729
  const action = positional(parsed, 2, "goals pdf action");
5631
5730
  if (action !== "upload") {
@@ -26,10 +26,12 @@ const BOOLEAN_FLAGS = new Set([
26
26
  "keep-tokens",
27
27
  "include-deleted",
28
28
  "include-archived",
29
+ "include-left",
29
30
  "json",
30
31
  "mirrored",
31
32
  "no-collision",
32
33
  "no-invite",
34
+ "no-join",
33
35
  "open",
34
36
  "refresh",
35
37
  "resend",
@@ -165,6 +167,8 @@ const FAMILY_AI_COMMANDS = new Set([
165
167
  "goal-templates list",
166
168
  "goal-templates snapshot-files",
167
169
  "goal-templates validate-spec",
170
+ "goals adaptations diff",
171
+ "goals adaptations list",
168
172
  "goals archive",
169
173
  "goals create",
170
174
  "goals edit",
@@ -177,6 +181,7 @@ const FAMILY_AI_COMMANDS = new Set([
177
181
  "goals list",
178
182
  "goals move-module",
179
183
  "goals pdf upload",
184
+ "goals video upload",
180
185
  "goals queue get",
181
186
  "goals queue set",
182
187
  "goals unarchive",
@@ -200,6 +205,8 @@ const FAMILY_AI_COMMANDS = new Set([
200
205
  "tutor-templates unassign",
201
206
  ]);
202
207
  const STAFF_COMMANDS = new Set([
208
+ "rooms transcripts get",
209
+ "rooms transcripts search",
203
210
  "cohorts schedule get",
204
211
  "cohorts schedule set",
205
212
  "cohorts schedule changes",
@@ -212,7 +212,7 @@ function agentsGuide(docs) {
212
212
  "widgets.js, engine.js, key.js — a modified locked file fails validation.",
213
213
  "",
214
214
  "Before publishing, write two files at the root:",
215
- "- contract.json — the BuildContract described below (copy the shape in the template contract).",
215
+ "- contract.json — the BuildContract: copy the example in the contract.json section below.",
216
216
  '- manifest.json — { "title": "...", "description": "...", "template": { "ccss": "5.NF.A.1", "modes": ["prepare","remediate"], "misconceptions": ["..."] } }',
217
217
  "",
218
218
  'Then: `recess apps validate --reason "..."` (reports without publishing) and',
@@ -228,6 +228,10 @@ function agentsGuide(docs) {
228
228
  "",
229
229
  "---",
230
230
  "",
231
+ docs.contract ?? "",
232
+ "",
233
+ "---",
234
+ "",
231
235
  docs.pedagogy ? `# Pedagogy\n\n${docs.pedagogy}` : "",
232
236
  "",
233
237
  "---",
@@ -0,0 +1,271 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { unwrap } from "../api.js";
5
+ import { flagNumber, flagString, hasFlag } from "../args.js";
6
+ import { CliError } from "../errors.js";
7
+ import { assertChoice, positional } from "./shared.js";
8
+ // Runtime mirrors of the generated unions; `satisfies` fails the build if the
9
+ // backend enums move.
10
+ const CHANNEL_TYPES = [
11
+ "COMMUNITY",
12
+ "SYSTEM_GENERATED",
13
+ "COHORT_PARENT",
14
+ "DIRECT_MESSAGE",
15
+ "GROUP_MESSAGE",
16
+ "COURSE_REQUEST",
17
+ "RECESS_EVENT",
18
+ "LFG_GROUP",
19
+ "CHALLENGE_FINISHERS",
20
+ "RECESS",
21
+ "STREAMING",
22
+ ];
23
+ const LIST_TYPES = [
24
+ ...CHANNEL_TYPES,
25
+ "WORLD",
26
+ ];
27
+ const AUDIENCES = ["KID", "PARENT"];
28
+ const STATUSES = [
29
+ "ACTIVE",
30
+ "HIDDEN",
31
+ "ARCHIVED",
32
+ ];
33
+ const MEMBER_ROLES = [
34
+ "MEMBER",
35
+ "MODERATOR",
36
+ "ADMIN",
37
+ ];
38
+ /** The type set post.channel-image.ts accepts, keyed by extension. */
39
+ const IMAGE_MIME_BY_EXT = {
40
+ ".jpg": "image/jpeg",
41
+ ".jpeg": "image/jpeg",
42
+ ".png": "image/png",
43
+ ".gif": "image/gif",
44
+ ".webp": "image/webp",
45
+ };
46
+ const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
47
+ async function readChannelImage(filePath) {
48
+ const absolutePath = path.resolve(filePath);
49
+ const mimeType = IMAGE_MIME_BY_EXT[path.extname(absolutePath).toLowerCase()];
50
+ if (!mimeType) {
51
+ throw new CliError("invalid_arguments", "The image must be a .png, .jpg, .jpeg, .gif, or .webp file.");
52
+ }
53
+ let bytes;
54
+ try {
55
+ bytes = await fs.readFile(absolutePath);
56
+ }
57
+ catch (error) {
58
+ if (error.code === "ENOENT") {
59
+ throw new CliError("invalid_arguments", `Image file does not exist: ${absolutePath}`);
60
+ }
61
+ throw error;
62
+ }
63
+ if (bytes.byteLength > MAX_IMAGE_BYTES) {
64
+ throw new CliError("invalid_arguments", "The image must be 10MB or smaller.");
65
+ }
66
+ return {
67
+ bytes,
68
+ fileName: path.basename(absolutePath),
69
+ mimeType,
70
+ sha256: createHash("sha256").update(bytes).digest("hex"),
71
+ };
72
+ }
73
+ export async function runChannelsCommand({ parsed, api, writeCommand, }) {
74
+ const verb = parsed.positionals[1];
75
+ if (verb === "list") {
76
+ const type = flagString(parsed, "type");
77
+ const audience = flagString(parsed, "audience");
78
+ const status = flagString(parsed, "status");
79
+ const limit = flagNumber(parsed, "limit");
80
+ if (limit !== undefined &&
81
+ (!Number.isInteger(limit) || limit < 1 || limit > 200)) {
82
+ throw new CliError("invalid_arguments", "--limit must be an integer from 1 to 200.");
83
+ }
84
+ const q = parsed.positionals.slice(2).join(" ").trim();
85
+ return unwrap(await api.client.GET("/admin/channels/", {
86
+ params: {
87
+ query: {
88
+ ...(type ? { type: assertChoice(type, LIST_TYPES, "--type") } : {}),
89
+ ...(audience
90
+ ? { audience: assertChoice(audience, AUDIENCES, "--audience") }
91
+ : {}),
92
+ ...(status
93
+ ? { status: assertChoice(status, STATUSES, "--status") }
94
+ : {}),
95
+ ...(q ? { q } : {}),
96
+ ...(limit !== undefined ? { limit } : {}),
97
+ },
98
+ },
99
+ }));
100
+ }
101
+ if (verb === "create") {
102
+ const name = flagString(parsed, "name", { required: true }).trim();
103
+ if (!name || name.length > 100) {
104
+ throw new CliError("invalid_arguments", "--name must be 1 to 100 characters.");
105
+ }
106
+ const type = assertChoice(flagString(parsed, "type") ?? "COMMUNITY", CHANNEL_TYPES, "--type");
107
+ const audienceFlag = flagString(parsed, "audience");
108
+ const audience = audienceFlag === undefined
109
+ ? undefined
110
+ : audienceFlag === "NONE"
111
+ ? null
112
+ : assertChoice(audienceFlag, AUDIENCES, "--audience");
113
+ const description = flagString(parsed, "description");
114
+ const joinAsAdmin = !hasFlag(parsed, "no-join");
115
+ const imagePath = flagString(parsed, "image");
116
+ const image = imagePath ? await readChannelImage(imagePath) : undefined;
117
+ const body = {
118
+ name,
119
+ type,
120
+ joinAsAdmin,
121
+ ...(audience !== undefined ? { audience } : {}),
122
+ ...(description !== undefined ? { description } : {}),
123
+ };
124
+ return writeCommand(parsed, {
125
+ action: type === "COMMUNITY"
126
+ ? `create a ${audience ?? "KID"}-audience community channel that ${audience === "PARENT" ? "guardians" : "kids"} can find and join from Browse`
127
+ : `create a standalone ${type} channel (not linked to any cohort, event, challenge, or course)`,
128
+ target: { name, type },
129
+ // The image's hash is in the approved request, so confirming with a
130
+ // different file fails the operation-key check.
131
+ request: image
132
+ ? {
133
+ ...body,
134
+ image: {
135
+ fileName: image.fileName,
136
+ mimeType: image.mimeType,
137
+ bytes: image.bytes.byteLength,
138
+ sha256: image.sha256,
139
+ },
140
+ }
141
+ : body,
142
+ ...(joinAsAdmin
143
+ ? {}
144
+ : {
145
+ details: {
146
+ note: "You will not be a member; use channels join to enter it.",
147
+ },
148
+ }),
149
+ }, async () => {
150
+ const channel = unwrap(await api.client.POST("/admin/channels/", { body }));
151
+ if (!image)
152
+ return channel;
153
+ try {
154
+ const uploaded = (await api.uploadChannelImage(channel.id, image.bytes, image.fileName, image.mimeType));
155
+ return { ...channel, imageUrl: uploaded.imageUrl };
156
+ }
157
+ catch (error) {
158
+ throw new CliError("partial_failure", `Channel ${channel.id} was created, but its image upload failed: ${error instanceof Error ? error.message : String(error)}. Retry with channels set-image ${channel.id} <path>.`, 1, { channel });
159
+ }
160
+ });
161
+ }
162
+ if (verb === "set-image") {
163
+ const channelId = positional(parsed, 2, "channel ID");
164
+ const imagePath = parsed.positionals[3];
165
+ const clear = hasFlag(parsed, "clear");
166
+ if (clear === Boolean(imagePath)) {
167
+ throw new CliError("invalid_arguments", "Pass an image path or --clear (not both).");
168
+ }
169
+ if (clear) {
170
+ return writeCommand(parsed, {
171
+ action: "remove the channel's image",
172
+ target: { channelId },
173
+ request: { clear: true },
174
+ }, async () => unwrap(await api.client.DELETE("/admin/channels/{channelId}/image", {
175
+ params: { path: { channelId } },
176
+ })));
177
+ }
178
+ const image = await readChannelImage(imagePath);
179
+ return writeCommand(parsed, {
180
+ action: "upload this image and set it as the channel's image",
181
+ target: { channelId },
182
+ request: {
183
+ fileName: image.fileName,
184
+ mimeType: image.mimeType,
185
+ bytes: image.bytes.byteLength,
186
+ sha256: image.sha256,
187
+ },
188
+ }, async () => api.uploadChannelImage(channelId, image.bytes, image.fileName, image.mimeType));
189
+ }
190
+ if (verb === "join") {
191
+ const channelId = positional(parsed, 2, "channel ID");
192
+ const body = { channelId };
193
+ return writeCommand(parsed, {
194
+ action: "join the channel yourself (rejoins if you left)",
195
+ target: { channelId },
196
+ request: body,
197
+ }, async () => unwrap(await api.client.POST("/admin/channels/join/", { body })));
198
+ }
199
+ if (verb === "members") {
200
+ const action = parsed.positionals[2];
201
+ const channelId = positional(parsed, 3, "channel ID");
202
+ if (action === "list") {
203
+ return unwrap(await api.client.GET("/admin/channels/{channelId}/members", {
204
+ params: {
205
+ path: { channelId },
206
+ query: {
207
+ includeLeft: hasFlag(parsed, "include-left") ? "true" : "false",
208
+ },
209
+ },
210
+ }));
211
+ }
212
+ if (action === "add" || action === "set-role" || action === "remove") {
213
+ const userIds = parsed.positionals.slice(4);
214
+ if (userIds.length === 0) {
215
+ throw new CliError("invalid_arguments", "Pass at least one user ID.");
216
+ }
217
+ if (new Set(userIds).size !== userIds.length) {
218
+ throw new CliError("invalid_arguments", "User IDs must be unique.");
219
+ }
220
+ // Read-only preflight: the approval preview names the exact channel
221
+ // (and audience) people are entering or leaving. Only stable fields go
222
+ // in the preview, since the operation key is bound to its hash.
223
+ const current = unwrap(await api.client.GET("/admin/channels/{channelId}/members", {
224
+ params: { path: { channelId }, query: { includeLeft: "false" } },
225
+ }));
226
+ const channel = {
227
+ name: current.channel.name,
228
+ type: current.channel.type,
229
+ audience: current.channel.audience,
230
+ status: current.channel.status,
231
+ };
232
+ if (action === "remove") {
233
+ const active = new Set(current.members.map((m) => m.userId));
234
+ const notMembers = userIds.filter((id) => !active.has(id));
235
+ if (notMembers.length > 0) {
236
+ throw new CliError("invalid_arguments", `Not active members of this channel: ${notMembers.join(", ")}`);
237
+ }
238
+ return writeCommand(parsed, {
239
+ action: `remove ${userIds.length} member(s) from the channel (soft: they can be re-added; a LEFT notice posts in the channel)`,
240
+ target: { channelId, userIds },
241
+ request: { userIds },
242
+ details: { channel },
243
+ }, async () => unwrap(await api.client.POST("/admin/channels/{channelId}/members/remove", { params: { path: { channelId } }, body: { userIds } })));
244
+ }
245
+ const role = assertChoice(flagString(parsed, "role", { required: action === "set-role" }) ??
246
+ "MEMBER", MEMBER_ROLES, "--role");
247
+ if (action === "set-role") {
248
+ const active = new Set(current.members.map((m) => m.userId));
249
+ const notMembers = userIds.filter((id) => !active.has(id));
250
+ if (notMembers.length > 0) {
251
+ throw new CliError("invalid_arguments", `Not active members (use members add): ${notMembers.join(", ")}`);
252
+ }
253
+ }
254
+ const body = { userIds, role };
255
+ return writeCommand(parsed, {
256
+ action: action === "set-role"
257
+ ? `set the channel role of ${userIds.length} member(s) to ${role}`
258
+ : `add ${userIds.length} user(s) to the channel as ${role} (a JOINED notice posts for each new arrival)`,
259
+ target: { channelId, userIds },
260
+ request: body,
261
+ details: { channel },
262
+ }, async () => unwrap(await api.client.POST("/admin/channels/{channelId}/members", {
263
+ params: { path: { channelId } },
264
+ body,
265
+ })));
266
+ }
267
+ throw new CliError("invalid_arguments", "Use channels members list|add|set-role|remove <channel-id> ...");
268
+ }
269
+ throw new CliError("invalid_arguments", "Use channels list|create|set-image|join or channels members list|add|set-role|remove.");
270
+ }
271
+ //# sourceMappingURL=channels.js.map
@@ -0,0 +1,39 @@
1
+ import { createHash } from "node:crypto";
2
+ import { unwrap } from "../api.js";
3
+ import { flagString } from "../args.js";
4
+ import { CliError } from "../errors.js";
5
+ import { readJsonValue } from "./shared.js";
6
+ export async function runFamiliesCommand({ parsed, api, writeCommand, }) {
7
+ const verb = parsed.positionals[1];
8
+ if (verb === "set-addresses") {
9
+ const filePath = flagString(parsed, "file", { required: true });
10
+ const { absolutePath, raw, parsed: json, } = await readJsonValue(filePath, "Address file");
11
+ if (!Array.isArray(json) || json.length === 0) {
12
+ throw new CliError("invalid_arguments", `Address file must be a non-empty JSON array of {familyId, postalCode?, stateRegion?, city?, streetAddress?, country?, source} (${absolutePath}).`);
13
+ }
14
+ const entries = json;
15
+ const sha256 = createHash("sha256").update(raw).digest("hex");
16
+ // Read-only preflight: the server's dry run validates every entry and
17
+ // returns each family's current and proposed address for the approval.
18
+ const dryRun = unwrap(await api.client.POST("/admin/users/family-addresses/", {
19
+ body: { dryRun: true, entries },
20
+ }));
21
+ const changes = dryRun.results.filter((r) => r.changed);
22
+ return writeCommand(parsed, {
23
+ action: `set household addresses on ${changes.length} of ${entries.length} families (the rest already match)`,
24
+ target: { families: entries.length },
25
+ request: { file: absolutePath, sha256, entries: entries.length },
26
+ details: {
27
+ changes: changes.map((r) => ({
28
+ familyId: r.familyId,
29
+ before: r.before,
30
+ after: r.after,
31
+ })),
32
+ },
33
+ }, async () => unwrap(await api.client.POST("/admin/users/family-addresses/", {
34
+ body: { dryRun: false, entries },
35
+ })));
36
+ }
37
+ throw new CliError("invalid_arguments", "Use families set-addresses.");
38
+ }
39
+ //# sourceMappingURL=families.js.map
@@ -0,0 +1,70 @@
1
+ import { unwrap } from "../api.js";
2
+ import { flagNumber, flagString } from "../args.js";
3
+ import { CliError } from "../errors.js";
4
+ import { positional } from "./shared.js";
5
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
6
+ function dateFlag(parsed, name) {
7
+ const value = flagString(parsed, name);
8
+ if (value !== undefined && !DATE.test(value)) {
9
+ throw new CliError("invalid_arguments", `--${name} must be YYYY-MM-DD.`);
10
+ }
11
+ return value;
12
+ }
13
+ function intFlag(parsed, name, min, max) {
14
+ const value = flagNumber(parsed, name);
15
+ if (value !== undefined &&
16
+ (!Number.isInteger(value) || value < min || value > max)) {
17
+ throw new CliError("invalid_arguments", `--${name} must be an integer from ${min} to ${max}.`);
18
+ }
19
+ return value;
20
+ }
21
+ export async function runRoomsCommand({ parsed, api, }) {
22
+ const group = parsed.positionals[1];
23
+ const verb = parsed.positionals[2];
24
+ if (group !== "transcripts" || (verb !== "search" && verb !== "get")) {
25
+ throw new CliError("invalid_arguments", "Use `rooms transcripts search` or `rooms transcripts get <meeting-id>`.");
26
+ }
27
+ if (verb === "search") {
28
+ return unwrap(await api.client.GET("/voice/room-transcripts/", {
29
+ params: {
30
+ query: {
31
+ roomName: flagString(parsed, "room"),
32
+ startDate: dateFlag(parsed, "from"),
33
+ endDate: dateFlag(parsed, "to"),
34
+ query: flagString(parsed, "query"),
35
+ limit: intFlag(parsed, "limit", 1, 20),
36
+ },
37
+ },
38
+ }));
39
+ }
40
+ const meetingId = positional(parsed, 3, "meeting ID");
41
+ const breakoutIndex = intFlag(parsed, "breakout", 0, 1000);
42
+ const query = flagString(parsed, "query");
43
+ const read = (offset) => api.client.GET("/voice/room-transcripts/{meetingId}", {
44
+ params: {
45
+ path: { meetingId },
46
+ query: { breakoutIndex, query, offset, maxChars: 30000 },
47
+ },
48
+ });
49
+ const first = unwrap(await read());
50
+ if (query || first.text === undefined)
51
+ return first;
52
+ let text = first.text;
53
+ let next = first.nextOffset ?? null;
54
+ while (next !== null) {
55
+ const page = unwrap(await read(next));
56
+ if (page.offset !== next || page.text === undefined) {
57
+ throw new CliError("pagination_error", "Transcript pagination did not advance; no complete download was produced.");
58
+ }
59
+ text += page.text;
60
+ next = page.nextOffset ?? null;
61
+ }
62
+ return {
63
+ ...first,
64
+ offset: 0,
65
+ nextOffset: null,
66
+ text,
67
+ complete: true,
68
+ };
69
+ }
70
+ //# sourceMappingURL=rooms.js.map
package/dist/help.js CHANGED
@@ -67,6 +67,9 @@ Usage:
67
67
  recess [--json] chat-logs list --student <user-id> [--limit 100] [--cursor <cursor>]
68
68
  recess [--json] chat-logs get <conversation-id>
69
69
  recess [--json] chat-logs by-todo <todo-id>
70
+ recess [--json] rooms transcripts search [--room TEXT] [--from YYYY-MM-DD]
71
+ [--to YYYY-MM-DD] [--query TEXT] [--limit 10]
72
+ recess [--json] rooms transcripts get <meeting-id> [--breakout <n>] [--query TEXT]
70
73
  recess [--json] students list [--scope mine|family]
71
74
  recess [--json] students today --student <kid-id> [--date YYYY-MM-DD]
72
75
  recess [--json] students schedule --student <kid-id> [--days 14]
@@ -160,6 +163,17 @@ Usage:
160
163
  recess [--json] payout items edit <item-id> [--amount-cents N]
161
164
  [--description TEXT] [--date YYYY-MM-DD] [--confirm]
162
165
  recess [--json] payout items delete <item-id> [--confirm]
166
+ recess [--json] families set-addresses --file <json> [--confirm]
167
+ recess [--json] channels list [<query>...] [--type <channel-type>] [--audience KID|PARENT]
168
+ [--status ACTIVE|HIDDEN|ARCHIVED] [--limit 1..200]
169
+ recess [--json] channels create --name TEXT [--type <channel-type>]
170
+ [--audience KID|PARENT|NONE] [--description TEXT] [--image <path>] [--no-join] [--confirm]
171
+ recess [--json] channels set-image <channel-id> [path] [--clear] [--confirm]
172
+ recess [--json] channels join <channel-id> [--confirm]
173
+ recess [--json] channels members list <channel-id> [--include-left]
174
+ recess [--json] channels members add <channel-id> <user-id...> [--role MEMBER|MODERATOR|ADMIN] [--confirm]
175
+ recess [--json] channels members set-role <channel-id> <user-id...> --role MEMBER|MODERATOR|ADMIN [--confirm]
176
+ recess [--json] channels members remove <channel-id> <user-id...> [--confirm]
163
177
  recess [--json] cohorts get <cohort-id> [--events-tab ACTIVE|ENDED|CANCELED|ARCHIVED]
164
178
  recess [--json] cohorts schedule get <cohort-id>
165
179
  recess [--json] cohorts schedule set <cohort-id> --days <MO,WE> --time <09:30> [--timezone <zone>] --effective-date <YYYY-MM-DD> --notify none|parents [--confirm] [--approval-token <token>]
@@ -391,7 +405,7 @@ Usage:
391
405
  [--title TEXT] [--description TEXT] [--emoji X] [--category TEXT] [--tags A,B]
392
406
  [--image-url URL] [--coin-amount N] [--sort-order N] [--is-starter true|false]
393
407
  [--setup-audience KID_FRIENDLY|PARENT_SETUP] [--kind SIMPLE|BLUEPRINT]
394
- [--agent-instructions-file <path>]
408
+ [--visibility PUBLIC|PRIVATE|STAFF_ONLY] [--agent-instructions-file <path>]
395
409
  [--output-template-file <path>] [--confirm]
396
410
  recess [--json] goal-templates delete <template-id> --expected-version N [--confirm]
397
411
  recess [--json] goal-templates generate-image <template-id|slug>
@@ -468,6 +482,9 @@ Usage:
468
482
  [--lessons-dir <dir>] [--emoji TEXT] [--grade-min N] [--grade-max N]
469
483
  [--visibility PUBLIC|STAFF] [--confirm]
470
484
  recess [--json] tutor-templates delete <slug> [--confirm]
485
+ recess [--json] goals adaptations list --student <goal-owner-id> --goal <goal-id>
486
+ recess [--json] goals adaptations diff <run-id> --student <goal-owner-id>
487
+ --goal <goal-id> [--path P]
471
488
  recess [--json] goals files list --student <goal-owner-id> --goal <goal-id>
472
489
  recess [--json] goals files read --student <goal-owner-id> --goal <goal-id> --path P
473
490
  recess [--json] goals files init --student <goal-owner-id> --draft <draft-slug>
@@ -487,6 +504,8 @@ Usage:
487
504
  (--goal <goal-id> | --draft <draft-slug>) --source-file <local.pdf>
488
505
  [--path uploads/name.pdf] [--message TEXT]
489
506
  [--confirm --approval-token TOKEN]
507
+ recess [--json] goals video upload --source-file <local.mp4>
508
+ [--confirm --approval-token TOKEN]
490
509
 
491
510
  Authoring notes: "skills" serves the in-product tutor skills (the private
492
511
  packages/skills workspace package) read-only over your admin session — they are never
@@ -584,6 +603,25 @@ Class-ops notes: "events cancel" notifies families (chat + parent email blast +
584
603
  credit notes + Slack); "events set-status --status CANCELED" is a silent status
585
604
  change. Reschedule times are cohort-local wall-clock (zoneless).
586
605
 
606
+ Channel notes: channel types are COMMUNITY (default), SYSTEM_GENERATED,
607
+ COHORT_PARENT, DIRECT_MESSAGE, GROUP_MESSAGE, COURSE_REQUEST, RECESS_EVENT,
608
+ LFG_GROUP, CHALLENGE_FINISHERS, RECESS, STREAMING; WORLD channels belong to
609
+ apps/world and can only be listed. Guardians browse PARENT-audience COMMUNITY
610
+ channels and kids browse KID ones; "create" defaults a COMMUNITY channel to KID
611
+ and COHORT_PARENT to PARENT (--audience NONE clears it). Non-community types
612
+ created here stand alone: nothing links them to a cohort, event, challenge, or
613
+ course. "members add" also rejoins past members and applies --role to everyone
614
+ named; kids are refused from PARENT-audience and COHORT_PARENT channels.
615
+ "members remove" is soft (sets leftAt) and posts a LEFT notice. Images
616
+ (--image, set-image) are .png/.jpg/.gif/.webp, 10MB max.
617
+
618
+ Family address notes: "families set-addresses --file" takes a JSON array of
619
+ {familyId, streetAddress?, city?, stateRegion?, postalCode?, country?, source},
620
+ source one of PARENT|STAFF|APPLICATION|LIGHTFIELD|STRIPE. Country is ISO-2 and
621
+ omitted for US; US ZIPs must be 12345 or 12345-6789. Each entry replaces the
622
+ family's whole address. The preview is the server's dry run: before and after
623
+ for every family that changes.
624
+
587
625
  Payout notes: amounts are integer cents. "payout items add" without --date
588
626
  defaults the item date to the penultimate day of the invoice's cycle (its
589
627
  endDate minus one day) and shows the computed date in the preview.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "recess-cli",
3
- "version": "3.4.0",
3
+ "version": "3.5.0",
4
4
  "description": "Safe Recess administration and family AI tools from the command line.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {