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 +23 -0
- package/dist/args.js +1 -0
- package/dist/cli.js +184 -7
- package/dist/command-schema.js +15 -1
- package/dist/commands/apps.js +413 -0
- package/dist/commands/onboarding.js +15 -0
- package/dist/commands/village-events.js +150 -0
- package/dist/help.js +46 -0
- package/package.json +1 -1
- package/skill/recess-cli/SKILL.md +36 -0
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
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 () =>
|
|
4159
|
-
|
|
4160
|
-
|
|
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,
|
package/dist/command-schema.js
CHANGED
|
@@ -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
|
@@ -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:
|