recess-cli 3.4.1 → 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";
@@ -4340,7 +4344,7 @@ export async function executeRecessCommand(argv, options = {}) {
4340
4344
  }));
4341
4345
  const prompt = flagString(parsed, "prompt");
4342
4346
  return writeCommand(parsed, {
4343
- action: "queue paid GPT-Image-2 art regeneration",
4347
+ action: "queue paid challenge-art regeneration",
4344
4348
  target: {
4345
4349
  templateId: id,
4346
4350
  slug: current.slug,
@@ -4348,7 +4352,7 @@ export async function executeRecessCommand(argv, options = {}) {
4348
4352
  },
4349
4353
  request: { prompt: prompt ?? null },
4350
4354
  details: {
4351
- 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.",
4352
4356
  },
4353
4357
  }, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/generate-image", {
4354
4358
  params: { path: { id } },
@@ -4582,7 +4586,10 @@ export async function executeRecessCommand(argv, options = {}) {
4582
4586
  }
4583
4587
  throw new CliError("invalid_arguments", "Use goals adaptations list|diff.");
4584
4588
  }
4585
- if (noun === "goals" && verb !== "files" && verb !== "pdf") {
4589
+ if (noun === "goals" &&
4590
+ verb !== "files" &&
4591
+ verb !== "pdf" &&
4592
+ verb !== "video") {
4586
4593
  if (verb === "complete" || verb === "undo-completion") {
4587
4594
  const goalId = positional(parsed, 2, "goal ID");
4588
4595
  const undo = verb === "undo-completion";
@@ -5074,11 +5081,20 @@ export async function executeRecessCommand(argv, options = {}) {
5074
5081
  }
5075
5082
  throw new CliError("invalid_arguments", "Use goals queue get|set.");
5076
5083
  }
5077
- 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 });
5078
5091
  }
5079
5092
  if (noun === "chat-logs") {
5080
5093
  return runChatLogsCommand({ parsed, api, writeCommand });
5081
5094
  }
5095
+ if (noun === "rooms") {
5096
+ return runRoomsCommand({ parsed, api, writeCommand });
5097
+ }
5082
5098
  if (noun === "students") {
5083
5099
  if (verb === "list") {
5084
5100
  const requestedScope = flagString(parsed, "scope");
@@ -5651,6 +5667,64 @@ export async function executeRecessCommand(argv, options = {}) {
5651
5667
  }
5652
5668
  throw new CliError("invalid_arguments", "Use tutor-templates list|get|create|update|delete|assign|unassign.");
5653
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
+ }
5654
5728
  if (noun === "goals" && verb === "pdf") {
5655
5729
  const action = positional(parsed, 2, "goals pdf action");
5656
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",
@@ -179,6 +181,7 @@ const FAMILY_AI_COMMANDS = new Set([
179
181
  "goals list",
180
182
  "goals move-module",
181
183
  "goals pdf upload",
184
+ "goals video upload",
182
185
  "goals queue get",
183
186
  "goals queue set",
184
187
  "goals unarchive",
@@ -202,6 +205,8 @@ const FAMILY_AI_COMMANDS = new Set([
202
205
  "tutor-templates unassign",
203
206
  ]);
204
207
  const STAFF_COMMANDS = new Set([
208
+ "rooms transcripts get",
209
+ "rooms transcripts search",
205
210
  "cohorts schedule get",
206
211
  "cohorts schedule set",
207
212
  "cohorts schedule changes",
@@ -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>]
@@ -490,6 +504,8 @@ Usage:
490
504
  (--goal <goal-id> | --draft <draft-slug>) --source-file <local.pdf>
491
505
  [--path uploads/name.pdf] [--message TEXT]
492
506
  [--confirm --approval-token TOKEN]
507
+ recess [--json] goals video upload --source-file <local.mp4>
508
+ [--confirm --approval-token TOKEN]
493
509
 
494
510
  Authoring notes: "skills" serves the in-product tutor skills (the private
495
511
  packages/skills workspace package) read-only over your admin session — they are never
@@ -587,6 +603,25 @@ Class-ops notes: "events cancel" notifies families (chat + parent email blast +
587
603
  credit notes + Slack); "events set-status --status CANCELED" is a silent status
588
604
  change. Reschedule times are cohort-local wall-clock (zoneless).
589
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
+
590
625
  Payout notes: amounts are integer cents. "payout items add" without --date
591
626
  defaults the item date to the penultimate day of the invoice's cycle (its
592
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.1",
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": {