recess-cli 2.6.1 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -6,9 +6,10 @@ import { RecessAdminApi, unwrap, withIdempotencyContext } from "./api.js";
6
6
  import { flagNumber, flagString, hasFlag, parseArgs, } from "./args.js";
7
7
  import { login, pollDeviceAuth, requestDeviceAuth } from "./auth.js";
8
8
  import { clearStoredSession, deleteProfile, listProfiles, resolveConfig, saveProfile, useProfile, } from "./config.js";
9
- import { agentContext, buildCommandSchema, scopedHelp, validateInvocation, } from "./command-schema.js";
9
+ import { agentContext, buildCommandSchema, findCommandSchema, remoteCommands, scopedHelp, validateInvocation, } from "./command-schema.js";
10
10
  import { runApplicationsCommand } from "./commands/applications.js";
11
11
  import { runAppsCommand } from "./commands/apps.js";
12
+ import { runMasteryCommand } from "./commands/mastery.js";
12
13
  import { runOnboardingCommand } from "./commands/onboarding.js";
13
14
  import { runSchoolCommand } from "./commands/school.js";
14
15
  import { runVillageEventsCommand } from "./commands/village-events.js";
@@ -22,6 +23,7 @@ import { requireConfirmation } from "./safety.js";
22
23
  import { installSkill, isEphemeralInstall, readBundledSkillVersion, readCliVersion, } from "./setup.js";
23
24
  import { compareVersions, updateSkillFromServer } from "./skill-update.js";
24
25
  import { readSkillCache, writeSkillCache } from "./skills-cache.js";
26
+ import { sanitizeWorkspaceUploadName } from "./upload-names.js";
25
27
  import { appendJobEvent, getJob, listJobs, pruneJobs } from "./jobs.js";
26
28
  function requiredRequestReason(parsed) {
27
29
  return requireCliRequestReason(flagString(parsed, "reason", { required: true }));
@@ -36,9 +38,6 @@ function requiredRequestReason(parsed) {
36
38
  * worse than no preview at all.
37
39
  */
38
40
  async function resolveSessionRole(api) {
39
- if (api.config.authSource === "config" && api.config.user?.role) {
40
- return api.config.user.role;
41
- }
42
41
  const session = await api.client.GET("/auth/admin-cli/session/");
43
42
  return session.data?.user.role;
44
43
  }
@@ -232,7 +231,7 @@ async function skillStatus(config, reason) {
232
231
  };
233
232
  }
234
233
  }
235
- async function writeCommand(parsed, preview, execute) {
234
+ async function localWriteCommand(parsed, preview, execute) {
236
235
  const fingerprint = approvalTokenFor(preview);
237
236
  const suppliedOperationKey = flagString(parsed, "operation-key");
238
237
  if (!hasFlag(parsed, "confirm")) {
@@ -252,6 +251,7 @@ async function writeCommand(parsed, preview, execute) {
252
251
  action: preview.action,
253
252
  fingerprint,
254
253
  target: preview.target,
254
+ ...(preview.action.startsWith("mastery.") ? { preview } : {}),
255
255
  });
256
256
  requireConfirmation(false, boundPreview);
257
257
  }
@@ -699,9 +699,6 @@ async function cleanupPlannerUpload(api, conversationId, signed, includeDelete)
699
699
  .catch(() => undefined);
700
700
  }
701
701
  }
702
- function safeGoalUploadName(fileName) {
703
- return fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
704
- }
705
702
  async function sha256File(absolutePath) {
706
703
  const handle = await fs.open(absolutePath, "r");
707
704
  const hash = createHash("sha256");
@@ -917,6 +914,8 @@ const CONTENT_LIBRARY_DISCOVERY_LANES = [
917
914
  "puzzles",
918
915
  "wonder",
919
916
  "idea-games",
917
+ "makers",
918
+ "drills",
920
919
  ];
921
920
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
922
921
  function parseGoalQueueEntries(value, label) {
@@ -1554,13 +1553,86 @@ function tierSlots(parsed) {
1554
1553
  }
1555
1554
  return slots;
1556
1555
  }
1557
- export async function runCommand(argv) {
1556
+ function cliErrorShape(error) {
1557
+ const cliError = error instanceof CliError
1558
+ ? error
1559
+ : new CliError("unexpected_error", error instanceof Error ? error.message : String(error));
1560
+ return {
1561
+ code: cliError.code,
1562
+ message: cliError.message,
1563
+ ...(cliError.details === undefined ? {} : { details: cliError.details }),
1564
+ };
1565
+ }
1566
+ function remoteWriteCommand(argv, structuredInput, store) {
1567
+ return async (parsed, preview, execute) => {
1568
+ const fingerprint = approvalTokenFor({
1569
+ ...preview,
1570
+ details: {
1571
+ ...preview.details,
1572
+ command: parsed.positionals.join(" "),
1573
+ },
1574
+ });
1575
+ const suppliedOperationKey = flagString(parsed, "operation-key");
1576
+ if (!hasFlag(parsed, "confirm")) {
1577
+ const operationKey = await store.stage({
1578
+ argv,
1579
+ structuredInput,
1580
+ preview,
1581
+ fingerprint,
1582
+ });
1583
+ requireConfirmation(false, {
1584
+ ...preview,
1585
+ details: {
1586
+ ...preview.details,
1587
+ operationKey,
1588
+ retry: "Confirm with the same command path plus --confirm --operation-key <operationKey>. The hosted service will execute the stored exact payload; do not retransmit structured input.",
1589
+ },
1590
+ });
1591
+ }
1592
+ if (!suppliedOperationKey) {
1593
+ throw new CliError("confirmation_required", "A confirmed hosted write requires --operation-key from the preview.", 2, { preview, requiredFlag: "--operation-key" });
1594
+ }
1595
+ const begun = await store.begin(suppliedOperationKey, fingerprint);
1596
+ if (begun.state === "completed")
1597
+ return begun.result;
1598
+ if (begun.state === "failed") {
1599
+ throw new CliError(begun.error.code, begun.error.message, 1, begun.error.details);
1600
+ }
1601
+ try {
1602
+ const result = await withIdempotencyContext({ operationKey: suppliedOperationKey, fingerprint }, execute);
1603
+ await store.complete(suppliedOperationKey, result);
1604
+ return result;
1605
+ }
1606
+ catch (error) {
1607
+ await store.fail(suppliedOperationKey, cliErrorShape(error));
1608
+ throw error;
1609
+ }
1610
+ };
1611
+ }
1612
+ export async function executeRecessCommand(argv, options = {}) {
1558
1613
  const parsed = parseArgs(argv);
1559
1614
  const [noun, verb] = parsed.positionals;
1560
1615
  const commands = buildCommandSchema(HELP);
1616
+ const isRemote = options.transport === "remote";
1617
+ if (isRemote &&
1618
+ (!options.api || !options.config || !options.operationStore)) {
1619
+ throw new CliError("remote_context_required", "Hosted execution requires injected API, configuration, and operation-store adapters.");
1620
+ }
1621
+ if (isRemote) {
1622
+ const forbiddenTransportFlag = ["deliver", "profile", "json"].find((name) => hasFlag(parsed, name));
1623
+ if (forbiddenTransportFlag) {
1624
+ throw new CliError("remote_flag_unavailable", `--${forbiddenTransportFlag} is not available over hosted MCP.`);
1625
+ }
1626
+ }
1627
+ const writeCommand = options.operationStore
1628
+ ? remoteWriteCommand(argv, options.structuredInput, options.operationStore)
1629
+ : localWriteCommand;
1561
1630
  // Before the help branch: `--version` parses as a FLAG, so `noun` is
1562
1631
  // undefined and `!noun` would return help instead. (Found by running it.)
1563
- if (noun === "version" || hasFlag(parsed, "version")) {
1632
+ if (noun === "version" || (!noun && hasFlag(parsed, "version"))) {
1633
+ if (isRemote) {
1634
+ throw new CliError("remote_command_unavailable", "The version command is not exposed by the hosted Recess CLI.");
1635
+ }
1564
1636
  const unknown = Array.from(parsed.flags.keys()).filter((name) => !["deliver", "json", "version"].includes(name));
1565
1637
  if (unknown.length > 0) {
1566
1638
  throw new CliError("unknown_flag", `Unknown flag${unknown.length === 1 ? "" : "s"}: ${unknown
@@ -1579,28 +1651,70 @@ export async function runCommand(argv) {
1579
1651
  .map((name) => `--${name}`)
1580
1652
  .join(", ")}.`, 1, { validFlags: ["--deliver", "--help", "--json", "--profile"] });
1581
1653
  }
1582
- const config = await resolveConfig(flagString(parsed, "profile"));
1583
- const discovery = resolveCommandDiscovery(config);
1584
- return { help: scopedHelp(HELP, commands, [], discovery) };
1654
+ const config = options.config ?? (await resolveConfig(flagString(parsed, "profile")));
1655
+ const discovery = options.discovery ?? resolveCommandDiscovery(config);
1656
+ const visibleCommands = isRemote ? remoteCommands(commands) : commands;
1657
+ return {
1658
+ help: scopedHelp(HELP, visibleCommands, [], discovery, {
1659
+ remoteOnly: isRemote,
1660
+ }),
1661
+ };
1585
1662
  }
1586
1663
  if (noun === "help" || hasFlag(parsed, "help")) {
1587
1664
  const scope = noun === "help" ? parsed.positionals.slice(1) : parsed.positionals;
1588
- const config = await resolveConfig(flagString(parsed, "profile"));
1589
- const discovery = resolveCommandDiscovery(config);
1590
- return { help: scopedHelp(HELP, commands, scope, discovery) };
1665
+ const config = options.config ?? (await resolveConfig(flagString(parsed, "profile")));
1666
+ const discovery = options.discovery ?? resolveCommandDiscovery(config);
1667
+ const visibleCommands = isRemote ? remoteCommands(commands) : commands;
1668
+ return {
1669
+ help: scopedHelp(HELP, visibleCommands, scope, discovery, {
1670
+ remoteOnly: isRemote,
1671
+ }),
1672
+ };
1673
+ }
1674
+ validateInvocation(parsed, commands, { remote: isRemote });
1675
+ if (isRemote) {
1676
+ const command = findCommandSchema(commands, parsed.positionals);
1677
+ if (!command?.remoteCapable) {
1678
+ throw new CliError("remote_command_unavailable", `\`${parsed.positionals.join(" ")}\` is not exposed by the hosted Recess CLI. Use help or agent-context to discover the remote catalog.`);
1679
+ }
1680
+ if (options.structuredInput !== undefined &&
1681
+ ![
1682
+ "apps validate",
1683
+ "goal-templates validate-spec",
1684
+ "goal-templates create",
1685
+ ].includes(command.path.join(" "))) {
1686
+ throw new CliError("invalid_arguments", "Structured input is accepted only by hosted apps validate and goal-templates validate-spec/create.");
1687
+ }
1688
+ if ((noun === "goal-templates" || noun === "skills") &&
1689
+ options.session?.user.role !== "GUIDE" &&
1690
+ options.session?.user.role !== "ADMIN") {
1691
+ throw new CliError("forbidden", "Hosted goal-template authoring requires a guide or admin.");
1692
+ }
1693
+ if (noun === "skills" &&
1694
+ parsed.positionals[3] !== "recess-goal-authoring") {
1695
+ throw new CliError("remote_command_unavailable", "Hosted skills only exposes recess-goal-authoring.");
1696
+ }
1697
+ const granted = options.grantedScopes ?? new Set();
1698
+ const missing = command.oauthScopes.filter((scope) => !granted.has(scope));
1699
+ if (missing.length > 0) {
1700
+ throw new CliError("insufficient_scope", `This command requires OAuth scope${missing.length === 1 ? "" : "s"}: ${missing.join(", ")}.`, 1, { requiredScopes: command.oauthScopes });
1701
+ }
1591
1702
  }
1592
- validateInvocation(parsed, commands);
1593
1703
  if (noun === "agent-context") {
1594
- const [profiles, config] = await Promise.all([
1595
- listProfiles(),
1596
- resolveConfig(flagString(parsed, "profile")),
1597
- ]);
1598
- const discovery = resolveCommandDiscovery(config);
1704
+ const [profiles, config] = isRemote
1705
+ ? [{ profiles: [] }, options.config ?? (await resolveConfig(undefined))]
1706
+ : await Promise.all([
1707
+ listProfiles(),
1708
+ options.config ?? resolveConfig(flagString(parsed, "profile")),
1709
+ ]);
1710
+ const discovery = options.discovery ?? resolveCommandDiscovery(config);
1599
1711
  return agentContext(commands, {
1600
- cliVersion: await readCliVersion(),
1712
+ cliVersion: options.versions?.cliVersion ??
1713
+ (isRemote ? "hosted" : await readCliVersion()),
1601
1714
  availableProfiles: profiles.profiles.map((profile) => profile.name),
1602
1715
  feedbackUpstreamConfigured: Boolean(process.env.RECESS_CLI_FEEDBACK_ENDPOINT),
1603
1716
  discovery,
1717
+ remoteOnly: isRemote,
1604
1718
  });
1605
1719
  }
1606
1720
  if (noun === "profile") {
@@ -1704,8 +1818,17 @@ export async function runCommand(argv) {
1704
1818
  }));
1705
1819
  }
1706
1820
  }
1707
- const config = await resolveConfig(flagString(parsed, "profile"));
1821
+ const config = options.config ?? (await resolveConfig(flagString(parsed, "profile")));
1708
1822
  const requestReason = noun === "auth" ? undefined : requiredRequestReason(parsed);
1823
+ if (noun === "doctor" && isRemote) {
1824
+ return {
1825
+ transport: "streamable-http-mcp",
1826
+ resource: options.remoteResource ?? config.apiOrigin.replace(/\/$/, "") + "/mcp",
1827
+ authenticated: true,
1828
+ session: options.session ?? null,
1829
+ scopes: Array.from(options.grantedScopes ?? []).sort(),
1830
+ };
1831
+ }
1709
1832
  if (noun === "doctor")
1710
1833
  return doctor(config, requestReason);
1711
1834
  if (noun === "setup") {
@@ -1775,7 +1898,7 @@ export async function runCommand(argv) {
1775
1898
  }
1776
1899
  throw new CliError("invalid_arguments", "Use auth login, request, poll, status, or logout.");
1777
1900
  }
1778
- const api = new RecessAdminApi(config, requestReason);
1901
+ const api = options.api ?? new RecessAdminApi(config, requestReason);
1779
1902
  api.requireAuth();
1780
1903
  if (noun === "village") {
1781
1904
  const targetWorldId = flagString(parsed, "world") ?? "village-1";
@@ -2539,7 +2662,16 @@ export async function runCommand(argv) {
2539
2662
  return runApplicationsCommand({ parsed, api, writeCommand });
2540
2663
  }
2541
2664
  if (noun === "apps") {
2542
- return runAppsCommand({ parsed, api, writeCommand });
2665
+ return runAppsCommand({
2666
+ parsed,
2667
+ api,
2668
+ writeCommand,
2669
+ transport: options.transport,
2670
+ structuredInput: options.structuredInput,
2671
+ });
2672
+ }
2673
+ if (noun === "mastery") {
2674
+ return runMasteryCommand({ parsed, api, writeCommand });
2543
2675
  }
2544
2676
  if (noun === "school") {
2545
2677
  return runSchoolCommand({ parsed, api, writeCommand });
@@ -3130,7 +3262,7 @@ export async function runCommand(argv) {
3130
3262
  // infers whether `get` means guardian guidance or staff-only operations.
3131
3263
  const audience = assertChoice(verb, ["admin", "guardian"], "skill audience");
3132
3264
  const action = positional(parsed, 2, "skills action");
3133
- const refresh = hasFlag(parsed, "refresh");
3265
+ const refresh = isRemote || hasFlag(parsed, "refresh");
3134
3266
  if (action === "list") {
3135
3267
  const query = flagString(parsed, "query");
3136
3268
  const category = flagString(parsed, "category");
@@ -3149,7 +3281,8 @@ export async function runCommand(argv) {
3149
3281
  },
3150
3282
  },
3151
3283
  }));
3152
- await writeSkillCache(config.apiOrigin, cacheKey, data);
3284
+ if (!isRemote)
3285
+ await writeSkillCache(config.apiOrigin, cacheKey, data);
3153
3286
  return { ...data, cached: false };
3154
3287
  }
3155
3288
  if (action === "get") {
@@ -3174,7 +3307,8 @@ export async function runCommand(argv) {
3174
3307
  },
3175
3308
  },
3176
3309
  }));
3177
- await writeSkillCache(config.apiOrigin, cacheKey, data);
3310
+ if (!isRemote)
3311
+ await writeSkillCache(config.apiOrigin, cacheKey, data);
3178
3312
  return { ...data, cached: false };
3179
3313
  }
3180
3314
  throw new CliError("invalid_arguments", "Use skills guardian list|get or skills admin list|get.");
@@ -3261,7 +3395,12 @@ export async function runCommand(argv) {
3261
3395
  timeoutSeconds > 7200) {
3262
3396
  throw new CliError("invalid_arguments", "--timeout must be an integer number of seconds from 10 through 7200.");
3263
3397
  }
3264
- const body = { stage, items: input.items };
3398
+ const allowPossibleDuplicate = hasFlag(parsed, "allow-possible-duplicate");
3399
+ const body = {
3400
+ stage,
3401
+ items: input.items,
3402
+ ...(allowPossibleDuplicate ? { allowPossibleDuplicate: true } : {}),
3403
+ };
3265
3404
  const preview = {
3266
3405
  action: "content-library.submit",
3267
3406
  target: { stage, resourceCount: input.items.length },
@@ -3270,7 +3409,9 @@ export async function runCommand(argv) {
3270
3409
  admission: stage === "polish"
3271
3410
  ? "Starts automatic decoration; the island promotes each successful resource to LIVE after the polish and deterministic tail finish."
3272
3411
  : "Holds each new resource in REVIEW until an admin approves it.",
3273
- duplicateBehavior: "Existing URLs are returned as duplicates and are not overwritten.",
3412
+ duplicateBehavior: allowPossibleDuplicate
3413
+ ? "Exact duplicates and previously rejected URLs are still returned as duplicates; fuzzy possible-duplicate candidates are admitted to REVIEW for a curator to settle."
3414
+ : "Existing URLs are returned as duplicates and are not overwritten.",
3274
3415
  deployOrderFence: "The server verifies the island's review/polish lifecycle capability before its first write.",
3275
3416
  waitForLive: wait,
3276
3417
  ...(wait ? { timeoutSeconds } : {}),
@@ -3292,6 +3433,21 @@ export async function runCommand(argv) {
3292
3433
  throw new CliError("invalid_arguments", "Use content-library search, status, set-stage, or submit.");
3293
3434
  }
3294
3435
  if (noun === "goal-templates") {
3436
+ const readTemplateDocument = async () => {
3437
+ if (!isRemote) {
3438
+ return readJsonFile(flagString(parsed, "file", { required: true }), "Template file");
3439
+ }
3440
+ const input = options.structuredInput;
3441
+ const template = input?.template;
3442
+ if (!input ||
3443
+ Object.keys(input).some((key) => key !== "template") ||
3444
+ !template ||
3445
+ typeof template !== "object" ||
3446
+ Array.isArray(template)) {
3447
+ throw new CliError("invalid_arguments", "Supply the complete template document as input.template.");
3448
+ }
3449
+ return template;
3450
+ };
3295
3451
  if (verb === "list") {
3296
3452
  const query = flagString(parsed, "query");
3297
3453
  const kind = flagString(parsed, "kind");
@@ -3365,7 +3521,7 @@ export async function runCommand(argv) {
3365
3521
  return version;
3366
3522
  }
3367
3523
  if (verb === "validate-spec") {
3368
- const document = parseGoalTemplateDocument(await readJsonFile(flagString(parsed, "file", { required: true }), "Template file"));
3524
+ const document = parseGoalTemplateDocument(await readTemplateDocument());
3369
3525
  // A read-only validation: no gate, and iterating on it is the whole point.
3370
3526
  return {
3371
3527
  slug: document.slug,
@@ -3379,8 +3535,10 @@ export async function runCommand(argv) {
3379
3535
  };
3380
3536
  }
3381
3537
  if (verb === "create") {
3382
- const filePath = flagString(parsed, "file", { required: true });
3383
- const authoredDocument = parseGoalTemplateDocument(await readJsonFile(filePath, "Template file"));
3538
+ const filePath = isRemote
3539
+ ? undefined
3540
+ : flagString(parsed, "file", { required: true });
3541
+ const authoredDocument = parseGoalTemplateDocument(await readTemplateDocument());
3384
3542
  const coinAmountOverride = flagNumber(parsed, "coin-amount");
3385
3543
  if (coinAmountOverride !== undefined &&
3386
3544
  (!Number.isInteger(coinAmountOverride) || coinAmountOverride < 1)) {
@@ -3405,13 +3563,15 @@ export async function runCommand(argv) {
3405
3563
  },
3406
3564
  }));
3407
3565
  if (!validation.valid) {
3408
- throw new CliError("invalid_spec", `The setupWorkflowSpec is invalid, so nothing was created: ${validation.error}`, 1, { file: path.resolve(filePath) });
3566
+ throw new CliError("invalid_spec", `The setupWorkflowSpec is invalid, so nothing was created: ${validation.error}`, 1, filePath ? { file: path.resolve(filePath) } : { input: "template" });
3409
3567
  }
3410
3568
  return writeCommand(parsed, {
3411
3569
  action: "create a NEW global goal template (DETERMINISTIC_WORKFLOW — this cannot be converted back)",
3412
3570
  target: { slug: document.slug, title: document.title },
3413
3571
  request: {
3414
- file: path.resolve(filePath),
3572
+ ...(filePath
3573
+ ? { file: path.resolve(filePath) }
3574
+ : { template: document }),
3415
3575
  kind: document.kind,
3416
3576
  setupAudience: document.setupAudience,
3417
3577
  category: document.category ?? null,
@@ -4086,7 +4246,7 @@ export async function runCommand(argv) {
4086
4246
  // answers 401. A preview is a promise about what --confirm will do.
4087
4247
  const sessionRole = await resolveSessionRole(api);
4088
4248
  if (sessionRole && sessionRole !== "ADMIN") {
4089
- throw new CliError("forbidden", "Deleting a goal is staff-only. To free an open-goal slot for a finished course, use `recess goals archive <goal-id> --student <kid-id>` — it sets the terminal status, keeps the goal and its history, and needs no staff involvement.", 1, { sessionRole, requiredRole: "ADMIN", alternative: "goals archive" });
4249
+ throw new CliError("forbidden", "Deleting a goal is admin-only. To free an open-goal slot for a finished course, use `recess goals archive <goal-id> --student <kid-id>` — it sets the terminal status, keeps the goal and its history, and needs no staff involvement.", 1, { sessionRole, requiredRole: "ADMIN", alternative: "goals archive" });
4090
4250
  }
4091
4251
  const current = unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
4092
4252
  params: { path: { userId: studentId } },
@@ -4104,7 +4264,7 @@ export async function runCommand(argv) {
4104
4264
  },
4105
4265
  request: { expectedUpdatedAt: current.updatedAt },
4106
4266
  details: {
4107
- note: "Soft-deletes the goal and its current/future dated todos. Historical todos and the deletion audit remain available.",
4267
+ note: "Soft-deletes the goal and its current/future dated todos. Historical todos, the deletion audit, and any Mesa workspace remain available; `goals restore` reverses this tombstone.",
4108
4268
  },
4109
4269
  };
4110
4270
  return previewBoundWrite(parsed, preview, async () => {
@@ -4114,6 +4274,39 @@ export async function runCommand(argv) {
4114
4274
  return { deleted: true, goalId, studentUserId: studentId };
4115
4275
  });
4116
4276
  }
4277
+ if (verb === "restore") {
4278
+ const goalId = positional(parsed, 2, "goal ID");
4279
+ const studentId = flagString(parsed, "student", { required: true });
4280
+ const sessionRole = await resolveSessionRole(api);
4281
+ if (sessionRole && sessionRole !== "ADMIN") {
4282
+ throw new CliError("forbidden", "Restoring a deleted goal is admin-only.", 1, { sessionRole, requiredRole: "ADMIN" });
4283
+ }
4284
+ const state = unwrap(await api.client.GET("/admin/browser/students/goals/{goalId}/deletion-state/", { params: { path: { goalId } } }));
4285
+ if (state.userId !== studentId) {
4286
+ throw new CliError("not_found", `Goal ${goalId} was not found for student ${studentId}.`);
4287
+ }
4288
+ if (!state.deletedAt) {
4289
+ throw new CliError("not_deleted", `Goal ${goalId} is not deleted.`);
4290
+ }
4291
+ if (!state.restorable) {
4292
+ throw new CliError("restore_unavailable", state.restoreBlockedReason ?? "This goal cannot be restored safely.");
4293
+ }
4294
+ const body = { expectedDeletedAt: state.deletedAt };
4295
+ const preview = {
4296
+ action: "restore a soft-deleted goal for a managed student",
4297
+ target: {
4298
+ goalId,
4299
+ studentUserId: studentId,
4300
+ title: state.title,
4301
+ status: state.status,
4302
+ },
4303
+ request: body,
4304
+ details: {
4305
+ note: "Clears the goal tombstone and restores only linked todos that were tombstoned by the same goal deletion. Restoring an ACTIVE or PAUSED goal rechecks the student's open-goal capacity.",
4306
+ },
4307
+ };
4308
+ return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/admin/browser/students/goals/{goalId}/restore/", { params: { path: { goalId } }, body })));
4309
+ }
4117
4310
  if (verb === "queue") {
4118
4311
  const subverb = positional(parsed, 2, "queue action (get|set)");
4119
4312
  const goalId = positional(parsed, 3, "goal ID");
@@ -4205,12 +4398,29 @@ export async function runCommand(argv) {
4205
4398
  }
4206
4399
  throw new CliError("invalid_arguments", "Use goals queue get|set.");
4207
4400
  }
4208
- throw new CliError("invalid_arguments", "Use goals list|create|edit|delete|archive|unarchive|complete|undo-completion|queue|files|pdf.");
4401
+ throw new CliError("invalid_arguments", "Use goals list|create|edit|delete|restore|archive|unarchive|complete|undo-completion|queue|files|pdf.");
4209
4402
  }
4210
4403
  if (noun === "students") {
4211
4404
  if (verb === "list") {
4212
- // A guide has no family — their roster is the students they are
4213
- // actively assigned to, which the tutor surface already scopes. The
4405
+ const requestedScope = flagString(parsed, "scope");
4406
+ const scope = requestedScope
4407
+ ? assertChoice(requestedScope, ["mine", "family"], "--scope")
4408
+ : isRemote
4409
+ ? "mine"
4410
+ : undefined;
4411
+ if (scope === "mine") {
4412
+ return unwrap(await api.client.GET("/tutor/students", {
4413
+ params: { query: { scope: "mine" } },
4414
+ }));
4415
+ }
4416
+ if (scope === "family") {
4417
+ return unwrap(await api.client.GET("/family/kids", {
4418
+ params: { query: { includeSelf: "false" } },
4419
+ }));
4420
+ }
4421
+ // A guide has no family — their roster is the tutor surface's, which the
4422
+ // server scopes per surface (`STUDENT_SCOPE_POLICY_BY_SURFACE`: manual
4423
+ // assignments plus live cohorts, never the institution roster). The
4214
4424
  // stored identity is absent on an env-cookie session, so fall back to
4215
4425
  // asking the server who this session belongs to.
4216
4426
  // The stored identity only describes a `auth login` session; an
@@ -4248,13 +4458,38 @@ export async function runCommand(argv) {
4248
4458
  }));
4249
4459
  }
4250
4460
  if (verb === "todos") {
4251
- const studentId = flagString(parsed, "student", { required: true });
4461
+ const student = flagString(parsed, "student", { required: true });
4462
+ const date = flagString(parsed, "date");
4463
+ if (date !== undefined &&
4464
+ date !== "today" &&
4465
+ (!/^\d{4}-\d{2}-\d{2}$/.test(date) ||
4466
+ !Number.isFinite(Date.parse(`${date}T00:00:00.000Z`)) ||
4467
+ new Date(`${date}T00:00:00.000Z`).toISOString().slice(0, 10) !== date)) {
4468
+ throw new CliError("invalid_arguments", "--date must be today or a valid YYYY-MM-DD calendar date.");
4469
+ }
4470
+ const cursor = flagString(parsed, "cursor");
4252
4471
  const limit = flagNumber(parsed, "limit");
4472
+ let studentId = student;
4473
+ if (!/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(student)) {
4474
+ const roster = unwrap(await api.client.GET("/tutor/students", {
4475
+ params: { query: { scope: "mine" } },
4476
+ }));
4477
+ const normalize = (name) => name.trim().replace(/\s+/g, " ").toLowerCase();
4478
+ const matches = roster.items.filter((item) => normalize(item.name) === normalize(student));
4479
+ if (matches.length !== 1) {
4480
+ throw new CliError(matches.length ? "ambiguous_student" : "student_not_found", matches.length
4481
+ ? "Multiple students have that name. Ask which student, then retry with their ID."
4482
+ : "No exact name match in your roster. Use students list to find the student's ID.", 1, { matches: matches.map(({ userId, name }) => ({ userId, name })) });
4483
+ }
4484
+ studentId = matches[0].userId;
4485
+ }
4253
4486
  return unwrap(await api.client.GET("/studio/students/{studentId}/todos", {
4254
4487
  params: {
4255
4488
  path: { studentId },
4256
4489
  query: {
4257
4490
  ...(limit === undefined ? {} : { limit }),
4491
+ ...(date === undefined ? {} : { date }),
4492
+ ...(cursor === undefined ? {} : { cursor }),
4258
4493
  ...(hasFlag(parsed, "analyzed") ? { analyzed: true } : {}),
4259
4494
  },
4260
4495
  },
@@ -4397,7 +4632,7 @@ export async function runCommand(argv) {
4397
4632
  params: { path: { id: todoId } },
4398
4633
  }));
4399
4634
  const preview = {
4400
- action: "permanently delete a todo for a managed student",
4635
+ action: "soft-delete a todo for a managed student",
4401
4636
  target: {
4402
4637
  todoId,
4403
4638
  studentUserId: current.userId,
@@ -4411,13 +4646,91 @@ export async function runCommand(argv) {
4411
4646
  creationSource: current.creationSource,
4412
4647
  creationSourceId: current.creationSourceId,
4413
4648
  url: "url" in current ? current.url : null,
4414
- note: "Hard-deletes this todo without awarding XP or running todo-completion side effects. This is permanent; use it only when the preview identifies a disposable todo with no work to preserve.",
4649
+ note: "Sets deletedAt without awarding XP or running todo-completion side effects. The todo, its work evidence, and related records remain available; `todos restore` reverses the tombstone.",
4415
4650
  },
4416
4651
  };
4417
4652
  return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.DELETE("/admin/todos/{id}/", {
4418
4653
  params: { path: { id: todoId } },
4419
4654
  })));
4420
4655
  }
4656
+ if (verb === "restore") {
4657
+ const todoId = positional(parsed, 2, "todo ID");
4658
+ const state = unwrap(await api.client.GET("/admin/todos/{id}/deletion-state", {
4659
+ params: { path: { id: todoId } },
4660
+ }));
4661
+ if (!state.deletedAt) {
4662
+ throw new CliError("not_deleted", `Todo ${todoId} is not deleted.`);
4663
+ }
4664
+ if (state.goal?.deletedAt) {
4665
+ throw new CliError("restore_parent_goal", `Todo ${todoId} belongs to deleted goal ${state.goal.id}. Restore the goal instead so its deletion set is reversed together.`);
4666
+ }
4667
+ const body = { expectedDeletedAt: state.deletedAt };
4668
+ const preview = {
4669
+ action: "restore a soft-deleted todo for a managed student",
4670
+ target: {
4671
+ todoId,
4672
+ studentUserId: state.userId,
4673
+ title: state.title,
4674
+ status: state.status,
4675
+ goalId: state.goal?.id ?? null,
4676
+ },
4677
+ request: body,
4678
+ details: {
4679
+ dueDate: state.dueDate,
4680
+ note: "Clears deletedAt only when the tombstone still matches this preview. All preserved work evidence and related records become visible with the todo again.",
4681
+ },
4682
+ };
4683
+ return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/admin/todos/{id}/restore", {
4684
+ params: { path: { id: todoId } },
4685
+ body,
4686
+ })));
4687
+ }
4688
+ if (verb === "generate-learning-analysis") {
4689
+ const todoId = positional(parsed, 2, "todo ID");
4690
+ const sessionRole = await resolveSessionRole(api);
4691
+ if (sessionRole !== "ADMIN") {
4692
+ throw new CliError("forbidden", "Generating a learning analysis is admin-only.", 1, { sessionRole: sessionRole ?? null, requiredRole: "ADMIN" });
4693
+ }
4694
+ const [todoResponse, detailsResponse, geminiResponse] = await Promise.all([
4695
+ api.client.GET("/tutor/browser/todos/{id}/", {
4696
+ params: { path: { id: todoId } },
4697
+ }),
4698
+ api.client.GET("/admin/todos/{id}/details", {
4699
+ params: { path: { id: todoId } },
4700
+ }),
4701
+ api.client.GET("/admin/todos/{id}/gemini-analysis", {
4702
+ params: { path: { id: todoId } },
4703
+ }),
4704
+ ]);
4705
+ const currentTodo = unwrap(todoResponse);
4706
+ const details = unwrap(detailsResponse);
4707
+ const gemini = unwrap(geminiResponse);
4708
+ const latestCompletedGeminiAnalysis = gemini.newPipelineAnalyses.find((analysis) => analysis.status === "completed");
4709
+ if (!latestCompletedGeminiAnalysis) {
4710
+ throw new CliError("not_found", "This todo does not have a completed Gemini analysis to use.");
4711
+ }
4712
+ const body = {
4713
+ geminiAnalysisId: latestCompletedGeminiAnalysis.id,
4714
+ };
4715
+ const preview = {
4716
+ action: "queue a learning analysis from a todo's latest completed Gemini analysis",
4717
+ target: {
4718
+ todoId,
4719
+ studentUserId: currentTodo.userId,
4720
+ title: currentTodo.title,
4721
+ status: currentTodo.status,
4722
+ },
4723
+ request: body,
4724
+ details: {
4725
+ existingLearningAnalysisId: details.learningAnalysis?.id ?? null,
4726
+ note: "The forensic learning pipeline is idempotent for this Gemini analysis. The command does not rerun completion or change rewards.",
4727
+ },
4728
+ };
4729
+ return writeCommand(parsed, preview, async () => unwrap(await api.client.POST("/admin/learning-pipeline/{todoId}/start/", {
4730
+ params: { path: { todoId } },
4731
+ body,
4732
+ })));
4733
+ }
4421
4734
  if (verb === "generate-applet") {
4422
4735
  const todoId = positional(parsed, 2, "todo ID");
4423
4736
  const studentId = flagString(parsed, "student", { required: true });
@@ -4459,7 +4772,7 @@ export async function runCommand(argv) {
4459
4772
  body: targetDueDateISO ? { targetDueDateISO } : {},
4460
4773
  })));
4461
4774
  }
4462
- throw new CliError("invalid_arguments", "Use todos create|edit|complete|delete|generate-applet.");
4775
+ throw new CliError("invalid_arguments", "Use todos create|edit|complete|delete|restore|generate-learning-analysis|generate-applet.");
4463
4776
  }
4464
4777
  if (noun === "memories") {
4465
4778
  const studentId = flagString(parsed, "student", { required: true });
@@ -4533,7 +4846,8 @@ export async function runCommand(argv) {
4533
4846
  throw new CliError("invalid_arguments", `PDF size must be between 1 byte and ${GOAL_PDF_UPLOAD_MAX_BYTES} bytes.`);
4534
4847
  }
4535
4848
  const fileName = path.basename(absolutePath);
4536
- const workspacePath = flagString(parsed, "path") ?? `uploads/${safeGoalUploadName(fileName)}`;
4849
+ const workspacePath = flagString(parsed, "path") ??
4850
+ `uploads/${sanitizeWorkspaceUploadName(fileName)}`;
4537
4851
  if (!workspacePath.startsWith("uploads/")) {
4538
4852
  throw new CliError("invalid_arguments", "Textbook PDFs must be attached under uploads/.");
4539
4853
  }
@@ -4862,4 +5176,7 @@ export async function runCommand(argv) {
4862
5176
  }
4863
5177
  throw new CliError("unknown_command", `Unknown command: ${parsed.positionals.join(" ")}\n\n${HELP}`);
4864
5178
  }
5179
+ export async function runCommand(argv) {
5180
+ return executeRecessCommand(argv);
5181
+ }
4865
5182
  //# sourceMappingURL=cli.js.map