recess-cli 1.4.0 → 1.7.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 +57 -26
- package/dist/api.js +1 -1
- package/dist/args.js +2 -0
- package/dist/auth.js +1 -1
- package/dist/cli.js +611 -70
- 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 -484
- 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
|
|
@@ -35,6 +35,11 @@ Usage:
|
|
|
35
35
|
--first-name TEXT [--last-name TEXT] [--no-invite] [--confirm]
|
|
36
36
|
recess [--json] students upload-map-scores --student <kid-id>
|
|
37
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]
|
|
38
43
|
recess [--json] enrollments list --user <user-id>
|
|
39
44
|
recess [--json] subscriptions list --family <family-id> [--kid <kid-id>]
|
|
40
45
|
recess [--json] invoices list --subscription <subscription-id>
|
|
@@ -125,8 +130,12 @@ Usage:
|
|
|
125
130
|
recess [--json] store-items set-status <village-store-item-id>
|
|
126
131
|
--status ACTIVE|INACTIVE|COMING_SOON [--confirm]
|
|
127
132
|
recess [--json] content-library search <query> [--limit 8]
|
|
128
|
-
recess [--json] skills list [--query TEXT] [--category TEXT]
|
|
129
|
-
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]
|
|
130
139
|
[--refresh]
|
|
131
140
|
recess [--json] goal-templates list [--query TEXT] [--category TEXT]
|
|
132
141
|
[--kind SIMPLE|BLUEPRINT] [--starter-only] [--include-deleted]
|
|
@@ -144,22 +153,39 @@ Usage:
|
|
|
144
153
|
recess [--json] goal-templates delete <template-id> --expected-version N [--confirm]
|
|
145
154
|
recess [--json] goal-templates snapshot-files <template-id> [--path P]
|
|
146
155
|
recess [--json] goal-templates capture-snapshot <template-id|slug>
|
|
147
|
-
(--source-goal <goal-id> | --source-draft <draft-slug> --student <
|
|
156
|
+
(--source-goal <goal-id> | --source-draft <draft-slug> --student <goal-owner-id>)
|
|
148
157
|
[--dry-run] [--confirm]
|
|
149
158
|
recess [--json] goal-templates apply <template-id> --answers-file <path>
|
|
150
|
-
[--dry-run] [--confirm]
|
|
159
|
+
[--dry-run] [--confirm --approval-token TOKEN]
|
|
151
160
|
recess [--json] goal-templates apply-starter <template-id> --student <kid-id>
|
|
152
161
|
[--answers-file <path>] [--confirm]
|
|
153
162
|
recess [--json] goals list --student <kid-id>
|
|
154
163
|
recess [--json] goals create --student <kid-id> --title TEXT
|
|
155
164
|
(--description TEXT | --description-file <path>) [--target-date <iso>]
|
|
156
|
-
[--schedule TEXT] [--
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
recess [--json]
|
|
165
|
+
[--schedule TEXT] [--draft <draft-slug>
|
|
166
|
+
(--enable-applet-follow-ups | --disable-applet-follow-ups)]
|
|
167
|
+
[--confirm --approval-token TOKEN]
|
|
168
|
+
recess [--json] goals edit <goal-id> --student <kid-id> --patch-file <path/patch.json>
|
|
169
|
+
--delta TEXT [--confirm --approval-token TOKEN]
|
|
170
|
+
recess [--json] todos create --student <kid-id> --title TEXT
|
|
171
|
+
[--due-date YYYY-MM-DD] [--estimated-minutes N] [--url URL] [--confirm]
|
|
172
|
+
recess [--json] todos edit <todo-id> --patch-file <path/patch.json>
|
|
173
|
+
[--confirm --approval-token TOKEN]
|
|
174
|
+
recess [--json] memories context --student <kid-id>
|
|
175
|
+
recess [--json] memories log --student <kid-id> --date YYYY-MM-DD
|
|
176
|
+
recess [--json] rocky get --student <kid-id>
|
|
177
|
+
recess [--json] rocky set --student <kid-id> --patch-file <path/patch.json>
|
|
178
|
+
--expected-updated-at ISO|none [--confirm]
|
|
179
|
+
recess [--json] goals files list --student <goal-owner-id> --goal <goal-id>
|
|
180
|
+
recess [--json] goals files read --student <goal-owner-id> --goal <goal-id> --path P
|
|
181
|
+
recess [--json] goals files write --student <goal-owner-id>
|
|
160
182
|
(--goal <goal-id> | --draft <draft-slug>)
|
|
161
183
|
(--source-dir <local-dir> | --source-file <local-file> --path P)
|
|
162
|
-
[--message TEXT] [--confirm]
|
|
184
|
+
[--message TEXT] [--confirm --approval-token TOKEN]
|
|
185
|
+
recess [--json] goals pdf upload --student <goal-owner-id>
|
|
186
|
+
(--goal <goal-id> | --draft <draft-slug>) --source-file <local.pdf>
|
|
187
|
+
[--path uploads/name.pdf] [--message TEXT]
|
|
188
|
+
[--confirm --approval-token TOKEN]
|
|
163
189
|
|
|
164
190
|
Authoring notes: "skills" serves the in-product tutor skills (the PRIVATE
|
|
165
191
|
packages/skills submodule) read-only over your admin session — they are never
|
|
@@ -167,6 +193,13 @@ bundled into this npm package. Load os-v2-goal-template-builder and its
|
|
|
167
193
|
references/deterministic-workflow-setup.md BEFORE authoring a template; that is
|
|
168
194
|
the same guidance the recess.gg/ai agent follows, so there is exactly one
|
|
169
195
|
standard. Responses cache under ~/.recess-cli/skills-cache/ (--refresh re-fetches).
|
|
196
|
+
For goal workspace and PDF commands, --student names the goal owner. A
|
|
197
|
+
full_admin session may use a KID or ADMIN user id, including its own ADMIN id;
|
|
198
|
+
family_ai sessions remain limited to managed KID profiles.
|
|
199
|
+
"goals create --draft" materializes that validated draft directly as a personal
|
|
200
|
+
module-backed goal; it does not create or apply a reusable template. Its preview
|
|
201
|
+
is bound to the exact workspace revision, hash, module inventory, and applet
|
|
202
|
+
follow-up choice.
|
|
170
203
|
Every template created here is setupMode DETERMINISTIC_WORKFLOW and CANNOT be
|
|
171
204
|
converted back, so "validate-spec" against the same file until it passes, then
|
|
172
205
|
"create". "create" runs a real server-side validation before its gate, so the
|
|
@@ -199,8 +232,9 @@ Payout notes: amounts are integer cents. "payout items add" without --date
|
|
|
199
232
|
defaults the item date to the penultimate day of the invoice's cycle (its
|
|
200
233
|
endDate minus one day) and shows the computed date in the preview.
|
|
201
234
|
|
|
202
|
-
Auth notes: "auth login" runs the browser loopback flow
|
|
203
|
-
|
|
235
|
+
Auth notes: "auth login" runs the browser loopback flow for ADMIN or a GUARDIAN with
|
|
236
|
+
access:ai. Guardian sessions are family-scoped and cannot call /admin. For a headless
|
|
237
|
+
cloud agent, the ADMIN-only "auth request" prints an approval URL to hand a Recess admin; after they
|
|
204
238
|
approve it in a browser, "auth poll" collects the 12h session. When it lapses, run
|
|
205
239
|
"auth request" again for a fresh link. Both paths yield the same session.
|
|
206
240
|
|
|
@@ -237,7 +271,7 @@ async function sessionStatus(config) {
|
|
|
237
271
|
authSource: config.authSource,
|
|
238
272
|
status: result.response.status,
|
|
239
273
|
expiresAt: config.sessionExpiresAt ?? null,
|
|
240
|
-
message: "The stored
|
|
274
|
+
message: "The stored Recess CLI session is invalid, expired, or revoked. Run `recess auth login`.",
|
|
241
275
|
};
|
|
242
276
|
}
|
|
243
277
|
return { ...result.data, authSource: config.authSource };
|
|
@@ -281,7 +315,7 @@ async function skillStatus(config) {
|
|
|
281
315
|
return { ...base, checked: false, reason: "no session" };
|
|
282
316
|
}
|
|
283
317
|
try {
|
|
284
|
-
const response = await fetch(new URL("/admin/
|
|
318
|
+
const response = await fetch(new URL("/auth/admin-cli/skill/", config.apiOrigin), {
|
|
285
319
|
headers: { cookie: config.sessionCookie },
|
|
286
320
|
signal: AbortSignal.timeout(5000),
|
|
287
321
|
});
|
|
@@ -320,9 +354,114 @@ async function writeCommand(parsed, preview, execute) {
|
|
|
320
354
|
requireConfirmation(hasFlag(parsed, "confirm"), preview);
|
|
321
355
|
return execute();
|
|
322
356
|
}
|
|
323
|
-
const
|
|
324
|
-
const
|
|
325
|
-
|
|
357
|
+
const GOAL_WORKSPACE_WRITE_MAX_FILES = 1_000;
|
|
358
|
+
const GOAL_WORKSPACE_WRITE_MAX_TOTAL_BYTES = 20 * 1024 * 1024;
|
|
359
|
+
const GOAL_PDF_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024;
|
|
360
|
+
function approvalTokenFor(preview) {
|
|
361
|
+
return createHash("sha256").update(JSON.stringify(preview)).digest("hex");
|
|
362
|
+
}
|
|
363
|
+
function requirePreviewBoundConfirmation(parsed, preview) {
|
|
364
|
+
const approvalToken = approvalTokenFor(preview);
|
|
365
|
+
const boundPreview = {
|
|
366
|
+
...preview,
|
|
367
|
+
details: { ...preview.details, approvalToken },
|
|
368
|
+
};
|
|
369
|
+
if (!hasFlag(parsed, "confirm")) {
|
|
370
|
+
requireConfirmation(false, boundPreview);
|
|
371
|
+
}
|
|
372
|
+
const supplied = flagString(parsed, "approval-token");
|
|
373
|
+
if (supplied !== approvalToken) {
|
|
374
|
+
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" });
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
async function cleanupPlannerUpload(api, conversationId, signed, includeDelete) {
|
|
378
|
+
if (signed.kind === "multipart") {
|
|
379
|
+
await api.client
|
|
380
|
+
.POST("/os-v2-mesa/planner/uploads/cleanup", {
|
|
381
|
+
body: {
|
|
382
|
+
action: "abort",
|
|
383
|
+
conversationId,
|
|
384
|
+
key: signed.key,
|
|
385
|
+
uploadId: signed.uploadId,
|
|
386
|
+
},
|
|
387
|
+
})
|
|
388
|
+
.catch(() => undefined);
|
|
389
|
+
}
|
|
390
|
+
if (includeDelete) {
|
|
391
|
+
await api.client
|
|
392
|
+
.POST("/os-v2-mesa/planner/uploads/cleanup", {
|
|
393
|
+
body: { action: "delete", conversationId, key: signed.key },
|
|
394
|
+
})
|
|
395
|
+
.catch(() => undefined);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
function safeGoalUploadName(fileName) {
|
|
399
|
+
return fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
400
|
+
}
|
|
401
|
+
async function sha256File(absolutePath) {
|
|
402
|
+
const handle = await fs.open(absolutePath, "r");
|
|
403
|
+
const hash = createHash("sha256");
|
|
404
|
+
const buffer = Buffer.allocUnsafe(4 * 1024 * 1024);
|
|
405
|
+
try {
|
|
406
|
+
let position = 0;
|
|
407
|
+
while (true) {
|
|
408
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, position);
|
|
409
|
+
if (bytesRead === 0)
|
|
410
|
+
break;
|
|
411
|
+
hash.update(buffer.subarray(0, bytesRead));
|
|
412
|
+
position += bytesRead;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
finally {
|
|
416
|
+
await handle.close();
|
|
417
|
+
}
|
|
418
|
+
return hash.digest("hex");
|
|
419
|
+
}
|
|
420
|
+
function uploadBody(bytes) {
|
|
421
|
+
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
422
|
+
}
|
|
423
|
+
async function putSignedPart(uploadUrl, bytes, contentType) {
|
|
424
|
+
const response = await fetch(uploadUrl, {
|
|
425
|
+
method: "PUT",
|
|
426
|
+
headers: contentType ? { "content-type": contentType } : undefined,
|
|
427
|
+
body: uploadBody(bytes),
|
|
428
|
+
});
|
|
429
|
+
if (!response.ok) {
|
|
430
|
+
throw new CliError("upload_failed", `Textbook upload failed with HTTP ${response.status}. The goal workspace was not changed.`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
async function uploadPdfToSignedUrls(absolutePath, contentType, signed) {
|
|
434
|
+
if (signed.kind === "single") {
|
|
435
|
+
const bytes = await fs.readFile(absolutePath);
|
|
436
|
+
await putSignedPart(signed.uploadUrl, bytes, contentType);
|
|
437
|
+
return {
|
|
438
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
439
|
+
sizeBytes: bytes.byteLength,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
const handle = await fs.open(absolutePath, "r");
|
|
443
|
+
const hash = createHash("sha256");
|
|
444
|
+
let sizeBytes = 0;
|
|
445
|
+
try {
|
|
446
|
+
for (const part of signed.parts) {
|
|
447
|
+
const position = (part.partNumber - 1) * signed.chunkSize;
|
|
448
|
+
const buffer = Buffer.allocUnsafe(signed.chunkSize);
|
|
449
|
+
const { bytesRead } = await handle.read(buffer, 0, signed.chunkSize, position);
|
|
450
|
+
if (bytesRead === 0) {
|
|
451
|
+
throw new CliError("upload_failed", `Textbook upload part ${part.partNumber} had no bytes. The goal workspace was not changed.`);
|
|
452
|
+
}
|
|
453
|
+
const bytes = buffer.subarray(0, bytesRead);
|
|
454
|
+
hash.update(bytes);
|
|
455
|
+
sizeBytes += bytesRead;
|
|
456
|
+
await putSignedPart(part.uploadUrl, bytes);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
finally {
|
|
460
|
+
await handle.close();
|
|
461
|
+
}
|
|
462
|
+
return { sha256: hash.digest("hex"), sizeBytes };
|
|
463
|
+
}
|
|
464
|
+
async function readGoalWorkspaceWriteSource(parsed) {
|
|
326
465
|
const sourceDirectory = flagString(parsed, "source-dir");
|
|
327
466
|
const sourceFile = flagString(parsed, "source-file");
|
|
328
467
|
if (Boolean(sourceDirectory) === Boolean(sourceFile)) {
|
|
@@ -333,12 +472,12 @@ async function readMesaWriteSource(parsed) {
|
|
|
333
472
|
let sizeBytes = 0;
|
|
334
473
|
let source;
|
|
335
474
|
const addFile = async (absolutePath, workspacePath) => {
|
|
336
|
-
if (files.length >=
|
|
337
|
-
throw new CliError("invalid_arguments", `
|
|
475
|
+
if (files.length >= GOAL_WORKSPACE_WRITE_MAX_FILES) {
|
|
476
|
+
throw new CliError("invalid_arguments", `Goal workspace writes are limited to ${GOAL_WORKSPACE_WRITE_MAX_FILES} files.`);
|
|
338
477
|
}
|
|
339
478
|
const fileStat = await fs.stat(absolutePath);
|
|
340
|
-
if (sizeBytes + fileStat.size >
|
|
341
|
-
throw new CliError("invalid_arguments", `
|
|
479
|
+
if (sizeBytes + fileStat.size > GOAL_WORKSPACE_WRITE_MAX_TOTAL_BYTES) {
|
|
480
|
+
throw new CliError("invalid_arguments", `Goal workspace writes are limited to ${GOAL_WORKSPACE_WRITE_MAX_TOTAL_BYTES} decoded bytes.`);
|
|
342
481
|
}
|
|
343
482
|
const bytes = await fs.readFile(absolutePath);
|
|
344
483
|
sizeBytes += bytes.byteLength;
|
|
@@ -387,7 +526,7 @@ async function readMesaWriteSource(parsed) {
|
|
|
387
526
|
await addFile(absolutePath, workspacePath);
|
|
388
527
|
}
|
|
389
528
|
if (files.length === 0) {
|
|
390
|
-
throw new CliError("invalid_arguments", "The
|
|
529
|
+
throw new CliError("invalid_arguments", "The goal workspace source contains no files.");
|
|
391
530
|
}
|
|
392
531
|
for (const file of files) {
|
|
393
532
|
hash.update(file.path);
|
|
@@ -603,7 +742,7 @@ function parseGoalTemplateDocument(doc) {
|
|
|
603
742
|
}
|
|
604
743
|
const spec = doc.setupWorkflowSpec;
|
|
605
744
|
if (spec === null || typeof spec !== "object" || Array.isArray(spec)) {
|
|
606
|
-
throw new CliError("invalid_arguments", 'Template file requires an object "setupWorkflowSpec" (the fixed wizard).
|
|
745
|
+
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`.');
|
|
607
746
|
}
|
|
608
747
|
const rawTags = doc.tags;
|
|
609
748
|
let tags = [];
|
|
@@ -681,8 +820,8 @@ function parseGoalTemplateDocument(doc) {
|
|
|
681
820
|
async function resolveGoalTemplateId(api, reference) {
|
|
682
821
|
if (UUID_RE.test(reference))
|
|
683
822
|
return reference;
|
|
684
|
-
const list = unwrap(await api.client.GET("/
|
|
685
|
-
params: { query: { includeDeleted: "
|
|
823
|
+
const list = unwrap(await api.client.GET("/ai/goal-templates/", {
|
|
824
|
+
params: { query: { includeDeleted: "false" } },
|
|
686
825
|
}));
|
|
687
826
|
const match = list.items.find((item) => item.slug === reference);
|
|
688
827
|
if (!match) {
|
|
@@ -2241,21 +2380,23 @@ export async function runCommand(argv) {
|
|
|
2241
2380
|
throw new CliError("invalid_arguments", "Use onboarding status|kids|intake-session|intake-session-create|set-stage|set-account-state|attest|set-intake|extract.");
|
|
2242
2381
|
}
|
|
2243
2382
|
if (noun === "skills") {
|
|
2244
|
-
// Reads only — no gate.
|
|
2245
|
-
//
|
|
2246
|
-
|
|
2383
|
+
// Reads only — no confirmation gate. Audience is explicit so an agent never
|
|
2384
|
+
// infers whether `get` means guardian guidance or staff-only operations.
|
|
2385
|
+
const audience = assertChoice(verb, ["admin", "guardian"], "skill audience");
|
|
2386
|
+
const action = positional(parsed, 2, "skills action");
|
|
2247
2387
|
const refresh = hasFlag(parsed, "refresh");
|
|
2248
|
-
if (
|
|
2388
|
+
if (action === "list") {
|
|
2249
2389
|
const query = flagString(parsed, "query");
|
|
2250
2390
|
const category = flagString(parsed, "category");
|
|
2251
|
-
const cacheKey =
|
|
2391
|
+
const cacheKey = `${audience}:list:${query ?? ""}:${category ?? ""}`;
|
|
2252
2392
|
if (!refresh) {
|
|
2253
2393
|
const cached = await readSkillCache(config.apiOrigin, cacheKey);
|
|
2254
2394
|
if (cached)
|
|
2255
2395
|
return { ...cached, cached: true };
|
|
2256
2396
|
}
|
|
2257
|
-
const data = unwrap(await api.client.GET("/admin/skills/", {
|
|
2397
|
+
const data = unwrap(await api.client.GET("/auth/admin-cli/skills/{audience}", {
|
|
2258
2398
|
params: {
|
|
2399
|
+
path: { audience },
|
|
2259
2400
|
query: {
|
|
2260
2401
|
...(query ? { query } : {}),
|
|
2261
2402
|
...(category ? { category } : {}),
|
|
@@ -2265,22 +2406,22 @@ export async function runCommand(argv) {
|
|
|
2265
2406
|
await writeSkillCache(config.apiOrigin, cacheKey, data);
|
|
2266
2407
|
return { ...data, cached: false };
|
|
2267
2408
|
}
|
|
2268
|
-
if (
|
|
2269
|
-
const name = positional(parsed,
|
|
2409
|
+
if (action === "get") {
|
|
2410
|
+
const name = positional(parsed, 3, "skill name");
|
|
2270
2411
|
const reference = flagString(parsed, "reference");
|
|
2271
2412
|
const allReferences = hasFlag(parsed, "all-references");
|
|
2272
2413
|
if (reference && allReferences) {
|
|
2273
2414
|
throw new CliError("invalid_arguments", "Pass --reference <name> for one reference or --all-references for the whole tree, not both.");
|
|
2274
2415
|
}
|
|
2275
|
-
const cacheKey =
|
|
2416
|
+
const cacheKey = `${audience}:get:${name}:${reference ?? ""}:${allReferences}`;
|
|
2276
2417
|
if (!refresh) {
|
|
2277
2418
|
const cached = await readSkillCache(config.apiOrigin, cacheKey);
|
|
2278
2419
|
if (cached)
|
|
2279
2420
|
return { ...cached, cached: true };
|
|
2280
2421
|
}
|
|
2281
|
-
const data = unwrap(await api.client.GET("/admin/skills/{name}", {
|
|
2422
|
+
const data = unwrap(await api.client.GET("/auth/admin-cli/skills/{audience}/{name}", {
|
|
2282
2423
|
params: {
|
|
2283
|
-
path: { name },
|
|
2424
|
+
path: { audience, name },
|
|
2284
2425
|
query: {
|
|
2285
2426
|
...(reference ? { reference } : {}),
|
|
2286
2427
|
...(allReferences ? { references: "all" } : {}),
|
|
@@ -2290,7 +2431,7 @@ export async function runCommand(argv) {
|
|
|
2290
2431
|
await writeSkillCache(config.apiOrigin, cacheKey, data);
|
|
2291
2432
|
return { ...data, cached: false };
|
|
2292
2433
|
}
|
|
2293
|
-
throw new CliError("invalid_arguments", "Use skills list|get.");
|
|
2434
|
+
throw new CliError("invalid_arguments", "Use skills guardian list|get or skills admin list|get.");
|
|
2294
2435
|
}
|
|
2295
2436
|
if (noun === "content-library") {
|
|
2296
2437
|
if (verb === "search") {
|
|
@@ -2299,7 +2440,7 @@ export async function runCommand(argv) {
|
|
|
2299
2440
|
if (!Number.isInteger(limit) || limit < 1 || limit > 40) {
|
|
2300
2441
|
throw new CliError("invalid_arguments", "--limit must be an integer between 1 and 40.");
|
|
2301
2442
|
}
|
|
2302
|
-
return unwrap(await api.client.GET("/
|
|
2443
|
+
return unwrap(await api.client.GET("/ai/content-library/search", {
|
|
2303
2444
|
params: { query: { q: query, limit } },
|
|
2304
2445
|
}));
|
|
2305
2446
|
}
|
|
@@ -2309,7 +2450,7 @@ export async function runCommand(argv) {
|
|
|
2309
2450
|
if (verb === "list") {
|
|
2310
2451
|
const query = flagString(parsed, "query")?.toLowerCase();
|
|
2311
2452
|
const kind = flagString(parsed, "kind");
|
|
2312
|
-
const data = unwrap(await api.client.GET("/
|
|
2453
|
+
const data = unwrap(await api.client.GET("/ai/goal-templates/", {
|
|
2313
2454
|
params: {
|
|
2314
2455
|
query: {
|
|
2315
2456
|
...(flagString(parsed, "category")
|
|
@@ -2344,7 +2485,7 @@ export async function runCommand(argv) {
|
|
|
2344
2485
|
}
|
|
2345
2486
|
if (verb === "get") {
|
|
2346
2487
|
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
2347
|
-
const template = unwrap(await api.client.GET("/
|
|
2488
|
+
const template = unwrap(await api.client.GET("/ai/goal-templates/{id}", {
|
|
2348
2489
|
params: { path: { id } },
|
|
2349
2490
|
}));
|
|
2350
2491
|
// A full spec is large; `--spec-only` is what an agent pipes into a file
|
|
@@ -2361,7 +2502,7 @@ export async function runCommand(argv) {
|
|
|
2361
2502
|
}
|
|
2362
2503
|
if (verb === "versions") {
|
|
2363
2504
|
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
2364
|
-
const data = unwrap(await api.client.GET("/
|
|
2505
|
+
const data = unwrap(await api.client.GET("/ai/goal-templates/{id}/versions", {
|
|
2365
2506
|
params: { path: { id } },
|
|
2366
2507
|
}));
|
|
2367
2508
|
const wanted = flagNumber(parsed, "version");
|
|
@@ -2388,7 +2529,7 @@ export async function runCommand(argv) {
|
|
|
2388
2529
|
return {
|
|
2389
2530
|
slug: document.slug,
|
|
2390
2531
|
kind: document.kind,
|
|
2391
|
-
...unwrap(await api.client.POST("/
|
|
2532
|
+
...unwrap(await api.client.POST("/ai/goal-templates/validate-spec", {
|
|
2392
2533
|
body: {
|
|
2393
2534
|
setupWorkflowSpec: document.setupWorkflowSpec,
|
|
2394
2535
|
kind: document.kind,
|
|
@@ -2405,7 +2546,7 @@ export async function runCommand(argv) {
|
|
|
2405
2546
|
// answer keys `apply` will demand all come out of the spec's own
|
|
2406
2547
|
// validation — a locally guessed preview could name a different handler
|
|
2407
2548
|
// than the one that lands. Validation writes nothing.
|
|
2408
|
-
const validation = unwrap(await api.client.POST("/
|
|
2549
|
+
const validation = unwrap(await api.client.POST("/ai/goal-templates/validate-spec", {
|
|
2409
2550
|
body: {
|
|
2410
2551
|
setupWorkflowSpec: document.setupWorkflowSpec,
|
|
2411
2552
|
kind: document.kind,
|
|
@@ -2433,7 +2574,7 @@ export async function runCommand(argv) {
|
|
|
2433
2574
|
specInventory: validation.totals,
|
|
2434
2575
|
note: "Setup mode is DETERMINISTIC_WORKFLOW and is one-way: this template can never be converted back to a chat-driven setup.",
|
|
2435
2576
|
},
|
|
2436
|
-
}, async () => unwrap(await api.client.POST("/
|
|
2577
|
+
}, async () => unwrap(await api.client.POST("/ai/goal-templates/", {
|
|
2437
2578
|
body: document,
|
|
2438
2579
|
})));
|
|
2439
2580
|
}
|
|
@@ -2603,11 +2744,11 @@ export async function runCommand(argv) {
|
|
|
2603
2744
|
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
2604
2745
|
const filePath = flagString(parsed, "path");
|
|
2605
2746
|
if (filePath) {
|
|
2606
|
-
return unwrap(await api.client.GET("/
|
|
2747
|
+
return unwrap(await api.client.GET("/ai/goal-templates/{id}/snapshot/file", {
|
|
2607
2748
|
params: { path: { id }, query: { path: filePath } },
|
|
2608
2749
|
}));
|
|
2609
2750
|
}
|
|
2610
|
-
return unwrap(await api.client.GET("/
|
|
2751
|
+
return unwrap(await api.client.GET("/ai/goal-templates/{id}/snapshot/files", {
|
|
2611
2752
|
params: { path: { id } },
|
|
2612
2753
|
}));
|
|
2613
2754
|
}
|
|
@@ -2627,7 +2768,7 @@ export async function runCommand(argv) {
|
|
|
2627
2768
|
sourceWorkspacePath: `drafts/${sourceDraft}/workspace`,
|
|
2628
2769
|
sourceStudentUserId,
|
|
2629
2770
|
};
|
|
2630
|
-
const preflight = unwrap(await api.client.POST("/
|
|
2771
|
+
const preflight = unwrap(await api.client.POST("/ai/goal-templates/{id}/snapshot/capture", {
|
|
2631
2772
|
params: { path: { id } },
|
|
2632
2773
|
body: { ...source, dryRun: true },
|
|
2633
2774
|
}));
|
|
@@ -2637,7 +2778,7 @@ export async function runCommand(argv) {
|
|
|
2637
2778
|
if (hasFlag(parsed, "dry-run"))
|
|
2638
2779
|
return preflight;
|
|
2639
2780
|
const preview = {
|
|
2640
|
-
action: "capture a validated
|
|
2781
|
+
action: "capture a validated goal workspace as this global template's instant-apply snapshot",
|
|
2641
2782
|
target: {
|
|
2642
2783
|
templateId: preflight.template.id,
|
|
2643
2784
|
slug: preflight.template.slug,
|
|
@@ -2655,11 +2796,11 @@ export async function runCommand(argv) {
|
|
|
2655
2796
|
modules: preflight.snapshot.modules,
|
|
2656
2797
|
paths: preflight.snapshot.paths,
|
|
2657
2798
|
warnings: preflight.warnings,
|
|
2658
|
-
note: "The confirmed request is fenced to both this template version and this exact
|
|
2799
|
+
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.",
|
|
2659
2800
|
},
|
|
2660
2801
|
};
|
|
2661
2802
|
requireConfirmation(hasFlag(parsed, "confirm"), preview);
|
|
2662
|
-
return unwrap(await api.client.POST("/
|
|
2803
|
+
return unwrap(await api.client.POST("/ai/goal-templates/{id}/snapshot/capture", {
|
|
2663
2804
|
params: { path: { id } },
|
|
2664
2805
|
body: {
|
|
2665
2806
|
...source,
|
|
@@ -2692,7 +2833,7 @@ export async function runCommand(argv) {
|
|
|
2692
2833
|
...totals,
|
|
2693
2834
|
[result.action]: (totals[result.action] ?? 0) + 1,
|
|
2694
2835
|
}), {});
|
|
2695
|
-
|
|
2836
|
+
const preview = {
|
|
2696
2837
|
action: "apply a goal template to students (creates goals and todos)",
|
|
2697
2838
|
target: { templateId: id, students: preflight.results.length },
|
|
2698
2839
|
request: { answers },
|
|
@@ -2707,12 +2848,19 @@ export async function runCommand(argv) {
|
|
|
2707
2848
|
warnings: result.warnings,
|
|
2708
2849
|
})),
|
|
2709
2850
|
missingCoverage: preflight.missingCoverage,
|
|
2851
|
+
templateVersion: preflight.templateVersion,
|
|
2710
2852
|
note: "Counts come from the backend's own dry run. `skipped_existing` items are idempotent — re-applying does not duplicate them.",
|
|
2711
2853
|
},
|
|
2712
|
-
}
|
|
2854
|
+
};
|
|
2855
|
+
requirePreviewBoundConfirmation(parsed, preview);
|
|
2856
|
+
return unwrap(await api.client.POST("/ai/goal-templates/{id}/apply-workflow", {
|
|
2713
2857
|
params: { path: { id } },
|
|
2714
|
-
body: {
|
|
2715
|
-
|
|
2858
|
+
body: {
|
|
2859
|
+
answers,
|
|
2860
|
+
dryRun: false,
|
|
2861
|
+
expectedTemplateVersion: preflight.templateVersion,
|
|
2862
|
+
},
|
|
2863
|
+
}));
|
|
2716
2864
|
}
|
|
2717
2865
|
if (verb === "apply-starter") {
|
|
2718
2866
|
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
@@ -2738,10 +2886,10 @@ export async function runCommand(argv) {
|
|
|
2738
2886
|
}
|
|
2739
2887
|
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.");
|
|
2740
2888
|
}
|
|
2741
|
-
if (noun === "goals") {
|
|
2889
|
+
if (noun === "goals" && verb !== "files" && verb !== "pdf") {
|
|
2742
2890
|
if (verb === "list") {
|
|
2743
2891
|
const userId = flagString(parsed, "student", { required: true });
|
|
2744
|
-
return unwrap(await api.client.GET("/
|
|
2892
|
+
return unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
|
|
2745
2893
|
params: { path: { userId } },
|
|
2746
2894
|
}));
|
|
2747
2895
|
}
|
|
@@ -2762,6 +2910,65 @@ export async function runCommand(argv) {
|
|
|
2762
2910
|
throw new CliError("invalid_arguments", "--target-date must be an ISO 8601 datetime.");
|
|
2763
2911
|
}
|
|
2764
2912
|
const schedule = flagString(parsed, "schedule");
|
|
2913
|
+
const draftSlug = flagString(parsed, "draft");
|
|
2914
|
+
const enablesAppletFollowUps = hasFlag(parsed, "enable-applet-follow-ups");
|
|
2915
|
+
const disablesAppletFollowUps = hasFlag(parsed, "disable-applet-follow-ups");
|
|
2916
|
+
if (draftSlug && enablesAppletFollowUps === disablesAppletFollowUps) {
|
|
2917
|
+
throw new CliError("invalid_arguments", "A module-backed goal requires exactly one of --enable-applet-follow-ups or --disable-applet-follow-ups.");
|
|
2918
|
+
}
|
|
2919
|
+
if (!draftSlug && (enablesAppletFollowUps || disablesAppletFollowUps)) {
|
|
2920
|
+
throw new CliError("invalid_arguments", "Applet follow-up flags are used with --draft for direct module-backed goals.");
|
|
2921
|
+
}
|
|
2922
|
+
if (draftSlug) {
|
|
2923
|
+
const body = {
|
|
2924
|
+
studentUserId: userId,
|
|
2925
|
+
draftSlug,
|
|
2926
|
+
title,
|
|
2927
|
+
description,
|
|
2928
|
+
enableAppletFollowUps: enablesAppletFollowUps,
|
|
2929
|
+
...(targetDate ? { targetDate } : {}),
|
|
2930
|
+
...(schedule ? { schedule } : {}),
|
|
2931
|
+
};
|
|
2932
|
+
const preflight = unwrap(await api.client.POST("/tutor/browser/mesa/goals", {
|
|
2933
|
+
body: { ...body, dryRun: true },
|
|
2934
|
+
}));
|
|
2935
|
+
if (preflight.action !== "preview_module_goal_create") {
|
|
2936
|
+
throw new CliError("unexpected_response", "Direct module goal creation did not return a preview; no goal was created.");
|
|
2937
|
+
}
|
|
2938
|
+
const preview = {
|
|
2939
|
+
action: "create a module-backed goal directly from a validated draft workspace",
|
|
2940
|
+
target: preflight.target,
|
|
2941
|
+
request: {
|
|
2942
|
+
title,
|
|
2943
|
+
descriptionChars: description.length,
|
|
2944
|
+
descriptionSha256: createHash("sha256")
|
|
2945
|
+
.update(description)
|
|
2946
|
+
.digest("hex"),
|
|
2947
|
+
targetDate: targetDate ?? null,
|
|
2948
|
+
schedule: schedule ?? null,
|
|
2949
|
+
enableAppletFollowUps: enablesAppletFollowUps,
|
|
2950
|
+
},
|
|
2951
|
+
details: {
|
|
2952
|
+
currentChangeId: preflight.currentChangeId,
|
|
2953
|
+
workspaceSha256: preflight.workspaceSha256,
|
|
2954
|
+
fileCount: preflight.fileCount,
|
|
2955
|
+
sizeBytes: preflight.sizeBytes,
|
|
2956
|
+
modules: preflight.modules,
|
|
2957
|
+
warnings: preflight.warnings,
|
|
2958
|
+
currentGoalCount: preflight.currentGoalCount,
|
|
2959
|
+
goalLimit: preflight.goalLimit,
|
|
2960
|
+
note: "Creates a personal module-backed goal directly. It does not create, capture, or apply a reusable template. After success, Recess removes the draft only when no newer Mesa edit has landed; otherwise it preserves the draft.",
|
|
2961
|
+
},
|
|
2962
|
+
};
|
|
2963
|
+
requirePreviewBoundConfirmation(parsed, preview);
|
|
2964
|
+
return unwrap(await api.client.POST("/tutor/browser/mesa/goals", {
|
|
2965
|
+
body: {
|
|
2966
|
+
...body,
|
|
2967
|
+
dryRun: false,
|
|
2968
|
+
expectedWorkspaceChangeId: preflight.currentChangeId,
|
|
2969
|
+
},
|
|
2970
|
+
}));
|
|
2971
|
+
}
|
|
2765
2972
|
const body = {
|
|
2766
2973
|
title,
|
|
2767
2974
|
description,
|
|
@@ -2778,17 +2985,351 @@ export async function runCommand(argv) {
|
|
|
2778
2985
|
schedule: schedule ?? null,
|
|
2779
2986
|
},
|
|
2780
2987
|
details: {
|
|
2781
|
-
note: "Creates a description-only goal with no
|
|
2988
|
+
note: "Creates a description-only goal with no modules or course workspace. Pass --draft to create a personal module-backed goal directly. A 409 GOAL_LIMIT_REACHED means the kid is at capacity and nothing was created.",
|
|
2782
2989
|
},
|
|
2783
|
-
}, async () => unwrap(await api.client.POST("/
|
|
2990
|
+
}, async () => unwrap(await api.client.POST("/tutor/browser/students/{userId}/goals/", {
|
|
2784
2991
|
params: { path: { userId } },
|
|
2785
2992
|
body,
|
|
2786
2993
|
})));
|
|
2787
2994
|
}
|
|
2788
|
-
|
|
2995
|
+
if (verb === "edit") {
|
|
2996
|
+
const goalId = positional(parsed, 2, "goal ID");
|
|
2997
|
+
const studentId = flagString(parsed, "student", { required: true });
|
|
2998
|
+
const patchFile = flagString(parsed, "patch-file", { required: true });
|
|
2999
|
+
const patch = (await readJsonFile(patchFile, "Goal patch file"));
|
|
3000
|
+
patch.delta = flagString(parsed, "delta", { required: true });
|
|
3001
|
+
const current = unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
|
|
3002
|
+
params: { path: { userId: studentId } },
|
|
3003
|
+
})).goals.find((goal) => goal.id === goalId);
|
|
3004
|
+
if (!current) {
|
|
3005
|
+
throw new CliError("not_found", `Goal ${goalId} was not found for student ${studentId}.`);
|
|
3006
|
+
}
|
|
3007
|
+
patch.expectedUpdatedAt = current.updatedAt;
|
|
3008
|
+
const preview = {
|
|
3009
|
+
action: "edit a goal for a managed student",
|
|
3010
|
+
target: { goalId, studentUserId: studentId },
|
|
3011
|
+
request: {
|
|
3012
|
+
patchFile: path.resolve(patchFile),
|
|
3013
|
+
expectedUpdatedAt: current.updatedAt,
|
|
3014
|
+
patch,
|
|
3015
|
+
},
|
|
3016
|
+
};
|
|
3017
|
+
requirePreviewBoundConfirmation(parsed, preview);
|
|
3018
|
+
return unwrap(await api.client.PATCH("/tutor/browser/students/goals/{goalId}/", {
|
|
3019
|
+
params: { path: { goalId } },
|
|
3020
|
+
body: patch,
|
|
3021
|
+
}));
|
|
3022
|
+
}
|
|
3023
|
+
throw new CliError("invalid_arguments", "Use goals list|create|edit|files|pdf.");
|
|
3024
|
+
}
|
|
3025
|
+
if (noun === "students") {
|
|
3026
|
+
if (verb === "list") {
|
|
3027
|
+
return unwrap(await api.client.GET("/family/kids", {
|
|
3028
|
+
params: { query: { includeSelf: "false" } },
|
|
3029
|
+
}));
|
|
3030
|
+
}
|
|
3031
|
+
if (verb === "today") {
|
|
3032
|
+
const kidId = flagString(parsed, "student", { required: true });
|
|
3033
|
+
const date = flagString(parsed, "date");
|
|
3034
|
+
return unwrap(await api.client.GET("/parent/kid/{kidId}/today/", {
|
|
3035
|
+
params: { path: { kidId }, query: date ? { date } : {} },
|
|
3036
|
+
}));
|
|
3037
|
+
}
|
|
3038
|
+
if (verb === "schedule") {
|
|
3039
|
+
const studentId = flagString(parsed, "student", { required: true });
|
|
3040
|
+
const days = flagNumber(parsed, "days");
|
|
3041
|
+
if (days !== undefined &&
|
|
3042
|
+
(!Number.isInteger(days) || days < 1 || days > 60)) {
|
|
3043
|
+
throw new CliError("invalid_arguments", "--days must be an integer from 1 to 60.");
|
|
3044
|
+
}
|
|
3045
|
+
return unwrap(await api.client.GET("/tutor/students/{studentId}/schedule", {
|
|
3046
|
+
params: {
|
|
3047
|
+
path: { studentId },
|
|
3048
|
+
query: days === undefined ? {} : { days },
|
|
3049
|
+
},
|
|
3050
|
+
}));
|
|
3051
|
+
}
|
|
3052
|
+
if (verb === "xp-history") {
|
|
3053
|
+
const kidId = flagString(parsed, "student", { required: true });
|
|
3054
|
+
const range = flagString(parsed, "range");
|
|
3055
|
+
const parsedRange = range
|
|
3056
|
+
? assertChoice(range, ["week", "month", "quarter", "year"], "--range")
|
|
3057
|
+
: undefined;
|
|
3058
|
+
return unwrap(await api.client.GET("/parent/kid/{kidId}/xp-history/", {
|
|
3059
|
+
params: {
|
|
3060
|
+
path: { kidId },
|
|
3061
|
+
query: parsedRange ? { range: parsedRange } : {},
|
|
3062
|
+
},
|
|
3063
|
+
}));
|
|
3064
|
+
}
|
|
3065
|
+
throw new CliError("invalid_arguments", "Use students list|today|schedule|xp-history|upload-map-scores.");
|
|
3066
|
+
}
|
|
3067
|
+
if (noun === "todos") {
|
|
3068
|
+
if (verb === "create") {
|
|
3069
|
+
const studentId = flagString(parsed, "student", { required: true });
|
|
3070
|
+
const title = flagString(parsed, "title", { required: true });
|
|
3071
|
+
const estimatedTimeInMins = flagNumber(parsed, "estimated-minutes");
|
|
3072
|
+
const body = {
|
|
3073
|
+
userId: studentId,
|
|
3074
|
+
title,
|
|
3075
|
+
...(flagString(parsed, "due-date")
|
|
3076
|
+
? { dueDate: flagString(parsed, "due-date") }
|
|
3077
|
+
: {}),
|
|
3078
|
+
...(flagString(parsed, "url")
|
|
3079
|
+
? { url: flagString(parsed, "url") }
|
|
3080
|
+
: {}),
|
|
3081
|
+
...(estimatedTimeInMins !== undefined ? { estimatedTimeInMins } : {}),
|
|
3082
|
+
};
|
|
3083
|
+
return writeCommand(parsed, {
|
|
3084
|
+
action: "create a todo for a managed student",
|
|
3085
|
+
target: { studentUserId: studentId },
|
|
3086
|
+
request: body,
|
|
3087
|
+
}, async () => unwrap(await api.client.POST("/tutor/browser/todos/", { body })));
|
|
3088
|
+
}
|
|
3089
|
+
if (verb === "edit") {
|
|
3090
|
+
const todoId = positional(parsed, 2, "todo ID");
|
|
3091
|
+
const patchFile = flagString(parsed, "patch-file", { required: true });
|
|
3092
|
+
const body = (await readJsonFile(patchFile, "Todo patch file"));
|
|
3093
|
+
const current = unwrap(await api.client.GET("/tutor/browser/todos/{id}/", {
|
|
3094
|
+
params: { path: { id: todoId } },
|
|
3095
|
+
}));
|
|
3096
|
+
body.expectedUpdatedAt = current.updatedAt;
|
|
3097
|
+
const preview = {
|
|
3098
|
+
action: "edit a todo for a managed student",
|
|
3099
|
+
target: { todoId },
|
|
3100
|
+
request: {
|
|
3101
|
+
patchFile: path.resolve(patchFile),
|
|
3102
|
+
expectedUpdatedAt: current.updatedAt,
|
|
3103
|
+
patch: body,
|
|
3104
|
+
},
|
|
3105
|
+
};
|
|
3106
|
+
requirePreviewBoundConfirmation(parsed, preview);
|
|
3107
|
+
return unwrap(await api.client.PATCH("/tutor/browser/todos/{id}", {
|
|
3108
|
+
params: { path: { id: todoId } },
|
|
3109
|
+
body,
|
|
3110
|
+
}));
|
|
3111
|
+
}
|
|
3112
|
+
throw new CliError("invalid_arguments", "Use todos create|edit.");
|
|
3113
|
+
}
|
|
3114
|
+
if (noun === "memories") {
|
|
3115
|
+
const studentId = flagString(parsed, "student", { required: true });
|
|
3116
|
+
if (verb === "context") {
|
|
3117
|
+
return unwrap(await api.client.GET("/tutor/students/{studentId}/prompt-memory", {
|
|
3118
|
+
params: { path: { studentId } },
|
|
3119
|
+
}));
|
|
3120
|
+
}
|
|
3121
|
+
if (verb === "log") {
|
|
3122
|
+
const date = flagString(parsed, "date", { required: true });
|
|
3123
|
+
return unwrap(await api.client.GET("/tutor/students/{studentId}/prompt-memory/log", {
|
|
3124
|
+
params: { path: { studentId }, query: { date } },
|
|
3125
|
+
}));
|
|
3126
|
+
}
|
|
3127
|
+
throw new CliError("invalid_arguments", "Use memories context|log.");
|
|
3128
|
+
}
|
|
3129
|
+
if (noun === "rocky") {
|
|
3130
|
+
const studentId = flagString(parsed, "student", { required: true });
|
|
3131
|
+
if (verb === "get") {
|
|
3132
|
+
return unwrap(await api.client.GET("/tutor/students/{studentId}/rocky-config", {
|
|
3133
|
+
params: { path: { studentId } },
|
|
3134
|
+
}));
|
|
3135
|
+
}
|
|
3136
|
+
if (verb === "set") {
|
|
3137
|
+
const patchFile = flagString(parsed, "patch-file", { required: true });
|
|
3138
|
+
const patch = (await readJsonFile(patchFile, "Rocky config patch file"));
|
|
3139
|
+
const expected = flagString(parsed, "expected-updated-at", {
|
|
3140
|
+
required: true,
|
|
3141
|
+
});
|
|
3142
|
+
const expectedUpdatedAt = expected === "none" ? null : expected;
|
|
3143
|
+
if (expectedUpdatedAt !== null &&
|
|
3144
|
+
Number.isNaN(Date.parse(expectedUpdatedAt))) {
|
|
3145
|
+
throw new CliError("invalid_arguments", "--expected-updated-at must be an ISO datetime from `rocky get`, or `none`.");
|
|
3146
|
+
}
|
|
3147
|
+
return writeCommand(parsed, {
|
|
3148
|
+
action: "change Rocky's configuration for a managed student",
|
|
3149
|
+
target: { studentUserId: studentId },
|
|
3150
|
+
request: {
|
|
3151
|
+
patchFile: path.resolve(patchFile),
|
|
3152
|
+
expectedUpdatedAt,
|
|
3153
|
+
patch,
|
|
3154
|
+
},
|
|
3155
|
+
}, async () => unwrap(await api.client.PATCH("/tutor/students/{studentId}/rocky-config", {
|
|
3156
|
+
params: { path: { studentId } },
|
|
3157
|
+
body: { expectedUpdatedAt, patch },
|
|
3158
|
+
})));
|
|
3159
|
+
}
|
|
3160
|
+
throw new CliError("invalid_arguments", "Use rocky get|set.");
|
|
2789
3161
|
}
|
|
2790
|
-
if (noun === "
|
|
2791
|
-
const action = positional(parsed, 2, "
|
|
3162
|
+
if (noun === "goals" && verb === "pdf") {
|
|
3163
|
+
const action = positional(parsed, 2, "goals pdf action");
|
|
3164
|
+
if (action !== "upload") {
|
|
3165
|
+
throw new CliError("invalid_arguments", "Use goals pdf upload.");
|
|
3166
|
+
}
|
|
3167
|
+
const studentId = flagString(parsed, "student", { required: true });
|
|
3168
|
+
const goalId = flagString(parsed, "goal");
|
|
3169
|
+
const draftSlug = flagString(parsed, "draft");
|
|
3170
|
+
if (Boolean(goalId) === Boolean(draftSlug)) {
|
|
3171
|
+
throw new CliError("invalid_arguments", "Pass exactly one of --goal or --draft for goals pdf upload.");
|
|
3172
|
+
}
|
|
3173
|
+
const sourceFile = flagString(parsed, "source-file", { required: true });
|
|
3174
|
+
const absolutePath = path.resolve(sourceFile);
|
|
3175
|
+
const fileStat = await fs.lstat(absolutePath).catch(() => null);
|
|
3176
|
+
if (!fileStat?.isFile() || fileStat.isSymbolicLink()) {
|
|
3177
|
+
throw new CliError("invalid_arguments", `--source-file is not a file: ${absolutePath}`);
|
|
3178
|
+
}
|
|
3179
|
+
if (!absolutePath.toLowerCase().endsWith(".pdf")) {
|
|
3180
|
+
throw new CliError("invalid_arguments", "goals pdf upload accepts PDF files only.");
|
|
3181
|
+
}
|
|
3182
|
+
if (fileStat.size <= 0 || fileStat.size > GOAL_PDF_UPLOAD_MAX_BYTES) {
|
|
3183
|
+
throw new CliError("invalid_arguments", `PDF size must be between 1 byte and ${GOAL_PDF_UPLOAD_MAX_BYTES} bytes.`);
|
|
3184
|
+
}
|
|
3185
|
+
const fileName = path.basename(absolutePath);
|
|
3186
|
+
const workspacePath = flagString(parsed, "path") ?? `uploads/${safeGoalUploadName(fileName)}`;
|
|
3187
|
+
if (!workspacePath.startsWith("uploads/")) {
|
|
3188
|
+
throw new CliError("invalid_arguments", "Textbook PDFs must be attached under uploads/.");
|
|
3189
|
+
}
|
|
3190
|
+
const sourceSha256 = await sha256File(absolutePath);
|
|
3191
|
+
const target = goalId
|
|
3192
|
+
? { kind: "goal", goalId }
|
|
3193
|
+
: { kind: "draft", draftSlug: draftSlug };
|
|
3194
|
+
const message = flagString(parsed, "message") ??
|
|
3195
|
+
`recess-cli: attach textbook PDF ${fileName}`;
|
|
3196
|
+
const placeholderManifest = JSON.stringify({
|
|
3197
|
+
kind: "pending-pdf-upload",
|
|
3198
|
+
fileName,
|
|
3199
|
+
contentType: "application/pdf",
|
|
3200
|
+
sizeBytes: fileStat.size,
|
|
3201
|
+
});
|
|
3202
|
+
const body = {
|
|
3203
|
+
studentUserId: studentId,
|
|
3204
|
+
target,
|
|
3205
|
+
message,
|
|
3206
|
+
files: [
|
|
3207
|
+
{
|
|
3208
|
+
path: workspacePath,
|
|
3209
|
+
content: placeholderManifest,
|
|
3210
|
+
contentEncoding: "utf8",
|
|
3211
|
+
},
|
|
3212
|
+
],
|
|
3213
|
+
};
|
|
3214
|
+
const preflight = unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
|
|
3215
|
+
body: { ...body, dryRun: true },
|
|
3216
|
+
}));
|
|
3217
|
+
if (preflight.action !== "preview_workspace_files_write") {
|
|
3218
|
+
throw new CliError("unexpected_response", "Goal PDF upload did not return a preview; nothing was uploaded.");
|
|
3219
|
+
}
|
|
3220
|
+
requirePreviewBoundConfirmation(parsed, {
|
|
3221
|
+
action: "upload a textbook PDF and attach it to a student's goal",
|
|
3222
|
+
target: preflight.target,
|
|
3223
|
+
request: {
|
|
3224
|
+
sourceFile: absolutePath,
|
|
3225
|
+
sourceSha256,
|
|
3226
|
+
sizeBytes: fileStat.size,
|
|
3227
|
+
workspacePath,
|
|
3228
|
+
message,
|
|
3229
|
+
},
|
|
3230
|
+
details: {
|
|
3231
|
+
currentChangeId: preflight.currentChangeId,
|
|
3232
|
+
note: "The PDF is stored separately and the goal workspace receives a small verified reference, matching planner-authored textbook goals.",
|
|
3233
|
+
},
|
|
3234
|
+
});
|
|
3235
|
+
const conversationId = randomUUID();
|
|
3236
|
+
const signed = unwrap(await api.client.POST("/os-v2-mesa/planner/uploads/sign", {
|
|
3237
|
+
body: {
|
|
3238
|
+
conversationId,
|
|
3239
|
+
studentUserId: studentId,
|
|
3240
|
+
fileName,
|
|
3241
|
+
contentType: "application/pdf",
|
|
3242
|
+
sizeBytes: fileStat.size,
|
|
3243
|
+
},
|
|
3244
|
+
}));
|
|
3245
|
+
let uploaded;
|
|
3246
|
+
try {
|
|
3247
|
+
const uploadedSource = await uploadPdfToSignedUrls(absolutePath, "application/pdf", signed);
|
|
3248
|
+
if (uploadedSource.sha256 !== sourceSha256 ||
|
|
3249
|
+
uploadedSource.sizeBytes !== fileStat.size) {
|
|
3250
|
+
throw new CliError("source_changed", "The source PDF changed after approval. The upload was discarded; preview the current file again.");
|
|
3251
|
+
}
|
|
3252
|
+
uploaded =
|
|
3253
|
+
signed.kind === "multipart"
|
|
3254
|
+
? unwrap(await api.client.POST("/os-v2-mesa/planner/uploads/complete", {
|
|
3255
|
+
body: {
|
|
3256
|
+
conversationId,
|
|
3257
|
+
fileName,
|
|
3258
|
+
contentType: "application/pdf",
|
|
3259
|
+
key: signed.key,
|
|
3260
|
+
uploadId: signed.uploadId,
|
|
3261
|
+
sizeBytes: fileStat.size,
|
|
3262
|
+
},
|
|
3263
|
+
}))
|
|
3264
|
+
: unwrap(await api.client.POST("/os-v2-mesa/planner/uploads/verify", {
|
|
3265
|
+
body: {
|
|
3266
|
+
conversationId,
|
|
3267
|
+
fileName,
|
|
3268
|
+
contentType: "application/pdf",
|
|
3269
|
+
key: signed.key,
|
|
3270
|
+
sizeBytes: fileStat.size,
|
|
3271
|
+
},
|
|
3272
|
+
}));
|
|
3273
|
+
}
|
|
3274
|
+
catch (error) {
|
|
3275
|
+
// A failed multipart complete may already have materialized the object,
|
|
3276
|
+
// so attempt both abort and delete. These are compensation attempts; the
|
|
3277
|
+
// original upload error remains authoritative.
|
|
3278
|
+
await cleanupPlannerUpload(api, conversationId, signed, true);
|
|
3279
|
+
throw error;
|
|
3280
|
+
}
|
|
3281
|
+
const manifest = JSON.stringify({
|
|
3282
|
+
kind: "r2-pdf",
|
|
3283
|
+
version: 2,
|
|
3284
|
+
r2Key: uploaded.key,
|
|
3285
|
+
signature: uploaded.manifestSignature,
|
|
3286
|
+
fileName,
|
|
3287
|
+
contentType: uploaded.contentType,
|
|
3288
|
+
sizeBytes: uploaded.sizeBytes,
|
|
3289
|
+
});
|
|
3290
|
+
let workspace;
|
|
3291
|
+
try {
|
|
3292
|
+
workspace = unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
|
|
3293
|
+
body: {
|
|
3294
|
+
...body,
|
|
3295
|
+
files: [
|
|
3296
|
+
{
|
|
3297
|
+
path: workspacePath,
|
|
3298
|
+
content: manifest,
|
|
3299
|
+
contentEncoding: "utf8",
|
|
3300
|
+
},
|
|
3301
|
+
],
|
|
3302
|
+
dryRun: false,
|
|
3303
|
+
expectedChangeId: preflight.currentChangeId,
|
|
3304
|
+
},
|
|
3305
|
+
}));
|
|
3306
|
+
}
|
|
3307
|
+
catch (error) {
|
|
3308
|
+
// Only a returned 4xx from this route is a definitive no-write response.
|
|
3309
|
+
// A transport/5xx failure is ambiguous: the workspace commit may have landed,
|
|
3310
|
+
// so keep the object rather than creating a dangling manifest.
|
|
3311
|
+
const status = error instanceof CliError &&
|
|
3312
|
+
error.details &&
|
|
3313
|
+
typeof error.details === "object" &&
|
|
3314
|
+
"status" in error.details
|
|
3315
|
+
? Number(error.details.status)
|
|
3316
|
+
: 0;
|
|
3317
|
+
if (status >= 400 && status < 500) {
|
|
3318
|
+
await cleanupPlannerUpload(api, conversationId, signed, true);
|
|
3319
|
+
}
|
|
3320
|
+
throw error;
|
|
3321
|
+
}
|
|
3322
|
+
return {
|
|
3323
|
+
action: "upload_textbook_pdf",
|
|
3324
|
+
sourceSha256,
|
|
3325
|
+
fileName,
|
|
3326
|
+
sizeBytes: uploaded.sizeBytes,
|
|
3327
|
+
workspacePath,
|
|
3328
|
+
workspace,
|
|
3329
|
+
};
|
|
3330
|
+
}
|
|
3331
|
+
if (noun === "goals" && verb === "files") {
|
|
3332
|
+
const action = positional(parsed, 2, "goals files action");
|
|
2792
3333
|
const studentId = flagString(parsed, "student", { required: true });
|
|
2793
3334
|
if (action === "list") {
|
|
2794
3335
|
const goalId = flagString(parsed, "goal", { required: true });
|
|
@@ -2807,9 +3348,9 @@ export async function runCommand(argv) {
|
|
|
2807
3348
|
const goalId = flagString(parsed, "goal");
|
|
2808
3349
|
const draftSlug = flagString(parsed, "draft");
|
|
2809
3350
|
if (Boolean(goalId) === Boolean(draftSlug)) {
|
|
2810
|
-
throw new CliError("invalid_arguments", "Pass exactly one of --goal or --draft for
|
|
3351
|
+
throw new CliError("invalid_arguments", "Pass exactly one of --goal or --draft for goals files write.");
|
|
2811
3352
|
}
|
|
2812
|
-
const input = await
|
|
3353
|
+
const input = await readGoalWorkspaceWriteSource(parsed);
|
|
2813
3354
|
const target = goalId
|
|
2814
3355
|
? { kind: "goal", goalId }
|
|
2815
3356
|
: { kind: "draft", draftSlug: draftSlug };
|
|
@@ -2821,14 +3362,14 @@ export async function runCommand(argv) {
|
|
|
2821
3362
|
message,
|
|
2822
3363
|
files: input.files,
|
|
2823
3364
|
};
|
|
2824
|
-
const preflight = unwrap(await api.client.POST("/
|
|
3365
|
+
const preflight = unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
|
|
2825
3366
|
body: { ...body, dryRun: true },
|
|
2826
3367
|
}));
|
|
2827
3368
|
if (preflight.action !== "preview_workspace_files_write") {
|
|
2828
|
-
throw new CliError("unexpected_response", "
|
|
3369
|
+
throw new CliError("unexpected_response", "Goal workspace write did not return a preview; nothing was written.");
|
|
2829
3370
|
}
|
|
2830
3371
|
const preview = {
|
|
2831
|
-
action: "batch-upsert files in a student's
|
|
3372
|
+
action: "batch-upsert files in a student's goal workspace",
|
|
2832
3373
|
target: preflight.target,
|
|
2833
3374
|
request: {
|
|
2834
3375
|
source: input.source,
|
|
@@ -2845,8 +3386,8 @@ export async function runCommand(argv) {
|
|
|
2845
3386
|
: "Live goal modules/ and state/ are blocked here because they have database projections; use a draft + template capture for structural course changes.",
|
|
2846
3387
|
},
|
|
2847
3388
|
};
|
|
2848
|
-
|
|
2849
|
-
return unwrap(await api.client.POST("/
|
|
3389
|
+
requirePreviewBoundConfirmation(parsed, preview);
|
|
3390
|
+
return unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
|
|
2850
3391
|
body: {
|
|
2851
3392
|
...body,
|
|
2852
3393
|
dryRun: false,
|
|
@@ -2854,7 +3395,7 @@ export async function runCommand(argv) {
|
|
|
2854
3395
|
},
|
|
2855
3396
|
}));
|
|
2856
3397
|
}
|
|
2857
|
-
throw new CliError("invalid_arguments", "Use
|
|
3398
|
+
throw new CliError("invalid_arguments", "Use goals files list|read|write.");
|
|
2858
3399
|
}
|
|
2859
3400
|
if (noun === "request" && verb === "get") {
|
|
2860
3401
|
return api.rawGet(positional(parsed, 2, "request path"));
|