recess-cli 2.3.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 +28 -2
- package/dist/args.js +1 -0
- package/dist/cli.js +310 -7
- package/dist/command-schema.js +237 -9
- 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 +48 -0
- package/package.json +1 -1
- package/skill/recess-cli/SKILL.md +40 -3
- package/skill/recess-cli/agents/version.json +2 -2
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:
|
|
@@ -111,7 +134,7 @@ Error or write preview:
|
|
|
111
134
|
|
|
112
135
|
Exit code `0` means success, `1` means an input/auth/API failure, and `2` means a write is awaiting explicit human confirmation.
|
|
113
136
|
|
|
114
|
-
`recess --json agent-context` returns the canonical command/flag/positional schema. `recess --json help payout recipients` returns scoped help
|
|
137
|
+
`recess --json agent-context` returns the canonical command/flag/positional schema filtered to the scope claim already stored in the current CLI session. Bare help, scoped help, and `agent-context` make no API request; real commands still go through server authorization, while `auth status` and `doctor` perform live session checks. `recess --json help payout recipients` returns scoped help, and human help marks exact-admin commands with `◆`. Unknown flags, duplicate non-repeatable flags, missing values, and extra positionals are errors instead of being silently ignored.
|
|
115
138
|
|
|
116
139
|
Every command-driven request to the Recess API except `auth` requires `--reason "..."`: a
|
|
117
140
|
non-empty, human-readable purpose of at most 1024 characters. The CLI sends it as
|
|
@@ -249,6 +272,7 @@ recess --json students schedule --student <kid-id> --days 30 --reason "Review th
|
|
|
249
272
|
recess --json students xp-history --student <kid-id> --range month --reason "Review recent XP history"
|
|
250
273
|
recess --json goals list --student <kid-id> --reason "Review the student's goals"
|
|
251
274
|
recess --json todos create --student <kid-id> --title "Read chapter 4" --reason "Add the assigned reading"
|
|
275
|
+
recess --json todos complete <todo-id> --xp 35 --reason "Complete the todo with a 35 XP total reward"
|
|
252
276
|
recess --json goals delete <goal-id> --student <kid-id> --reason "Remove this obsolete goal"
|
|
253
277
|
recess --json todos delete <todo-id> --reason "Remove this disposable todo"
|
|
254
278
|
recess --json todos generate-applet <todo-id> --student <kid-id> --reason "Generate this todo's applet"
|
|
@@ -258,7 +282,9 @@ recess --json rocky get --student <kid-id> --reason "Inspect the student's Rocky
|
|
|
258
282
|
```
|
|
259
283
|
|
|
260
284
|
All family writes still preview first. Goal/todo/Rocky edits also carry the current server version
|
|
261
|
-
into the confirmed request.
|
|
285
|
+
into the confirmed request. `todos complete` is staff-only; `--xp` is the target total XP for the
|
|
286
|
+
todo, so its preview reports prior credit and the new delta before the normal completion and reward
|
|
287
|
+
side effects run. Applet generation is also staff-only: it resolves the todo's latest learning
|
|
262
288
|
analysis, defaults the generated todo to tomorrow in the student's timezone, and lets the server
|
|
263
289
|
select v1 or v2 for that student. A guardian cannot target another family, inspect frozen/deleted
|
|
264
290
|
template history, choose todo rewards/completion/internal fields, or use the ADMIN-only
|
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";
|
|
@@ -91,6 +93,69 @@ async function sessionStatus(config) {
|
|
|
91
93
|
}
|
|
92
94
|
return { ...result.data, authSource: config.authSource };
|
|
93
95
|
}
|
|
96
|
+
const DISCOVERY_ROLE_BY_SCOPE = {
|
|
97
|
+
full_admin: "ADMIN",
|
|
98
|
+
family_ai: "GUARDIAN",
|
|
99
|
+
guide_students: "GUIDE",
|
|
100
|
+
village_home: "KID",
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* Command discovery is intentionally local: the signed session cookie already
|
|
104
|
+
* carries the role, CLI scope, and expiry minted at login. Decoding those
|
|
105
|
+
* unverified claims is safe here because they only hide or reveal help text;
|
|
106
|
+
* every real command still sends the cookie to the server for authorization.
|
|
107
|
+
*/
|
|
108
|
+
function resolveCommandDiscovery(config) {
|
|
109
|
+
const signedOut = {
|
|
110
|
+
scope: null,
|
|
111
|
+
role: null,
|
|
112
|
+
source: "signed_out",
|
|
113
|
+
};
|
|
114
|
+
const unavailable = {
|
|
115
|
+
scope: null,
|
|
116
|
+
role: null,
|
|
117
|
+
source: "unavailable",
|
|
118
|
+
};
|
|
119
|
+
if (!config.sessionCookie)
|
|
120
|
+
return signedOut;
|
|
121
|
+
try {
|
|
122
|
+
const separator = config.sessionCookie.indexOf("=");
|
|
123
|
+
if (separator < 1)
|
|
124
|
+
return unavailable;
|
|
125
|
+
const cookieValue = decodeURIComponent(config.sessionCookie.slice(separator + 1).split(";", 1)[0]);
|
|
126
|
+
const payloadSegment = cookieValue.split(".")[1];
|
|
127
|
+
if (!payloadSegment)
|
|
128
|
+
return unavailable;
|
|
129
|
+
const value = JSON.parse(Buffer.from(payloadSegment, "base64url").toString("utf8"));
|
|
130
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
131
|
+
return unavailable;
|
|
132
|
+
}
|
|
133
|
+
const claim = value;
|
|
134
|
+
if (typeof claim.exp !== "number" || !Number.isFinite(claim.exp)) {
|
|
135
|
+
return unavailable;
|
|
136
|
+
}
|
|
137
|
+
if (claim.exp * 1000 <= Date.now()) {
|
|
138
|
+
return signedOut;
|
|
139
|
+
}
|
|
140
|
+
const scope = typeof claim.cliScope === "string" &&
|
|
141
|
+
Object.hasOwn(DISCOVERY_ROLE_BY_SCOPE, claim.cliScope)
|
|
142
|
+
? claim.cliScope
|
|
143
|
+
: claim.role === "ADMIN" && claim.adminCli === true
|
|
144
|
+
? "full_admin"
|
|
145
|
+
: null;
|
|
146
|
+
if (!scope || claim.role !== DISCOVERY_ROLE_BY_SCOPE[scope]) {
|
|
147
|
+
return unavailable;
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
scope,
|
|
151
|
+
role: DISCOVERY_ROLE_BY_SCOPE[scope],
|
|
152
|
+
source: "session_claim",
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return unavailable;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
94
159
|
async function doctor(config, reason) {
|
|
95
160
|
const checks = {
|
|
96
161
|
config: {
|
|
@@ -806,6 +871,10 @@ const SCHOOL_TIER_OPTIONS = [
|
|
|
806
871
|
{ id: "platform", name: "Platform Only", defaultClassSlots: 0 },
|
|
807
872
|
];
|
|
808
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"];
|
|
809
878
|
const MAX_MAP_PDF_BYTES = 15 * 1024 * 1024;
|
|
810
879
|
const GOAL_TEMPLATE_KINDS = ["SIMPLE", "BLUEPRINT"];
|
|
811
880
|
const GOAL_TEMPLATE_SETUP_AUDIENCES = ["KID_FRIENDLY", "PARENT_SETUP"];
|
|
@@ -1510,19 +1579,28 @@ export async function runCommand(argv) {
|
|
|
1510
1579
|
.map((name) => `--${name}`)
|
|
1511
1580
|
.join(", ")}.`, 1, { validFlags: ["--deliver", "--help", "--json", "--profile"] });
|
|
1512
1581
|
}
|
|
1513
|
-
|
|
1582
|
+
const config = await resolveConfig(flagString(parsed, "profile"));
|
|
1583
|
+
const discovery = resolveCommandDiscovery(config);
|
|
1584
|
+
return { help: scopedHelp(HELP, commands, [], discovery) };
|
|
1514
1585
|
}
|
|
1515
1586
|
if (noun === "help" || hasFlag(parsed, "help")) {
|
|
1516
1587
|
const scope = noun === "help" ? parsed.positionals.slice(1) : parsed.positionals;
|
|
1517
|
-
|
|
1588
|
+
const config = await resolveConfig(flagString(parsed, "profile"));
|
|
1589
|
+
const discovery = resolveCommandDiscovery(config);
|
|
1590
|
+
return { help: scopedHelp(HELP, commands, scope, discovery) };
|
|
1518
1591
|
}
|
|
1519
1592
|
validateInvocation(parsed, commands);
|
|
1520
1593
|
if (noun === "agent-context") {
|
|
1521
|
-
const profiles = await
|
|
1594
|
+
const [profiles, config] = await Promise.all([
|
|
1595
|
+
listProfiles(),
|
|
1596
|
+
resolveConfig(flagString(parsed, "profile")),
|
|
1597
|
+
]);
|
|
1598
|
+
const discovery = resolveCommandDiscovery(config);
|
|
1522
1599
|
return agentContext(commands, {
|
|
1523
1600
|
cliVersion: await readCliVersion(),
|
|
1524
1601
|
availableProfiles: profiles.profiles.map((profile) => profile.name),
|
|
1525
1602
|
feedbackUpstreamConfigured: Boolean(process.env.RECESS_CLI_FEEDBACK_ENDPOINT),
|
|
1603
|
+
discovery,
|
|
1526
1604
|
});
|
|
1527
1605
|
}
|
|
1528
1606
|
if (noun === "profile") {
|
|
@@ -1888,8 +1966,11 @@ export async function runCommand(argv) {
|
|
|
1888
1966
|
}
|
|
1889
1967
|
throw new CliError("invalid_arguments", "Use village worlds export|import|promote.");
|
|
1890
1968
|
}
|
|
1969
|
+
if (verb === "events" || verb === "link-event") {
|
|
1970
|
+
return runVillageEventsCommand({ parsed, api, writeCommand }, verb);
|
|
1971
|
+
}
|
|
1891
1972
|
if (verb !== "models") {
|
|
1892
|
-
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 …");
|
|
1893
1974
|
}
|
|
1894
1975
|
const action = positional(parsed, 2, "Village model action");
|
|
1895
1976
|
if (action === "list") {
|
|
@@ -2187,6 +2268,140 @@ export async function runCommand(argv) {
|
|
|
2187
2268
|
params: { path: { userId } },
|
|
2188
2269
|
}));
|
|
2189
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
|
+
}
|
|
2190
2405
|
if (noun === "guides" && verb === "create") {
|
|
2191
2406
|
const email = flagString(parsed, "email", { required: true })
|
|
2192
2407
|
.trim()
|
|
@@ -2323,6 +2538,9 @@ export async function runCommand(argv) {
|
|
|
2323
2538
|
if (noun === "applications" || noun === "quotes") {
|
|
2324
2539
|
return runApplicationsCommand({ parsed, api, writeCommand });
|
|
2325
2540
|
}
|
|
2541
|
+
if (noun === "apps") {
|
|
2542
|
+
return runAppsCommand({ parsed, api, writeCommand });
|
|
2543
|
+
}
|
|
2326
2544
|
if (noun === "school") {
|
|
2327
2545
|
return runSchoolCommand({ parsed, api, writeCommand });
|
|
2328
2546
|
}
|
|
@@ -4029,6 +4247,25 @@ export async function runCommand(argv) {
|
|
|
4029
4247
|
},
|
|
4030
4248
|
}));
|
|
4031
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
|
+
}
|
|
4032
4269
|
if (verb === "xp-history") {
|
|
4033
4270
|
const kidId = flagString(parsed, "student", { required: true });
|
|
4034
4271
|
const range = flagString(parsed, "range");
|
|
@@ -4042,7 +4279,7 @@ export async function runCommand(argv) {
|
|
|
4042
4279
|
},
|
|
4043
4280
|
}));
|
|
4044
4281
|
}
|
|
4045
|
-
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.");
|
|
4046
4283
|
}
|
|
4047
4284
|
if (noun === "todos") {
|
|
4048
4285
|
if (verb === "create") {
|
|
@@ -4070,6 +4307,7 @@ export async function runCommand(argv) {
|
|
|
4070
4307
|
const todoId = positional(parsed, 2, "todo ID");
|
|
4071
4308
|
const patchFile = flagString(parsed, "patch-file", { required: true });
|
|
4072
4309
|
const body = (await readJsonFile(patchFile, "Todo patch file"));
|
|
4310
|
+
const sessionRole = await resolveSessionRole(api);
|
|
4073
4311
|
const current = unwrap(await api.client.GET("/tutor/browser/todos/{id}/", {
|
|
4074
4312
|
params: { path: { id: todoId } },
|
|
4075
4313
|
}));
|
|
@@ -4082,8 +4320,73 @@ export async function runCommand(argv) {
|
|
|
4082
4320
|
expectedUpdatedAt: current.updatedAt,
|
|
4083
4321
|
patch: body,
|
|
4084
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
|
+
},
|
|
4330
|
+
};
|
|
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
|
+
})));
|
|
4340
|
+
}
|
|
4341
|
+
if (verb === "complete") {
|
|
4342
|
+
const todoId = positional(parsed, 2, "todo ID");
|
|
4343
|
+
const targetXp = flagNumber(parsed, "xp");
|
|
4344
|
+
if (targetXp === undefined ||
|
|
4345
|
+
!Number.isInteger(targetXp) ||
|
|
4346
|
+
targetXp <= 0) {
|
|
4347
|
+
throw new CliError("invalid_arguments", "--xp must be a positive integer. It is the target total XP credited for this todo; prior awards count toward that total.");
|
|
4348
|
+
}
|
|
4349
|
+
const [currentResponse, detailsResponse] = await Promise.all([
|
|
4350
|
+
api.client.GET("/tutor/browser/todos/{id}/", {
|
|
4351
|
+
params: { path: { id: todoId } },
|
|
4352
|
+
}),
|
|
4353
|
+
api.client.GET("/admin/todos/{id}/details", {
|
|
4354
|
+
params: { path: { id: todoId } },
|
|
4355
|
+
}),
|
|
4356
|
+
]);
|
|
4357
|
+
const current = unwrap(currentResponse);
|
|
4358
|
+
const completionXpAwarded = unwrap(detailsResponse).totalXpAwarded ?? 0;
|
|
4359
|
+
if (current.status === "COMPLETED") {
|
|
4360
|
+
throw new CliError("already_completed", `Todo ${todoId} ("${current.title}") is already completed with ${completionXpAwarded} completion XP credited.`);
|
|
4361
|
+
}
|
|
4362
|
+
const body = {
|
|
4363
|
+
status: "COMPLETED",
|
|
4364
|
+
xpReward: targetXp,
|
|
4365
|
+
expectedUpdatedAt: current.updatedAt,
|
|
4366
|
+
};
|
|
4367
|
+
const newXp = Math.max(0, targetXp - completionXpAwarded);
|
|
4368
|
+
const preview = {
|
|
4369
|
+
action: "complete a todo through the staff reward path and set its total credited XP target",
|
|
4370
|
+
target: {
|
|
4371
|
+
todoId,
|
|
4372
|
+
studentUserId: current.userId,
|
|
4373
|
+
studentName: [current.user.firstName, current.user.lastName]
|
|
4374
|
+
.filter(Boolean)
|
|
4375
|
+
.join(" ") || null,
|
|
4376
|
+
title: current.title,
|
|
4377
|
+
status: current.status,
|
|
4378
|
+
goal: current.goal,
|
|
4379
|
+
},
|
|
4380
|
+
request: body,
|
|
4381
|
+
details: {
|
|
4382
|
+
alreadyAwardedXp: completionXpAwarded,
|
|
4383
|
+
targetTotalXp: targetXp,
|
|
4384
|
+
newlyAwardedXp: newXp,
|
|
4385
|
+
configuredCoinReward: current.reward,
|
|
4386
|
+
note: `The XP ledger is delta-guarded: this writes at most ${newXp} new XP so the todo reaches ${targetXp} total. Completion also claims any unawarded portion of the todo's configured ${current.reward}-coin reward, replaces its active completion analysis with a manual-completion record, records completion activity, may complete a linked goal module and its configured rewards, and may notify the parent through the normal completion pipeline.`,
|
|
4387
|
+
},
|
|
4085
4388
|
};
|
|
4086
|
-
return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.PATCH("/
|
|
4389
|
+
return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.PATCH("/admin/todos/{id}", {
|
|
4087
4390
|
params: { path: { id: todoId } },
|
|
4088
4391
|
body,
|
|
4089
4392
|
})));
|
|
@@ -4156,7 +4459,7 @@ export async function runCommand(argv) {
|
|
|
4156
4459
|
body: targetDueDateISO ? { targetDueDateISO } : {},
|
|
4157
4460
|
})));
|
|
4158
4461
|
}
|
|
4159
|
-
throw new CliError("invalid_arguments", "Use todos create|edit|delete|generate-applet.");
|
|
4462
|
+
throw new CliError("invalid_arguments", "Use todos create|edit|complete|delete|generate-applet.");
|
|
4160
4463
|
}
|
|
4161
4464
|
if (noun === "memories") {
|
|
4162
4465
|
const studentId = flagString(parsed, "student", { required: true });
|