recess-cli 3.1.0 → 3.3.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 CHANGED
@@ -77,13 +77,19 @@ Create an approved `OAuthClient` row in each Recess environment. This remains a
77
77
 
78
78
  The production row ID (`c7e34138-18f9-45b1-a2fb-26a4e3a6d739`) is the CLI's built-in default, so production login needs no client-ID setup. `--client-id`, `RECESS_CLI_OAUTH_CLIENT_ID`, and a stored client ID remain overrides for local/staging clients. The web-server decodes the assertion audience, loads that exact `OAuthClient`, and requires both `approved` and `adminCliEnabled`; there is no separate server environment allowlist. Production redirect validation is exact, so a different callback port must also be explicitly registered.
79
79
 
80
- The browser SSO assertion is exchanged once and discarded. The CLI stores a separate 12-hour signed Recess session at `~/.recess-cli/config.json` with mode `0600`. A guardian must hold the live `access:ai` permission; removing it immediately invalidates session checks. Guardian sessions cannot call `/admin` routes and every target is independently restricted to their family. A KID session has `village_home` scope: application-level and shared-auth fences permit only session inspection and Village assertion minting, denying every other backend route.
80
+ The browser SSO assertion is exchanged once and discarded. The CLI stores a separate signed Recess session (12 hours by default) at `~/.recess-cli/config.json` with mode `0600`. A guardian must hold the live `access:ai` permission; removing it immediately invalidates session checks. Guardian sessions cannot call `/admin` routes and every target is independently restricted to their family. A KID session has `village_home` scope: application-level and shared-auth fences permit only session inspection and Village assertion minting, denying every other backend route.
81
81
 
82
82
  ```bash
83
83
  recess --json auth login
84
84
  recess --json doctor --reason "Verify CLI connectivity, identity, and scope"
85
85
  ```
86
86
 
87
+ Sessions default to 12 hours. Admins can request 30 days locally with
88
+ `recess --json auth login --duration 30d`, or remotely with
89
+ `recess --json auth request --duration 30d --label "remote agent"`, approve the link as an
90
+ admin, then run `recess --json auth poll`. The approval page shows the requested duration;
91
+ the link still expires after 10 minutes. Non-admins cannot authorize 30-day sessions.
92
+
87
93
  ## Testing against a local server
88
94
 
89
95
  `auth login` opens production SSO, so local iteration uses the env-cookie hatch instead:
package/dist/args.js CHANGED
@@ -6,6 +6,7 @@ import { CliError } from "./errors.js";
6
6
  */
7
7
  export const REPEATABLE_FLAGS = new Set(["kid", "unassign"]);
8
8
  const BOOLEAN_FLAGS = new Set([
9
+ "allow-assigned-edits",
9
10
  "all-references",
10
11
  "allow-strand",
11
12
  "cancel-subscriptions",
package/dist/auth.js CHANGED
@@ -77,7 +77,11 @@ export async function login(config, options) {
77
77
  const assertion = await callback;
78
78
  const response = await fetch(new URL("/auth/admin-cli/exchange/", config.apiOrigin), {
79
79
  method: "POST",
80
- headers: cliRequestHeaders({ authorization: `Bearer ${assertion}` }),
80
+ headers: cliRequestHeaders({
81
+ authorization: `Bearer ${assertion}`,
82
+ "content-type": "application/json",
83
+ }),
84
+ body: JSON.stringify({ sessionDuration: options.sessionDuration }),
81
85
  });
82
86
  const body = (await response.json());
83
87
  if (!response.ok)
@@ -109,7 +113,7 @@ export async function requestDeviceAuth(config, options) {
109
113
  const response = await fetch(new URL("/auth/admin-cli/device/authorize/", config.apiOrigin), {
110
114
  method: "POST",
111
115
  headers: cliRequestHeaders({ "content-type": "application/json" }),
112
- body: JSON.stringify(options.label ? { label: options.label } : {}),
116
+ body: JSON.stringify(options),
113
117
  });
114
118
  const body = (await response.json());
115
119
  if (!response.ok)
@@ -128,7 +132,8 @@ export async function requestDeviceAuth(config, options) {
128
132
  userCode: authorize.userCode,
129
133
  expiresAt: authorize.expiresAt,
130
134
  interval: authorize.interval,
131
- instructions: `Open ${authorize.approvalUrl} in a browser and approve it while signed in to Recess as the person this agent should act as — the session takes on THAT account's scope, so a guardian grants family-only access. Then run \`recess auth poll\`.`,
135
+ sessionDuration: options.sessionDuration ?? "12h",
136
+ instructions: `Open ${authorize.approvalUrl} in a browser and approve it while signed in to Recess ${options.sessionDuration === "30d" ? "as an admin to authorize 30 days of access" : "as the person this agent should act as"} — the session takes on THAT account's scope, so a guardian grants family-only access. Then run \`recess auth poll\`.`,
132
137
  };
133
138
  }
134
139
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
package/dist/cli.js CHANGED
@@ -659,6 +659,44 @@ async function requirePreviewBoundConfirmation(parsed, preview, persistPreview =
659
659
  });
660
660
  return { operationKey: suppliedOperationKey, fingerprint: approvalToken };
661
661
  }
662
+ /**
663
+ * A tutor template's `body` is the `## Subjects` block markdown (no `###`
664
+ * heading — the server renders that from `title` + `cadence`).
665
+ */
666
+ async function readTutorTemplateBody(filePath) {
667
+ const absolutePath = path.resolve(filePath);
668
+ const contents = await fs.readFile(absolutePath, "utf8").catch(() => null);
669
+ if (contents === null) {
670
+ throw new CliError("invalid_arguments", `--body-file is not readable: ${absolutePath}`);
671
+ }
672
+ if (contents.trim().length === 0) {
673
+ throw new CliError("invalid_arguments", "--body-file is empty.");
674
+ }
675
+ return contents;
676
+ }
677
+ /**
678
+ * Every `*.md` in the directory becomes one `lessons[name]` entry, referenced
679
+ * from a module line as `lesson:<slug>/<name>`.
680
+ */
681
+ async function readTutorTemplateLessons(directory) {
682
+ const absolutePath = path.resolve(directory);
683
+ const entries = await fs
684
+ .readdir(absolutePath, { withFileTypes: true })
685
+ .catch(() => null);
686
+ if (entries === null) {
687
+ throw new CliError("invalid_arguments", `--lessons-dir is not a readable directory: ${absolutePath}`);
688
+ }
689
+ const lessons = {};
690
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
691
+ if (!entry.isFile() || !entry.name.toLowerCase().endsWith(".md"))
692
+ continue;
693
+ lessons[entry.name] = await fs.readFile(path.join(absolutePath, entry.name), "utf8");
694
+ }
695
+ if (Object.keys(lessons).length === 0) {
696
+ throw new CliError("invalid_arguments", `--lessons-dir contains no .md files: ${absolutePath}`);
697
+ }
698
+ return lessons;
699
+ }
662
700
  async function previewBoundWrite(parsed, preview, execute, persistPreview = false) {
663
701
  const context = await requirePreviewBoundConfirmation(parsed, preview, persistPreview);
664
702
  try {
@@ -848,6 +886,9 @@ async function readGoalWorkspaceWriteSource(parsed) {
848
886
  const entries = await fs.readdir(directory, { withFileTypes: true });
849
887
  entries.sort((a, b) => a.name.localeCompare(b.name));
850
888
  for (const entry of entries) {
889
+ // Git metadata is local checkout state, including worktree .git files.
890
+ if (entry.name === ".git")
891
+ continue;
851
892
  const relativePath = relativeRoot
852
893
  ? path.join(relativeRoot, entry.name)
853
894
  : entry.name;
@@ -1916,6 +1957,10 @@ export async function executeRecessCommand(argv, options = {}) {
1916
1957
  };
1917
1958
  }
1918
1959
  if (noun === "auth") {
1960
+ const duration = flagString(parsed, "duration");
1961
+ if (duration !== undefined && duration !== "12h" && duration !== "30d") {
1962
+ throw new CliError("invalid_arguments", "--duration must be 12h or 30d (admins only).");
1963
+ }
1919
1964
  if (verb === "login") {
1920
1965
  const oauthClientId = flagString(parsed, "client-id") ?? config.oauthClientId;
1921
1966
  const callbackPort = flagNumber(parsed, "callback-port") ?? 8765;
@@ -1924,11 +1969,15 @@ export async function executeRecessCommand(argv, options = {}) {
1924
1969
  callbackPort > 65535) {
1925
1970
  throw new CliError("invalid_arguments", "--callback-port must be an integer from 1024 through 65535.");
1926
1971
  }
1927
- return login(config, { callbackPort, oauthClientId });
1972
+ return login(config, {
1973
+ callbackPort,
1974
+ oauthClientId,
1975
+ sessionDuration: duration,
1976
+ });
1928
1977
  }
1929
1978
  if (verb === "request") {
1930
1979
  const label = flagString(parsed, "label");
1931
- return requestDeviceAuth(config, label ? { label } : {});
1980
+ return requestDeviceAuth(config, { label, sessionDuration: duration });
1932
1981
  }
1933
1982
  if (verb === "poll") {
1934
1983
  const timeoutSeconds = flagNumber(parsed, "timeout") ?? 300;
@@ -2973,6 +3022,9 @@ export async function executeRecessCommand(argv, options = {}) {
2973
3022
  ...(userId ? { userId } : {}),
2974
3023
  ...(id ? { id } : {}),
2975
3024
  limit,
3025
+ ...(hasFlag(parsed, "include-archived")
3026
+ ? { includeArchived: "true" }
3027
+ : {}),
2976
3028
  ...(flagString(parsed, "cursor")
2977
3029
  ? { cursor: flagString(parsed, "cursor") }
2978
3030
  : {}),
@@ -2980,6 +3032,53 @@ export async function executeRecessCommand(argv, options = {}) {
2980
3032
  },
2981
3033
  }));
2982
3034
  }
3035
+ if ((area === "recipients" &&
3036
+ ["archive", "unarchive", "remove-schedule", "add-schedule"].includes(action)) ||
3037
+ (area === "payruns" && action === "remove-account")) {
3038
+ const accountId = area === "payruns"
3039
+ ? flagString(parsed, "recipient", { required: true })
3040
+ : positional(parsed, 3, "account ID");
3041
+ const payRunId = area === "payruns" ? positional(parsed, 3, "payrun ID") : undefined;
3042
+ const scheduleId = action.endsWith("schedule")
3043
+ ? flagString(parsed, "schedule", { required: true })
3044
+ : undefined;
3045
+ const account = unwrap(await api.client.GET("/admin/payout/recipient/", {
3046
+ params: { query: { id: accountId, includeArchived: "true" } },
3047
+ })).recipients[0];
3048
+ const effects = action === "archive"
3049
+ ? "Archive payout account, end all schedules and cancel all draft/review invoices. Stops new Recess accruals; preserves payment history and the guide login."
3050
+ : action === "unarchive"
3051
+ ? "Unarchive payout account. Does not restore schedules or canceled invoices; add a schedule separately to resume payroll."
3052
+ : action === "remove-schedule"
3053
+ ? "Remove account from recurring schedule and cancel its draft/review invoices for that schedule. Other schedules and payment history are preserved."
3054
+ : action === "add-schedule"
3055
+ ? "Add active payout account to recurring schedule. Future payroll cycles will accrue compensation in Recess."
3056
+ : "Remove account from this payroll cycle and cancel its draft/review invoices. Future cycles are unchanged; regeneration will not re-add this account.";
3057
+ return writeCommand(parsed, {
3058
+ action: effects,
3059
+ target: {
3060
+ accountId,
3061
+ ...(payRunId ? { payRunId } : {}),
3062
+ ...(scheduleId ? { scheduleId } : {}),
3063
+ },
3064
+ request: { action },
3065
+ details: { accountName: account.name, user: account.user },
3066
+ }, async () => {
3067
+ if (action === "archive")
3068
+ return unwrap(await api.client.DELETE("/admin/payout/recipient/{accountId}", {
3069
+ params: { path: { accountId } },
3070
+ }));
3071
+ if (action === "unarchive")
3072
+ return unwrap(await api.client.POST("/admin/payout/recipient/{accountId}/unarchive", { params: { path: { accountId } } }));
3073
+ if (action === "add-schedule")
3074
+ return unwrap(await api.client.POST("/admin/payout/payrun/add-account/", {
3075
+ body: { accountId, scheduleId: scheduleId },
3076
+ }));
3077
+ if (action === "remove-schedule")
3078
+ return unwrap(await api.client.DELETE("/admin/payout/schedule/{targetId}/recipient/{accountId}", { params: { path: { accountId, targetId: scheduleId } } }));
3079
+ return unwrap(await api.client.DELETE("/admin/payout/cycle/{targetId}/recipient/{accountId}", { params: { path: { accountId, targetId: payRunId } } }));
3080
+ });
3081
+ }
2983
3082
  if (area === "invoices" && action === "list") {
2984
3083
  const payRunId = flagString(parsed, "payrun");
2985
3084
  const accountId = flagString(parsed, "recipient");
@@ -4307,6 +4406,65 @@ export async function executeRecessCommand(argv, options = {}) {
4307
4406
  .includes(query))),
4308
4407
  };
4309
4408
  }
4409
+ // Moves ONE kid's lesson, not the template's. The daily queue serves
4410
+ // modules by `ordinal ASC`, so pushing the one a kid is stuck on past the
4411
+ // rest is what hands them the next lesson tomorrow — no template version,
4412
+ // no retiring the work they have already done, nobody else's goal touched.
4413
+ if (verb === "move-module") {
4414
+ const moduleRef = positional(parsed, 2, "module ref (e.g. Q006)");
4415
+ const studentId = flagString(parsed, "student", { required: true });
4416
+ const goalId = flagString(parsed, "goal", { required: true });
4417
+ const toEnd = hasFlag(parsed, "to-end");
4418
+ const position = flagNumber(parsed, "position");
4419
+ if (toEnd === (position !== undefined)) {
4420
+ throw new CliError("invalid_arguments", "Pass exactly one of --to-end or --position <n>.");
4421
+ }
4422
+ const goal = unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
4423
+ params: { path: { userId: studentId } },
4424
+ })).goals.find((candidate) => candidate.id === goalId);
4425
+ const modules = goal?.osV2?.modules ?? [];
4426
+ const target = modules.find((module) => module.moduleRef.toLocaleLowerCase() === moduleRef.toLocaleLowerCase());
4427
+ if (!target) {
4428
+ throw new CliError("not_found", `Module ${moduleRef} was not found on goal ${goalId}. Known refs: ${modules.map((m) => m.moduleRef).join(", ") || "none"}.`);
4429
+ }
4430
+ // Sparse past the current maximum, so no sibling has to be renumbered.
4431
+ const last = modules.reduce((max, m) => Math.max(max, m.ordinal), 0);
4432
+ const ordinal = toEnd ? last + 1 : position;
4433
+ if (ordinal === target.ordinal) {
4434
+ throw new CliError("already_positioned", `Module ${target.moduleRef} ("${target.title}") is already at position ${ordinal}.`);
4435
+ }
4436
+ const collision = modules.find((m) => m.id !== target.id && m.ordinal === ordinal);
4437
+ const preview = {
4438
+ action: toEnd
4439
+ ? "move a lesson to the end of this kid's curriculum"
4440
+ : "move a lesson to a specific position in this kid's curriculum",
4441
+ target: {
4442
+ studentUserId: studentId,
4443
+ goalId,
4444
+ goalTitle: goal?.title ?? null,
4445
+ moduleRef: target.moduleRef,
4446
+ title: target.title,
4447
+ from: target.ordinal,
4448
+ to: ordinal,
4449
+ completed: target.completedAt !== null,
4450
+ },
4451
+ request: { moduleId: target.id, ordinal },
4452
+ details: {
4453
+ note: "Affects only this student's copy of the curriculum — the goal template and every other kid on it are untouched. Completion state is preserved: an unfinished lesson stays unfinished and is simply served later.",
4454
+ ...(collision
4455
+ ? {
4456
+ warning: `Position ${ordinal} is already held by ${collision.moduleRef} ("${collision.title}"). Both will sit at the same position and the queue will order them by creation date — pass --to-end instead if you just want this one out of the way.`,
4457
+ }
4458
+ : {}),
4459
+ },
4460
+ };
4461
+ return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.PATCH("/tutor/students/{studentId}/goals/{goalId}/modules/{moduleId}", {
4462
+ params: {
4463
+ path: { studentId, goalId, moduleId: target.id },
4464
+ },
4465
+ body: { ordinal },
4466
+ })));
4467
+ }
4310
4468
  if (verb === "create") {
4311
4469
  const requestedStudentId = flagString(parsed, "student");
4312
4470
  const sourceDirectory = flagString(parsed, "source-dir");
@@ -4654,7 +4812,7 @@ export async function executeRecessCommand(argv, options = {}) {
4654
4812
  }
4655
4813
  throw new CliError("invalid_arguments", "Use goals queue get|set.");
4656
4814
  }
4657
- throw new CliError("invalid_arguments", "Use goals list|create|edit|delete|restore|archive|unarchive|complete|undo-completion|queue|files|pdf.");
4815
+ throw new CliError("invalid_arguments", "Use goals list|create|edit|delete|restore|archive|unarchive|complete|undo-completion|move-module|queue|files|pdf.");
4658
4816
  }
4659
4817
  if (noun === "chat-logs") {
4660
4818
  return runChatLogsCommand({ parsed, api, writeCommand });
@@ -5081,6 +5239,156 @@ export async function executeRecessCommand(argv, options = {}) {
5081
5239
  }
5082
5240
  throw new CliError("invalid_arguments", "Use rocky get|set.");
5083
5241
  }
5242
+ if (noun === "tutor-templates") {
5243
+ if (verb === "list") {
5244
+ const grade = flagNumber(parsed, "grade");
5245
+ return unwrap(await api.client.GET("/tutor-templates/", {
5246
+ params: {
5247
+ query: {
5248
+ q: flagString(parsed, "q"),
5249
+ subject: flagString(parsed, "subject"),
5250
+ ...(grade === undefined ? {} : { grade }),
5251
+ },
5252
+ },
5253
+ }));
5254
+ }
5255
+ if (verb === "get") {
5256
+ const slug = positional(parsed, 2, "template slug");
5257
+ return unwrap(await api.client.GET("/tutor-templates/{slug}", {
5258
+ params: { path: { slug } },
5259
+ }));
5260
+ }
5261
+ if (verb === "assign" || verb === "unassign") {
5262
+ const slug = positional(parsed, 2, "template slug");
5263
+ const studentId = flagString(parsed, "student");
5264
+ const body = studentId ? { studentId } : {};
5265
+ const template = unwrap(await api.client.GET("/tutor-templates/{slug}", {
5266
+ params: { path: { slug } },
5267
+ }));
5268
+ return writeCommand(parsed, {
5269
+ action: verb === "assign"
5270
+ ? "add a tutor template's subject block to a kid's plan"
5271
+ : "remove a tutor template's subject block from a kid's plan",
5272
+ target: { slug, studentUserId: studentId ?? "the signed-in kid" },
5273
+ request: body,
5274
+ details: {
5275
+ title: template.title,
5276
+ cadence: template.cadence,
5277
+ consequence: verb === "assign"
5278
+ ? "Appends `### <title> — <cadence>` to rocky-v3/plan.md, rebuilds Today, and writes an ACTIVE assignment row."
5279
+ : "Deletes that subject block from rocky-v3/plan.md and marks the assignment REMOVED.",
5280
+ },
5281
+ }, async () => verb === "assign"
5282
+ ? unwrap(await api.client.POST("/tutor-templates/{slug}/assign", {
5283
+ params: { path: { slug } },
5284
+ body,
5285
+ }))
5286
+ : unwrap(await api.client.POST("/tutor-templates/{slug}/unassign", {
5287
+ params: { path: { slug } },
5288
+ body,
5289
+ })));
5290
+ }
5291
+ if (verb === "create" || verb === "update") {
5292
+ const bodyFile = flagString(parsed, "body-file", {
5293
+ required: verb === "create",
5294
+ });
5295
+ const lessonsDir = flagString(parsed, "lessons-dir");
5296
+ const visibility = flagString(parsed, "visibility");
5297
+ const emoji = flagString(parsed, "emoji");
5298
+ const gradeMin = flagNumber(parsed, "grade-min");
5299
+ const gradeMax = flagNumber(parsed, "grade-max");
5300
+ const shared = {
5301
+ ...(flagString(parsed, "title") === undefined
5302
+ ? {}
5303
+ : { title: flagString(parsed, "title") }),
5304
+ ...(flagString(parsed, "summary") === undefined
5305
+ ? {}
5306
+ : { summary: flagString(parsed, "summary") }),
5307
+ ...(flagString(parsed, "subject") === undefined
5308
+ ? {}
5309
+ : { subject: flagString(parsed, "subject") }),
5310
+ ...(flagString(parsed, "cadence") === undefined
5311
+ ? {}
5312
+ : { cadence: flagString(parsed, "cadence") }),
5313
+ ...(emoji === undefined ? {} : { emoji }),
5314
+ ...(gradeMin === undefined ? {} : { gradeMin }),
5315
+ ...(gradeMax === undefined ? {} : { gradeMax }),
5316
+ ...(visibility === undefined
5317
+ ? {}
5318
+ : {
5319
+ visibility: assertChoice(visibility, ["PUBLIC", "STAFF"], "--visibility"),
5320
+ }),
5321
+ ...(bodyFile === undefined
5322
+ ? {}
5323
+ : { body: await readTutorTemplateBody(bodyFile) }),
5324
+ ...(lessonsDir === undefined
5325
+ ? {}
5326
+ : { lessons: await readTutorTemplateLessons(lessonsDir) }),
5327
+ };
5328
+ if (verb === "create") {
5329
+ const missing = ["title", "summary", "subject", "cadence"].filter((name) => !(name in shared));
5330
+ if (missing.length > 0) {
5331
+ throw new CliError("invalid_arguments", `tutor-templates create requires ${missing
5332
+ .map((name) => `--${name}`)
5333
+ .join(", ")}.`);
5334
+ }
5335
+ const slug = flagString(parsed, "slug", { required: true });
5336
+ const body = { slug, ...shared };
5337
+ return writeCommand(parsed, {
5338
+ action: "create a tutor template in the subject catalog",
5339
+ target: { slug },
5340
+ request: {
5341
+ ...body,
5342
+ bodyFile: bodyFile ? path.resolve(bodyFile) : null,
5343
+ lessonsDir: lessonsDir ? path.resolve(lessonsDir) : null,
5344
+ },
5345
+ details: {
5346
+ lessonNames: Object.keys(shared.lessons ?? {}),
5347
+ renamingNote: "`title` is the `### <heading>` join key in every kid's plan.md — renaming later orphans existing plans.",
5348
+ },
5349
+ }, async () => unwrap(await api.client.POST("/tutor-templates/", { body })));
5350
+ }
5351
+ const slug = positional(parsed, 2, "template slug");
5352
+ const body = shared;
5353
+ if (Object.keys(body).length === 0) {
5354
+ throw new CliError("invalid_arguments", "tutor-templates update needs at least one field to change.");
5355
+ }
5356
+ return writeCommand(parsed, {
5357
+ action: "update a tutor template in the subject catalog",
5358
+ target: { slug },
5359
+ request: {
5360
+ ...body,
5361
+ bodyFile: bodyFile ? path.resolve(bodyFile) : null,
5362
+ lessonsDir: lessonsDir ? path.resolve(lessonsDir) : null,
5363
+ },
5364
+ details: {
5365
+ fields: Object.keys(body),
5366
+ renamingNote: "Changing `title` orphans the `### <heading>` in every plan.md already carrying this template.",
5367
+ },
5368
+ }, async () => unwrap(await api.client.PATCH("/tutor-templates/{slug}", {
5369
+ params: { path: { slug } },
5370
+ body,
5371
+ })));
5372
+ }
5373
+ if (verb === "delete") {
5374
+ const slug = positional(parsed, 2, "template slug");
5375
+ const template = unwrap(await api.client.GET("/tutor-templates/{slug}", {
5376
+ params: { path: { slug } },
5377
+ }));
5378
+ return writeCommand(parsed, {
5379
+ action: "soft-delete a tutor template from the subject catalog",
5380
+ target: { slug },
5381
+ request: {},
5382
+ details: {
5383
+ title: template.title,
5384
+ consequence: "The row is soft-deleted; subject blocks already written into kids' plan.md stay where they are.",
5385
+ },
5386
+ }, async () => unwrap(await api.client.DELETE("/tutor-templates/{slug}", {
5387
+ params: { path: { slug } },
5388
+ })));
5389
+ }
5390
+ throw new CliError("invalid_arguments", "Use tutor-templates list|get|create|update|delete|assign|unassign.");
5391
+ }
5084
5392
  if (noun === "goals" && verb === "pdf") {
5085
5393
  const action = positional(parsed, 2, "goals pdf action");
5086
5394
  if (action !== "upload") {
@@ -5351,7 +5659,15 @@ export async function executeRecessCommand(argv, options = {}) {
5351
5659
  content: change.content,
5352
5660
  contentEncoding: change.contentEncoding,
5353
5661
  });
5662
+ const assignedEdits = hasFlag(parsed, "allow-assigned-edits")
5663
+ ? { allowAssignedEdits: true }
5664
+ : {};
5665
+ const stateModification = hasFlag(parsed, "allow-state-modification")
5666
+ ? { allowStateModification: true }
5667
+ : {};
5354
5668
  const body = {
5669
+ ...stateModification,
5670
+ ...assignedEdits,
5355
5671
  studentUserId: checkout.metadata.studentId,
5356
5672
  target: checkout.metadata.target,
5357
5673
  message,
@@ -5367,6 +5683,8 @@ export async function executeRecessCommand(argv, options = {}) {
5367
5683
  throw new CliError("unexpected_response", "Goal workspace push did not return a preview; nothing was written.");
5368
5684
  }
5369
5685
  const request = {
5686
+ ...stateModification,
5687
+ ...assignedEdits,
5370
5688
  sourceDirectory: checkout.root,
5371
5689
  mesaBaseChangeId,
5372
5690
  localBaseCommit,
@@ -5392,6 +5710,8 @@ export async function executeRecessCommand(argv, options = {}) {
5392
5710
  target: boundTarget ?? preflight.target,
5393
5711
  request,
5394
5712
  details: {
5713
+ stateModificationPaths: preflight.stateModificationPaths ?? [],
5714
+ warnings: preflight.warnings ?? [],
5395
5715
  currentChangeId: preflight.currentChangeId,
5396
5716
  resolvedTarget: preflight.target,
5397
5717
  note: "The committed local diff is published as one atomic Mesa change. Deletes and renames follow Git's diff. A changed Mesa tip refuses the push.",
@@ -5436,7 +5756,15 @@ export async function executeRecessCommand(argv, options = {}) {
5436
5756
  : { kind: "draft", draftSlug: draftSlug };
5437
5757
  const message = flagString(parsed, "message") ??
5438
5758
  `recess-cli: write ${input.files.length} workspace file${input.files.length === 1 ? "" : "s"}`;
5759
+ const assignedEdits = hasFlag(parsed, "allow-assigned-edits")
5760
+ ? { allowAssignedEdits: true }
5761
+ : {};
5762
+ const stateModification = hasFlag(parsed, "allow-state-modification")
5763
+ ? { allowStateModification: true }
5764
+ : {};
5439
5765
  const body = {
5766
+ ...stateModification,
5767
+ ...assignedEdits,
5440
5768
  studentUserId: studentId,
5441
5769
  target,
5442
5770
  message,
@@ -5446,6 +5774,8 @@ export async function executeRecessCommand(argv, options = {}) {
5446
5774
  ? unwrap(await api.client.GET("/auth/admin-cli/session/"))
5447
5775
  : null;
5448
5776
  const request = {
5777
+ ...stateModification,
5778
+ ...assignedEdits,
5449
5779
  source: input.source,
5450
5780
  sourceSha256: input.sha256,
5451
5781
  fileCount: input.files.length,
@@ -5480,6 +5810,8 @@ export async function executeRecessCommand(argv, options = {}) {
5480
5810
  target: boundTarget ?? preflight.target,
5481
5811
  request,
5482
5812
  details: {
5813
+ stateModificationPaths: preflight.stateModificationPaths ?? [],
5814
+ warnings: preflight.warnings ?? [],
5483
5815
  currentChangeId: preflight.currentChangeId,
5484
5816
  files: preflight.files,
5485
5817
  resolvedTarget: preflight.target,
@@ -2,6 +2,8 @@ import { CliError } from "./errors.js";
2
2
  export const AGENT_CONTEXT_SCHEMA_VERSION = "4";
3
3
  const BOOLEAN_FLAGS = new Set([
4
4
  "allow-possible-duplicate",
5
+ "allow-state-modification",
6
+ "allow-assigned-edits",
5
7
  "all-references",
6
8
  "analyzed",
7
9
  "apply",
@@ -27,6 +29,7 @@ const BOOLEAN_FLAGS = new Set([
27
29
  "mirrored",
28
30
  "no-collision",
29
31
  "no-invite",
32
+ "open",
30
33
  "refresh",
31
34
  "resend",
32
35
  "restore",
@@ -139,6 +142,15 @@ const AUTHENTICATED_COMMAND_PREFIXES = [
139
142
  "village render",
140
143
  ];
141
144
  const FAMILY_AI_COMMANDS = new Set([
145
+ "apps assign",
146
+ "apps list",
147
+ "apps preview",
148
+ "apps publish",
149
+ "apps pull",
150
+ "apps scaffold",
151
+ "apps standards",
152
+ "apps status",
153
+ "apps validate",
142
154
  "chat-logs by-todo",
143
155
  "chat-logs get",
144
156
  "chat-logs list",
@@ -161,6 +173,7 @@ const FAMILY_AI_COMMANDS = new Set([
161
173
  "goals files read",
162
174
  "goals files write",
163
175
  "goals list",
176
+ "goals move-module",
164
177
  "goals pdf upload",
165
178
  "goals queue get",
166
179
  "goals queue set",
@@ -179,6 +192,10 @@ const FAMILY_AI_COMMANDS = new Set([
179
192
  "students xp-history",
180
193
  "todos create",
181
194
  "todos edit",
195
+ "tutor-templates assign",
196
+ "tutor-templates get",
197
+ "tutor-templates list",
198
+ "tutor-templates unassign",
182
199
  ]);
183
200
  const STAFF_COMMANDS = new Set([
184
201
  "cohorts schedule get",
@@ -191,15 +208,6 @@ const STAFF_COMMANDS = new Set([
191
208
  "memories history",
192
209
  "memories revision",
193
210
  "memories restore",
194
- "apps assign",
195
- "apps list",
196
- "apps preview",
197
- "apps publish",
198
- "apps pull",
199
- "apps scaffold",
200
- "apps standards",
201
- "apps status",
202
- "apps validate",
203
211
  "cohorts email",
204
212
  "cohorts end",
205
213
  "cohorts get",
@@ -291,6 +299,9 @@ const STAFF_COMMANDS = new Set([
291
299
  "todos delete",
292
300
  "todos restore",
293
301
  "todos generate-applet",
302
+ "tutor-templates create",
303
+ "tutor-templates delete",
304
+ "tutor-templates update",
294
305
  "users get",
295
306
  "users tier list-tiers",
296
307
  "village events",
@@ -358,13 +358,22 @@ async function submit(ctx, dir, dryRun) {
358
358
  const manifest = await readManifest(dir);
359
359
  const link = await readAppLink(dir);
360
360
  const name = flagString(ctx.parsed, "name") ?? manifest.title ?? path.basename(dir);
361
+ // --open ships the folder's frame as written: Studio skips the locked-scaffold check and the
362
+ // guide's own shell, styles and screens are what the kid gets. Plain apps already own every file.
363
+ const open = hasFlag(ctx.parsed, "open");
364
+ if (open && !manifest.template) {
365
+ throw new CliError("invalid_arguments", "--open is for template apps (manifest.template); a plain app already ships every file as written.");
366
+ }
367
+ const template = manifest.template
368
+ ? { ...manifest.template, ...(open ? { scaffold: false } : {}) }
369
+ : undefined;
361
370
  const body = {
362
371
  ...(link?.projectId ? { projectId: link.projectId } : {}),
363
372
  name,
364
373
  ...(manifest.description ? { description: manifest.description } : {}),
365
374
  files,
366
375
  contract,
367
- ...(manifest.template ? { template: manifest.template } : {}),
376
+ ...(template ? { template } : {}),
368
377
  ...(manifest.primitives?.length ? { primitives: manifest.primitives } : {}),
369
378
  dryRun,
370
379
  };
package/dist/help.js CHANGED
@@ -28,8 +28,8 @@ Usage:
28
28
  recess [--json] jobs prune [--older-than-days 30]
29
29
  recess [--json] feedback list [--limit 20]
30
30
  recess [--json] feedback submit <text> [--confirm]
31
- recess [--json] auth login [--client-id ID] [--callback-port 8765]
32
- recess [--json] auth request [--label TEXT]
31
+ recess [--json] auth login [--client-id ID] [--callback-port 8765] [--duration 12h|30d]
32
+ recess [--json] auth request [--label TEXT] [--duration 12h|30d]
33
33
  recess [--json] auth poll [--timeout 300]
34
34
  recess [--json] auth status|logout
35
35
  recess [--json] users search <name-or-id> [--limit 10]
@@ -78,8 +78,8 @@ Usage:
78
78
  recess [--json] apps standards <query>
79
79
  recess [--json] apps list
80
80
  recess [--json] apps pull <project-id> [dir]
81
- recess [--json] apps validate [dir] [--name TEXT] [--no-wait]
82
- recess [--json] apps publish [dir] [--name TEXT] [--assign <kid-id>] [--due YYYY-MM-DD] [--no-wait]
81
+ recess [--json] apps validate [dir] [--name TEXT] [--open] [--no-wait]
82
+ recess [--json] apps publish [dir] [--name TEXT] [--open] [--assign <kid-id>] [--due YYYY-MM-DD] [--no-wait]
83
83
  recess [--json] apps assign [dir] --student <kid-id> [--due YYYY-MM-DD]
84
84
  recess [--json] apps status <build-id>
85
85
  recess [--json] applications list [--status SUBMITTED|CLAIMED|ENROLLED|CLOSED]
@@ -130,10 +130,15 @@ Usage:
130
130
  recess [--json] billing cancel-subscription --subscription <id>
131
131
  [--immediate] [--reason TEXT] [--restore] [--confirm]
132
132
  recess [--json] payout payruns list [--status A,B] [--schedule <id>]
133
- recess [--json] payout recipients list [--search <name>] [--user <id>] [--id <id>]
133
+ recess [--json] payout recipients list [--search <name>] [--user <id>] [--id <id>] [--include-archived]
134
134
  [--limit 20] [--cursor <id>]
135
135
  recess [--json] payout invoices list [--payrun <id>] [--recipient <account-id>]
136
136
  [--user <id>] [--status A,B] (at least one filter)
137
+ recess [--json] payout recipients archive <account-id> [--confirm]
138
+ recess [--json] payout recipients unarchive <account-id> [--confirm]
139
+ recess [--json] payout recipients remove-schedule <account-id> --schedule <id> [--confirm]
140
+ recess [--json] payout recipients add-schedule <account-id> --schedule <id> [--confirm]
141
+ recess [--json] payout payruns remove-account <payrun-id> --recipient <account-id> [--confirm]
137
142
  recess [--json] payout invoices get <invoice-id>
138
143
  recess [--json] payout invoices set-status <invoice-id>
139
144
  --status IN_REVIEW|OPEN|PAID|CANCELED [--send-email] [--confirm]
@@ -405,6 +410,8 @@ Usage:
405
410
  [--confirm --approval-token TOKEN]
406
411
  recess [--json] goals unarchive <goal-id> --student <kid-id>
407
412
  [--confirm --approval-token TOKEN]
413
+ recess [--json] goals move-module <module-ref> --student <kid-id> --goal <goal-id>
414
+ (--to-end | --position <n>) [--confirm --approval-token TOKEN]
408
415
  recess [--json] goals queue get <goal-id> --student <kid-id>
409
416
  recess [--json] goals queue set <goal-id> --student <kid-id>
410
417
  --entries-file <path.json> --delta TEXT [--replace-description-pointer]
@@ -435,6 +442,19 @@ Usage:
435
442
  recess [--json] rocky get --student <kid-id>
436
443
  recess [--json] rocky set --student <kid-id> --patch-file <path/patch.json>
437
444
  --expected-updated-at ISO|none [--confirm]
445
+ recess [--json] tutor-templates list [--q TEXT] [--subject TEXT] [--grade N]
446
+ recess [--json] tutor-templates get <slug>
447
+ recess [--json] tutor-templates assign <slug> [--student <kid-id>] [--confirm]
448
+ recess [--json] tutor-templates unassign <slug> [--student <kid-id>] [--confirm]
449
+ recess [--json] tutor-templates create --slug SLUG --title TEXT --summary TEXT
450
+ --subject TEXT --cadence TEXT --body-file <path.md> [--lessons-dir <dir>]
451
+ [--emoji TEXT] [--grade-min N] [--grade-max N]
452
+ [--visibility PUBLIC|STAFF] [--confirm]
453
+ recess [--json] tutor-templates update <slug> [--title TEXT] [--summary TEXT]
454
+ [--subject TEXT] [--cadence TEXT] [--body-file <path.md>]
455
+ [--lessons-dir <dir>] [--emoji TEXT] [--grade-min N] [--grade-max N]
456
+ [--visibility PUBLIC|STAFF] [--confirm]
457
+ recess [--json] tutor-templates delete <slug> [--confirm]
438
458
  recess [--json] goals files list --student <goal-owner-id> --goal <goal-id>
439
459
  recess [--json] goals files read --student <goal-owner-id> --goal <goal-id> --path P
440
460
  recess [--json] goals files init --student <goal-owner-id> --draft <draft-slug>
@@ -443,11 +463,13 @@ Usage:
443
463
  (--goal <goal-id> | --draft <draft-slug>)
444
464
  --output-dir <local-dir>
445
465
  recess [--json] goals files push --source-dir <local-checkout>
446
- [--message TEXT] [--confirm --approval-token TOKEN]
466
+ [--message TEXT] [--allow-state-modification] [--allow-assigned-edits]
467
+ [--confirm --approval-token TOKEN]
447
468
  recess [--json] goals files write --student <goal-owner-id>
448
469
  (--goal <goal-id> | --draft <draft-slug>)
449
470
  (--source-dir <local-dir> | --source-file <local-file> --path P)
450
- [--message TEXT] [--confirm --approval-token TOKEN]
471
+ [--message TEXT] [--allow-state-modification] [--allow-assigned-edits]
472
+ [--confirm --approval-token TOKEN]
451
473
  recess [--json] goals pdf upload --student <goal-owner-id>
452
474
  (--goal <goal-id> | --draft <draft-slug>) --source-file <local.pdf>
453
475
  [--path uploads/name.pdf] [--message TEXT]
@@ -459,6 +481,14 @@ bundled into this npm package. Load os-v2-goal-template-builder and its
459
481
  references/deterministic-workflow-setup.md BEFORE authoring a template; that is
460
482
  the same guidance the recess.gg/ai agent follows, so there is exactly one
461
483
  standard. Responses cache under ~/.recess-cli/skills-cache/ (--refresh re-fetches).
484
+ Goal file writes/pushes warn and refuse curriculum edits with unfinished assignments
485
+ unless --allow-assigned-edits is supplied. This can change lessons while a student
486
+ is working; assignments are not retired/completed and student answers are not merged.
487
+ The flag requires a fresh preview and confirmation; revision checks still apply.
488
+ Goal file writes/pushes require --allow-state-modification to modify state or
489
+ student-work folders, or replace/delete an existing resources/artifacts file.
490
+ Review the protected paths in the warning; the flag does not preserve answers
491
+ automatically or bypass assignment/revision checks. Adding it needs a new preview.
462
492
  For goal workspace and PDF commands, --student names the goal owner. A
463
493
  full_admin session may use a KID or ADMIN user id, including its own ADMIN id;
464
494
  family_ai sessions remain limited to managed KID profiles.
@@ -551,7 +581,8 @@ cannot call /admin; KID sessions cannot call any non-Village API command. For a
551
581
  cloud agent that has no local browser to open, "auth request" prints an approval URL; whoever
552
582
  opens it and approves in a signed-in web session grants THEIR OWN scope, so a guardian approving
553
583
  mints a family-scoped session and only an admin can mint a full-admin one. "auth poll" then
554
- collects the 12h session. Kids cannot approve. When it lapses, run "auth request" again for a
584
+ collects the session. Sessions default to 12h; admins can request 30 days with --duration 30d
585
+ on either login or request. Only admins can approve 30-day requests. Kids cannot approve. When it lapses, run "auth request" again for a
555
586
  fresh link. Both paths yield the same session.
556
587
 
557
588
  Skill notes: this CLI's own agent skill ships inside the npm package AND is served
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "recess-cli",
3
- "version": "3.1.0",
3
+ "version": "3.3.0",
4
4
  "description": "Safe Recess administration and family AI tools from the command line.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -41,7 +41,8 @@ For machine-readable discovery filtered to the current session's locally decoded
41
41
  - Headless (no browser): `auth request`, then a human approves the printed URL while signed in to
42
42
  Recess, then `auth poll`. The session takes on the **approver's** scope — a guardian approving
43
43
  grants family-only access, not staff access. Kids cannot approve.
44
- - Sessions last 12 hours. `auth status` and `doctor` recheck the live user role and permissions;
44
+ - Sessions default to 12 hours. Admins can use `auth login --duration 30d` locally or
45
+ `auth request --duration 30d` remotely; only an admin can approve a 30-day request. `auth status` and `doctor` recheck the live user role and permissions;
45
46
  discovery stays local and may reflect the minted scope until the next login.
46
47
  - `auth logout` clears the stored session.
47
48
  - The default API is production. If `RECESS_CLI_API_ORIGIN` is set, state the non-default origin before acting.
@@ -90,7 +91,7 @@ bundles/authorized URLs. `/mcp/legacy` temporarily retains `recess_exec({command
90
91
 
91
92
  ## Building Studio apps (`recess apps`)
92
93
 
93
- Guides build Recess Studio apps in their own editor and publish them with the CLI; Studio never
94
+ Guides and guardians build Recess Studio apps in their own editor and publish them with the CLI; Studio never
94
95
  prompts a model for them.
95
96
 
96
97
  - `recess --json apps init <dir> --reason "..."` writes the locked scaffold plus `AGENTS.md` (the
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "3.0.0",
3
- "minCliVersion": "3.0.0"
2
+ "version": "3.2.0",
3
+ "minCliVersion": "3.2.0"
4
4
  }