recess-cli 1.3.3 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -23
- package/dist/api.js +1 -1
- package/dist/auth.js +1 -1
- package/dist/cli.js +636 -68
- package/dist/safety.js +1 -1
- package/dist/setup.js +3 -5
- package/dist/skill-update.js +1 -1
- package/package.json +3 -3
- package/skill/recess-cli/SKILL.md +42 -472
- package/skill/recess-cli/agents/openai.yaml +2 -2
- package/skill/recess-cli/agents/version.json +4 -0
- package/skill/recess-cli/reference/billing.md +0 -146
- package/skill/recess-cli/reference/cancellation-credits.md +0 -53
- package/skill/recess-cli/reference/class-ops-reschedule.md +0 -41
- package/skill/recess-cli/reference/class-ops.md +0 -89
- package/skill/recess-cli/reference/goal-authoring.md +0 -274
- package/skill/recess-cli/reference/map-scores.md +0 -28
- package/skill/recess-cli/reference/onboarding.md +0 -79
- package/skill/recess-cli/reference/payout.md +0 -74
package/dist/cli.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { RecessAdminApi, unwrap } from "./api.js";
|
|
@@ -10,7 +10,7 @@ import { requireConfirmation } from "./safety.js";
|
|
|
10
10
|
import { installSkill, isEphemeralInstall, readBundledSkillVersion, readCliVersion, } from "./setup.js";
|
|
11
11
|
import { compareVersions, updateSkillFromServer } from "./skill-update.js";
|
|
12
12
|
import { readSkillCache, writeSkillCache } from "./skills-cache.js";
|
|
13
|
-
export const HELP = `recess — safe Recess administration
|
|
13
|
+
export const HELP = `recess — safe Recess administration and family AI tools
|
|
14
14
|
|
|
15
15
|
Usage:
|
|
16
16
|
recess [--json] --version
|
|
@@ -28,8 +28,18 @@ Usage:
|
|
|
28
28
|
[--slots N]
|
|
29
29
|
recess [--json] users tier set <kid-id> --tier social|academics|lite|complete|platform
|
|
30
30
|
[--slots N] --expected-updated-at <iso> [--allow-strand] [--confirm]
|
|
31
|
+
recess [--json] guides create --email <email> --first-name TEXT
|
|
32
|
+
[--last-name TEXT] [--no-invite] [--confirm]
|
|
33
|
+
recess [--json] guides invite --user <user-id> [--confirm]
|
|
34
|
+
recess [--json] guardians invite --family <family-id> --email <email>
|
|
35
|
+
--first-name TEXT [--last-name TEXT] [--no-invite] [--confirm]
|
|
31
36
|
recess [--json] students upload-map-scores --student <kid-id>
|
|
32
37
|
--file </path/to/map-report.pdf> [--confirm]
|
|
38
|
+
recess [--json] students list
|
|
39
|
+
recess [--json] students today --student <kid-id> [--date YYYY-MM-DD]
|
|
40
|
+
recess [--json] students schedule --student <kid-id> [--days 14]
|
|
41
|
+
recess [--json] students xp-history --student <kid-id>
|
|
42
|
+
[--range week|month|quarter|year]
|
|
33
43
|
recess [--json] enrollments list --user <user-id>
|
|
34
44
|
recess [--json] subscriptions list --family <family-id> [--kid <kid-id>]
|
|
35
45
|
recess [--json] invoices list --subscription <subscription-id>
|
|
@@ -120,8 +130,12 @@ Usage:
|
|
|
120
130
|
recess [--json] store-items set-status <village-store-item-id>
|
|
121
131
|
--status ACTIVE|INACTIVE|COMING_SOON [--confirm]
|
|
122
132
|
recess [--json] content-library search <query> [--limit 8]
|
|
123
|
-
recess [--json] skills list [--query TEXT] [--category TEXT]
|
|
124
|
-
recess [--json] skills get <skill-name>
|
|
133
|
+
recess [--json] skills guardian list [--query TEXT] [--category TEXT]
|
|
134
|
+
recess [--json] skills guardian get <skill-name>
|
|
135
|
+
[--reference NAME | --all-references] [--refresh]
|
|
136
|
+
recess [--json] skills admin list [--query TEXT] [--category TEXT]
|
|
137
|
+
recess [--json] skills admin get <skill-name>
|
|
138
|
+
[--reference NAME | --all-references]
|
|
125
139
|
[--refresh]
|
|
126
140
|
recess [--json] goal-templates list [--query TEXT] [--category TEXT]
|
|
127
141
|
[--kind SIMPLE|BLUEPRINT] [--starter-only] [--include-deleted]
|
|
@@ -142,19 +156,34 @@ Usage:
|
|
|
142
156
|
(--source-goal <goal-id> | --source-draft <draft-slug> --student <kid-id>)
|
|
143
157
|
[--dry-run] [--confirm]
|
|
144
158
|
recess [--json] goal-templates apply <template-id> --answers-file <path>
|
|
145
|
-
[--dry-run] [--confirm]
|
|
159
|
+
[--dry-run] [--confirm --approval-token TOKEN]
|
|
146
160
|
recess [--json] goal-templates apply-starter <template-id> --student <kid-id>
|
|
147
161
|
[--answers-file <path>] [--confirm]
|
|
148
162
|
recess [--json] goals list --student <kid-id>
|
|
149
163
|
recess [--json] goals create --student <kid-id> --title TEXT
|
|
150
164
|
(--description TEXT | --description-file <path>) [--target-date <iso>]
|
|
151
165
|
[--schedule TEXT] [--confirm]
|
|
152
|
-
recess [--json]
|
|
153
|
-
|
|
154
|
-
recess [--json]
|
|
166
|
+
recess [--json] goals edit <goal-id> --student <kid-id> --patch-file <path/patch.json>
|
|
167
|
+
--delta TEXT [--confirm --approval-token TOKEN]
|
|
168
|
+
recess [--json] todos create --student <kid-id> --title TEXT
|
|
169
|
+
[--due-date YYYY-MM-DD] [--estimated-minutes N] [--url URL] [--confirm]
|
|
170
|
+
recess [--json] todos edit <todo-id> --patch-file <path/patch.json>
|
|
171
|
+
[--confirm --approval-token TOKEN]
|
|
172
|
+
recess [--json] memories context --student <kid-id>
|
|
173
|
+
recess [--json] memories log --student <kid-id> --date YYYY-MM-DD
|
|
174
|
+
recess [--json] rocky get --student <kid-id>
|
|
175
|
+
recess [--json] rocky set --student <kid-id> --patch-file <path/patch.json>
|
|
176
|
+
--expected-updated-at ISO|none [--confirm]
|
|
177
|
+
recess [--json] goals files list --student <kid-id> --goal <goal-id>
|
|
178
|
+
recess [--json] goals files read --student <kid-id> --goal <goal-id> --path P
|
|
179
|
+
recess [--json] goals files write --student <kid-id>
|
|
155
180
|
(--goal <goal-id> | --draft <draft-slug>)
|
|
156
181
|
(--source-dir <local-dir> | --source-file <local-file> --path P)
|
|
157
|
-
[--message TEXT] [--confirm]
|
|
182
|
+
[--message TEXT] [--confirm --approval-token TOKEN]
|
|
183
|
+
recess [--json] goals pdf upload --student <kid-id>
|
|
184
|
+
(--goal <goal-id> | --draft <draft-slug>) --source-file <local.pdf>
|
|
185
|
+
[--path uploads/name.pdf] [--message TEXT]
|
|
186
|
+
[--confirm --approval-token TOKEN]
|
|
158
187
|
|
|
159
188
|
Authoring notes: "skills" serves the in-product tutor skills (the PRIVATE
|
|
160
189
|
packages/skills submodule) read-only over your admin session — they are never
|
|
@@ -194,8 +223,9 @@ Payout notes: amounts are integer cents. "payout items add" without --date
|
|
|
194
223
|
defaults the item date to the penultimate day of the invoice's cycle (its
|
|
195
224
|
endDate minus one day) and shows the computed date in the preview.
|
|
196
225
|
|
|
197
|
-
Auth notes: "auth login" runs the browser loopback flow
|
|
198
|
-
|
|
226
|
+
Auth notes: "auth login" runs the browser loopback flow for ADMIN or a GUARDIAN with
|
|
227
|
+
access:ai. Guardian sessions are family-scoped and cannot call /admin. For a headless
|
|
228
|
+
cloud agent, the ADMIN-only "auth request" prints an approval URL to hand a Recess admin; after they
|
|
199
229
|
approve it in a browser, "auth poll" collects the 12h session. When it lapses, run
|
|
200
230
|
"auth request" again for a fresh link. Both paths yield the same session.
|
|
201
231
|
|
|
@@ -232,7 +262,7 @@ async function sessionStatus(config) {
|
|
|
232
262
|
authSource: config.authSource,
|
|
233
263
|
status: result.response.status,
|
|
234
264
|
expiresAt: config.sessionExpiresAt ?? null,
|
|
235
|
-
message: "The stored
|
|
265
|
+
message: "The stored Recess CLI session is invalid, expired, or revoked. Run `recess auth login`.",
|
|
236
266
|
};
|
|
237
267
|
}
|
|
238
268
|
return { ...result.data, authSource: config.authSource };
|
|
@@ -276,7 +306,7 @@ async function skillStatus(config) {
|
|
|
276
306
|
return { ...base, checked: false, reason: "no session" };
|
|
277
307
|
}
|
|
278
308
|
try {
|
|
279
|
-
const response = await fetch(new URL("/admin/
|
|
309
|
+
const response = await fetch(new URL("/auth/admin-cli/skill/", config.apiOrigin), {
|
|
280
310
|
headers: { cookie: config.sessionCookie },
|
|
281
311
|
signal: AbortSignal.timeout(5000),
|
|
282
312
|
});
|
|
@@ -315,9 +345,114 @@ async function writeCommand(parsed, preview, execute) {
|
|
|
315
345
|
requireConfirmation(hasFlag(parsed, "confirm"), preview);
|
|
316
346
|
return execute();
|
|
317
347
|
}
|
|
318
|
-
const
|
|
319
|
-
const
|
|
320
|
-
|
|
348
|
+
const GOAL_WORKSPACE_WRITE_MAX_FILES = 1_000;
|
|
349
|
+
const GOAL_WORKSPACE_WRITE_MAX_TOTAL_BYTES = 20 * 1024 * 1024;
|
|
350
|
+
const GOAL_PDF_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024;
|
|
351
|
+
function approvalTokenFor(preview) {
|
|
352
|
+
return createHash("sha256").update(JSON.stringify(preview)).digest("hex");
|
|
353
|
+
}
|
|
354
|
+
function requirePreviewBoundConfirmation(parsed, preview) {
|
|
355
|
+
const approvalToken = approvalTokenFor(preview);
|
|
356
|
+
const boundPreview = {
|
|
357
|
+
...preview,
|
|
358
|
+
details: { ...preview.details, approvalToken },
|
|
359
|
+
};
|
|
360
|
+
if (!hasFlag(parsed, "confirm")) {
|
|
361
|
+
requireConfirmation(false, boundPreview);
|
|
362
|
+
}
|
|
363
|
+
const supplied = flagString(parsed, "approval-token");
|
|
364
|
+
if (supplied !== approvalToken) {
|
|
365
|
+
throw new CliError("approval_mismatch", "The approved preview no longer matches this request. Review the current preview and rerun with --confirm --approval-token <token>.", 2, { preview: boundPreview, requiredFlag: "--approval-token" });
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
async function cleanupPlannerUpload(api, conversationId, signed, includeDelete) {
|
|
369
|
+
if (signed.kind === "multipart") {
|
|
370
|
+
await api.client
|
|
371
|
+
.POST("/os-v2-mesa/planner/uploads/cleanup", {
|
|
372
|
+
body: {
|
|
373
|
+
action: "abort",
|
|
374
|
+
conversationId,
|
|
375
|
+
key: signed.key,
|
|
376
|
+
uploadId: signed.uploadId,
|
|
377
|
+
},
|
|
378
|
+
})
|
|
379
|
+
.catch(() => undefined);
|
|
380
|
+
}
|
|
381
|
+
if (includeDelete) {
|
|
382
|
+
await api.client
|
|
383
|
+
.POST("/os-v2-mesa/planner/uploads/cleanup", {
|
|
384
|
+
body: { action: "delete", conversationId, key: signed.key },
|
|
385
|
+
})
|
|
386
|
+
.catch(() => undefined);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
function safeGoalUploadName(fileName) {
|
|
390
|
+
return fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
391
|
+
}
|
|
392
|
+
async function sha256File(absolutePath) {
|
|
393
|
+
const handle = await fs.open(absolutePath, "r");
|
|
394
|
+
const hash = createHash("sha256");
|
|
395
|
+
const buffer = Buffer.allocUnsafe(4 * 1024 * 1024);
|
|
396
|
+
try {
|
|
397
|
+
let position = 0;
|
|
398
|
+
while (true) {
|
|
399
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, position);
|
|
400
|
+
if (bytesRead === 0)
|
|
401
|
+
break;
|
|
402
|
+
hash.update(buffer.subarray(0, bytesRead));
|
|
403
|
+
position += bytesRead;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
finally {
|
|
407
|
+
await handle.close();
|
|
408
|
+
}
|
|
409
|
+
return hash.digest("hex");
|
|
410
|
+
}
|
|
411
|
+
function uploadBody(bytes) {
|
|
412
|
+
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
413
|
+
}
|
|
414
|
+
async function putSignedPart(uploadUrl, bytes, contentType) {
|
|
415
|
+
const response = await fetch(uploadUrl, {
|
|
416
|
+
method: "PUT",
|
|
417
|
+
headers: contentType ? { "content-type": contentType } : undefined,
|
|
418
|
+
body: uploadBody(bytes),
|
|
419
|
+
});
|
|
420
|
+
if (!response.ok) {
|
|
421
|
+
throw new CliError("upload_failed", `Textbook upload failed with HTTP ${response.status}. The goal workspace was not changed.`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
async function uploadPdfToSignedUrls(absolutePath, contentType, signed) {
|
|
425
|
+
if (signed.kind === "single") {
|
|
426
|
+
const bytes = await fs.readFile(absolutePath);
|
|
427
|
+
await putSignedPart(signed.uploadUrl, bytes, contentType);
|
|
428
|
+
return {
|
|
429
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
430
|
+
sizeBytes: bytes.byteLength,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
const handle = await fs.open(absolutePath, "r");
|
|
434
|
+
const hash = createHash("sha256");
|
|
435
|
+
let sizeBytes = 0;
|
|
436
|
+
try {
|
|
437
|
+
for (const part of signed.parts) {
|
|
438
|
+
const position = (part.partNumber - 1) * signed.chunkSize;
|
|
439
|
+
const buffer = Buffer.allocUnsafe(signed.chunkSize);
|
|
440
|
+
const { bytesRead } = await handle.read(buffer, 0, signed.chunkSize, position);
|
|
441
|
+
if (bytesRead === 0) {
|
|
442
|
+
throw new CliError("upload_failed", `Textbook upload part ${part.partNumber} had no bytes. The goal workspace was not changed.`);
|
|
443
|
+
}
|
|
444
|
+
const bytes = buffer.subarray(0, bytesRead);
|
|
445
|
+
hash.update(bytes);
|
|
446
|
+
sizeBytes += bytesRead;
|
|
447
|
+
await putSignedPart(part.uploadUrl, bytes);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
finally {
|
|
451
|
+
await handle.close();
|
|
452
|
+
}
|
|
453
|
+
return { sha256: hash.digest("hex"), sizeBytes };
|
|
454
|
+
}
|
|
455
|
+
async function readGoalWorkspaceWriteSource(parsed) {
|
|
321
456
|
const sourceDirectory = flagString(parsed, "source-dir");
|
|
322
457
|
const sourceFile = flagString(parsed, "source-file");
|
|
323
458
|
if (Boolean(sourceDirectory) === Boolean(sourceFile)) {
|
|
@@ -328,12 +463,12 @@ async function readMesaWriteSource(parsed) {
|
|
|
328
463
|
let sizeBytes = 0;
|
|
329
464
|
let source;
|
|
330
465
|
const addFile = async (absolutePath, workspacePath) => {
|
|
331
|
-
if (files.length >=
|
|
332
|
-
throw new CliError("invalid_arguments", `
|
|
466
|
+
if (files.length >= GOAL_WORKSPACE_WRITE_MAX_FILES) {
|
|
467
|
+
throw new CliError("invalid_arguments", `Goal workspace writes are limited to ${GOAL_WORKSPACE_WRITE_MAX_FILES} files.`);
|
|
333
468
|
}
|
|
334
469
|
const fileStat = await fs.stat(absolutePath);
|
|
335
|
-
if (sizeBytes + fileStat.size >
|
|
336
|
-
throw new CliError("invalid_arguments", `
|
|
470
|
+
if (sizeBytes + fileStat.size > GOAL_WORKSPACE_WRITE_MAX_TOTAL_BYTES) {
|
|
471
|
+
throw new CliError("invalid_arguments", `Goal workspace writes are limited to ${GOAL_WORKSPACE_WRITE_MAX_TOTAL_BYTES} decoded bytes.`);
|
|
337
472
|
}
|
|
338
473
|
const bytes = await fs.readFile(absolutePath);
|
|
339
474
|
sizeBytes += bytes.byteLength;
|
|
@@ -382,7 +517,7 @@ async function readMesaWriteSource(parsed) {
|
|
|
382
517
|
await addFile(absolutePath, workspacePath);
|
|
383
518
|
}
|
|
384
519
|
if (files.length === 0) {
|
|
385
|
-
throw new CliError("invalid_arguments", "The
|
|
520
|
+
throw new CliError("invalid_arguments", "The goal workspace source contains no files.");
|
|
386
521
|
}
|
|
387
522
|
for (const file of files) {
|
|
388
523
|
hash.update(file.path);
|
|
@@ -598,7 +733,7 @@ function parseGoalTemplateDocument(doc) {
|
|
|
598
733
|
}
|
|
599
734
|
const spec = doc.setupWorkflowSpec;
|
|
600
735
|
if (spec === null || typeof spec !== "object" || Array.isArray(spec)) {
|
|
601
|
-
throw new CliError("invalid_arguments", 'Template file requires an object "setupWorkflowSpec" (the fixed wizard).
|
|
736
|
+
throw new CliError("invalid_arguments", 'Template file requires an object "setupWorkflowSpec" (the fixed wizard). Load the guardian authoring guide first: `recess --json skills guardian get recess-goal-authoring --all-references`.');
|
|
602
737
|
}
|
|
603
738
|
const rawTags = doc.tags;
|
|
604
739
|
let tags = [];
|
|
@@ -676,8 +811,8 @@ function parseGoalTemplateDocument(doc) {
|
|
|
676
811
|
async function resolveGoalTemplateId(api, reference) {
|
|
677
812
|
if (UUID_RE.test(reference))
|
|
678
813
|
return reference;
|
|
679
|
-
const list = unwrap(await api.client.GET("/
|
|
680
|
-
params: { query: { includeDeleted: "
|
|
814
|
+
const list = unwrap(await api.client.GET("/ai/goal-templates/", {
|
|
815
|
+
params: { query: { includeDeleted: "false" } },
|
|
681
816
|
}));
|
|
682
817
|
const match = list.items.find((item) => item.slug === reference);
|
|
683
818
|
if (!match) {
|
|
@@ -1274,6 +1409,96 @@ export async function runCommand(argv) {
|
|
|
1274
1409
|
params: { path: { userId } },
|
|
1275
1410
|
}));
|
|
1276
1411
|
}
|
|
1412
|
+
if (noun === "guides" && verb === "create") {
|
|
1413
|
+
const email = flagString(parsed, "email", { required: true })
|
|
1414
|
+
.trim()
|
|
1415
|
+
.toLowerCase();
|
|
1416
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
1417
|
+
throw new CliError("invalid_arguments", "--email does not look like a valid email address.");
|
|
1418
|
+
}
|
|
1419
|
+
const firstName = flagString(parsed, "first-name", { required: true });
|
|
1420
|
+
const lastName = flagString(parsed, "last-name");
|
|
1421
|
+
const sendInvite = !hasFlag(parsed, "no-invite");
|
|
1422
|
+
const body = {
|
|
1423
|
+
email,
|
|
1424
|
+
firstName,
|
|
1425
|
+
...(lastName !== undefined ? { lastName } : {}),
|
|
1426
|
+
sendInvite,
|
|
1427
|
+
};
|
|
1428
|
+
return writeCommand(parsed, {
|
|
1429
|
+
action: sendInvite
|
|
1430
|
+
? "provision a GUIDE staff account and email its sign-in invite (outward email)"
|
|
1431
|
+
: "provision a GUIDE staff account without sending the invite email",
|
|
1432
|
+
target: { email },
|
|
1433
|
+
request: body,
|
|
1434
|
+
}, async () => unwrap(await api.client.POST("/admin/guides/", { body })));
|
|
1435
|
+
}
|
|
1436
|
+
if (noun === "guardians" && verb === "invite") {
|
|
1437
|
+
const familyId = flagString(parsed, "family", { required: true });
|
|
1438
|
+
const email = flagString(parsed, "email", { required: true })
|
|
1439
|
+
.trim()
|
|
1440
|
+
.toLowerCase();
|
|
1441
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
1442
|
+
throw new CliError("invalid_arguments", "--email does not look like a valid email address.");
|
|
1443
|
+
}
|
|
1444
|
+
const firstName = flagString(parsed, "first-name", { required: true });
|
|
1445
|
+
const lastName = flagString(parsed, "last-name");
|
|
1446
|
+
const sendInvite = !hasFlag(parsed, "no-invite");
|
|
1447
|
+
// Read-only preflight: the approval preview must name the exact family
|
|
1448
|
+
// this guardian is being granted over — a wrong family id hands a
|
|
1449
|
+
// stranger full guardian access to someone's kids.
|
|
1450
|
+
const family = unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/status", {
|
|
1451
|
+
params: { path: { familyId } },
|
|
1452
|
+
}));
|
|
1453
|
+
const body = {
|
|
1454
|
+
email,
|
|
1455
|
+
firstName,
|
|
1456
|
+
...(lastName !== undefined ? { lastName } : {}),
|
|
1457
|
+
sendInvite,
|
|
1458
|
+
};
|
|
1459
|
+
return writeCommand(parsed, {
|
|
1460
|
+
action: sendInvite
|
|
1461
|
+
? "invite a new GUARDIAN into an existing family and email the sign-in invite (outward email)"
|
|
1462
|
+
: "add a new GUARDIAN to an existing family without sending the invite email",
|
|
1463
|
+
target: { familyId, email },
|
|
1464
|
+
request: body,
|
|
1465
|
+
details: {
|
|
1466
|
+
familyName: family.familyName,
|
|
1467
|
+
kids: family.kids.map((kid) => kid.firstName),
|
|
1468
|
+
},
|
|
1469
|
+
}, async () => unwrap(await api.client.POST("/admin/users/family/{familyId}/guardians/", {
|
|
1470
|
+
params: { path: { familyId } },
|
|
1471
|
+
body,
|
|
1472
|
+
})));
|
|
1473
|
+
}
|
|
1474
|
+
if (noun === "guides" && verb === "invite") {
|
|
1475
|
+
const userId = flagString(parsed, "user", { required: true });
|
|
1476
|
+
// Read-only preflight: the approval preview must name the exact person
|
|
1477
|
+
// about to receive an outward email, and refuse a non-guide before the
|
|
1478
|
+
// gate rather than after approval (the backend enforces the same rule).
|
|
1479
|
+
const { user } = unwrap(await api.client.GET("/admin/users/{userId}", {
|
|
1480
|
+
params: { path: { userId } },
|
|
1481
|
+
}));
|
|
1482
|
+
if (user.role !== "GUIDE") {
|
|
1483
|
+
throw new CliError("invalid_arguments", `User ${userId} has role ${user.role}; only GUIDE accounts can be invited. Use guides create for a new guide.`);
|
|
1484
|
+
}
|
|
1485
|
+
if (!user.email) {
|
|
1486
|
+
throw new CliError("invalid_arguments", `Guide ${userId} has no email address on file, so no invite can be sent.`);
|
|
1487
|
+
}
|
|
1488
|
+
return writeCommand(parsed, {
|
|
1489
|
+
action: "send the guide sign-in invite email (outward email)",
|
|
1490
|
+
target: { userId },
|
|
1491
|
+
request: {},
|
|
1492
|
+
details: {
|
|
1493
|
+
to: user.email,
|
|
1494
|
+
firstName: user.firstName,
|
|
1495
|
+
lastName: user.lastName,
|
|
1496
|
+
role: user.role,
|
|
1497
|
+
},
|
|
1498
|
+
}, async () => unwrap(await api.client.POST("/admin/guides/{userId}/invite/", {
|
|
1499
|
+
params: { path: { userId } },
|
|
1500
|
+
})));
|
|
1501
|
+
}
|
|
1277
1502
|
if (noun === "students" && verb === "upload-map-scores") {
|
|
1278
1503
|
const studentId = flagString(parsed, "student", { required: true });
|
|
1279
1504
|
const pdf = await readMapScorePdf(flagString(parsed, "file", { required: true }));
|
|
@@ -2146,21 +2371,23 @@ export async function runCommand(argv) {
|
|
|
2146
2371
|
throw new CliError("invalid_arguments", "Use onboarding status|kids|intake-session|intake-session-create|set-stage|set-account-state|attest|set-intake|extract.");
|
|
2147
2372
|
}
|
|
2148
2373
|
if (noun === "skills") {
|
|
2149
|
-
// Reads only — no gate.
|
|
2150
|
-
//
|
|
2151
|
-
|
|
2374
|
+
// Reads only — no confirmation gate. Audience is explicit so an agent never
|
|
2375
|
+
// infers whether `get` means guardian guidance or staff-only operations.
|
|
2376
|
+
const audience = assertChoice(verb, ["admin", "guardian"], "skill audience");
|
|
2377
|
+
const action = positional(parsed, 2, "skills action");
|
|
2152
2378
|
const refresh = hasFlag(parsed, "refresh");
|
|
2153
|
-
if (
|
|
2379
|
+
if (action === "list") {
|
|
2154
2380
|
const query = flagString(parsed, "query");
|
|
2155
2381
|
const category = flagString(parsed, "category");
|
|
2156
|
-
const cacheKey =
|
|
2382
|
+
const cacheKey = `${audience}:list:${query ?? ""}:${category ?? ""}`;
|
|
2157
2383
|
if (!refresh) {
|
|
2158
2384
|
const cached = await readSkillCache(config.apiOrigin, cacheKey);
|
|
2159
2385
|
if (cached)
|
|
2160
2386
|
return { ...cached, cached: true };
|
|
2161
2387
|
}
|
|
2162
|
-
const data = unwrap(await api.client.GET("/admin/skills/", {
|
|
2388
|
+
const data = unwrap(await api.client.GET("/auth/admin-cli/skills/{audience}", {
|
|
2163
2389
|
params: {
|
|
2390
|
+
path: { audience },
|
|
2164
2391
|
query: {
|
|
2165
2392
|
...(query ? { query } : {}),
|
|
2166
2393
|
...(category ? { category } : {}),
|
|
@@ -2170,22 +2397,22 @@ export async function runCommand(argv) {
|
|
|
2170
2397
|
await writeSkillCache(config.apiOrigin, cacheKey, data);
|
|
2171
2398
|
return { ...data, cached: false };
|
|
2172
2399
|
}
|
|
2173
|
-
if (
|
|
2174
|
-
const name = positional(parsed,
|
|
2400
|
+
if (action === "get") {
|
|
2401
|
+
const name = positional(parsed, 3, "skill name");
|
|
2175
2402
|
const reference = flagString(parsed, "reference");
|
|
2176
2403
|
const allReferences = hasFlag(parsed, "all-references");
|
|
2177
2404
|
if (reference && allReferences) {
|
|
2178
2405
|
throw new CliError("invalid_arguments", "Pass --reference <name> for one reference or --all-references for the whole tree, not both.");
|
|
2179
2406
|
}
|
|
2180
|
-
const cacheKey =
|
|
2407
|
+
const cacheKey = `${audience}:get:${name}:${reference ?? ""}:${allReferences}`;
|
|
2181
2408
|
if (!refresh) {
|
|
2182
2409
|
const cached = await readSkillCache(config.apiOrigin, cacheKey);
|
|
2183
2410
|
if (cached)
|
|
2184
2411
|
return { ...cached, cached: true };
|
|
2185
2412
|
}
|
|
2186
|
-
const data = unwrap(await api.client.GET("/admin/skills/{name}", {
|
|
2413
|
+
const data = unwrap(await api.client.GET("/auth/admin-cli/skills/{audience}/{name}", {
|
|
2187
2414
|
params: {
|
|
2188
|
-
path: { name },
|
|
2415
|
+
path: { audience, name },
|
|
2189
2416
|
query: {
|
|
2190
2417
|
...(reference ? { reference } : {}),
|
|
2191
2418
|
...(allReferences ? { references: "all" } : {}),
|
|
@@ -2195,7 +2422,7 @@ export async function runCommand(argv) {
|
|
|
2195
2422
|
await writeSkillCache(config.apiOrigin, cacheKey, data);
|
|
2196
2423
|
return { ...data, cached: false };
|
|
2197
2424
|
}
|
|
2198
|
-
throw new CliError("invalid_arguments", "Use skills list|get.");
|
|
2425
|
+
throw new CliError("invalid_arguments", "Use skills guardian list|get or skills admin list|get.");
|
|
2199
2426
|
}
|
|
2200
2427
|
if (noun === "content-library") {
|
|
2201
2428
|
if (verb === "search") {
|
|
@@ -2204,7 +2431,7 @@ export async function runCommand(argv) {
|
|
|
2204
2431
|
if (!Number.isInteger(limit) || limit < 1 || limit > 40) {
|
|
2205
2432
|
throw new CliError("invalid_arguments", "--limit must be an integer between 1 and 40.");
|
|
2206
2433
|
}
|
|
2207
|
-
return unwrap(await api.client.GET("/
|
|
2434
|
+
return unwrap(await api.client.GET("/ai/content-library/search", {
|
|
2208
2435
|
params: { query: { q: query, limit } },
|
|
2209
2436
|
}));
|
|
2210
2437
|
}
|
|
@@ -2214,7 +2441,7 @@ export async function runCommand(argv) {
|
|
|
2214
2441
|
if (verb === "list") {
|
|
2215
2442
|
const query = flagString(parsed, "query")?.toLowerCase();
|
|
2216
2443
|
const kind = flagString(parsed, "kind");
|
|
2217
|
-
const data = unwrap(await api.client.GET("/
|
|
2444
|
+
const data = unwrap(await api.client.GET("/ai/goal-templates/", {
|
|
2218
2445
|
params: {
|
|
2219
2446
|
query: {
|
|
2220
2447
|
...(flagString(parsed, "category")
|
|
@@ -2249,7 +2476,7 @@ export async function runCommand(argv) {
|
|
|
2249
2476
|
}
|
|
2250
2477
|
if (verb === "get") {
|
|
2251
2478
|
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
2252
|
-
const template = unwrap(await api.client.GET("/
|
|
2479
|
+
const template = unwrap(await api.client.GET("/ai/goal-templates/{id}", {
|
|
2253
2480
|
params: { path: { id } },
|
|
2254
2481
|
}));
|
|
2255
2482
|
// A full spec is large; `--spec-only` is what an agent pipes into a file
|
|
@@ -2266,7 +2493,7 @@ export async function runCommand(argv) {
|
|
|
2266
2493
|
}
|
|
2267
2494
|
if (verb === "versions") {
|
|
2268
2495
|
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
2269
|
-
const data = unwrap(await api.client.GET("/
|
|
2496
|
+
const data = unwrap(await api.client.GET("/ai/goal-templates/{id}/versions", {
|
|
2270
2497
|
params: { path: { id } },
|
|
2271
2498
|
}));
|
|
2272
2499
|
const wanted = flagNumber(parsed, "version");
|
|
@@ -2293,7 +2520,7 @@ export async function runCommand(argv) {
|
|
|
2293
2520
|
return {
|
|
2294
2521
|
slug: document.slug,
|
|
2295
2522
|
kind: document.kind,
|
|
2296
|
-
...unwrap(await api.client.POST("/
|
|
2523
|
+
...unwrap(await api.client.POST("/ai/goal-templates/validate-spec", {
|
|
2297
2524
|
body: {
|
|
2298
2525
|
setupWorkflowSpec: document.setupWorkflowSpec,
|
|
2299
2526
|
kind: document.kind,
|
|
@@ -2310,7 +2537,7 @@ export async function runCommand(argv) {
|
|
|
2310
2537
|
// answer keys `apply` will demand all come out of the spec's own
|
|
2311
2538
|
// validation — a locally guessed preview could name a different handler
|
|
2312
2539
|
// than the one that lands. Validation writes nothing.
|
|
2313
|
-
const validation = unwrap(await api.client.POST("/
|
|
2540
|
+
const validation = unwrap(await api.client.POST("/ai/goal-templates/validate-spec", {
|
|
2314
2541
|
body: {
|
|
2315
2542
|
setupWorkflowSpec: document.setupWorkflowSpec,
|
|
2316
2543
|
kind: document.kind,
|
|
@@ -2338,7 +2565,7 @@ export async function runCommand(argv) {
|
|
|
2338
2565
|
specInventory: validation.totals,
|
|
2339
2566
|
note: "Setup mode is DETERMINISTIC_WORKFLOW and is one-way: this template can never be converted back to a chat-driven setup.",
|
|
2340
2567
|
},
|
|
2341
|
-
}, async () => unwrap(await api.client.POST("/
|
|
2568
|
+
}, async () => unwrap(await api.client.POST("/ai/goal-templates/", {
|
|
2342
2569
|
body: document,
|
|
2343
2570
|
})));
|
|
2344
2571
|
}
|
|
@@ -2508,11 +2735,11 @@ export async function runCommand(argv) {
|
|
|
2508
2735
|
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
2509
2736
|
const filePath = flagString(parsed, "path");
|
|
2510
2737
|
if (filePath) {
|
|
2511
|
-
return unwrap(await api.client.GET("/
|
|
2738
|
+
return unwrap(await api.client.GET("/ai/goal-templates/{id}/snapshot/file", {
|
|
2512
2739
|
params: { path: { id }, query: { path: filePath } },
|
|
2513
2740
|
}));
|
|
2514
2741
|
}
|
|
2515
|
-
return unwrap(await api.client.GET("/
|
|
2742
|
+
return unwrap(await api.client.GET("/ai/goal-templates/{id}/snapshot/files", {
|
|
2516
2743
|
params: { path: { id } },
|
|
2517
2744
|
}));
|
|
2518
2745
|
}
|
|
@@ -2532,7 +2759,7 @@ export async function runCommand(argv) {
|
|
|
2532
2759
|
sourceWorkspacePath: `drafts/${sourceDraft}/workspace`,
|
|
2533
2760
|
sourceStudentUserId,
|
|
2534
2761
|
};
|
|
2535
|
-
const preflight = unwrap(await api.client.POST("/
|
|
2762
|
+
const preflight = unwrap(await api.client.POST("/ai/goal-templates/{id}/snapshot/capture", {
|
|
2536
2763
|
params: { path: { id } },
|
|
2537
2764
|
body: { ...source, dryRun: true },
|
|
2538
2765
|
}));
|
|
@@ -2542,7 +2769,7 @@ export async function runCommand(argv) {
|
|
|
2542
2769
|
if (hasFlag(parsed, "dry-run"))
|
|
2543
2770
|
return preflight;
|
|
2544
2771
|
const preview = {
|
|
2545
|
-
action: "capture a validated
|
|
2772
|
+
action: "capture a validated goal workspace as this global template's instant-apply snapshot",
|
|
2546
2773
|
target: {
|
|
2547
2774
|
templateId: preflight.template.id,
|
|
2548
2775
|
slug: preflight.template.slug,
|
|
@@ -2560,11 +2787,11 @@ export async function runCommand(argv) {
|
|
|
2560
2787
|
modules: preflight.snapshot.modules,
|
|
2561
2788
|
paths: preflight.snapshot.paths,
|
|
2562
2789
|
warnings: preflight.warnings,
|
|
2563
|
-
note: "The confirmed request is fenced to both this template version and this exact
|
|
2790
|
+
note: "The confirmed request is fenced to both this template version and this exact source revision. Capturing bumps the template version unless the snapshot is unchanged.",
|
|
2564
2791
|
},
|
|
2565
2792
|
};
|
|
2566
2793
|
requireConfirmation(hasFlag(parsed, "confirm"), preview);
|
|
2567
|
-
return unwrap(await api.client.POST("/
|
|
2794
|
+
return unwrap(await api.client.POST("/ai/goal-templates/{id}/snapshot/capture", {
|
|
2568
2795
|
params: { path: { id } },
|
|
2569
2796
|
body: {
|
|
2570
2797
|
...source,
|
|
@@ -2597,7 +2824,7 @@ export async function runCommand(argv) {
|
|
|
2597
2824
|
...totals,
|
|
2598
2825
|
[result.action]: (totals[result.action] ?? 0) + 1,
|
|
2599
2826
|
}), {});
|
|
2600
|
-
|
|
2827
|
+
const preview = {
|
|
2601
2828
|
action: "apply a goal template to students (creates goals and todos)",
|
|
2602
2829
|
target: { templateId: id, students: preflight.results.length },
|
|
2603
2830
|
request: { answers },
|
|
@@ -2612,12 +2839,19 @@ export async function runCommand(argv) {
|
|
|
2612
2839
|
warnings: result.warnings,
|
|
2613
2840
|
})),
|
|
2614
2841
|
missingCoverage: preflight.missingCoverage,
|
|
2842
|
+
templateVersion: preflight.templateVersion,
|
|
2615
2843
|
note: "Counts come from the backend's own dry run. `skipped_existing` items are idempotent — re-applying does not duplicate them.",
|
|
2616
2844
|
},
|
|
2617
|
-
}
|
|
2845
|
+
};
|
|
2846
|
+
requirePreviewBoundConfirmation(parsed, preview);
|
|
2847
|
+
return unwrap(await api.client.POST("/ai/goal-templates/{id}/apply-workflow", {
|
|
2618
2848
|
params: { path: { id } },
|
|
2619
|
-
body: {
|
|
2620
|
-
|
|
2849
|
+
body: {
|
|
2850
|
+
answers,
|
|
2851
|
+
dryRun: false,
|
|
2852
|
+
expectedTemplateVersion: preflight.templateVersion,
|
|
2853
|
+
},
|
|
2854
|
+
}));
|
|
2621
2855
|
}
|
|
2622
2856
|
if (verb === "apply-starter") {
|
|
2623
2857
|
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
@@ -2643,10 +2877,10 @@ export async function runCommand(argv) {
|
|
|
2643
2877
|
}
|
|
2644
2878
|
throw new CliError("invalid_arguments", "Use goal-templates list|get|versions|validate-spec|create|patch-spec|set-metadata|delete|snapshot-files|capture-snapshot|apply|apply-starter.");
|
|
2645
2879
|
}
|
|
2646
|
-
if (noun === "goals") {
|
|
2880
|
+
if (noun === "goals" && verb !== "files" && verb !== "pdf") {
|
|
2647
2881
|
if (verb === "list") {
|
|
2648
2882
|
const userId = flagString(parsed, "student", { required: true });
|
|
2649
|
-
return unwrap(await api.client.GET("/
|
|
2883
|
+
return unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
|
|
2650
2884
|
params: { path: { userId } },
|
|
2651
2885
|
}));
|
|
2652
2886
|
}
|
|
@@ -2683,17 +2917,351 @@ export async function runCommand(argv) {
|
|
|
2683
2917
|
schedule: schedule ?? null,
|
|
2684
2918
|
},
|
|
2685
2919
|
details: {
|
|
2686
|
-
note: "Creates a description-only goal with no
|
|
2920
|
+
note: "Creates a description-only goal with no modules or course workspace. For a module-backed course, apply a BLUEPRINT template instead. A 409 GOAL_LIMIT_REACHED means the kid is at capacity and nothing was created.",
|
|
2687
2921
|
},
|
|
2688
|
-
}, async () => unwrap(await api.client.POST("/
|
|
2922
|
+
}, async () => unwrap(await api.client.POST("/tutor/browser/students/{userId}/goals/", {
|
|
2689
2923
|
params: { path: { userId } },
|
|
2690
2924
|
body,
|
|
2691
2925
|
})));
|
|
2692
2926
|
}
|
|
2693
|
-
|
|
2927
|
+
if (verb === "edit") {
|
|
2928
|
+
const goalId = positional(parsed, 2, "goal ID");
|
|
2929
|
+
const studentId = flagString(parsed, "student", { required: true });
|
|
2930
|
+
const patchFile = flagString(parsed, "patch-file", { required: true });
|
|
2931
|
+
const patch = (await readJsonFile(patchFile, "Goal patch file"));
|
|
2932
|
+
patch.delta = flagString(parsed, "delta", { required: true });
|
|
2933
|
+
const current = unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
|
|
2934
|
+
params: { path: { userId: studentId } },
|
|
2935
|
+
})).goals.find((goal) => goal.id === goalId);
|
|
2936
|
+
if (!current) {
|
|
2937
|
+
throw new CliError("not_found", `Goal ${goalId} was not found for student ${studentId}.`);
|
|
2938
|
+
}
|
|
2939
|
+
patch.expectedUpdatedAt = current.updatedAt;
|
|
2940
|
+
const preview = {
|
|
2941
|
+
action: "edit a goal for a managed student",
|
|
2942
|
+
target: { goalId, studentUserId: studentId },
|
|
2943
|
+
request: {
|
|
2944
|
+
patchFile: path.resolve(patchFile),
|
|
2945
|
+
expectedUpdatedAt: current.updatedAt,
|
|
2946
|
+
patch,
|
|
2947
|
+
},
|
|
2948
|
+
};
|
|
2949
|
+
requirePreviewBoundConfirmation(parsed, preview);
|
|
2950
|
+
return unwrap(await api.client.PATCH("/tutor/browser/students/goals/{goalId}/", {
|
|
2951
|
+
params: { path: { goalId } },
|
|
2952
|
+
body: patch,
|
|
2953
|
+
}));
|
|
2954
|
+
}
|
|
2955
|
+
throw new CliError("invalid_arguments", "Use goals list|create|edit|files|pdf.");
|
|
2956
|
+
}
|
|
2957
|
+
if (noun === "students") {
|
|
2958
|
+
if (verb === "list") {
|
|
2959
|
+
return unwrap(await api.client.GET("/family/kids", {
|
|
2960
|
+
params: { query: { includeSelf: "false" } },
|
|
2961
|
+
}));
|
|
2962
|
+
}
|
|
2963
|
+
if (verb === "today") {
|
|
2964
|
+
const kidId = flagString(parsed, "student", { required: true });
|
|
2965
|
+
const date = flagString(parsed, "date");
|
|
2966
|
+
return unwrap(await api.client.GET("/parent/kid/{kidId}/today/", {
|
|
2967
|
+
params: { path: { kidId }, query: date ? { date } : {} },
|
|
2968
|
+
}));
|
|
2969
|
+
}
|
|
2970
|
+
if (verb === "schedule") {
|
|
2971
|
+
const studentId = flagString(parsed, "student", { required: true });
|
|
2972
|
+
const days = flagNumber(parsed, "days");
|
|
2973
|
+
if (days !== undefined &&
|
|
2974
|
+
(!Number.isInteger(days) || days < 1 || days > 60)) {
|
|
2975
|
+
throw new CliError("invalid_arguments", "--days must be an integer from 1 to 60.");
|
|
2976
|
+
}
|
|
2977
|
+
return unwrap(await api.client.GET("/tutor/students/{studentId}/schedule", {
|
|
2978
|
+
params: {
|
|
2979
|
+
path: { studentId },
|
|
2980
|
+
query: days === undefined ? {} : { days },
|
|
2981
|
+
},
|
|
2982
|
+
}));
|
|
2983
|
+
}
|
|
2984
|
+
if (verb === "xp-history") {
|
|
2985
|
+
const kidId = flagString(parsed, "student", { required: true });
|
|
2986
|
+
const range = flagString(parsed, "range");
|
|
2987
|
+
const parsedRange = range
|
|
2988
|
+
? assertChoice(range, ["week", "month", "quarter", "year"], "--range")
|
|
2989
|
+
: undefined;
|
|
2990
|
+
return unwrap(await api.client.GET("/parent/kid/{kidId}/xp-history/", {
|
|
2991
|
+
params: {
|
|
2992
|
+
path: { kidId },
|
|
2993
|
+
query: parsedRange ? { range: parsedRange } : {},
|
|
2994
|
+
},
|
|
2995
|
+
}));
|
|
2996
|
+
}
|
|
2997
|
+
throw new CliError("invalid_arguments", "Use students list|today|schedule|xp-history|upload-map-scores.");
|
|
2998
|
+
}
|
|
2999
|
+
if (noun === "todos") {
|
|
3000
|
+
if (verb === "create") {
|
|
3001
|
+
const studentId = flagString(parsed, "student", { required: true });
|
|
3002
|
+
const title = flagString(parsed, "title", { required: true });
|
|
3003
|
+
const estimatedTimeInMins = flagNumber(parsed, "estimated-minutes");
|
|
3004
|
+
const body = {
|
|
3005
|
+
userId: studentId,
|
|
3006
|
+
title,
|
|
3007
|
+
...(flagString(parsed, "due-date")
|
|
3008
|
+
? { dueDate: flagString(parsed, "due-date") }
|
|
3009
|
+
: {}),
|
|
3010
|
+
...(flagString(parsed, "url")
|
|
3011
|
+
? { url: flagString(parsed, "url") }
|
|
3012
|
+
: {}),
|
|
3013
|
+
...(estimatedTimeInMins !== undefined ? { estimatedTimeInMins } : {}),
|
|
3014
|
+
};
|
|
3015
|
+
return writeCommand(parsed, {
|
|
3016
|
+
action: "create a todo for a managed student",
|
|
3017
|
+
target: { studentUserId: studentId },
|
|
3018
|
+
request: body,
|
|
3019
|
+
}, async () => unwrap(await api.client.POST("/tutor/browser/todos/", { body })));
|
|
3020
|
+
}
|
|
3021
|
+
if (verb === "edit") {
|
|
3022
|
+
const todoId = positional(parsed, 2, "todo ID");
|
|
3023
|
+
const patchFile = flagString(parsed, "patch-file", { required: true });
|
|
3024
|
+
const body = (await readJsonFile(patchFile, "Todo patch file"));
|
|
3025
|
+
const current = unwrap(await api.client.GET("/tutor/browser/todos/{id}/", {
|
|
3026
|
+
params: { path: { id: todoId } },
|
|
3027
|
+
}));
|
|
3028
|
+
body.expectedUpdatedAt = current.updatedAt;
|
|
3029
|
+
const preview = {
|
|
3030
|
+
action: "edit a todo for a managed student",
|
|
3031
|
+
target: { todoId },
|
|
3032
|
+
request: {
|
|
3033
|
+
patchFile: path.resolve(patchFile),
|
|
3034
|
+
expectedUpdatedAt: current.updatedAt,
|
|
3035
|
+
patch: body,
|
|
3036
|
+
},
|
|
3037
|
+
};
|
|
3038
|
+
requirePreviewBoundConfirmation(parsed, preview);
|
|
3039
|
+
return unwrap(await api.client.PATCH("/tutor/browser/todos/{id}", {
|
|
3040
|
+
params: { path: { id: todoId } },
|
|
3041
|
+
body,
|
|
3042
|
+
}));
|
|
3043
|
+
}
|
|
3044
|
+
throw new CliError("invalid_arguments", "Use todos create|edit.");
|
|
3045
|
+
}
|
|
3046
|
+
if (noun === "memories") {
|
|
3047
|
+
const studentId = flagString(parsed, "student", { required: true });
|
|
3048
|
+
if (verb === "context") {
|
|
3049
|
+
return unwrap(await api.client.GET("/tutor/students/{studentId}/prompt-memory", {
|
|
3050
|
+
params: { path: { studentId } },
|
|
3051
|
+
}));
|
|
3052
|
+
}
|
|
3053
|
+
if (verb === "log") {
|
|
3054
|
+
const date = flagString(parsed, "date", { required: true });
|
|
3055
|
+
return unwrap(await api.client.GET("/tutor/students/{studentId}/prompt-memory/log", {
|
|
3056
|
+
params: { path: { studentId }, query: { date } },
|
|
3057
|
+
}));
|
|
3058
|
+
}
|
|
3059
|
+
throw new CliError("invalid_arguments", "Use memories context|log.");
|
|
3060
|
+
}
|
|
3061
|
+
if (noun === "rocky") {
|
|
3062
|
+
const studentId = flagString(parsed, "student", { required: true });
|
|
3063
|
+
if (verb === "get") {
|
|
3064
|
+
return unwrap(await api.client.GET("/tutor/students/{studentId}/rocky-config", {
|
|
3065
|
+
params: { path: { studentId } },
|
|
3066
|
+
}));
|
|
3067
|
+
}
|
|
3068
|
+
if (verb === "set") {
|
|
3069
|
+
const patchFile = flagString(parsed, "patch-file", { required: true });
|
|
3070
|
+
const patch = (await readJsonFile(patchFile, "Rocky config patch file"));
|
|
3071
|
+
const expected = flagString(parsed, "expected-updated-at", {
|
|
3072
|
+
required: true,
|
|
3073
|
+
});
|
|
3074
|
+
const expectedUpdatedAt = expected === "none" ? null : expected;
|
|
3075
|
+
if (expectedUpdatedAt !== null &&
|
|
3076
|
+
Number.isNaN(Date.parse(expectedUpdatedAt))) {
|
|
3077
|
+
throw new CliError("invalid_arguments", "--expected-updated-at must be an ISO datetime from `rocky get`, or `none`.");
|
|
3078
|
+
}
|
|
3079
|
+
return writeCommand(parsed, {
|
|
3080
|
+
action: "change Rocky's configuration for a managed student",
|
|
3081
|
+
target: { studentUserId: studentId },
|
|
3082
|
+
request: {
|
|
3083
|
+
patchFile: path.resolve(patchFile),
|
|
3084
|
+
expectedUpdatedAt,
|
|
3085
|
+
patch,
|
|
3086
|
+
},
|
|
3087
|
+
}, async () => unwrap(await api.client.PATCH("/tutor/students/{studentId}/rocky-config", {
|
|
3088
|
+
params: { path: { studentId } },
|
|
3089
|
+
body: { expectedUpdatedAt, patch },
|
|
3090
|
+
})));
|
|
3091
|
+
}
|
|
3092
|
+
throw new CliError("invalid_arguments", "Use rocky get|set.");
|
|
3093
|
+
}
|
|
3094
|
+
if (noun === "goals" && verb === "pdf") {
|
|
3095
|
+
const action = positional(parsed, 2, "goals pdf action");
|
|
3096
|
+
if (action !== "upload") {
|
|
3097
|
+
throw new CliError("invalid_arguments", "Use goals pdf upload.");
|
|
3098
|
+
}
|
|
3099
|
+
const studentId = flagString(parsed, "student", { required: true });
|
|
3100
|
+
const goalId = flagString(parsed, "goal");
|
|
3101
|
+
const draftSlug = flagString(parsed, "draft");
|
|
3102
|
+
if (Boolean(goalId) === Boolean(draftSlug)) {
|
|
3103
|
+
throw new CliError("invalid_arguments", "Pass exactly one of --goal or --draft for goals pdf upload.");
|
|
3104
|
+
}
|
|
3105
|
+
const sourceFile = flagString(parsed, "source-file", { required: true });
|
|
3106
|
+
const absolutePath = path.resolve(sourceFile);
|
|
3107
|
+
const fileStat = await fs.lstat(absolutePath).catch(() => null);
|
|
3108
|
+
if (!fileStat?.isFile() || fileStat.isSymbolicLink()) {
|
|
3109
|
+
throw new CliError("invalid_arguments", `--source-file is not a file: ${absolutePath}`);
|
|
3110
|
+
}
|
|
3111
|
+
if (!absolutePath.toLowerCase().endsWith(".pdf")) {
|
|
3112
|
+
throw new CliError("invalid_arguments", "goals pdf upload accepts PDF files only.");
|
|
3113
|
+
}
|
|
3114
|
+
if (fileStat.size <= 0 || fileStat.size > GOAL_PDF_UPLOAD_MAX_BYTES) {
|
|
3115
|
+
throw new CliError("invalid_arguments", `PDF size must be between 1 byte and ${GOAL_PDF_UPLOAD_MAX_BYTES} bytes.`);
|
|
3116
|
+
}
|
|
3117
|
+
const fileName = path.basename(absolutePath);
|
|
3118
|
+
const workspacePath = flagString(parsed, "path") ?? `uploads/${safeGoalUploadName(fileName)}`;
|
|
3119
|
+
if (!workspacePath.startsWith("uploads/")) {
|
|
3120
|
+
throw new CliError("invalid_arguments", "Textbook PDFs must be attached under uploads/.");
|
|
3121
|
+
}
|
|
3122
|
+
const sourceSha256 = await sha256File(absolutePath);
|
|
3123
|
+
const target = goalId
|
|
3124
|
+
? { kind: "goal", goalId }
|
|
3125
|
+
: { kind: "draft", draftSlug: draftSlug };
|
|
3126
|
+
const message = flagString(parsed, "message") ??
|
|
3127
|
+
`recess-cli: attach textbook PDF ${fileName}`;
|
|
3128
|
+
const placeholderManifest = JSON.stringify({
|
|
3129
|
+
kind: "pending-pdf-upload",
|
|
3130
|
+
fileName,
|
|
3131
|
+
contentType: "application/pdf",
|
|
3132
|
+
sizeBytes: fileStat.size,
|
|
3133
|
+
});
|
|
3134
|
+
const body = {
|
|
3135
|
+
studentUserId: studentId,
|
|
3136
|
+
target,
|
|
3137
|
+
message,
|
|
3138
|
+
files: [
|
|
3139
|
+
{
|
|
3140
|
+
path: workspacePath,
|
|
3141
|
+
content: placeholderManifest,
|
|
3142
|
+
contentEncoding: "utf8",
|
|
3143
|
+
},
|
|
3144
|
+
],
|
|
3145
|
+
};
|
|
3146
|
+
const preflight = unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
|
|
3147
|
+
body: { ...body, dryRun: true },
|
|
3148
|
+
}));
|
|
3149
|
+
if (preflight.action !== "preview_workspace_files_write") {
|
|
3150
|
+
throw new CliError("unexpected_response", "Goal PDF upload did not return a preview; nothing was uploaded.");
|
|
3151
|
+
}
|
|
3152
|
+
requirePreviewBoundConfirmation(parsed, {
|
|
3153
|
+
action: "upload a textbook PDF and attach it to a student's goal",
|
|
3154
|
+
target: preflight.target,
|
|
3155
|
+
request: {
|
|
3156
|
+
sourceFile: absolutePath,
|
|
3157
|
+
sourceSha256,
|
|
3158
|
+
sizeBytes: fileStat.size,
|
|
3159
|
+
workspacePath,
|
|
3160
|
+
message,
|
|
3161
|
+
},
|
|
3162
|
+
details: {
|
|
3163
|
+
currentChangeId: preflight.currentChangeId,
|
|
3164
|
+
note: "The PDF is stored separately and the goal workspace receives a small verified reference, matching planner-authored textbook goals.",
|
|
3165
|
+
},
|
|
3166
|
+
});
|
|
3167
|
+
const conversationId = randomUUID();
|
|
3168
|
+
const signed = unwrap(await api.client.POST("/os-v2-mesa/planner/uploads/sign", {
|
|
3169
|
+
body: {
|
|
3170
|
+
conversationId,
|
|
3171
|
+
studentUserId: studentId,
|
|
3172
|
+
fileName,
|
|
3173
|
+
contentType: "application/pdf",
|
|
3174
|
+
sizeBytes: fileStat.size,
|
|
3175
|
+
},
|
|
3176
|
+
}));
|
|
3177
|
+
let uploaded;
|
|
3178
|
+
try {
|
|
3179
|
+
const uploadedSource = await uploadPdfToSignedUrls(absolutePath, "application/pdf", signed);
|
|
3180
|
+
if (uploadedSource.sha256 !== sourceSha256 ||
|
|
3181
|
+
uploadedSource.sizeBytes !== fileStat.size) {
|
|
3182
|
+
throw new CliError("source_changed", "The source PDF changed after approval. The upload was discarded; preview the current file again.");
|
|
3183
|
+
}
|
|
3184
|
+
uploaded =
|
|
3185
|
+
signed.kind === "multipart"
|
|
3186
|
+
? unwrap(await api.client.POST("/os-v2-mesa/planner/uploads/complete", {
|
|
3187
|
+
body: {
|
|
3188
|
+
conversationId,
|
|
3189
|
+
fileName,
|
|
3190
|
+
contentType: "application/pdf",
|
|
3191
|
+
key: signed.key,
|
|
3192
|
+
uploadId: signed.uploadId,
|
|
3193
|
+
sizeBytes: fileStat.size,
|
|
3194
|
+
},
|
|
3195
|
+
}))
|
|
3196
|
+
: unwrap(await api.client.POST("/os-v2-mesa/planner/uploads/verify", {
|
|
3197
|
+
body: {
|
|
3198
|
+
conversationId,
|
|
3199
|
+
fileName,
|
|
3200
|
+
contentType: "application/pdf",
|
|
3201
|
+
key: signed.key,
|
|
3202
|
+
sizeBytes: fileStat.size,
|
|
3203
|
+
},
|
|
3204
|
+
}));
|
|
3205
|
+
}
|
|
3206
|
+
catch (error) {
|
|
3207
|
+
// A failed multipart complete may already have materialized the object,
|
|
3208
|
+
// so attempt both abort and delete. These are compensation attempts; the
|
|
3209
|
+
// original upload error remains authoritative.
|
|
3210
|
+
await cleanupPlannerUpload(api, conversationId, signed, true);
|
|
3211
|
+
throw error;
|
|
3212
|
+
}
|
|
3213
|
+
const manifest = JSON.stringify({
|
|
3214
|
+
kind: "r2-pdf",
|
|
3215
|
+
version: 2,
|
|
3216
|
+
r2Key: uploaded.key,
|
|
3217
|
+
signature: uploaded.manifestSignature,
|
|
3218
|
+
fileName,
|
|
3219
|
+
contentType: uploaded.contentType,
|
|
3220
|
+
sizeBytes: uploaded.sizeBytes,
|
|
3221
|
+
});
|
|
3222
|
+
let workspace;
|
|
3223
|
+
try {
|
|
3224
|
+
workspace = unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
|
|
3225
|
+
body: {
|
|
3226
|
+
...body,
|
|
3227
|
+
files: [
|
|
3228
|
+
{
|
|
3229
|
+
path: workspacePath,
|
|
3230
|
+
content: manifest,
|
|
3231
|
+
contentEncoding: "utf8",
|
|
3232
|
+
},
|
|
3233
|
+
],
|
|
3234
|
+
dryRun: false,
|
|
3235
|
+
expectedChangeId: preflight.currentChangeId,
|
|
3236
|
+
},
|
|
3237
|
+
}));
|
|
3238
|
+
}
|
|
3239
|
+
catch (error) {
|
|
3240
|
+
// Only a returned 4xx from this route is a definitive no-write response.
|
|
3241
|
+
// A transport/5xx failure is ambiguous: the workspace commit may have landed,
|
|
3242
|
+
// so keep the object rather than creating a dangling manifest.
|
|
3243
|
+
const status = error instanceof CliError &&
|
|
3244
|
+
error.details &&
|
|
3245
|
+
typeof error.details === "object" &&
|
|
3246
|
+
"status" in error.details
|
|
3247
|
+
? Number(error.details.status)
|
|
3248
|
+
: 0;
|
|
3249
|
+
if (status >= 400 && status < 500) {
|
|
3250
|
+
await cleanupPlannerUpload(api, conversationId, signed, true);
|
|
3251
|
+
}
|
|
3252
|
+
throw error;
|
|
3253
|
+
}
|
|
3254
|
+
return {
|
|
3255
|
+
action: "upload_textbook_pdf",
|
|
3256
|
+
sourceSha256,
|
|
3257
|
+
fileName,
|
|
3258
|
+
sizeBytes: uploaded.sizeBytes,
|
|
3259
|
+
workspacePath,
|
|
3260
|
+
workspace,
|
|
3261
|
+
};
|
|
2694
3262
|
}
|
|
2695
|
-
if (noun === "
|
|
2696
|
-
const action = positional(parsed, 2, "
|
|
3263
|
+
if (noun === "goals" && verb === "files") {
|
|
3264
|
+
const action = positional(parsed, 2, "goals files action");
|
|
2697
3265
|
const studentId = flagString(parsed, "student", { required: true });
|
|
2698
3266
|
if (action === "list") {
|
|
2699
3267
|
const goalId = flagString(parsed, "goal", { required: true });
|
|
@@ -2712,9 +3280,9 @@ export async function runCommand(argv) {
|
|
|
2712
3280
|
const goalId = flagString(parsed, "goal");
|
|
2713
3281
|
const draftSlug = flagString(parsed, "draft");
|
|
2714
3282
|
if (Boolean(goalId) === Boolean(draftSlug)) {
|
|
2715
|
-
throw new CliError("invalid_arguments", "Pass exactly one of --goal or --draft for
|
|
3283
|
+
throw new CliError("invalid_arguments", "Pass exactly one of --goal or --draft for goals files write.");
|
|
2716
3284
|
}
|
|
2717
|
-
const input = await
|
|
3285
|
+
const input = await readGoalWorkspaceWriteSource(parsed);
|
|
2718
3286
|
const target = goalId
|
|
2719
3287
|
? { kind: "goal", goalId }
|
|
2720
3288
|
: { kind: "draft", draftSlug: draftSlug };
|
|
@@ -2726,14 +3294,14 @@ export async function runCommand(argv) {
|
|
|
2726
3294
|
message,
|
|
2727
3295
|
files: input.files,
|
|
2728
3296
|
};
|
|
2729
|
-
const preflight = unwrap(await api.client.POST("/
|
|
3297
|
+
const preflight = unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
|
|
2730
3298
|
body: { ...body, dryRun: true },
|
|
2731
3299
|
}));
|
|
2732
3300
|
if (preflight.action !== "preview_workspace_files_write") {
|
|
2733
|
-
throw new CliError("unexpected_response", "
|
|
3301
|
+
throw new CliError("unexpected_response", "Goal workspace write did not return a preview; nothing was written.");
|
|
2734
3302
|
}
|
|
2735
3303
|
const preview = {
|
|
2736
|
-
action: "batch-upsert files in a student's
|
|
3304
|
+
action: "batch-upsert files in a student's goal workspace",
|
|
2737
3305
|
target: preflight.target,
|
|
2738
3306
|
request: {
|
|
2739
3307
|
source: input.source,
|
|
@@ -2750,8 +3318,8 @@ export async function runCommand(argv) {
|
|
|
2750
3318
|
: "Live goal modules/ and state/ are blocked here because they have database projections; use a draft + template capture for structural course changes.",
|
|
2751
3319
|
},
|
|
2752
3320
|
};
|
|
2753
|
-
|
|
2754
|
-
return unwrap(await api.client.POST("/
|
|
3321
|
+
requirePreviewBoundConfirmation(parsed, preview);
|
|
3322
|
+
return unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
|
|
2755
3323
|
body: {
|
|
2756
3324
|
...body,
|
|
2757
3325
|
dryRun: false,
|
|
@@ -2759,7 +3327,7 @@ export async function runCommand(argv) {
|
|
|
2759
3327
|
},
|
|
2760
3328
|
}));
|
|
2761
3329
|
}
|
|
2762
|
-
throw new CliError("invalid_arguments", "Use
|
|
3330
|
+
throw new CliError("invalid_arguments", "Use goals files list|read|write.");
|
|
2763
3331
|
}
|
|
2764
3332
|
if (noun === "request" && verb === "get") {
|
|
2765
3333
|
return api.rawGet(positional(parsed, 2, "request path"));
|