recess-cli 2.7.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/api.js CHANGED
@@ -19,16 +19,19 @@ export class RecessAdminApi {
19
19
  clientTag;
20
20
  client;
21
21
  villageSessionPromise;
22
- constructor(config, reason, clientTag = RECESS_CLIENT_CLI) {
22
+ constructor(config, reason, clientTag = RECESS_CLIENT_CLI, clientOverride, fetchOverride) {
23
23
  this.config = config;
24
24
  this.reason = reason;
25
25
  this.clientTag = clientTag;
26
- this.client = createClient({
27
- baseUrl: config.apiOrigin,
28
- headers: config.sessionCookie
29
- ? { cookie: config.sessionCookie }
30
- : undefined,
31
- });
26
+ this.client =
27
+ clientOverride ??
28
+ createClient({
29
+ baseUrl: config.apiOrigin,
30
+ headers: config.sessionCookie
31
+ ? { cookie: config.sessionCookie }
32
+ : undefined,
33
+ ...(fetchOverride ? { fetch: fetchOverride } : {}),
34
+ });
32
35
  const requestReason = this.reason;
33
36
  const requestClientTag = this.clientTag;
34
37
  this.client.use({
@@ -162,7 +165,9 @@ export class RecessAdminApi {
162
165
  async uploadMapTestScores(studentId, pdf, fileName) {
163
166
  this.requireAuth();
164
167
  const formData = new FormData();
165
- formData.append("file", new Blob([pdf], { type: "application/pdf" }), fileName);
168
+ const pdfBody = new Uint8Array(pdf.byteLength);
169
+ pdfBody.set(pdf);
170
+ formData.append("file", new Blob([pdfBody.buffer], { type: "application/pdf" }), fileName);
166
171
  const headers = cliRequestHeaders({ cookie: this.config.sessionCookie }, this.reason, this.clientTag);
167
172
  applyIdempotencyHeaders(headers);
168
173
  const response = await fetch(new URL(`/tutor/students/${encodeURIComponent(studentId)}/map-test-scores/upload`, this.config.apiOrigin), {
package/dist/args.js CHANGED
@@ -32,7 +32,6 @@ const BOOLEAN_FLAGS = new Set([
32
32
  "starter-only",
33
33
  "unlink",
34
34
  "visual-only",
35
- "version",
36
35
  "wait",
37
36
  ]);
38
37
  export function parseArgs(args) {
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 }));
@@ -229,7 +231,7 @@ async function skillStatus(config, reason) {
229
231
  };
230
232
  }
231
233
  }
232
- async function writeCommand(parsed, preview, execute) {
234
+ async function localWriteCommand(parsed, preview, execute) {
233
235
  const fingerprint = approvalTokenFor(preview);
234
236
  const suppliedOperationKey = flagString(parsed, "operation-key");
235
237
  if (!hasFlag(parsed, "confirm")) {
@@ -249,6 +251,7 @@ async function writeCommand(parsed, preview, execute) {
249
251
  action: preview.action,
250
252
  fingerprint,
251
253
  target: preview.target,
254
+ ...(preview.action.startsWith("mastery.") ? { preview } : {}),
252
255
  });
253
256
  requireConfirmation(false, boundPreview);
254
257
  }
@@ -696,9 +699,6 @@ async function cleanupPlannerUpload(api, conversationId, signed, includeDelete)
696
699
  .catch(() => undefined);
697
700
  }
698
701
  }
699
- function safeGoalUploadName(fileName) {
700
- return fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
701
- }
702
702
  async function sha256File(absolutePath) {
703
703
  const handle = await fs.open(absolutePath, "r");
704
704
  const hash = createHash("sha256");
@@ -914,6 +914,8 @@ const CONTENT_LIBRARY_DISCOVERY_LANES = [
914
914
  "puzzles",
915
915
  "wonder",
916
916
  "idea-games",
917
+ "makers",
918
+ "drills",
917
919
  ];
918
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;
919
921
  function parseGoalQueueEntries(value, label) {
@@ -1551,13 +1553,86 @@ function tierSlots(parsed) {
1551
1553
  }
1552
1554
  return slots;
1553
1555
  }
1554
- 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 = {}) {
1555
1613
  const parsed = parseArgs(argv);
1556
1614
  const [noun, verb] = parsed.positionals;
1557
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;
1558
1630
  // Before the help branch: `--version` parses as a FLAG, so `noun` is
1559
1631
  // undefined and `!noun` would return help instead. (Found by running it.)
1560
- 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
+ }
1561
1636
  const unknown = Array.from(parsed.flags.keys()).filter((name) => !["deliver", "json", "version"].includes(name));
1562
1637
  if (unknown.length > 0) {
1563
1638
  throw new CliError("unknown_flag", `Unknown flag${unknown.length === 1 ? "" : "s"}: ${unknown
@@ -1576,28 +1651,70 @@ export async function runCommand(argv) {
1576
1651
  .map((name) => `--${name}`)
1577
1652
  .join(", ")}.`, 1, { validFlags: ["--deliver", "--help", "--json", "--profile"] });
1578
1653
  }
1579
- const config = await resolveConfig(flagString(parsed, "profile"));
1580
- const discovery = resolveCommandDiscovery(config);
1581
- 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
+ };
1582
1662
  }
1583
1663
  if (noun === "help" || hasFlag(parsed, "help")) {
1584
1664
  const scope = noun === "help" ? parsed.positionals.slice(1) : parsed.positionals;
1585
- const config = await resolveConfig(flagString(parsed, "profile"));
1586
- const discovery = resolveCommandDiscovery(config);
1587
- 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
+ }
1588
1702
  }
1589
- validateInvocation(parsed, commands);
1590
1703
  if (noun === "agent-context") {
1591
- const [profiles, config] = await Promise.all([
1592
- listProfiles(),
1593
- resolveConfig(flagString(parsed, "profile")),
1594
- ]);
1595
- 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);
1596
1711
  return agentContext(commands, {
1597
- cliVersion: await readCliVersion(),
1712
+ cliVersion: options.versions?.cliVersion ??
1713
+ (isRemote ? "hosted" : await readCliVersion()),
1598
1714
  availableProfiles: profiles.profiles.map((profile) => profile.name),
1599
1715
  feedbackUpstreamConfigured: Boolean(process.env.RECESS_CLI_FEEDBACK_ENDPOINT),
1600
1716
  discovery,
1717
+ remoteOnly: isRemote,
1601
1718
  });
1602
1719
  }
1603
1720
  if (noun === "profile") {
@@ -1701,8 +1818,17 @@ export async function runCommand(argv) {
1701
1818
  }));
1702
1819
  }
1703
1820
  }
1704
- const config = await resolveConfig(flagString(parsed, "profile"));
1821
+ const config = options.config ?? (await resolveConfig(flagString(parsed, "profile")));
1705
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
+ }
1706
1832
  if (noun === "doctor")
1707
1833
  return doctor(config, requestReason);
1708
1834
  if (noun === "setup") {
@@ -1772,7 +1898,7 @@ export async function runCommand(argv) {
1772
1898
  }
1773
1899
  throw new CliError("invalid_arguments", "Use auth login, request, poll, status, or logout.");
1774
1900
  }
1775
- const api = new RecessAdminApi(config, requestReason);
1901
+ const api = options.api ?? new RecessAdminApi(config, requestReason);
1776
1902
  api.requireAuth();
1777
1903
  if (noun === "village") {
1778
1904
  const targetWorldId = flagString(parsed, "world") ?? "village-1";
@@ -2536,7 +2662,16 @@ export async function runCommand(argv) {
2536
2662
  return runApplicationsCommand({ parsed, api, writeCommand });
2537
2663
  }
2538
2664
  if (noun === "apps") {
2539
- 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 });
2540
2675
  }
2541
2676
  if (noun === "school") {
2542
2677
  return runSchoolCommand({ parsed, api, writeCommand });
@@ -3127,7 +3262,7 @@ export async function runCommand(argv) {
3127
3262
  // infers whether `get` means guardian guidance or staff-only operations.
3128
3263
  const audience = assertChoice(verb, ["admin", "guardian"], "skill audience");
3129
3264
  const action = positional(parsed, 2, "skills action");
3130
- const refresh = hasFlag(parsed, "refresh");
3265
+ const refresh = isRemote || hasFlag(parsed, "refresh");
3131
3266
  if (action === "list") {
3132
3267
  const query = flagString(parsed, "query");
3133
3268
  const category = flagString(parsed, "category");
@@ -3146,7 +3281,8 @@ export async function runCommand(argv) {
3146
3281
  },
3147
3282
  },
3148
3283
  }));
3149
- await writeSkillCache(config.apiOrigin, cacheKey, data);
3284
+ if (!isRemote)
3285
+ await writeSkillCache(config.apiOrigin, cacheKey, data);
3150
3286
  return { ...data, cached: false };
3151
3287
  }
3152
3288
  if (action === "get") {
@@ -3171,7 +3307,8 @@ export async function runCommand(argv) {
3171
3307
  },
3172
3308
  },
3173
3309
  }));
3174
- await writeSkillCache(config.apiOrigin, cacheKey, data);
3310
+ if (!isRemote)
3311
+ await writeSkillCache(config.apiOrigin, cacheKey, data);
3175
3312
  return { ...data, cached: false };
3176
3313
  }
3177
3314
  throw new CliError("invalid_arguments", "Use skills guardian list|get or skills admin list|get.");
@@ -3258,7 +3395,12 @@ export async function runCommand(argv) {
3258
3395
  timeoutSeconds > 7200) {
3259
3396
  throw new CliError("invalid_arguments", "--timeout must be an integer number of seconds from 10 through 7200.");
3260
3397
  }
3261
- 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
+ };
3262
3404
  const preview = {
3263
3405
  action: "content-library.submit",
3264
3406
  target: { stage, resourceCount: input.items.length },
@@ -3267,7 +3409,9 @@ export async function runCommand(argv) {
3267
3409
  admission: stage === "polish"
3268
3410
  ? "Starts automatic decoration; the island promotes each successful resource to LIVE after the polish and deterministic tail finish."
3269
3411
  : "Holds each new resource in REVIEW until an admin approves it.",
3270
- 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.",
3271
3415
  deployOrderFence: "The server verifies the island's review/polish lifecycle capability before its first write.",
3272
3416
  waitForLive: wait,
3273
3417
  ...(wait ? { timeoutSeconds } : {}),
@@ -3289,6 +3433,21 @@ export async function runCommand(argv) {
3289
3433
  throw new CliError("invalid_arguments", "Use content-library search, status, set-stage, or submit.");
3290
3434
  }
3291
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
+ };
3292
3451
  if (verb === "list") {
3293
3452
  const query = flagString(parsed, "query");
3294
3453
  const kind = flagString(parsed, "kind");
@@ -3362,7 +3521,7 @@ export async function runCommand(argv) {
3362
3521
  return version;
3363
3522
  }
3364
3523
  if (verb === "validate-spec") {
3365
- const document = parseGoalTemplateDocument(await readJsonFile(flagString(parsed, "file", { required: true }), "Template file"));
3524
+ const document = parseGoalTemplateDocument(await readTemplateDocument());
3366
3525
  // A read-only validation: no gate, and iterating on it is the whole point.
3367
3526
  return {
3368
3527
  slug: document.slug,
@@ -3376,8 +3535,10 @@ export async function runCommand(argv) {
3376
3535
  };
3377
3536
  }
3378
3537
  if (verb === "create") {
3379
- const filePath = flagString(parsed, "file", { required: true });
3380
- 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());
3381
3542
  const coinAmountOverride = flagNumber(parsed, "coin-amount");
3382
3543
  if (coinAmountOverride !== undefined &&
3383
3544
  (!Number.isInteger(coinAmountOverride) || coinAmountOverride < 1)) {
@@ -3402,13 +3563,15 @@ export async function runCommand(argv) {
3402
3563
  },
3403
3564
  }));
3404
3565
  if (!validation.valid) {
3405
- 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" });
3406
3567
  }
3407
3568
  return writeCommand(parsed, {
3408
3569
  action: "create a NEW global goal template (DETERMINISTIC_WORKFLOW — this cannot be converted back)",
3409
3570
  target: { slug: document.slug, title: document.title },
3410
3571
  request: {
3411
- file: path.resolve(filePath),
3572
+ ...(filePath
3573
+ ? { file: path.resolve(filePath) }
3574
+ : { template: document }),
3412
3575
  kind: document.kind,
3413
3576
  setupAudience: document.setupAudience,
3414
3577
  category: document.category ?? null,
@@ -4239,8 +4402,25 @@ export async function runCommand(argv) {
4239
4402
  }
4240
4403
  if (noun === "students") {
4241
4404
  if (verb === "list") {
4242
- // A guide has no family — their roster is the students they are
4243
- // 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
4244
4424
  // stored identity is absent on an env-cookie session, so fall back to
4245
4425
  // asking the server who this session belongs to.
4246
4426
  // The stored identity only describes a `auth login` session; an
@@ -4278,13 +4458,38 @@ export async function runCommand(argv) {
4278
4458
  }));
4279
4459
  }
4280
4460
  if (verb === "todos") {
4281
- 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");
4282
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
+ }
4283
4486
  return unwrap(await api.client.GET("/studio/students/{studentId}/todos", {
4284
4487
  params: {
4285
4488
  path: { studentId },
4286
4489
  query: {
4287
4490
  ...(limit === undefined ? {} : { limit }),
4491
+ ...(date === undefined ? {} : { date }),
4492
+ ...(cursor === undefined ? {} : { cursor }),
4288
4493
  ...(hasFlag(parsed, "analyzed") ? { analyzed: true } : {}),
4289
4494
  },
4290
4495
  },
@@ -4641,7 +4846,8 @@ export async function runCommand(argv) {
4641
4846
  throw new CliError("invalid_arguments", `PDF size must be between 1 byte and ${GOAL_PDF_UPLOAD_MAX_BYTES} bytes.`);
4642
4847
  }
4643
4848
  const fileName = path.basename(absolutePath);
4644
- const workspacePath = flagString(parsed, "path") ?? `uploads/${safeGoalUploadName(fileName)}`;
4849
+ const workspacePath = flagString(parsed, "path") ??
4850
+ `uploads/${sanitizeWorkspaceUploadName(fileName)}`;
4645
4851
  if (!workspacePath.startsWith("uploads/")) {
4646
4852
  throw new CliError("invalid_arguments", "Textbook PDFs must be attached under uploads/.");
4647
4853
  }
@@ -4970,4 +5176,7 @@ export async function runCommand(argv) {
4970
5176
  }
4971
5177
  throw new CliError("unknown_command", `Unknown command: ${parsed.positionals.join(" ")}\n\n${HELP}`);
4972
5178
  }
5179
+ export async function runCommand(argv) {
5180
+ return executeRecessCommand(argv);
5181
+ }
4973
5182
  //# sourceMappingURL=cli.js.map