recess-cli 3.1.0 → 3.2.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;
@@ -4307,6 +4356,65 @@ export async function executeRecessCommand(argv, options = {}) {
4307
4356
  .includes(query))),
4308
4357
  };
4309
4358
  }
4359
+ // Moves ONE kid's lesson, not the template's. The daily queue serves
4360
+ // modules by `ordinal ASC`, so pushing the one a kid is stuck on past the
4361
+ // rest is what hands them the next lesson tomorrow — no template version,
4362
+ // no retiring the work they have already done, nobody else's goal touched.
4363
+ if (verb === "move-module") {
4364
+ const moduleRef = positional(parsed, 2, "module ref (e.g. Q006)");
4365
+ const studentId = flagString(parsed, "student", { required: true });
4366
+ const goalId = flagString(parsed, "goal", { required: true });
4367
+ const toEnd = hasFlag(parsed, "to-end");
4368
+ const position = flagNumber(parsed, "position");
4369
+ if (toEnd === (position !== undefined)) {
4370
+ throw new CliError("invalid_arguments", "Pass exactly one of --to-end or --position <n>.");
4371
+ }
4372
+ const goal = unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
4373
+ params: { path: { userId: studentId } },
4374
+ })).goals.find((candidate) => candidate.id === goalId);
4375
+ const modules = goal?.osV2?.modules ?? [];
4376
+ const target = modules.find((module) => module.moduleRef.toLocaleLowerCase() === moduleRef.toLocaleLowerCase());
4377
+ if (!target) {
4378
+ throw new CliError("not_found", `Module ${moduleRef} was not found on goal ${goalId}. Known refs: ${modules.map((m) => m.moduleRef).join(", ") || "none"}.`);
4379
+ }
4380
+ // Sparse past the current maximum, so no sibling has to be renumbered.
4381
+ const last = modules.reduce((max, m) => Math.max(max, m.ordinal), 0);
4382
+ const ordinal = toEnd ? last + 1 : position;
4383
+ if (ordinal === target.ordinal) {
4384
+ throw new CliError("already_positioned", `Module ${target.moduleRef} ("${target.title}") is already at position ${ordinal}.`);
4385
+ }
4386
+ const collision = modules.find((m) => m.id !== target.id && m.ordinal === ordinal);
4387
+ const preview = {
4388
+ action: toEnd
4389
+ ? "move a lesson to the end of this kid's curriculum"
4390
+ : "move a lesson to a specific position in this kid's curriculum",
4391
+ target: {
4392
+ studentUserId: studentId,
4393
+ goalId,
4394
+ goalTitle: goal?.title ?? null,
4395
+ moduleRef: target.moduleRef,
4396
+ title: target.title,
4397
+ from: target.ordinal,
4398
+ to: ordinal,
4399
+ completed: target.completedAt !== null,
4400
+ },
4401
+ request: { moduleId: target.id, ordinal },
4402
+ details: {
4403
+ 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.",
4404
+ ...(collision
4405
+ ? {
4406
+ 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.`,
4407
+ }
4408
+ : {}),
4409
+ },
4410
+ };
4411
+ return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.PATCH("/tutor/students/{studentId}/goals/{goalId}/modules/{moduleId}", {
4412
+ params: {
4413
+ path: { studentId, goalId, moduleId: target.id },
4414
+ },
4415
+ body: { ordinal },
4416
+ })));
4417
+ }
4310
4418
  if (verb === "create") {
4311
4419
  const requestedStudentId = flagString(parsed, "student");
4312
4420
  const sourceDirectory = flagString(parsed, "source-dir");
@@ -4654,7 +4762,7 @@ export async function executeRecessCommand(argv, options = {}) {
4654
4762
  }
4655
4763
  throw new CliError("invalid_arguments", "Use goals queue get|set.");
4656
4764
  }
4657
- throw new CliError("invalid_arguments", "Use goals list|create|edit|delete|restore|archive|unarchive|complete|undo-completion|queue|files|pdf.");
4765
+ throw new CliError("invalid_arguments", "Use goals list|create|edit|delete|restore|archive|unarchive|complete|undo-completion|move-module|queue|files|pdf.");
4658
4766
  }
4659
4767
  if (noun === "chat-logs") {
4660
4768
  return runChatLogsCommand({ parsed, api, writeCommand });
@@ -5081,6 +5189,156 @@ export async function executeRecessCommand(argv, options = {}) {
5081
5189
  }
5082
5190
  throw new CliError("invalid_arguments", "Use rocky get|set.");
5083
5191
  }
5192
+ if (noun === "tutor-templates") {
5193
+ if (verb === "list") {
5194
+ const grade = flagNumber(parsed, "grade");
5195
+ return unwrap(await api.client.GET("/tutor-templates/", {
5196
+ params: {
5197
+ query: {
5198
+ q: flagString(parsed, "q"),
5199
+ subject: flagString(parsed, "subject"),
5200
+ ...(grade === undefined ? {} : { grade }),
5201
+ },
5202
+ },
5203
+ }));
5204
+ }
5205
+ if (verb === "get") {
5206
+ const slug = positional(parsed, 2, "template slug");
5207
+ return unwrap(await api.client.GET("/tutor-templates/{slug}", {
5208
+ params: { path: { slug } },
5209
+ }));
5210
+ }
5211
+ if (verb === "assign" || verb === "unassign") {
5212
+ const slug = positional(parsed, 2, "template slug");
5213
+ const studentId = flagString(parsed, "student");
5214
+ const body = studentId ? { studentId } : {};
5215
+ const template = unwrap(await api.client.GET("/tutor-templates/{slug}", {
5216
+ params: { path: { slug } },
5217
+ }));
5218
+ return writeCommand(parsed, {
5219
+ action: verb === "assign"
5220
+ ? "add a tutor template's subject block to a kid's plan"
5221
+ : "remove a tutor template's subject block from a kid's plan",
5222
+ target: { slug, studentUserId: studentId ?? "the signed-in kid" },
5223
+ request: body,
5224
+ details: {
5225
+ title: template.title,
5226
+ cadence: template.cadence,
5227
+ consequence: verb === "assign"
5228
+ ? "Appends `### <title> — <cadence>` to rocky-v3/plan.md, rebuilds Today, and writes an ACTIVE assignment row."
5229
+ : "Deletes that subject block from rocky-v3/plan.md and marks the assignment REMOVED.",
5230
+ },
5231
+ }, async () => verb === "assign"
5232
+ ? unwrap(await api.client.POST("/tutor-templates/{slug}/assign", {
5233
+ params: { path: { slug } },
5234
+ body,
5235
+ }))
5236
+ : unwrap(await api.client.POST("/tutor-templates/{slug}/unassign", {
5237
+ params: { path: { slug } },
5238
+ body,
5239
+ })));
5240
+ }
5241
+ if (verb === "create" || verb === "update") {
5242
+ const bodyFile = flagString(parsed, "body-file", {
5243
+ required: verb === "create",
5244
+ });
5245
+ const lessonsDir = flagString(parsed, "lessons-dir");
5246
+ const visibility = flagString(parsed, "visibility");
5247
+ const emoji = flagString(parsed, "emoji");
5248
+ const gradeMin = flagNumber(parsed, "grade-min");
5249
+ const gradeMax = flagNumber(parsed, "grade-max");
5250
+ const shared = {
5251
+ ...(flagString(parsed, "title") === undefined
5252
+ ? {}
5253
+ : { title: flagString(parsed, "title") }),
5254
+ ...(flagString(parsed, "summary") === undefined
5255
+ ? {}
5256
+ : { summary: flagString(parsed, "summary") }),
5257
+ ...(flagString(parsed, "subject") === undefined
5258
+ ? {}
5259
+ : { subject: flagString(parsed, "subject") }),
5260
+ ...(flagString(parsed, "cadence") === undefined
5261
+ ? {}
5262
+ : { cadence: flagString(parsed, "cadence") }),
5263
+ ...(emoji === undefined ? {} : { emoji }),
5264
+ ...(gradeMin === undefined ? {} : { gradeMin }),
5265
+ ...(gradeMax === undefined ? {} : { gradeMax }),
5266
+ ...(visibility === undefined
5267
+ ? {}
5268
+ : {
5269
+ visibility: assertChoice(visibility, ["PUBLIC", "STAFF"], "--visibility"),
5270
+ }),
5271
+ ...(bodyFile === undefined
5272
+ ? {}
5273
+ : { body: await readTutorTemplateBody(bodyFile) }),
5274
+ ...(lessonsDir === undefined
5275
+ ? {}
5276
+ : { lessons: await readTutorTemplateLessons(lessonsDir) }),
5277
+ };
5278
+ if (verb === "create") {
5279
+ const missing = ["title", "summary", "subject", "cadence"].filter((name) => !(name in shared));
5280
+ if (missing.length > 0) {
5281
+ throw new CliError("invalid_arguments", `tutor-templates create requires ${missing
5282
+ .map((name) => `--${name}`)
5283
+ .join(", ")}.`);
5284
+ }
5285
+ const slug = flagString(parsed, "slug", { required: true });
5286
+ const body = { slug, ...shared };
5287
+ return writeCommand(parsed, {
5288
+ action: "create a tutor template in the subject catalog",
5289
+ target: { slug },
5290
+ request: {
5291
+ ...body,
5292
+ bodyFile: bodyFile ? path.resolve(bodyFile) : null,
5293
+ lessonsDir: lessonsDir ? path.resolve(lessonsDir) : null,
5294
+ },
5295
+ details: {
5296
+ lessonNames: Object.keys(shared.lessons ?? {}),
5297
+ renamingNote: "`title` is the `### <heading>` join key in every kid's plan.md — renaming later orphans existing plans.",
5298
+ },
5299
+ }, async () => unwrap(await api.client.POST("/tutor-templates/", { body })));
5300
+ }
5301
+ const slug = positional(parsed, 2, "template slug");
5302
+ const body = shared;
5303
+ if (Object.keys(body).length === 0) {
5304
+ throw new CliError("invalid_arguments", "tutor-templates update needs at least one field to change.");
5305
+ }
5306
+ return writeCommand(parsed, {
5307
+ action: "update a tutor template in the subject catalog",
5308
+ target: { slug },
5309
+ request: {
5310
+ ...body,
5311
+ bodyFile: bodyFile ? path.resolve(bodyFile) : null,
5312
+ lessonsDir: lessonsDir ? path.resolve(lessonsDir) : null,
5313
+ },
5314
+ details: {
5315
+ fields: Object.keys(body),
5316
+ renamingNote: "Changing `title` orphans the `### <heading>` in every plan.md already carrying this template.",
5317
+ },
5318
+ }, async () => unwrap(await api.client.PATCH("/tutor-templates/{slug}", {
5319
+ params: { path: { slug } },
5320
+ body,
5321
+ })));
5322
+ }
5323
+ if (verb === "delete") {
5324
+ const slug = positional(parsed, 2, "template slug");
5325
+ const template = unwrap(await api.client.GET("/tutor-templates/{slug}", {
5326
+ params: { path: { slug } },
5327
+ }));
5328
+ return writeCommand(parsed, {
5329
+ action: "soft-delete a tutor template from the subject catalog",
5330
+ target: { slug },
5331
+ request: {},
5332
+ details: {
5333
+ title: template.title,
5334
+ consequence: "The row is soft-deleted; subject blocks already written into kids' plan.md stay where they are.",
5335
+ },
5336
+ }, async () => unwrap(await api.client.DELETE("/tutor-templates/{slug}", {
5337
+ params: { path: { slug } },
5338
+ })));
5339
+ }
5340
+ throw new CliError("invalid_arguments", "Use tutor-templates list|get|create|update|delete|assign|unassign.");
5341
+ }
5084
5342
  if (noun === "goals" && verb === "pdf") {
5085
5343
  const action = positional(parsed, 2, "goals pdf action");
5086
5344
  if (action !== "upload") {
@@ -5351,7 +5609,15 @@ export async function executeRecessCommand(argv, options = {}) {
5351
5609
  content: change.content,
5352
5610
  contentEncoding: change.contentEncoding,
5353
5611
  });
5612
+ const assignedEdits = hasFlag(parsed, "allow-assigned-edits")
5613
+ ? { allowAssignedEdits: true }
5614
+ : {};
5615
+ const stateModification = hasFlag(parsed, "allow-state-modification")
5616
+ ? { allowStateModification: true }
5617
+ : {};
5354
5618
  const body = {
5619
+ ...stateModification,
5620
+ ...assignedEdits,
5355
5621
  studentUserId: checkout.metadata.studentId,
5356
5622
  target: checkout.metadata.target,
5357
5623
  message,
@@ -5367,6 +5633,8 @@ export async function executeRecessCommand(argv, options = {}) {
5367
5633
  throw new CliError("unexpected_response", "Goal workspace push did not return a preview; nothing was written.");
5368
5634
  }
5369
5635
  const request = {
5636
+ ...stateModification,
5637
+ ...assignedEdits,
5370
5638
  sourceDirectory: checkout.root,
5371
5639
  mesaBaseChangeId,
5372
5640
  localBaseCommit,
@@ -5392,6 +5660,8 @@ export async function executeRecessCommand(argv, options = {}) {
5392
5660
  target: boundTarget ?? preflight.target,
5393
5661
  request,
5394
5662
  details: {
5663
+ stateModificationPaths: preflight.stateModificationPaths ?? [],
5664
+ warnings: preflight.warnings ?? [],
5395
5665
  currentChangeId: preflight.currentChangeId,
5396
5666
  resolvedTarget: preflight.target,
5397
5667
  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 +5706,15 @@ export async function executeRecessCommand(argv, options = {}) {
5436
5706
  : { kind: "draft", draftSlug: draftSlug };
5437
5707
  const message = flagString(parsed, "message") ??
5438
5708
  `recess-cli: write ${input.files.length} workspace file${input.files.length === 1 ? "" : "s"}`;
5709
+ const assignedEdits = hasFlag(parsed, "allow-assigned-edits")
5710
+ ? { allowAssignedEdits: true }
5711
+ : {};
5712
+ const stateModification = hasFlag(parsed, "allow-state-modification")
5713
+ ? { allowStateModification: true }
5714
+ : {};
5439
5715
  const body = {
5716
+ ...stateModification,
5717
+ ...assignedEdits,
5440
5718
  studentUserId: studentId,
5441
5719
  target,
5442
5720
  message,
@@ -5446,6 +5724,8 @@ export async function executeRecessCommand(argv, options = {}) {
5446
5724
  ? unwrap(await api.client.GET("/auth/admin-cli/session/"))
5447
5725
  : null;
5448
5726
  const request = {
5727
+ ...stateModification,
5728
+ ...assignedEdits,
5449
5729
  source: input.source,
5450
5730
  sourceSha256: input.sha256,
5451
5731
  fileCount: input.files.length,
@@ -5480,6 +5760,8 @@ export async function executeRecessCommand(argv, options = {}) {
5480
5760
  target: boundTarget ?? preflight.target,
5481
5761
  request,
5482
5762
  details: {
5763
+ stateModificationPaths: preflight.stateModificationPaths ?? [],
5764
+ warnings: preflight.warnings ?? [],
5483
5765
  currentChangeId: preflight.currentChangeId,
5484
5766
  files: preflight.files,
5485
5767
  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]
@@ -405,6 +405,8 @@ Usage:
405
405
  [--confirm --approval-token TOKEN]
406
406
  recess [--json] goals unarchive <goal-id> --student <kid-id>
407
407
  [--confirm --approval-token TOKEN]
408
+ recess [--json] goals move-module <module-ref> --student <kid-id> --goal <goal-id>
409
+ (--to-end | --position <n>) [--confirm --approval-token TOKEN]
408
410
  recess [--json] goals queue get <goal-id> --student <kid-id>
409
411
  recess [--json] goals queue set <goal-id> --student <kid-id>
410
412
  --entries-file <path.json> --delta TEXT [--replace-description-pointer]
@@ -435,6 +437,19 @@ Usage:
435
437
  recess [--json] rocky get --student <kid-id>
436
438
  recess [--json] rocky set --student <kid-id> --patch-file <path/patch.json>
437
439
  --expected-updated-at ISO|none [--confirm]
440
+ recess [--json] tutor-templates list [--q TEXT] [--subject TEXT] [--grade N]
441
+ recess [--json] tutor-templates get <slug>
442
+ recess [--json] tutor-templates assign <slug> [--student <kid-id>] [--confirm]
443
+ recess [--json] tutor-templates unassign <slug> [--student <kid-id>] [--confirm]
444
+ recess [--json] tutor-templates create --slug SLUG --title TEXT --summary TEXT
445
+ --subject TEXT --cadence TEXT --body-file <path.md> [--lessons-dir <dir>]
446
+ [--emoji TEXT] [--grade-min N] [--grade-max N]
447
+ [--visibility PUBLIC|STAFF] [--confirm]
448
+ recess [--json] tutor-templates update <slug> [--title TEXT] [--summary TEXT]
449
+ [--subject TEXT] [--cadence TEXT] [--body-file <path.md>]
450
+ [--lessons-dir <dir>] [--emoji TEXT] [--grade-min N] [--grade-max N]
451
+ [--visibility PUBLIC|STAFF] [--confirm]
452
+ recess [--json] tutor-templates delete <slug> [--confirm]
438
453
  recess [--json] goals files list --student <goal-owner-id> --goal <goal-id>
439
454
  recess [--json] goals files read --student <goal-owner-id> --goal <goal-id> --path P
440
455
  recess [--json] goals files init --student <goal-owner-id> --draft <draft-slug>
@@ -443,11 +458,13 @@ Usage:
443
458
  (--goal <goal-id> | --draft <draft-slug>)
444
459
  --output-dir <local-dir>
445
460
  recess [--json] goals files push --source-dir <local-checkout>
446
- [--message TEXT] [--confirm --approval-token TOKEN]
461
+ [--message TEXT] [--allow-state-modification] [--allow-assigned-edits]
462
+ [--confirm --approval-token TOKEN]
447
463
  recess [--json] goals files write --student <goal-owner-id>
448
464
  (--goal <goal-id> | --draft <draft-slug>)
449
465
  (--source-dir <local-dir> | --source-file <local-file> --path P)
450
- [--message TEXT] [--confirm --approval-token TOKEN]
466
+ [--message TEXT] [--allow-state-modification] [--allow-assigned-edits]
467
+ [--confirm --approval-token TOKEN]
451
468
  recess [--json] goals pdf upload --student <goal-owner-id>
452
469
  (--goal <goal-id> | --draft <draft-slug>) --source-file <local.pdf>
453
470
  [--path uploads/name.pdf] [--message TEXT]
@@ -459,6 +476,14 @@ bundled into this npm package. Load os-v2-goal-template-builder and its
459
476
  references/deterministic-workflow-setup.md BEFORE authoring a template; that is
460
477
  the same guidance the recess.gg/ai agent follows, so there is exactly one
461
478
  standard. Responses cache under ~/.recess-cli/skills-cache/ (--refresh re-fetches).
479
+ Goal file writes/pushes warn and refuse curriculum edits with unfinished assignments
480
+ unless --allow-assigned-edits is supplied. This can change lessons while a student
481
+ is working; assignments are not retired/completed and student answers are not merged.
482
+ The flag requires a fresh preview and confirmation; revision checks still apply.
483
+ Goal file writes/pushes require --allow-state-modification to modify state or
484
+ student-work folders, or replace/delete an existing resources/artifacts file.
485
+ Review the protected paths in the warning; the flag does not preserve answers
486
+ automatically or bypass assignment/revision checks. Adding it needs a new preview.
462
487
  For goal workspace and PDF commands, --student names the goal owner. A
463
488
  full_admin session may use a KID or ADMIN user id, including its own ADMIN id;
464
489
  family_ai sessions remain limited to managed KID profiles.
@@ -551,7 +576,8 @@ cannot call /admin; KID sessions cannot call any non-Village API command. For a
551
576
  cloud agent that has no local browser to open, "auth request" prints an approval URL; whoever
552
577
  opens it and approves in a signed-in web session grants THEIR OWN scope, so a guardian approving
553
578
  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
579
+ collects the session. Sessions default to 12h; admins can request 30 days with --duration 30d
580
+ 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
581
  fresh link. Both paths yield the same session.
556
582
 
557
583
  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.2.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
  }