recess-cli 1.9.0 → 1.9.2

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 CHANGED
@@ -197,13 +197,16 @@ recess --json students schedule --student <kid-id> --days 30
197
197
  recess --json students xp-history --student <kid-id> --range month
198
198
  recess --json goals list --student <kid-id>
199
199
  recess --json todos create --student <kid-id> --title "Read chapter 4"
200
+ recess --json todos generate-applet <todo-id> --student <kid-id> [--due-date YYYY-MM-DD]
200
201
  recess --json memories context --student <kid-id>
201
202
  recess --json memories log --student <kid-id> --date 2026-08-12
202
203
  recess --json rocky get --student <kid-id>
203
204
  ```
204
205
 
205
206
  All family writes still preview first. Goal/todo/Rocky edits also carry the current server version
206
- into the confirmed request. A guardian cannot target another family, inspect frozen/deleted
207
+ into the confirmed request. Applet generation is staff-only: it resolves the todo's latest learning
208
+ analysis, defaults the generated todo to tomorrow in the student's timezone, and lets the server
209
+ select v1 or v2 for that student. A guardian cannot target another family, inspect frozen/deleted
207
210
  template history, choose todo rewards/completion/internal fields, or use the ADMIN-only
208
211
  device-authorization flow. `memories context` and `memories log` read only the tutor repository's
209
212
  spine, rules, reminders, and session logs; private guide remarks are never returned. The CLI does
package/dist/auth.js CHANGED
@@ -4,12 +4,16 @@ import { randomBytes } from "node:crypto";
4
4
  import { clearPendingDeviceAuth, updateStoredConfig } from "./config.js";
5
5
  import { apiError, CliError } from "./errors.js";
6
6
  function openBrowser(url) {
7
+ // Windows goes through rundll32, not `cmd /c start`: cmd treats `&` (and the
8
+ // `%`-encoded redirect_uri) as metacharacters and chops the URL at the first
9
+ // `&`, so the browser only ever received `?client_id=…` and the OAuth page
10
+ // rejected it. rundll32 takes the URL as a single, unparsed argument.
7
11
  const command = process.platform === "darwin"
8
12
  ? "open"
9
13
  : process.platform === "win32"
10
- ? "cmd"
14
+ ? "rundll32"
11
15
  : "xdg-open";
12
- const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
16
+ const args = process.platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
13
17
  const child = spawn(command, args, { detached: true, stdio: "ignore" });
14
18
  child.unref();
15
19
  }
package/dist/cli.js CHANGED
@@ -176,10 +176,16 @@ Usage:
176
176
  [--confirm --approval-token TOKEN]
177
177
  recess [--json] goals edit <goal-id> --student <kid-id> --patch-file <path/patch.json>
178
178
  --delta TEXT [--confirm --approval-token TOKEN]
179
+ recess [--json] goals queue get <goal-id> --student <kid-id>
180
+ recess [--json] goals queue set <goal-id> --student <kid-id>
181
+ --entries-file <path.json> --delta TEXT [--replace-description-pointer]
182
+ [--confirm --approval-token TOKEN]
179
183
  recess [--json] todos create --student <kid-id> --title TEXT
180
184
  [--due-date YYYY-MM-DD] [--estimated-minutes N] [--url URL] [--confirm]
181
185
  recess [--json] todos edit <todo-id> --patch-file <path/patch.json>
182
186
  [--confirm --approval-token TOKEN]
187
+ recess [--json] todos generate-applet <todo-id> --student <kid-id>
188
+ [--due-date YYYY-MM-DD] [--confirm --approval-token TOKEN]
183
189
  recess [--json] memories context --student <kid-id>
184
190
  recess [--json] memories log --student <kid-id> --date YYYY-MM-DD
185
191
  recess [--json] rocky get --student <kid-id>
@@ -366,6 +372,8 @@ async function writeCommand(parsed, preview, execute) {
366
372
  const GOAL_WORKSPACE_WRITE_MAX_FILES = 1_000;
367
373
  const GOAL_WORKSPACE_WRITE_MAX_TOTAL_BYTES = 20 * 1024 * 1024;
368
374
  const GOAL_PDF_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024;
375
+ const GOAL_QUEUE_MAX_ENTRIES = 500;
376
+ const URL_QUEUE_DESCRIPTION_POINTER = "Skill queue managed by the system.";
369
377
  function approvalTokenFor(preview) {
370
378
  return createHash("sha256").update(JSON.stringify(preview)).digest("hex");
371
379
  }
@@ -691,6 +699,61 @@ async function readJsonFile(filePath, label) {
691
699
  }
692
700
  return parsed;
693
701
  }
702
+ function parseGoalQueueEntries(value, label) {
703
+ if (!Array.isArray(value)) {
704
+ throw new CliError("invalid_arguments", `${label} must be a JSON array of queue entries.`);
705
+ }
706
+ if (value.length > GOAL_QUEUE_MAX_ENTRIES) {
707
+ throw new CliError("invalid_arguments", `${label} has ${value.length} entries; the maximum is ${GOAL_QUEUE_MAX_ENTRIES}.`);
708
+ }
709
+ return value.map((raw, index) => parseGoalQueueEntry(raw, `${label}[${index}]`));
710
+ }
711
+ function parseGoalQueueEntry(value, label) {
712
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
713
+ throw new CliError("invalid_arguments", `${label} must be an object with title and url.`);
714
+ }
715
+ const record = value;
716
+ const { title, url } = record;
717
+ if (typeof title !== "string" || !title.trim()) {
718
+ throw new CliError("invalid_arguments", `${label}.title is required.`);
719
+ }
720
+ if (typeof url !== "string" || !/^https:\/\//i.test(url.trim())) {
721
+ throw new CliError("invalid_arguments", `${label}.url must be an https URL.`);
722
+ }
723
+ const entry = { title: title.trim(), url: url.trim() };
724
+ if (record.completed !== undefined) {
725
+ if (typeof record.completed !== "boolean") {
726
+ throw new CliError("invalid_arguments", `${label}.completed must be a boolean.`);
727
+ }
728
+ entry.completed = record.completed;
729
+ }
730
+ if (record.sourceMeta !== undefined) {
731
+ if (!record.sourceMeta ||
732
+ typeof record.sourceMeta !== "object" ||
733
+ Array.isArray(record.sourceMeta)) {
734
+ throw new CliError("invalid_arguments", `${label}.sourceMeta must be an object.`);
735
+ }
736
+ const meta = record.sourceMeta;
737
+ const sourceMeta = {};
738
+ for (const key of [
739
+ "permacode",
740
+ "skillId",
741
+ "section",
742
+ "subjectKey",
743
+ "planKey",
744
+ ]) {
745
+ const raw = meta[key];
746
+ if (raw === undefined)
747
+ continue;
748
+ if (typeof raw !== "string") {
749
+ throw new CliError("invalid_arguments", `${label}.sourceMeta.${key} must be a string.`);
750
+ }
751
+ sourceMeta[key] = raw;
752
+ }
753
+ entry.sourceMeta = sourceMeta;
754
+ }
755
+ return entry;
756
+ }
694
757
  function contentLibrarySubmitItem(value, label) {
695
758
  const record = typeof value === "string"
696
759
  ? { url: value }
@@ -1175,6 +1238,20 @@ function flagItemDateMs(parsed) {
1175
1238
  }
1176
1239
  return ms;
1177
1240
  }
1241
+ function flagDateOnly(parsed, name) {
1242
+ const raw = flagString(parsed, name);
1243
+ if (raw === undefined)
1244
+ return undefined;
1245
+ const parsedDate = /^\d{4}-\d{2}-\d{2}$/.test(raw)
1246
+ ? new Date(`${raw}T00:00:00.000Z`)
1247
+ : null;
1248
+ if (!parsedDate ||
1249
+ Number.isNaN(parsedDate.getTime()) ||
1250
+ parsedDate.toISOString().slice(0, 10) !== raw) {
1251
+ throw new CliError("invalid_arguments", `--${name} must be a valid YYYY-MM-DD date.`);
1252
+ }
1253
+ return raw;
1254
+ }
1178
1255
  const DAY_MS = 24 * 60 * 60 * 1000;
1179
1256
  // The cohort event routes take zoneless local wall-clock datetimes
1180
1257
  // (interpreted in the cohort's timezone server-side).
@@ -3321,7 +3398,72 @@ export async function runCommand(argv) {
3321
3398
  body: patch,
3322
3399
  }));
3323
3400
  }
3324
- throw new CliError("invalid_arguments", "Use goals list|create|edit|files|pdf.");
3401
+ if (verb === "queue") {
3402
+ const subverb = positional(parsed, 2, "queue action (get|set)");
3403
+ const goalId = positional(parsed, 3, "goal ID");
3404
+ const studentId = flagString(parsed, "student", { required: true });
3405
+ if (subverb === "get") {
3406
+ return unwrap(await api.client.GET("/tutor/browser/students/goals/{goalId}/queue/", { params: { path: { goalId } } }));
3407
+ }
3408
+ if (subverb === "set") {
3409
+ const entriesFile = flagString(parsed, "entries-file", {
3410
+ required: true,
3411
+ });
3412
+ const delta = flagString(parsed, "delta", { required: true });
3413
+ const replaceDescriptionPointer = hasFlag(parsed, "replace-description-pointer");
3414
+ const { absolutePath, parsed: rawEntries } = await readJsonValue(entriesFile, "Queue entries file");
3415
+ const entries = parseGoalQueueEntries(rawEntries, "Queue entries file");
3416
+ const goal = unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
3417
+ params: { path: { userId: studentId } },
3418
+ })).goals.find((candidate) => candidate.id === goalId);
3419
+ if (!goal) {
3420
+ throw new CliError("not_found", `Goal ${goalId} was not found for student ${studentId}.`);
3421
+ }
3422
+ const current = unwrap(await api.client.GET("/tutor/browser/students/goals/{goalId}/queue/", { params: { path: { goalId } } }));
3423
+ const currentUrls = new Set(current.queue.map((item) => item.url));
3424
+ const proposedUrls = new Set(entries.map((entry) => entry.url));
3425
+ const preview = {
3426
+ action: "replace a goal's URL skill queue for a managed student",
3427
+ target: { goalId, studentUserId: studentId },
3428
+ request: {
3429
+ entriesFile: absolutePath,
3430
+ entryCount: entries.length,
3431
+ delta,
3432
+ replaceDescriptionPointer,
3433
+ expectedUpdatedAt: goal.updatedAt,
3434
+ },
3435
+ details: {
3436
+ currentQueue: current.queue.map((item) => ({
3437
+ moduleRef: item.moduleRef,
3438
+ url: item.url,
3439
+ completedAt: item.completedAt,
3440
+ })),
3441
+ added: entries
3442
+ .filter((entry) => !currentUrls.has(entry.url))
3443
+ .map((entry) => entry.url),
3444
+ removed: current.queue
3445
+ .filter((item) => !proposedUrls.has(item.url))
3446
+ .map((item) => item.url),
3447
+ note: "Replaces the entire EXTERNAL_URL queue. Completion is preserved for unchanged URLs; WORKSPACE modules are untouched. Daily generation mints the head entry for a flag-on kid.",
3448
+ },
3449
+ };
3450
+ requirePreviewBoundConfirmation(parsed, preview);
3451
+ const body = {
3452
+ delta,
3453
+ expectedUpdatedAt: goal.updatedAt,
3454
+ queue: entries,
3455
+ ...(replaceDescriptionPointer
3456
+ ? { description: URL_QUEUE_DESCRIPTION_POINTER }
3457
+ : {}),
3458
+ };
3459
+ return unwrap(await api.client.PATCH("/tutor/browser/students/goals/{goalId}/", {
3460
+ params: { path: { goalId } },
3461
+ body,
3462
+ }));
3463
+ }
3464
+ throw new CliError("invalid_arguments", "Use goals queue get|set.");
3465
+ }
3466
+ throw new CliError("invalid_arguments", "Use goals list|create|edit|queue|files|pdf.");
3325
3467
  }
3326
3468
  if (noun === "students") {
3327
3469
  if (verb === "list") {
@@ -3423,7 +3565,49 @@ export async function runCommand(argv) {
3423
3565
  body,
3424
3566
  }));
3425
3567
  }
3426
- throw new CliError("invalid_arguments", "Use todos create|edit.");
3568
+ if (verb === "generate-applet") {
3569
+ const todoId = positional(parsed, 2, "todo ID");
3570
+ const studentId = flagString(parsed, "student", { required: true });
3571
+ const targetDueDateISO = flagDateOnly(parsed, "due-date");
3572
+ const [todo, learningAnalysis] = await Promise.all([
3573
+ api.client.GET("/tutor/browser/todos/{id}/", {
3574
+ params: { path: { id: todoId } },
3575
+ }),
3576
+ api.client.GET("/tutor/students/{studentId}/todos/{todoId}/learning-analysis", {
3577
+ params: { path: { studentId, todoId } },
3578
+ }),
3579
+ ]);
3580
+ const currentTodo = unwrap(todo);
3581
+ if (currentTodo.userId !== studentId) {
3582
+ throw new CliError("not_found", "The todo does not belong to the requested student.");
3583
+ }
3584
+ const analysis = unwrap(learningAnalysis).analysis;
3585
+ if (!analysis) {
3586
+ throw new CliError("not_found", "This todo does not have a learning analysis to generate an applet from.");
3587
+ }
3588
+ const preview = {
3589
+ action: "generate an applet from a student's todo analysis",
3590
+ target: {
3591
+ studentUserId: studentId,
3592
+ todoId,
3593
+ todoTitle: currentTodo.title,
3594
+ analysisId: analysis.id,
3595
+ },
3596
+ request: {
3597
+ targetDueDateISO: targetDueDateISO ?? null,
3598
+ },
3599
+ details: {
3600
+ dueDate: targetDueDateISO ?? "tomorrow in the student's timezone",
3601
+ versionRouting: "The server evaluates applet-gen-v2 for this student and uses v1 when the flag is off or its evaluation fails.",
3602
+ },
3603
+ };
3604
+ requirePreviewBoundConfirmation(parsed, preview);
3605
+ return unwrap(await api.client.POST("/admin/learning-pipeline/{analysisId}/generate-applet/", {
3606
+ params: { path: { analysisId: analysis.id } },
3607
+ body: targetDueDateISO ? { targetDueDateISO } : {},
3608
+ }));
3609
+ }
3610
+ throw new CliError("invalid_arguments", "Use todos create|edit|generate-applet.");
3427
3611
  }
3428
3612
  if (noun === "memories") {
3429
3613
  const studentId = flagString(parsed, "student", { required: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "recess-cli",
3
- "version": "1.9.0",
3
+ "version": "1.9.2",
4
4
  "description": "Safe Recess administration and family AI tools from the command line.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {