recess-cli 2.4.0 → 2.5.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
@@ -22,6 +22,29 @@ recess setup --reason "Install and update the Recess agent skill"
22
22
 
23
23
  Publishing rides the production deploy (`.github/workflows/admin-cli-publish.yml`): bump `version` in `apps/admin-cli/package.json` in a normal PR to `staging`, and it publishes when `staging` promotes to `production`. A production deploy that did not bump the version is a no-op — a `gate` job checks the version against npm first. The same workflow is still dispatchable by hand for out-of-band releases. pnpm packs the CLI so the workspace `catalog:` dependency becomes a real range; npm then publishes that tarball through the workflow's OIDC trusted-publishing path. The registry-side publisher must be configured as described in `docs/codebase/admin-cli.md`.
24
24
 
25
+ ## Building Studio apps with your agent
26
+
27
+ ```bash
28
+ recess apps init my-applet --reason "New applet" # scaffold + AGENTS.md (the contract your agent follows)
29
+ # build it in your editor with Claude Code / Codex / Cursor, then:
30
+ recess --json apps validate my-applet --reason "Check it" # Studio's validation, nothing published
31
+ recess --json apps publish my-applet --reason "Ship it" # validation + independent review → live at appUrl
32
+ recess --json apps list --reason "See my apps"
33
+ recess apps pull <project-id> my-applet --reason "Keep editing"
34
+ ```
35
+
36
+ Guides and staff only. The app folder keeps its project id in `.recess/app.json`, so publishing again updates the same app.
37
+
38
+ For a specific kid, start from their session instead of a blank description:
39
+
40
+ ```bash
41
+ recess --json students todos --student <kid-id> --analyzed --reason "What did they struggle with"
42
+ recess --json students analysis --todo <todo-id> --reason "Read the analysis"
43
+ recess apps init fix-it --for-todo <todo-id> --reason "Build for this gap" # scaffold + brief.md
44
+ recess --json apps publish fix-it --assign <kid-id> --due 2026-09-01 --reason "Ship it to the kid"
45
+ recess --json apps standards "grade 4 adding fractions" --reason "Find the code"
46
+ ```
47
+
25
48
  ## Install (from a checkout — CLI development)
26
49
 
27
50
  From the monolith root:
package/dist/args.js CHANGED
@@ -30,6 +30,7 @@ const BOOLEAN_FLAGS = new Set([
30
30
  "skill-only",
31
31
  "spec-only",
32
32
  "starter-only",
33
+ "unlink",
33
34
  "visual-only",
34
35
  "version",
35
36
  "wait",
package/dist/cli.js CHANGED
@@ -8,8 +8,10 @@ import { login, pollDeviceAuth, requestDeviceAuth } from "./auth.js";
8
8
  import { clearStoredSession, deleteProfile, listProfiles, resolveConfig, saveProfile, useProfile, } from "./config.js";
9
9
  import { agentContext, buildCommandSchema, scopedHelp, validateInvocation, } from "./command-schema.js";
10
10
  import { runApplicationsCommand } from "./commands/applications.js";
11
+ import { runAppsCommand } from "./commands/apps.js";
11
12
  import { runOnboardingCommand } from "./commands/onboarding.js";
12
13
  import { runSchoolCommand } from "./commands/school.js";
14
+ import { runVillageEventsCommand } from "./commands/village-events.js";
13
15
  import { assertChoice, flagIdList, positional, readJsonFile, readJsonValue, } from "./commands/shared.js";
14
16
  import { CliError } from "./errors.js";
15
17
  import { listFeedback, submitFeedback } from "./feedback.js";
@@ -869,6 +871,10 @@ const SCHOOL_TIER_OPTIONS = [
869
871
  { id: "platform", name: "Platform Only", defaultClassSlots: 0 },
870
872
  ];
871
873
  const SCHOOL_TIER_IDS = SCHOOL_TIER_OPTIONS.map((tier) => tier.id);
874
+ // Roles `users restore` may hand back. ADMIN and MODERATOR are absent on
875
+ // purpose — a CLI session must never be able to mint staff; promotion has its
876
+ // own paths. The server's POST /admin/users/change-role/ enforces the same set.
877
+ const RESTORABLE_ROLES = ["KID", "GUARDIAN", "GUIDE", "PROGRAM"];
872
878
  const MAX_MAP_PDF_BYTES = 15 * 1024 * 1024;
873
879
  const GOAL_TEMPLATE_KINDS = ["SIMPLE", "BLUEPRINT"];
874
880
  const GOAL_TEMPLATE_SETUP_AUDIENCES = ["KID_FRIENDLY", "PARENT_SETUP"];
@@ -1960,8 +1966,11 @@ export async function runCommand(argv) {
1960
1966
  }
1961
1967
  throw new CliError("invalid_arguments", "Use village worlds export|import|promote.");
1962
1968
  }
1969
+ if (verb === "events" || verb === "link-event") {
1970
+ return runVillageEventsCommand({ parsed, api, writeCommand }, verb);
1971
+ }
1963
1972
  if (verb !== "models") {
1964
- throw new CliError("invalid_arguments", "Use village build …, village library …, village objects …, village worlds …, village models …, or village render …");
1973
+ throw new CliError("invalid_arguments", "Use village build …, village library …, village objects …, village worlds …, village models …, village events, village link-event …, or village render …");
1965
1974
  }
1966
1975
  const action = positional(parsed, 2, "Village model action");
1967
1976
  if (action === "list") {
@@ -2259,6 +2268,140 @@ export async function runCommand(argv) {
2259
2268
  params: { path: { userId } },
2260
2269
  }));
2261
2270
  }
2271
+ if (noun === "users" && (verb === "lock" || verb === "unlock")) {
2272
+ const userId = positional(parsed, 2, "user ID");
2273
+ const locking = verb === "lock";
2274
+ const profile = unwrap(await api.client.GET("/admin/users/{userId}", {
2275
+ params: { path: { userId } },
2276
+ }));
2277
+ const target = profile.user;
2278
+ // Mirrors the recess.gg/ai admin Lock/Unlock buttons exactly: a KID is
2279
+ // locked together with its GUARDIANs (an unlocked kid under a locked
2280
+ // guardian is not actually unlocked), anyone else is treated as the
2281
+ // guardian side alone.
2282
+ const guardians = (target.family?.users ?? []).filter((member) => member.role === "GUARDIAN");
2283
+ const kidIds = target.role === "KID" ? [target.id] : [];
2284
+ const guardianIds = target.role === "KID"
2285
+ ? guardians.map((member) => member.id)
2286
+ : [target.id];
2287
+ const isUnlocked = (attributes) => (attributes ?? []).some((attribute) => attribute.attribute === "FLAG_ACCOUNT_UNLOCKED");
2288
+ const describe = (member) => ({
2289
+ id: member.id,
2290
+ name: [member.firstName, member.lastName].filter(Boolean).join(" "),
2291
+ role: member.role,
2292
+ unlockedNow: isUnlocked(member.attributes),
2293
+ });
2294
+ const affected = [
2295
+ ...(target.role === "KID" ? [describe(target)] : []),
2296
+ ...(target.role === "KID" ? guardians.map(describe) : [describe(target)]),
2297
+ ];
2298
+ const preview = {
2299
+ userId: target.id,
2300
+ name: [target.firstName, target.lastName].filter(Boolean).join(" "),
2301
+ role: target.role,
2302
+ familyId: target.familyId,
2303
+ accountUnlockedNow: target.role === "KID"
2304
+ ? isUnlocked(target.attributes) &&
2305
+ guardians.every((member) => isUnlocked(member.attributes))
2306
+ : isUnlocked(target.attributes),
2307
+ accountUnlockedAfter: !locking,
2308
+ kidIds,
2309
+ guardianIds,
2310
+ affected,
2311
+ };
2312
+ if (target.role === "KID" && guardianIds.length === 0) {
2313
+ throw new CliError("invalid_arguments", `${preview.name || target.id} has no GUARDIAN in their family, so ${verb}ing the kid alone would leave the account in a mixed state. Resolve the family's guardians first.`, 1, { preview });
2314
+ }
2315
+ const body = { kidIds, guardianIds };
2316
+ return writeCommand(parsed, {
2317
+ action: locking
2318
+ ? "lock the account: revoke the kid and guardian access roles, drop the kid from their Recess channels, and clear FLAG_ACCOUNT_UNLOCKED"
2319
+ : "unlock the account: grant the kid and guardian access roles, restore the kid's Recess channel memberships, and set FLAG_ACCOUNT_UNLOCKED (the kid's clients are notified live)",
2320
+ target: { userId: target.id, kidIds, guardianIds },
2321
+ request: body,
2322
+ details: preview,
2323
+ }, async () => unwrap(await api.client.POST(locking ? "/admin/users/lock/" : "/admin/users/unlock/", { body })));
2324
+ }
2325
+ if (noun === "users" && (verb === "disable" || verb === "restore")) {
2326
+ const userId = positional(parsed, 2, "user ID");
2327
+ const disabling = verb === "disable";
2328
+ // Restoring is one person at a time on purpose: DISABLED does not record
2329
+ // what the account used to be, so the caller must name the role. --family
2330
+ // would have to guess one per member.
2331
+ const wholeFamily = disabling && hasFlag(parsed, "whole-family");
2332
+ const role = disabling
2333
+ ? "DISABLED"
2334
+ : assertChoice(flagString(parsed, "role", { required: true }).toUpperCase(), RESTORABLE_ROLES, "--role");
2335
+ // Server-resolved preflight (admin-cli.md C3): who else is in the family,
2336
+ // who is already DISABLED, and who still has billable enrollments. It reads
2337
+ // through findUnique, so it also resolves an already-disabled target — that
2338
+ // is what makes a rerun (and every restore) previewable.
2339
+ const state = unwrap(await api.client.GET("/admin/users/{userId}/role-preflight/", {
2340
+ params: { path: { userId } },
2341
+ }));
2342
+ const describe = (member) => ({
2343
+ id: member.id,
2344
+ name: [member.firstName, member.lastName].filter(Boolean).join(" "),
2345
+ role: member.role,
2346
+ email: member.email,
2347
+ billableEnrollments: member.billableEnrollments,
2348
+ // Informational: a role change does NOT revoke these. Stripping access
2349
+ // roles is what `users lock` does — run it alongside a disable when the
2350
+ // account should also lose its grants.
2351
+ accessRoles: member.accessRoles,
2352
+ });
2353
+ const candidates = wholeFamily ? state.members : [state.target];
2354
+ const skipped = [];
2355
+ const acting = [];
2356
+ for (const member of candidates) {
2357
+ const entry = describe(member);
2358
+ if (member.id === state.actorId) {
2359
+ skipped.push({ ...entry, reason: "this is your own account" });
2360
+ continue;
2361
+ }
2362
+ if (member.role === "ADMIN" || member.role === "MODERATOR") {
2363
+ skipped.push({
2364
+ ...entry,
2365
+ reason: `${member.role} accounts are never role-changed from the CLI`,
2366
+ });
2367
+ continue;
2368
+ }
2369
+ if (disabling && member.disabled) {
2370
+ skipped.push({ ...entry, reason: "already DISABLED" });
2371
+ continue;
2372
+ }
2373
+ if (!disabling && !member.disabled) {
2374
+ skipped.push({ ...entry, reason: `already ${member.role}` });
2375
+ continue;
2376
+ }
2377
+ acting.push(entry);
2378
+ }
2379
+ const preview = {
2380
+ userId,
2381
+ family: state.family,
2382
+ role,
2383
+ [disabling ? "disabling" : "restoring"]: acting,
2384
+ skipped,
2385
+ };
2386
+ if (acting.length === 0) {
2387
+ throw new CliError("no_op", `Nothing to ${verb}: every candidate was skipped. See preview.skipped for why.`, 1, { preview });
2388
+ }
2389
+ const billing = acting.filter((member) => member.billableEnrollments > 0);
2390
+ if (disabling && billing.length > 0 && !hasFlag(parsed, "allow-billing")) {
2391
+ throw new CliError("billable_enrollments", `${billing
2392
+ .map((member) => `${member.name} (${member.billableEnrollments})`)
2393
+ .join(", ")} still hold ACTIVE or PAST_DUE enrollments. Cancel billing first, or rerun with --allow-billing to orphan those paid seats deliberately.`, 1, { preview });
2394
+ }
2395
+ const body = { userIds: acting.map((member) => member.id), role };
2396
+ return writeCommand(parsed, {
2397
+ action: disabling
2398
+ ? `disable ${acting.length} account(s): set User.role to DISABLED, which hides them from every dashboard, search, chat, and todo query, and purges their coherent-feed personal data. Soft and reversible via users restore — this is NOT a GDPR erasure`
2399
+ : `restore ${acting.length} account(s) from DISABLED to ${role}`,
2400
+ target: { userId, userIds: body.userIds, family: state.family },
2401
+ request: body,
2402
+ details: preview,
2403
+ }, async () => unwrap(await api.client.POST("/admin/users/change-role/", { body })));
2404
+ }
2262
2405
  if (noun === "guides" && verb === "create") {
2263
2406
  const email = flagString(parsed, "email", { required: true })
2264
2407
  .trim()
@@ -2395,6 +2538,9 @@ export async function runCommand(argv) {
2395
2538
  if (noun === "applications" || noun === "quotes") {
2396
2539
  return runApplicationsCommand({ parsed, api, writeCommand });
2397
2540
  }
2541
+ if (noun === "apps") {
2542
+ return runAppsCommand({ parsed, api, writeCommand });
2543
+ }
2398
2544
  if (noun === "school") {
2399
2545
  return runSchoolCommand({ parsed, api, writeCommand });
2400
2546
  }
@@ -4101,6 +4247,25 @@ export async function runCommand(argv) {
4101
4247
  },
4102
4248
  }));
4103
4249
  }
4250
+ if (verb === "todos") {
4251
+ const studentId = flagString(parsed, "student", { required: true });
4252
+ const limit = flagNumber(parsed, "limit");
4253
+ return unwrap(await api.client.GET("/studio/students/{studentId}/todos", {
4254
+ params: {
4255
+ path: { studentId },
4256
+ query: {
4257
+ ...(limit === undefined ? {} : { limit }),
4258
+ ...(hasFlag(parsed, "analyzed") ? { analyzed: true } : {}),
4259
+ },
4260
+ },
4261
+ }));
4262
+ }
4263
+ if (verb === "analysis") {
4264
+ const todoId = flagString(parsed, "todo", { required: true });
4265
+ return unwrap(await api.client.GET("/studio/todos/{todoId}/analysis", {
4266
+ params: { path: { todoId } },
4267
+ }));
4268
+ }
4104
4269
  if (verb === "xp-history") {
4105
4270
  const kidId = flagString(parsed, "student", { required: true });
4106
4271
  const range = flagString(parsed, "range");
@@ -4114,7 +4279,7 @@ export async function runCommand(argv) {
4114
4279
  },
4115
4280
  }));
4116
4281
  }
4117
- throw new CliError("invalid_arguments", "Use students list|today|schedule|xp-history|upload-map-scores.");
4282
+ throw new CliError("invalid_arguments", "Use students list|today|schedule|todos|analysis|xp-history|upload-map-scores.");
4118
4283
  }
4119
4284
  if (noun === "todos") {
4120
4285
  if (verb === "create") {
@@ -4142,6 +4307,7 @@ export async function runCommand(argv) {
4142
4307
  const todoId = positional(parsed, 2, "todo ID");
4143
4308
  const patchFile = flagString(parsed, "patch-file", { required: true });
4144
4309
  const body = (await readJsonFile(patchFile, "Todo patch file"));
4310
+ const sessionRole = await resolveSessionRole(api);
4145
4311
  const current = unwrap(await api.client.GET("/tutor/browser/todos/{id}/", {
4146
4312
  params: { path: { id: todoId } },
4147
4313
  }));
@@ -4154,11 +4320,23 @@ export async function runCommand(argv) {
4154
4320
  expectedUpdatedAt: current.updatedAt,
4155
4321
  patch: body,
4156
4322
  },
4323
+ details: sessionRole === "ADMIN"
4324
+ ? {
4325
+ writeRoute: "/admin/todos/{id} (staff route; supports the full typed todo patch)",
4326
+ }
4327
+ : {
4328
+ writeRoute: "/tutor/browser/todos/{id} (family-scoped field allowlist)",
4329
+ },
4157
4330
  };
4158
- return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.PATCH("/tutor/browser/todos/{id}", {
4159
- params: { path: { id: todoId } },
4160
- body,
4161
- })));
4331
+ return previewBoundWrite(parsed, preview, async () => sessionRole === "ADMIN"
4332
+ ? unwrap(await api.client.PATCH("/admin/todos/{id}", {
4333
+ params: { path: { id: todoId } },
4334
+ body: body,
4335
+ }))
4336
+ : unwrap(await api.client.PATCH("/tutor/browser/todos/{id}", {
4337
+ params: { path: { id: todoId } },
4338
+ body,
4339
+ })));
4162
4340
  }
4163
4341
  if (verb === "complete") {
4164
4342
  const todoId = positional(parsed, 2, "todo ID");
@@ -4201,7 +4379,6 @@ export async function runCommand(argv) {
4201
4379
  },
4202
4380
  request: body,
4203
4381
  details: {
4204
- currentXpReward: current.xpReward,
4205
4382
  alreadyAwardedXp: completionXpAwarded,
4206
4383
  targetTotalXp: targetXp,
4207
4384
  newlyAwardedXp: newXp,
@@ -2,7 +2,9 @@ import { CliError } from "./errors.js";
2
2
  export const AGENT_CONTEXT_SCHEMA_VERSION = "4";
3
3
  const BOOLEAN_FLAGS = new Set([
4
4
  "all-references",
5
+ "analyzed",
5
6
  "apply",
7
+ "allow-billing",
6
8
  "allow-strand",
7
9
  "archived",
8
10
  "cancel-subscriptions",
@@ -11,6 +13,8 @@ const BOOLEAN_FLAGS = new Set([
11
13
  "confirm-destructive-changes",
12
14
  "disable-applet-follow-ups",
13
15
  "dry-run",
16
+ "force",
17
+ "no-wait",
14
18
  "enable-applet-follow-ups",
15
19
  "full",
16
20
  "help",
@@ -30,7 +34,9 @@ const BOOLEAN_FLAGS = new Set([
30
34
  "spec-only",
31
35
  "starter-only",
32
36
  "supersede",
37
+ "unlink",
33
38
  "visual-only",
39
+ "whole-family",
34
40
  "wait",
35
41
  ]);
36
42
  const REPEATABLE_FLAGS = new Set(["kid", "unassign"]);
@@ -142,6 +148,7 @@ const STAFF_COMMANDS = new Set([
142
148
  "onboarding ixl-preview",
143
149
  "onboarding kids",
144
150
  "onboarding lifecycle-prompts",
151
+ "onboarding link-math-academy",
145
152
  "onboarding mark-reviewed",
146
153
  "onboarding next",
147
154
  "onboarding orientation-attendance",
@@ -195,6 +202,8 @@ const STAFF_COMMANDS = new Set([
195
202
  "todos generate-applet",
196
203
  "users get",
197
204
  "users tier list-tiers",
205
+ "village events",
206
+ "village link-event",
198
207
  ]);
199
208
  /**
200
209
  * Classify the command itself, not the target passed to it. Target-level
@@ -307,8 +316,13 @@ function positionalBounds(usage) {
307
316
  const rest = usage.replace(/^recess\s+(?:\[--json\]\s+)?/, "");
308
317
  const tokens = rest.split(/\s+/);
309
318
  let count = 0;
319
+ let optional = 0;
310
320
  let variadic = false;
311
321
  for (const token of tokens) {
322
+ if (/^\[[a-z][a-z0-9-]*\]$/.test(token)) {
323
+ optional += 1;
324
+ continue;
325
+ }
312
326
  if (token.startsWith("--") ||
313
327
  token.startsWith("[") ||
314
328
  token.startsWith("(")) {
@@ -318,7 +332,7 @@ function positionalBounds(usage) {
318
332
  if (/^<[^,>]+\.\.\.>$/.test(token))
319
333
  variadic = true;
320
334
  }
321
- return { min: count, max: variadic ? null : count };
335
+ return { min: count, max: variadic ? null : count + optional };
322
336
  }
323
337
  export function buildCommandSchema(help) {
324
338
  return usageBlocks(help).flatMap((usage) => {
@@ -0,0 +1,413 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { unwrap } from "../api.js";
4
+ import { flagString, hasFlag } from "../args.js";
5
+ import { CliError } from "../errors.js";
6
+ import { positional } from "./shared.js";
7
+ const PROJECT_FILE = ".recess/app.json";
8
+ const CONTRACT_FILE = "contract.json";
9
+ const MANIFEST_FILE = "manifest.json";
10
+ const BRIEF_FILE = "brief.md";
11
+ const SKIP_DIRS = new Set([".recess", ".git", "node_modules"]);
12
+ const MAX_FILE_BYTES = 512 * 1024;
13
+ const TERMINAL = new Set(["COMPLETE", "FAILED", "REJECTED"]);
14
+ async function readAppLink(dir) {
15
+ try {
16
+ const raw = await fs.readFile(path.join(dir, PROJECT_FILE), "utf8");
17
+ const parsed = JSON.parse(raw);
18
+ return parsed && typeof parsed === "object" ? parsed : null;
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ async function writeAppLink(dir, link) {
25
+ await fs.mkdir(path.join(dir, ".recess"), { recursive: true });
26
+ await fs.writeFile(path.join(dir, PROJECT_FILE), JSON.stringify(link, null, 2) + "\n");
27
+ }
28
+ /** Every text file under dir (relative, forward-slash paths); the CLI's own state stays out. */
29
+ async function collectFiles(dir) {
30
+ const files = {};
31
+ async function walk(current, prefix) {
32
+ const entries = await fs.readdir(current, { withFileTypes: true });
33
+ for (const entry of entries) {
34
+ if (SKIP_DIRS.has(entry.name))
35
+ continue;
36
+ const full = path.join(current, entry.name);
37
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
38
+ if (entry.isDirectory()) {
39
+ await walk(full, rel);
40
+ continue;
41
+ }
42
+ if (!entry.isFile())
43
+ continue;
44
+ const stat = await fs.stat(full);
45
+ if (stat.size > MAX_FILE_BYTES) {
46
+ throw new CliError("invalid_arguments", `${rel} is larger than ${MAX_FILE_BYTES / 1024} KB; Studio apps are small text files.`);
47
+ }
48
+ files[rel] = await fs.readFile(full, "utf8");
49
+ }
50
+ }
51
+ await walk(dir, "");
52
+ if (!files["index.html"]) {
53
+ throw new CliError("invalid_arguments", `${dir} has no index.html. Run \`recess apps init\` first, or point --dir at the app.`);
54
+ }
55
+ return files;
56
+ }
57
+ async function writeFiles(dir, files) {
58
+ let count = 0;
59
+ for (const [rel, content] of Object.entries(files)) {
60
+ if (rel.includes("..") || path.isAbsolute(rel))
61
+ continue;
62
+ const full = path.join(dir, rel);
63
+ await fs.mkdir(path.dirname(full), { recursive: true });
64
+ await fs.writeFile(full, content);
65
+ count += 1;
66
+ }
67
+ return count;
68
+ }
69
+ function resolveDir(ctx, positionalIndex) {
70
+ const flag = flagString(ctx.parsed, "dir");
71
+ const fromPositional = ctx.parsed.positionals[positionalIndex];
72
+ return path.resolve(flag ?? fromPositional ?? ".");
73
+ }
74
+ async function readContract(dir) {
75
+ const raw = await fs
76
+ .readFile(path.join(dir, CONTRACT_FILE), "utf8")
77
+ .catch(() => null);
78
+ if (!raw) {
79
+ throw new CliError("invalid_arguments", `${CONTRACT_FILE} is missing. Your agent writes the BuildContract there (see AGENTS.md in the app folder).`);
80
+ }
81
+ let parsed;
82
+ try {
83
+ parsed = JSON.parse(raw);
84
+ }
85
+ catch {
86
+ throw new CliError("invalid_arguments", `${CONTRACT_FILE} is not valid JSON.`);
87
+ }
88
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
89
+ throw new CliError("invalid_arguments", `${CONTRACT_FILE} must be a JSON object (the BuildContract).`);
90
+ }
91
+ return parsed;
92
+ }
93
+ async function readManifest(dir) {
94
+ const raw = await fs
95
+ .readFile(path.join(dir, MANIFEST_FILE), "utf8")
96
+ .catch(() => null);
97
+ if (!raw)
98
+ return {};
99
+ try {
100
+ return JSON.parse(raw);
101
+ }
102
+ catch {
103
+ throw new CliError("invalid_arguments", `${MANIFEST_FILE} is not valid JSON.`);
104
+ }
105
+ }
106
+ function slug(value) {
107
+ return value
108
+ .toLowerCase()
109
+ .replace(/[^a-z0-9]+/g, "-")
110
+ .replace(/^-+|-+$/g, "")
111
+ .slice(0, 48);
112
+ }
113
+ function briefFor(data) {
114
+ const { todo, analysis } = data;
115
+ const misconceptions = analysis.patterns.map((p) => p.instanceKey ?? slug(p.name));
116
+ const lines = [
117
+ "# Build brief — remediation for a kid's todo",
118
+ "",
119
+ `Source todo: "${todo.title}" (${todo.id})${todo.completedAt ? `, completed ${todo.completedAt.slice(0, 10)}` : ""}`,
120
+ ...(todo.description ? [`Todo description: ${todo.description}`] : []),
121
+ ...(data.standards.length
122
+ ? [
123
+ "",
124
+ "## Standard(s)",
125
+ ...data.standards.map((s) => `- ${s.notation} — ${s.description}`),
126
+ ]
127
+ : [
128
+ "",
129
+ "## Standard(s)",
130
+ "- none mapped; pick one with `recess apps standards <words>`",
131
+ ]),
132
+ ...(data.gradeBand ? ["", `Grade band: ${data.gradeBand}`] : []),
133
+ ...(data.interests.length
134
+ ? [`Interests: ${data.interests.join(", ")}`]
135
+ : []),
136
+ "",
137
+ "## What went wrong (forensic analysis of the session)",
138
+ "",
139
+ ...(analysis.overallPerformance
140
+ ? [`Overall: ${analysis.overallPerformance}`, ""]
141
+ : []),
142
+ ...(analysis.rootCause ? [`Root cause: ${analysis.rootCause}`, ""] : []),
143
+ ...(analysis.summary ? [`Summary: ${analysis.summary}`, ""] : []),
144
+ "### Patterns",
145
+ ...(analysis.patterns.length
146
+ ? analysis.patterns.flatMap((p) => [
147
+ `- **${p.name}**${p.instanceKey ? ` (\`${p.instanceKey}\`)` : ""}: ${p.issue}`,
148
+ ` ${p.analysis}`,
149
+ ...(p.behavioralGap
150
+ ? [
151
+ ` Observed: ${p.behavioralGap.observed}`,
152
+ ` Expected: ${p.behavioralGap.expected}`,
153
+ ]
154
+ : []),
155
+ ])
156
+ : ["- none recorded"]),
157
+ "",
158
+ "### Prescriptions",
159
+ ...(analysis.prescriptions.length
160
+ ? analysis.prescriptions.map((p) => `- ${p.type}: ${p.recommendation}`)
161
+ : ["- none recorded"]),
162
+ "",
163
+ "## Build against this",
164
+ "",
165
+ "- Mode: remediate. The app exists to close the gap above — every practice item must be one the kid",
166
+ " would get wrong by the observed behaviour, and the fork must confront observed vs expected.",
167
+ `- In manifest.json declare template.misconceptions as ${JSON.stringify(misconceptions)} (match the`,
168
+ " keys above; add more only if the model teaches them) and template.ccss as the standard.",
169
+ "- Theme items with the interests listed; never use the kid's name or anything from the session.",
170
+ "- The kid must never learn this app exists because of a mistake: no 'last time', 'you got',",
171
+ " 'let's fix your'. The confront intro shows a stranger's attempt ('Someone tried this'); the",
172
+ " title and every line read like any other practice.",
173
+ "- Follow AGENTS.md for everything else (contract, no reveals, one reading per step).",
174
+ "",
175
+ ];
176
+ return lines.join("\n");
177
+ }
178
+ function agentsGuide(docs) {
179
+ return [
180
+ "# Building this Recess app",
181
+ "",
182
+ "If a brief.md sits next to this file, it is the assignment: build for that kid's gap first —",
183
+ "but the kid never hears about the gap: no copy that refers to their past work or a mistake.",
184
+ "",
185
+ "This folder is a Recess Studio app on the locked scaffold. Edit ONLY: skill.js, model.js,",
186
+ "model.css, params.schema.json, params.json. Never touch index.html, styles.css, shell.js,",
187
+ "widgets.js, engine.js, key.js — a modified locked file fails validation.",
188
+ "",
189
+ "Before publishing, write two files at the root:",
190
+ "- contract.json — the BuildContract described below (copy the shape in the template contract).",
191
+ '- manifest.json — { "title": "...", "description": "...", "template": { "ccss": "5.NF.A.1", "modes": ["prepare","remediate"], "misconceptions": ["..."] } }',
192
+ "",
193
+ 'Then: `recess apps validate --reason "..."` (reports without publishing) and',
194
+ '`recess apps publish --reason "..."` (validation + independent review; on pass the app is live).',
195
+ "",
196
+ "---",
197
+ "",
198
+ docs.runtime ?? "",
199
+ "",
200
+ "---",
201
+ "",
202
+ docs.template ?? "",
203
+ "",
204
+ "---",
205
+ "",
206
+ docs.pedagogy ? `# Pedagogy\n\n${docs.pedagogy}` : "",
207
+ "",
208
+ "---",
209
+ "",
210
+ docs.design ? `# Design\n\n${docs.design}` : "",
211
+ "",
212
+ ].join("\n");
213
+ }
214
+ async function pollBuild(ctx, buildId, timeoutMs) {
215
+ const started = Date.now();
216
+ let delay = 3_000;
217
+ for (;;) {
218
+ const status = unwrap(await ctx.api.client.GET("/studio/builds/{buildId}", {
219
+ params: { path: { buildId } },
220
+ }));
221
+ if (TERMINAL.has(status.status))
222
+ return status;
223
+ if (Date.now() - started > timeoutMs) {
224
+ throw new CliError("timeout", `Build ${buildId} is still ${status.status} after ${Math.round(timeoutMs / 1000)}s; check later with \`recess apps status ${buildId}\`.`);
225
+ }
226
+ await new Promise((r) => setTimeout(r, delay));
227
+ delay = Math.min(delay * 1.5, 15_000);
228
+ }
229
+ }
230
+ async function submit(ctx, dir, dryRun) {
231
+ const files = await collectFiles(dir);
232
+ const contract = await readContract(dir);
233
+ const manifest = await readManifest(dir);
234
+ const link = await readAppLink(dir);
235
+ const name = flagString(ctx.parsed, "name") ?? manifest.title ?? path.basename(dir);
236
+ const body = {
237
+ ...(link?.projectId ? { projectId: link.projectId } : {}),
238
+ name,
239
+ ...(manifest.description ? { description: manifest.description } : {}),
240
+ files,
241
+ contract,
242
+ ...(manifest.template ? { template: manifest.template } : {}),
243
+ dryRun,
244
+ };
245
+ const assignTo = flagString(ctx.parsed, "assign");
246
+ const due = flagString(ctx.parsed, "due");
247
+ if (assignTo && dryRun) {
248
+ throw new CliError("invalid_arguments", "--assign publishes; use `recess apps publish --assign <student-id>`.");
249
+ }
250
+ if (due && !/^\d{4}-\d{2}-\d{2}$/.test(due)) {
251
+ throw new CliError("invalid_arguments", "--due must be YYYY-MM-DD.");
252
+ }
253
+ const created = unwrap(await ctx.api.client.POST("/studio/imports", { body }));
254
+ await writeAppLink(dir, {
255
+ ...link,
256
+ projectId: created.projectId,
257
+ appUrl: created.appUrl,
258
+ });
259
+ const wait = !hasFlag(ctx.parsed, "no-wait");
260
+ if (!wait) {
261
+ if (assignTo) {
262
+ throw new CliError("invalid_arguments", "--assign needs the build result; drop --no-wait.");
263
+ }
264
+ return { ...created, dryRun };
265
+ }
266
+ const status = await pollBuild(ctx, created.buildId, 30 * 60 * 1000);
267
+ const result = {
268
+ projectId: created.projectId,
269
+ buildId: created.buildId,
270
+ appUrl: created.appUrl,
271
+ dryRun,
272
+ status: status.status,
273
+ report: status.report ?? null,
274
+ error: status.error ?? null,
275
+ };
276
+ if (!assignTo)
277
+ return result;
278
+ if (status.status !== "COMPLETE") {
279
+ return {
280
+ ...result,
281
+ assigned: null,
282
+ assignError: `Not assigned: the build ended ${status.status}. Fix the report and publish again.`,
283
+ };
284
+ }
285
+ const assigned = await assign(ctx, created.projectId, {
286
+ studentId: assignTo,
287
+ ...(due ? { dueDate: due } : {}),
288
+ ...(link?.forTodoId ? { sourceTodoId: link.forTodoId } : {}),
289
+ });
290
+ return { ...result, assigned };
291
+ }
292
+ async function assign(ctx, projectId, body) {
293
+ return unwrap(await ctx.api.client.POST("/studio/projects/{projectId}/assign", {
294
+ params: { path: { projectId } },
295
+ body,
296
+ }));
297
+ }
298
+ export async function runAppsCommand(ctx) {
299
+ const { parsed, api } = ctx;
300
+ const verb = parsed.positionals[1];
301
+ if (verb === "init") {
302
+ const dir = path.resolve(positional(parsed, 2, "app folder"));
303
+ await fs.mkdir(dir, { recursive: true });
304
+ const existing = await fs.readdir(dir);
305
+ if (existing.length > 0 && !hasFlag(parsed, "force")) {
306
+ throw new CliError("invalid_arguments", `${dir} is not empty. Use --force to write the scaffold over it.`);
307
+ }
308
+ const forTodo = flagString(parsed, "for-todo");
309
+ const analysis = forTodo
310
+ ? unwrap(await api.client.GET("/studio/todos/{todoId}/analysis", {
311
+ params: { path: { todoId: forTodo } },
312
+ }))
313
+ : null;
314
+ const scaffold = unwrap(await api.client.GET("/studio/scaffold"));
315
+ const written = await writeFiles(dir, scaffold.files);
316
+ await fs.writeFile(path.join(dir, "AGENTS.md"), agentsGuide(scaffold.docs));
317
+ if (analysis) {
318
+ await fs.writeFile(path.join(dir, BRIEF_FILE), briefFor(analysis));
319
+ await writeAppLink(dir, {
320
+ forTodoId: analysis.todo.id,
321
+ studentId: analysis.todo.studentId,
322
+ });
323
+ }
324
+ return {
325
+ dir,
326
+ filesWritten: written + (analysis ? 2 : 1),
327
+ ...(analysis
328
+ ? {
329
+ brief: BRIEF_FILE,
330
+ studentId: analysis.todo.studentId,
331
+ standards: analysis.standards.map((s) => s.notation),
332
+ misconceptions: analysis.analysis.patterns.map((p) => p.instanceKey ?? slug(p.name)),
333
+ }
334
+ : {}),
335
+ next: [
336
+ `cd ${dir}`,
337
+ analysis
338
+ ? "Open the folder with your agent: brief.md is the assignment, AGENTS.md the contract."
339
+ : "Open the folder with your agent and describe what the app should teach (AGENTS.md has the contract).",
340
+ 'recess apps validate --reason "Check my app"',
341
+ analysis
342
+ ? `recess apps publish --assign ${analysis.todo.studentId} --reason "Ship it to the kid"`
343
+ : 'recess apps publish --reason "Ship my app"',
344
+ ],
345
+ };
346
+ }
347
+ if (verb === "standards") {
348
+ const q = parsed.positionals.slice(2).join(" ").trim();
349
+ if (!q) {
350
+ throw new CliError("invalid_arguments", 'Give words or a notation: recess apps standards "grade 4 adding fractions".');
351
+ }
352
+ return unwrap(await api.client.GET("/studio/standards", {
353
+ params: { query: { q } },
354
+ }));
355
+ }
356
+ if (verb === "assign") {
357
+ const dir = resolveDir(ctx, 2);
358
+ const link = await readAppLink(dir);
359
+ const studentId = flagString(parsed, "student") ?? link?.studentId;
360
+ if (!link?.projectId) {
361
+ throw new CliError("invalid_arguments", `${dir} has no published app yet; run \`recess apps publish\` first.`);
362
+ }
363
+ if (!studentId) {
364
+ throw new CliError("invalid_arguments", "--student <student-id> is required (or init the app with --for-todo).");
365
+ }
366
+ const due = flagString(parsed, "due");
367
+ if (due && !/^\d{4}-\d{2}-\d{2}$/.test(due)) {
368
+ throw new CliError("invalid_arguments", "--due must be YYYY-MM-DD.");
369
+ }
370
+ return assign(ctx, link.projectId, {
371
+ studentId,
372
+ ...(due ? { dueDate: due } : {}),
373
+ ...(link.forTodoId ? { sourceTodoId: link.forTodoId } : {}),
374
+ });
375
+ }
376
+ if (verb === "list") {
377
+ return unwrap(await api.client.GET("/studio/projects"));
378
+ }
379
+ if (verb === "pull") {
380
+ const projectId = positional(parsed, 2, "project id");
381
+ const dir = resolveDir(ctx, 3);
382
+ const project = unwrap(await api.client.GET("/studio/projects/{projectId}/files", {
383
+ params: { path: { projectId } },
384
+ }));
385
+ await fs.mkdir(dir, { recursive: true });
386
+ const written = await writeFiles(dir, project.files);
387
+ if (project.contract)
388
+ await fs.writeFile(path.join(dir, CONTRACT_FILE), JSON.stringify(project.contract, null, 2) + "\n");
389
+ await fs.writeFile(path.join(dir, MANIFEST_FILE), JSON.stringify({ title: project.name }, null, 2) + "\n");
390
+ await writeAppLink(dir, { projectId });
391
+ return {
392
+ dir,
393
+ projectId,
394
+ name: project.name,
395
+ versionNumber: project.versionNumber,
396
+ filesWritten: written,
397
+ };
398
+ }
399
+ if (verb === "validate") {
400
+ return submit(ctx, resolveDir(ctx, 2), true);
401
+ }
402
+ if (verb === "publish") {
403
+ return submit(ctx, resolveDir(ctx, 2), false);
404
+ }
405
+ if (verb === "status") {
406
+ const buildId = positional(parsed, 2, "build id");
407
+ return unwrap(await api.client.GET("/studio/builds/{buildId}", {
408
+ params: { path: { buildId } },
409
+ }));
410
+ }
411
+ throw new CliError("invalid_arguments", "Unknown apps command. Use: apps init <dir> [--for-todo <todo-id>] | apps standards <words> | apps list | apps pull <project-id> [dir] | apps validate [dir] | apps publish [dir] [--assign <student-id>] | apps assign [dir] --student <id> | apps status <build-id>.");
412
+ }
413
+ //# sourceMappingURL=apps.js.map
@@ -503,6 +503,21 @@ export async function runOnboardingCommand({ parsed, api, writeCommand, }) {
503
503
  request: {},
504
504
  }, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/intake/generate-summaries", { params: { path: { familyId } } })));
505
505
  }
506
+ if (verb === "link-math-academy") {
507
+ const familyId = positional(parsed, 2, "family ID");
508
+ const kidUserId = flagString(parsed, "kid", { required: true });
509
+ const mathAcademyStudentId = flagInteger(parsed, "student-id", {
510
+ min: 1,
511
+ });
512
+ const body = mathAcademyStudentId === undefined ? {} : { mathAcademyStudentId };
513
+ return writeCommand(parsed, {
514
+ action: mathAcademyStudentId === undefined
515
+ ? "link the kid to an existing Math Academy account using the saved login or a unique name match"
516
+ : `link the kid to existing Math Academy student ${mathAcademyStudentId} after verifying that student exists`,
517
+ target: { familyId, kidUserId },
518
+ request: body,
519
+ }, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/kids/{kidUserId}/link/math-academy", { params: { path: { familyId, kidUserId } }, body })));
520
+ }
506
521
  if (verb === "provision-math-academy") {
507
522
  const familyId = positional(parsed, 2, "family ID");
508
523
  const kidUserId = flagString(parsed, "kid", { required: true });
@@ -0,0 +1,150 @@
1
+ import { unwrap } from "../api.js";
2
+ import { flagString, hasFlag } from "../args.js";
3
+ import { apiError, CliError } from "../errors.js";
4
+ const NONE = "—";
5
+ async function fetchTemplatesAndRooms(ctx) {
6
+ const [templatesResponse, rooms] = await Promise.all([
7
+ ctx.api.client.GET("/recess/event-templates-editable/"),
8
+ ctx.api.client.GET("/recess/village-rooms/"),
9
+ ]);
10
+ return {
11
+ templates: unwrap(templatesResponse).eventTemplates,
12
+ rooms: unwrap(rooms),
13
+ };
14
+ }
15
+ function schedule(template) {
16
+ if (template.singleEventDate)
17
+ return template.singleEventDate.slice(0, 10);
18
+ return template.rrule || NONE;
19
+ }
20
+ function renderColumns(rows) {
21
+ const widths = rows[0].map((_, column) => Math.max(...rows.map((row) => row[column].length)));
22
+ return rows
23
+ .map((row) => row
24
+ .map((cell, column) => column === row.length - 1 ? cell : cell.padEnd(widths[column]))
25
+ .join(" ")
26
+ .trimEnd())
27
+ .join("\n");
28
+ }
29
+ /** `village events` — every editable Recess Hour template and its Town Center zone. */
30
+ async function listEvents(ctx) {
31
+ const { templates, rooms } = await fetchTemplatesAndRooms(ctx);
32
+ const roomsById = new Map(rooms.map((room) => [room.id, room]));
33
+ const templateRows = templates.map((template) => {
34
+ const room = template.villageRoomId
35
+ ? roomsById.get(template.villageRoomId)
36
+ : undefined;
37
+ return {
38
+ id: template.id,
39
+ name: template.name,
40
+ status: template.status,
41
+ schedule: schedule(template),
42
+ villageRoomId: template.villageRoomId,
43
+ villageRoomName: room?.name ?? null,
44
+ joinUrl: template.joinUrl ?? null,
45
+ };
46
+ });
47
+ const unlinkedRooms = rooms
48
+ .filter((room) => !room.linkedTemplateId)
49
+ .map((room) => ({ id: room.id, name: room.name }));
50
+ if (hasFlag(ctx.parsed, "json")) {
51
+ return { templates: templateRows, unlinkedRooms };
52
+ }
53
+ const table = renderColumns([
54
+ ["TEMPLATE", "NAME", "STATUS", "SCHEDULE", "ZONE", "ZONE NAME", "URL"],
55
+ ...templateRows.map((row) => [
56
+ row.id,
57
+ row.name,
58
+ row.status,
59
+ row.schedule,
60
+ row.villageRoomId ?? NONE,
61
+ row.villageRoomName ?? (row.villageRoomId ? "(unknown zone)" : NONE),
62
+ row.joinUrl ?? NONE,
63
+ ]),
64
+ ]);
65
+ const unlinked = unlinkedRooms.length === 0
66
+ ? " (none)"
67
+ : renderColumns(unlinkedRooms.map((room) => [` ${room.id}`, room.name]));
68
+ return {
69
+ help: `${templates.length === 0 ? "No editable event templates." : table}\n\nUnlinked Town Center zones:\n${unlinked}`,
70
+ };
71
+ }
72
+ function isHttpUrl(value) {
73
+ try {
74
+ const url = new URL(value);
75
+ return url.protocol === "http:" || url.protocol === "https:";
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ }
81
+ /** `village link-event --template <id> (--room <zoneId> | --unlink) [--url <https://…>]` */
82
+ async function linkEvent(ctx) {
83
+ const { parsed, api, writeCommand } = ctx;
84
+ const templateId = flagString(parsed, "template", { required: true });
85
+ const roomId = flagString(parsed, "room");
86
+ const unlink = hasFlag(parsed, "unlink");
87
+ const url = flagString(parsed, "url");
88
+ if (url !== undefined && !isHttpUrl(url)) {
89
+ throw new CliError("invalid_arguments", "--url must be an http(s) URL (e.g. https://example.com/event).");
90
+ }
91
+ if (unlink === Boolean(roomId)) {
92
+ throw new CliError("invalid_arguments", "Pass exactly one of --room <zoneId> or --unlink.");
93
+ }
94
+ const { templates, rooms } = await fetchTemplatesAndRooms(ctx);
95
+ const template = templates.find((row) => row.id === templateId);
96
+ if (!template) {
97
+ throw new CliError("not_found", `No editable event template found for ${templateId}. Run \`recess village events\` to list them.`);
98
+ }
99
+ let room;
100
+ if (roomId) {
101
+ room = rooms.find((row) => row.id === roomId);
102
+ if (!room) {
103
+ throw new CliError("not_found", `No Village Town Center zone found for ${roomId}. Run \`recess village events\` to list linkable zones.`);
104
+ }
105
+ if (room.linkedTemplateId && room.linkedTemplateId !== templateId) {
106
+ throw new CliError("invalid_arguments", `Zone ${room.id} (${room.name}) is already linked to template ${room.linkedTemplateId} (${room.linkedTemplateName ?? "unnamed"}). Unlink that template first.`);
107
+ }
108
+ }
109
+ const previousRoom = template.villageRoomId
110
+ ? rooms.find((row) => row.id === template.villageRoomId)
111
+ : undefined;
112
+ const body = {
113
+ villageRoomId: room ? room.id : null,
114
+ ...(url !== undefined ? { joinUrl: url } : {}),
115
+ };
116
+ return writeCommand(parsed, {
117
+ action: room
118
+ ? `Link template ${template.id} (${template.name}) → zone ${room.id} (${room.name}); zone will be renamed to the event title${url ? ` and its link set to ${url}` : ""}`
119
+ : `Unlink template ${template.id} (${template.name}) from zone ${template.villageRoomId ?? NONE}${previousRoom ? ` (${previousRoom.name})` : ""}`,
120
+ target: {
121
+ templateId: template.id,
122
+ templateName: template.name,
123
+ currentVillageRoomId: template.villageRoomId,
124
+ },
125
+ request: body,
126
+ }, async () => {
127
+ const result = await api.client.PATCH("/recess/event-templates/{id}/", {
128
+ params: { path: { id: template.id } },
129
+ body,
130
+ });
131
+ if (!result.response.ok) {
132
+ throw apiError(result.response.status, result.error);
133
+ }
134
+ return {
135
+ templateId: template.id,
136
+ templateName: template.name,
137
+ villageRoomId: body.villageRoomId,
138
+ villageRoomName: room?.name ?? null,
139
+ ...(url !== undefined ? { joinUrl: url } : {}),
140
+ };
141
+ });
142
+ }
143
+ export async function runVillageEventsCommand(ctx, verb) {
144
+ if (verb === "events")
145
+ return listEvents(ctx);
146
+ if (verb === "link-event")
147
+ return linkEvent(ctx);
148
+ throw new CliError("invalid_arguments", "Use village events or link-event.");
149
+ }
150
+ //# sourceMappingURL=village-events.js.map
package/dist/help.js CHANGED
@@ -22,6 +22,10 @@ Usage:
22
22
  recess [--json] auth status|logout
23
23
  recess [--json] users search <name-or-id> [--limit 10]
24
24
  recess [--json] users get <user-id>
25
+ recess [--json] users lock <user-id> [--confirm]
26
+ recess [--json] users unlock <user-id> [--confirm]
27
+ recess [--json] users disable <user-id> [--whole-family] [--allow-billing] [--confirm]
28
+ recess [--json] users restore <user-id> --role kid|guardian|guide|program [--confirm]
25
29
  recess [--json] users tier list-tiers
26
30
  recess [--json] users tier get <kid-id>
27
31
  recess [--json] users tier preview <kid-id> --tier social|academics|lite|complete|platform
@@ -38,6 +42,8 @@ Usage:
38
42
  recess [--json] students list
39
43
  recess [--json] students today --student <kid-id> [--date YYYY-MM-DD]
40
44
  recess [--json] students schedule --student <kid-id> [--days 14]
45
+ recess [--json] students todos --student <kid-id> [--limit 30] [--analyzed]
46
+ recess [--json] students analysis --todo <todo-id>
41
47
  recess [--json] students xp-history --student <kid-id>
42
48
  [--range week|month|quarter|year]
43
49
  recess [--json] enrollments list --user <user-id>
@@ -48,6 +54,14 @@ Usage:
48
54
  recess [--json] invoices refund --invoice <id> --line-item <id>
49
55
  --method refund|credit|tokens [--full | --amount-cents N]
50
56
  [--who-pays guide|recess] [--reason TEXT] [--confirm]
57
+ recess [--json] apps init <dir> [--for-todo <todo-id>] [--force]
58
+ recess [--json] apps standards <query>
59
+ recess [--json] apps list
60
+ recess [--json] apps pull <project-id> [dir]
61
+ recess [--json] apps validate [dir] [--name TEXT] [--no-wait]
62
+ recess [--json] apps publish [dir] [--name TEXT] [--assign <kid-id>] [--due YYYY-MM-DD] [--no-wait]
63
+ recess [--json] apps assign [dir] --student <kid-id> [--due YYYY-MM-DD]
64
+ recess [--json] apps status <build-id>
51
65
  recess [--json] applications list [--status SUBMITTED|CLAIMED|ENROLLED|CLOSED]
52
66
  [--status-scope active|closed] [--disposition READY_TO_ENROLL|TRIAL|PENDING_FUNDING|NO]
53
67
  [--dispositioned true|false] [--quality QUALIFIED|NEEDS_NURTURE|UNKNOWN]
@@ -168,6 +182,8 @@ Usage:
168
182
  recess [--json] onboarding generate-summaries <family-id> [--confirm]
169
183
  recess [--json] onboarding provision-math-academy <family-id>
170
184
  --kid <kid-id> [--grade 1..12] [--confirm]
185
+ recess [--json] onboarding link-math-academy <family-id>
186
+ --kid <kid-id> [--student-id <math-academy-student-id>] [--confirm]
171
187
  recess [--json] onboarding provision-ixl <family-id>
172
188
  --kid <kid-id> [--credentials-file <credentials.json>] [--confirm]
173
189
  recess [--json] onboarding remove-ixl <family-id> --kid <kid-id> [--confirm]
@@ -270,6 +286,9 @@ Usage:
270
286
  recess [--json] village worlds export <world-id> [--out <bundle.json>] [--confirm]
271
287
  recess [--json] village worlds import <world-id> --file <bundle.json> [--confirm]
272
288
  recess [--json] village worlds promote <mirror-or-archive-id> [--confirm]
289
+ recess [--json] village events
290
+ recess [--json] village link-event --template <template-id>
291
+ [--room <zone-id>] [--unlink] [--url <https://…>] [--confirm]
273
292
  recess [--json] store-items list [--search TEXT]
274
293
  [--status ACTIVE|INACTIVE|COMING_SOON] [--item-type TYPE]
275
294
  [--page 0] [--limit 20] [--sort-by name|price|createdAt|updatedAt|order]
@@ -415,6 +434,33 @@ monthly top-up cron and are exact-ADMIN on update); descriptive edits
415
434
  (name/slug/logo) are ordinary staff writes. "school kid-slots" is the raw
416
435
  slot override; "users tier set" is the tier-driven path.
417
436
 
437
+ "users lock"/"users unlock" are the CLI twin of the Lock/Unlock buttons on
438
+ recess.gg/ai/students/<kid-id>/admin. They act on the whole account, not one
439
+ person: a KID target is locked or unlocked together with every GUARDIAN in
440
+ their family, because an unlocked kid under a locked guardian is not actually
441
+ unlocked. Unlock grants the kid and guardian access roles, restores the kid's
442
+ Recess channel memberships, sets FLAG_ACCOUNT_UNLOCKED, and notifies the kid's
443
+ live clients; lock is the exact inverse. Rerunning "unlock" on an already
444
+ unlocked account is the "Refresh Unlock" button and is safe. The preview shows
445
+ each affected person's current flag; "users get" is the read.
446
+
447
+ "users disable" is the harder stop, and a different mechanism: it sets
448
+ User.role to DISABLED, so the account stops resolving in every dashboard,
449
+ search, chat, and todo query at the database layer, and its coherent-feed
450
+ personal data is purged. "users lock" only strips access roles. Disabling is
451
+ SOFT and reversible — "users restore <id> --role ..." puts the account back —
452
+ and it is NOT a GDPR erasure; nothing is deleted. A read-only preflight resolves
453
+ the whole family (including members already DISABLED, which ordinary reads hide)
454
+ and each person's billable enrollments. "--whole-family" extends the disable to
455
+ every member of the target's family; ADMIN/MODERATOR members, your own account,
456
+ and anyone already in the destination role are listed under preview.skipped
457
+ rather than silently dropped. A target holding an ACTIVE or PAST_DUE enrollment
458
+ is refused until billing is cancelled or "--allow-billing" deliberately orphans
459
+ the seat. Restore names the role explicitly because DISABLED does not record
460
+ what the account was; ADMIN and MODERATOR are not restorable from the CLI.
461
+ Note that after a disable "users get" and "users search" no longer resolve the
462
+ account — the confirmed write's own response is the record of what changed.
463
+
418
464
  Onboarding notes: "status" and "intake-session" are reads — "intake-session"
419
465
  looks up the current IN_PROGRESS session without creating one (prints a "none
420
466
  yet" result when absent). "intake-session-create" is the explicit write that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "recess-cli",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "description": "Safe Recess administration and family AI tools from the command line.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -70,6 +70,42 @@ Every write is two-step:
70
70
 
71
71
  Any changed target, payload, local file, server revision, amount, recipient, or consequence requires a new preview and new approval. Never infer approval from the original request. If a confirmed invocation is interrupted, retry the unchanged command with the same operation key; never mint a new one for an uncertain write. Inspect recovery state with `recess --json jobs get <operation-key>`.
72
72
 
73
+ ## Building Studio apps (`recess apps`)
74
+
75
+ Guides build Recess Studio apps in their own editor and publish them with the CLI; Studio never
76
+ prompts a model for them.
77
+
78
+ - `recess --json apps init <dir> --reason "..."` writes the locked scaffold plus `AGENTS.md` (the
79
+ runtime + template contract, pedagogy, and design rules). Read `AGENTS.md` before editing.
80
+ - Edit only `skill.js`, `model.js`, `model.css`, `params.schema.json`, `params.json`. Locked files
81
+ fail validation if changed.
82
+ - Write `contract.json` (the BuildContract from `AGENTS.md`) and `manifest.json`
83
+ (`{title, description, template?}`) before validating.
84
+ - `recess --json apps validate [dir] --reason "..."` runs Studio's deterministic validation on the
85
+ uploaded files and returns `report.failReasons`; fix every reason and rerun. Nothing is published.
86
+ - `recess --json apps publish [dir] --reason "..."` validates, then the independent reviewer judges
87
+ the app; on pass it becomes the current version at `appUrl`. `REJECTED` carries the reviewer's
88
+ findings — fix them and publish again. Both commands wait for the result (`--no-wait` to return the
89
+ build id and poll with `apps status`).
90
+ - `recess --json apps pull <project-id> [dir]` downloads a project you own to keep editing it.
91
+ - `recess --json apps standards "<words or notation>"` finds the CCSS standard for `manifest.json`
92
+ (`"grade 4 adding fractions"` → `4.NF.B.3a`…); guides rarely know the codes, look them up.
93
+
94
+ Two ways in. If the guide names a kid or a todo, pull the analysis first and build against it;
95
+ otherwise build from their description:
96
+
97
+ - `recess --json students todos --student <kid-id> [--analyzed]` lists the kid's recent todos with
98
+ CCSS tags and `analyzed` (a forensic analysis exists); `recess --json students analysis --todo <id>`
99
+ returns it pruned for building — patterns with `instanceKey`, root cause, prescriptions, the
100
+ standard, grade band, interests. No name, no frames.
101
+ - `recess --json apps init <dir> --for-todo <todo-id>` writes the scaffold plus `brief.md` (that
102
+ analysis as an assignment) and remembers the kid. Build to the brief: remediate mode, items the
103
+ kid would miss the observed way, `template.misconceptions` = the pattern keys.
104
+ - `recess --json apps publish --assign <kid-id> [--due YYYY-MM-DD]` publishes and, on `COMPLETE`,
105
+ creates the kid's todo (linked to the source todo when init used `--for-todo`). A rejected build
106
+ assigns nothing. `recess --json apps assign [dir] --student <kid-id>` assigns an already-published
107
+ app.
108
+
73
109
  ## Shared constraints
74
110
 
75
111
  - Use high-level CLI commands. The raw escape hatch is GET-only: