recess-cli 2.2.0 → 2.4.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 -2
- package/dist/api.js +17 -0
- package/dist/auth.js +7 -2
- package/dist/cli.js +253 -9
- package/dist/command-schema.js +222 -8
- package/dist/commands/onboarding.js +186 -0
- package/dist/commands/school.js +262 -0
- package/dist/help.js +54 -3
- package/dist/http.js +45 -2
- package/package.json +1 -1
- package/skill/recess-cli/SKILL.md +7 -4
- package/skill/recess-cli/agents/version.json +2 -2
package/README.md
CHANGED
|
@@ -111,7 +111,7 @@ Error or write preview:
|
|
|
111
111
|
|
|
112
112
|
Exit code `0` means success, `1` means an input/auth/API failure, and `2` means a write is awaiting explicit human confirmation.
|
|
113
113
|
|
|
114
|
-
`recess --json agent-context` returns the canonical command/flag/positional schema. `recess --json help payout recipients` returns scoped help
|
|
114
|
+
`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
115
|
|
|
116
116
|
Every command-driven request to the Recess API except `auth` requires `--reason "..."`: a
|
|
117
117
|
non-empty, human-readable purpose of at most 1024 characters. The CLI sends it as
|
|
@@ -121,6 +121,24 @@ row per request. The human-only `recess ui` console is explicitly exempt and ide
|
|
|
121
121
|
|
|
122
122
|
## Common flow
|
|
123
123
|
|
|
124
|
+
School-onboarding work is family-first: one composite view, then a loop of "what's
|
|
125
|
+
next" → the suggested command → "what's next" again, with `doctor` as the
|
|
126
|
+
gate-observability instrument when something is unexpectedly dark:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
recess --json onboarding family <family-id> --reason "One-screen view of this school family"
|
|
130
|
+
recess --json onboarding next <family-id> --reason "What should happen next for this family"
|
|
131
|
+
recess --json onboarding doctor --family <family-id> --reason "Why is the school surface dark for them"
|
|
132
|
+
recess --json onboarding starter-coverage --reason "Run the pre-flip coverage gate"
|
|
133
|
+
recess --json onboarding backfill-trackers --reason "Census the WS-H guardian trackers"
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`next` returns the mission-control queue's current action plus `suggestedCommands`
|
|
137
|
+
with real ids substituted; every suggested write still previews and requires its
|
|
138
|
+
own `--confirm`. `doctor` reports the env kill-switches, comms mode, and
|
|
139
|
+
per-guardian/per-kid flag + capability-lock + cohort-gate state — its env values
|
|
140
|
+
are the answering service's only (the Worker can differ).
|
|
141
|
+
|
|
124
142
|
```bash
|
|
125
143
|
recess --json users search "Morgan Rivera" --reason "Find the exact student record"
|
|
126
144
|
recess --json users tier get <kid-id> --reason "Inspect the student's current tier"
|
|
@@ -231,6 +249,7 @@ recess --json students schedule --student <kid-id> --days 30 --reason "Review th
|
|
|
231
249
|
recess --json students xp-history --student <kid-id> --range month --reason "Review recent XP history"
|
|
232
250
|
recess --json goals list --student <kid-id> --reason "Review the student's goals"
|
|
233
251
|
recess --json todos create --student <kid-id> --title "Read chapter 4" --reason "Add the assigned reading"
|
|
252
|
+
recess --json todos complete <todo-id> --xp 35 --reason "Complete the todo with a 35 XP total reward"
|
|
234
253
|
recess --json goals delete <goal-id> --student <kid-id> --reason "Remove this obsolete goal"
|
|
235
254
|
recess --json todos delete <todo-id> --reason "Remove this disposable todo"
|
|
236
255
|
recess --json todos generate-applet <todo-id> --student <kid-id> --reason "Generate this todo's applet"
|
|
@@ -240,7 +259,9 @@ recess --json rocky get --student <kid-id> --reason "Inspect the student's Rocky
|
|
|
240
259
|
```
|
|
241
260
|
|
|
242
261
|
All family writes still preview first. Goal/todo/Rocky edits also carry the current server version
|
|
243
|
-
into the confirmed request.
|
|
262
|
+
into the confirmed request. `todos complete` is staff-only; `--xp` is the target total XP for the
|
|
263
|
+
todo, so its preview reports prior credit and the new delta before the normal completion and reward
|
|
264
|
+
side effects run. Applet generation is also staff-only: it resolves the todo's latest learning
|
|
244
265
|
analysis, defaults the generated todo to tomorrow in the student's timezone, and lets the server
|
|
245
266
|
select v1 or v2 for that student. A guardian cannot target another family, inspect frozen/deleted
|
|
246
267
|
template history, choose todo rewards/completion/internal fields, or use the ADMIN-only
|
package/dist/api.js
CHANGED
|
@@ -142,6 +142,23 @@ export class RecessAdminApi {
|
|
|
142
142
|
throw apiError(response.status, body);
|
|
143
143
|
return body;
|
|
144
144
|
}
|
|
145
|
+
async uploadPartnerLogo(image, fileName, mimeType) {
|
|
146
|
+
this.requireAuth();
|
|
147
|
+
const formData = new FormData();
|
|
148
|
+
const arrayBuffer = image.buffer.slice(image.byteOffset, image.byteOffset + image.byteLength);
|
|
149
|
+
formData.append("file", new Blob([arrayBuffer], { type: mimeType }), fileName);
|
|
150
|
+
const headers = cliRequestHeaders({ cookie: this.config.sessionCookie }, this.reason, this.clientTag);
|
|
151
|
+
applyIdempotencyHeaders(headers);
|
|
152
|
+
const response = await fetch(new URL("/admin/partner/logo-upload", this.config.apiOrigin), {
|
|
153
|
+
method: "POST",
|
|
154
|
+
headers,
|
|
155
|
+
body: formData,
|
|
156
|
+
});
|
|
157
|
+
const body = await readResponseBody(response);
|
|
158
|
+
if (!response.ok)
|
|
159
|
+
throw apiError(response.status, body);
|
|
160
|
+
return body;
|
|
161
|
+
}
|
|
145
162
|
async uploadMapTestScores(studentId, pdf, fileName) {
|
|
146
163
|
this.requireAuth();
|
|
147
164
|
const formData = new FormData();
|
package/dist/auth.js
CHANGED
|
@@ -100,6 +100,11 @@ export async function login(config, options) {
|
|
|
100
100
|
// Start the headless (device-authorization) sign-in. Non-blocking: it stores the secret
|
|
101
101
|
// device code locally and returns the approval URL for the agent to surface to a human, who
|
|
102
102
|
// approves it in a browser. The agent then calls `pollDeviceAuth` to collect the session.
|
|
103
|
+
//
|
|
104
|
+
// The approver grants their own scope: a guardian approving yields a family-scoped session
|
|
105
|
+
// over their own kids, an admin a full-admin one. That is what lets an agent running
|
|
106
|
+
// anywhere — a cloud box with no browser — hold a session at all, instead of a human having
|
|
107
|
+
// to be the hands for every command.
|
|
103
108
|
export async function requestDeviceAuth(config, options) {
|
|
104
109
|
const response = await fetch(new URL("/auth/admin-cli/device/authorize/", config.apiOrigin), {
|
|
105
110
|
method: "POST",
|
|
@@ -123,11 +128,11 @@ export async function requestDeviceAuth(config, options) {
|
|
|
123
128
|
userCode: authorize.userCode,
|
|
124
129
|
expiresAt: authorize.expiresAt,
|
|
125
130
|
interval: authorize.interval,
|
|
126
|
-
instructions: `Open ${authorize.approvalUrl} in a browser
|
|
131
|
+
instructions: `Open ${authorize.approvalUrl} in a browser and approve it while signed in to Recess as the person this agent should act as — the session takes on THAT account's scope, so a guardian grants family-only access. Then run \`recess auth poll\`.`,
|
|
127
132
|
};
|
|
128
133
|
}
|
|
129
134
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
130
|
-
// Poll the device-authorization request until
|
|
135
|
+
// Poll the device-authorization request until a human approves it (then store the session),
|
|
131
136
|
// or it is denied / expires / the timeout elapses. On a plain timeout the pending request is
|
|
132
137
|
// preserved so the agent can call `auth poll` again after the human approves.
|
|
133
138
|
export async function pollDeviceAuth(config, options) {
|
package/dist/cli.js
CHANGED
|
@@ -24,6 +24,52 @@ import { appendJobEvent, getJob, listJobs, pruneJobs } from "./jobs.js";
|
|
|
24
24
|
function requiredRequestReason(parsed) {
|
|
25
25
|
return requireCliRequestReason(flagString(parsed, "reason", { required: true }));
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* Role of whoever this session belongs to.
|
|
29
|
+
*
|
|
30
|
+
* The stored identity only describes an `auth login` session; an env-cookie
|
|
31
|
+
* session may belong to someone else entirely, so ask the server rather than
|
|
32
|
+
* trusting a stale login. Used by commands that must refuse *before* their
|
|
33
|
+
* confirmation gate — an approval a human grants and the server then rejects is
|
|
34
|
+
* worse than no preview at all.
|
|
35
|
+
*/
|
|
36
|
+
async function resolveSessionRole(api) {
|
|
37
|
+
if (api.config.authSource === "config" && api.config.user?.role) {
|
|
38
|
+
return api.config.user.role;
|
|
39
|
+
}
|
|
40
|
+
const session = await api.client.GET("/auth/admin-cli/session/");
|
|
41
|
+
return session.data?.user.role;
|
|
42
|
+
}
|
|
43
|
+
/** Status values `goals edit` may send. Deliberately narrow: the backend patch
|
|
44
|
+
* route is pause/resume only, and reaching the terminal state is
|
|
45
|
+
* `goals archive`, which frees an open-goal slot and writes a different audit
|
|
46
|
+
* row. */
|
|
47
|
+
const EDITABLE_GOAL_STATUSES = ["ACTIVE", "PAUSED"];
|
|
48
|
+
/**
|
|
49
|
+
* Reject bad enum values in a goal patch file *before* the preview is built.
|
|
50
|
+
*
|
|
51
|
+
* A preview is what a human approves, so it must not describe a write the
|
|
52
|
+
* server will refuse. Sending `{"status":"COMPLETED"}` used to preview cleanly,
|
|
53
|
+
* resolve a compare-and-set token, mint an approval token, and only then fail
|
|
54
|
+
* at confirm — spending a human approval on an impossible request.
|
|
55
|
+
*/
|
|
56
|
+
function assertGoalPatchValues(patch, patchFile) {
|
|
57
|
+
const status = patch.status;
|
|
58
|
+
if (status === undefined)
|
|
59
|
+
return;
|
|
60
|
+
if (typeof status !== "string" ||
|
|
61
|
+
!EDITABLE_GOAL_STATUSES.includes(status)) {
|
|
62
|
+
const archiveHint = status === "COMPLETED"
|
|
63
|
+
? " To close out a finished course and free an open-goal slot, use `recess goals archive <goal-id> --student <kid-id>` instead — `goals edit` cannot set a terminal status."
|
|
64
|
+
: "";
|
|
65
|
+
throw new CliError("invalid_arguments", `${path.resolve(patchFile)} sets status to ${JSON.stringify(status)}, which \`goals edit\` does not accept. Allowed: ${EDITABLE_GOAL_STATUSES.join(", ")}.${archiveHint}`, 1, {
|
|
66
|
+
field: "status",
|
|
67
|
+
value: status,
|
|
68
|
+
allowed: [...EDITABLE_GOAL_STATUSES],
|
|
69
|
+
patchFile: path.resolve(patchFile),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
27
73
|
async function sessionStatus(config) {
|
|
28
74
|
if (!config.sessionCookie) {
|
|
29
75
|
return {
|
|
@@ -45,6 +91,69 @@ async function sessionStatus(config) {
|
|
|
45
91
|
}
|
|
46
92
|
return { ...result.data, authSource: config.authSource };
|
|
47
93
|
}
|
|
94
|
+
const DISCOVERY_ROLE_BY_SCOPE = {
|
|
95
|
+
full_admin: "ADMIN",
|
|
96
|
+
family_ai: "GUARDIAN",
|
|
97
|
+
guide_students: "GUIDE",
|
|
98
|
+
village_home: "KID",
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* Command discovery is intentionally local: the signed session cookie already
|
|
102
|
+
* carries the role, CLI scope, and expiry minted at login. Decoding those
|
|
103
|
+
* unverified claims is safe here because they only hide or reveal help text;
|
|
104
|
+
* every real command still sends the cookie to the server for authorization.
|
|
105
|
+
*/
|
|
106
|
+
function resolveCommandDiscovery(config) {
|
|
107
|
+
const signedOut = {
|
|
108
|
+
scope: null,
|
|
109
|
+
role: null,
|
|
110
|
+
source: "signed_out",
|
|
111
|
+
};
|
|
112
|
+
const unavailable = {
|
|
113
|
+
scope: null,
|
|
114
|
+
role: null,
|
|
115
|
+
source: "unavailable",
|
|
116
|
+
};
|
|
117
|
+
if (!config.sessionCookie)
|
|
118
|
+
return signedOut;
|
|
119
|
+
try {
|
|
120
|
+
const separator = config.sessionCookie.indexOf("=");
|
|
121
|
+
if (separator < 1)
|
|
122
|
+
return unavailable;
|
|
123
|
+
const cookieValue = decodeURIComponent(config.sessionCookie.slice(separator + 1).split(";", 1)[0]);
|
|
124
|
+
const payloadSegment = cookieValue.split(".")[1];
|
|
125
|
+
if (!payloadSegment)
|
|
126
|
+
return unavailable;
|
|
127
|
+
const value = JSON.parse(Buffer.from(payloadSegment, "base64url").toString("utf8"));
|
|
128
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
129
|
+
return unavailable;
|
|
130
|
+
}
|
|
131
|
+
const claim = value;
|
|
132
|
+
if (typeof claim.exp !== "number" || !Number.isFinite(claim.exp)) {
|
|
133
|
+
return unavailable;
|
|
134
|
+
}
|
|
135
|
+
if (claim.exp * 1000 <= Date.now()) {
|
|
136
|
+
return signedOut;
|
|
137
|
+
}
|
|
138
|
+
const scope = typeof claim.cliScope === "string" &&
|
|
139
|
+
Object.hasOwn(DISCOVERY_ROLE_BY_SCOPE, claim.cliScope)
|
|
140
|
+
? claim.cliScope
|
|
141
|
+
: claim.role === "ADMIN" && claim.adminCli === true
|
|
142
|
+
? "full_admin"
|
|
143
|
+
: null;
|
|
144
|
+
if (!scope || claim.role !== DISCOVERY_ROLE_BY_SCOPE[scope]) {
|
|
145
|
+
return unavailable;
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
scope,
|
|
149
|
+
role: DISCOVERY_ROLE_BY_SCOPE[scope],
|
|
150
|
+
source: "session_claim",
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return unavailable;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
48
157
|
async function doctor(config, reason) {
|
|
49
158
|
const checks = {
|
|
50
159
|
config: {
|
|
@@ -131,7 +240,7 @@ async function writeCommand(parsed, preview, execute) {
|
|
|
131
240
|
details: {
|
|
132
241
|
...preview.details,
|
|
133
242
|
operationKey,
|
|
134
|
-
retry: "Rerun the unchanged command with --confirm --operation-key <operationKey>. Reuse that same key after an interrupted invocation.",
|
|
243
|
+
retry: "Rerun the unchanged command with --confirm --operation-key <operationKey>. Reuse that same key after an interrupted invocation. Previewing again is harmless: it mints another key that is equally valid, because keys are bound to the payload rather than to one preview run.",
|
|
135
244
|
},
|
|
136
245
|
};
|
|
137
246
|
await appendJobEvent({
|
|
@@ -145,7 +254,7 @@ async function writeCommand(parsed, preview, execute) {
|
|
|
145
254
|
requireConfirmation(false, boundPreview);
|
|
146
255
|
}
|
|
147
256
|
if (!suppliedOperationKey) {
|
|
148
|
-
throw new CliError("confirmation_required", "A confirmed write requires the
|
|
257
|
+
throw new CliError("confirmation_required", "A confirmed write requires --operation-key. Run this exact command without --confirm, then pass the operationKey from the preview details. Any preview of this same payload yields a usable key.", 2, { preview, requiredFlag: "--operation-key" });
|
|
149
258
|
}
|
|
150
259
|
assertOperationKeyMatchesPreview(suppliedOperationKey, fingerprint, preview);
|
|
151
260
|
await appendJobEvent({
|
|
@@ -477,9 +586,26 @@ function approvalTokenFor(preview) {
|
|
|
477
586
|
function operationKeyFor(fingerprint) {
|
|
478
587
|
return `op_${randomUUID().replaceAll("-", "")}_${fingerprint}`;
|
|
479
588
|
}
|
|
589
|
+
/**
|
|
590
|
+
* An operation key is `op_<random>_<fingerprint>`, and the fingerprint is a
|
|
591
|
+
* hash of the whole preview. Only the suffix is checked, which means **any**
|
|
592
|
+
* key minted from a preview of this exact payload works — including one from an
|
|
593
|
+
* earlier preview of the same command. That matters: previewing twice mints two
|
|
594
|
+
* different keys, both valid, and reaching for the newer one is not a mistake.
|
|
595
|
+
*
|
|
596
|
+
* The suffix is also what makes the key safe. It cannot be transplanted onto a
|
|
597
|
+
* different write, because a payload that differs by one character hashes
|
|
598
|
+
* differently and no key from the old preview will match.
|
|
599
|
+
*/
|
|
480
600
|
function assertOperationKeyMatchesPreview(operationKey, fingerprint, preview) {
|
|
481
601
|
if (!operationKey.endsWith(`_${fingerprint}`)) {
|
|
482
|
-
throw new CliError("approval_mismatch",
|
|
602
|
+
throw new CliError("approval_mismatch", `This operation key was minted for a different payload, so it cannot approve this one. The key must end in _${fingerprint} — copy the operationKey out of the preview for THIS exact command (any preview of the same payload will do, newest or oldest). If the payload really did change, that is the gate working: get approval on the current preview.`, 2, {
|
|
603
|
+
preview,
|
|
604
|
+
requiredFlag: "--operation-key",
|
|
605
|
+
expectedKeySuffix: `_${fingerprint}`,
|
|
606
|
+
suppliedKey: operationKey,
|
|
607
|
+
note: "Keys are bound to the payload, not to a particular preview run. Re-previewing an unchanged command mints a new key with the same suffix; every one of them is accepted.",
|
|
608
|
+
});
|
|
483
609
|
}
|
|
484
610
|
}
|
|
485
611
|
async function requirePreviewBoundConfirmation(parsed, preview) {
|
|
@@ -509,7 +635,7 @@ async function requirePreviewBoundConfirmation(parsed, preview) {
|
|
|
509
635
|
throw new CliError("approval_mismatch", "The approved preview no longer matches this request. Review the current preview and rerun with --confirm --approval-token <token>.", 2, { preview: boundPreview, requiredFlag: "--approval-token" });
|
|
510
636
|
}
|
|
511
637
|
if (!suppliedOperationKey) {
|
|
512
|
-
throw new CliError("confirmation_required", "A confirmed write requires the
|
|
638
|
+
throw new CliError("confirmation_required", "A confirmed write requires --operation-key. Run this exact command without --confirm, then pass the operationKey from the preview details. Any preview of this same payload yields a usable key.", 2, { preview: boundPreview, requiredFlag: "--operation-key" });
|
|
513
639
|
}
|
|
514
640
|
assertOperationKeyMatchesPreview(suppliedOperationKey, approvalToken, boundPreview);
|
|
515
641
|
await appendJobEvent({
|
|
@@ -1447,19 +1573,28 @@ export async function runCommand(argv) {
|
|
|
1447
1573
|
.map((name) => `--${name}`)
|
|
1448
1574
|
.join(", ")}.`, 1, { validFlags: ["--deliver", "--help", "--json", "--profile"] });
|
|
1449
1575
|
}
|
|
1450
|
-
|
|
1576
|
+
const config = await resolveConfig(flagString(parsed, "profile"));
|
|
1577
|
+
const discovery = resolveCommandDiscovery(config);
|
|
1578
|
+
return { help: scopedHelp(HELP, commands, [], discovery) };
|
|
1451
1579
|
}
|
|
1452
1580
|
if (noun === "help" || hasFlag(parsed, "help")) {
|
|
1453
1581
|
const scope = noun === "help" ? parsed.positionals.slice(1) : parsed.positionals;
|
|
1454
|
-
|
|
1582
|
+
const config = await resolveConfig(flagString(parsed, "profile"));
|
|
1583
|
+
const discovery = resolveCommandDiscovery(config);
|
|
1584
|
+
return { help: scopedHelp(HELP, commands, scope, discovery) };
|
|
1455
1585
|
}
|
|
1456
1586
|
validateInvocation(parsed, commands);
|
|
1457
1587
|
if (noun === "agent-context") {
|
|
1458
|
-
const profiles = await
|
|
1588
|
+
const [profiles, config] = await Promise.all([
|
|
1589
|
+
listProfiles(),
|
|
1590
|
+
resolveConfig(flagString(parsed, "profile")),
|
|
1591
|
+
]);
|
|
1592
|
+
const discovery = resolveCommandDiscovery(config);
|
|
1459
1593
|
return agentContext(commands, {
|
|
1460
1594
|
cliVersion: await readCliVersion(),
|
|
1461
1595
|
availableProfiles: profiles.profiles.map((profile) => profile.name),
|
|
1462
1596
|
feedbackUpstreamConfigured: Boolean(process.env.RECESS_CLI_FEEDBACK_ENDPOINT),
|
|
1597
|
+
discovery,
|
|
1463
1598
|
});
|
|
1464
1599
|
}
|
|
1465
1600
|
if (noun === "profile") {
|
|
@@ -3559,6 +3694,51 @@ export async function runCommand(argv) {
|
|
|
3559
3694
|
params: { path: { goalId } },
|
|
3560
3695
|
})));
|
|
3561
3696
|
}
|
|
3697
|
+
if (verb === "archive" || verb === "unarchive") {
|
|
3698
|
+
const goalId = positional(parsed, 2, "goal ID");
|
|
3699
|
+
const studentId = flagString(parsed, "student", { required: true });
|
|
3700
|
+
const reopen = verb === "unarchive";
|
|
3701
|
+
const current = unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
|
|
3702
|
+
params: { path: { userId: studentId } },
|
|
3703
|
+
})).goals.find((goal) => goal.id === goalId);
|
|
3704
|
+
if (!current) {
|
|
3705
|
+
throw new CliError("not_found", `Goal ${goalId} was not found for student ${studentId}.`);
|
|
3706
|
+
}
|
|
3707
|
+
// Refuse the no-op before asking a human to approve it. Approving
|
|
3708
|
+
// "archive a goal" only to be told it was already archived teaches an
|
|
3709
|
+
// agent to re-approve noise.
|
|
3710
|
+
if (!reopen && current.status === "COMPLETED") {
|
|
3711
|
+
throw new CliError("already_archived", `Goal ${goalId} ("${current.title}") is already archived and is not holding an open-goal slot.`);
|
|
3712
|
+
}
|
|
3713
|
+
if (reopen && current.status !== "COMPLETED") {
|
|
3714
|
+
throw new CliError("not_archived", `Goal ${goalId} ("${current.title}") is ${current.status}, not archived. There is nothing to reopen.`);
|
|
3715
|
+
}
|
|
3716
|
+
const preview = {
|
|
3717
|
+
action: reopen
|
|
3718
|
+
? "reopen an archived goal (consumes one of the kid's open-goal slots)"
|
|
3719
|
+
: "archive a finished goal (frees one of the kid's open-goal slots)",
|
|
3720
|
+
target: {
|
|
3721
|
+
goalId,
|
|
3722
|
+
studentUserId: studentId,
|
|
3723
|
+
title: current.title,
|
|
3724
|
+
status: current.status,
|
|
3725
|
+
progress: current.progress,
|
|
3726
|
+
},
|
|
3727
|
+
request: { goalId },
|
|
3728
|
+
details: {
|
|
3729
|
+
note: reopen
|
|
3730
|
+
? "Returns the goal to ACTIVE so it generates daily todos again. Refused with GOAL_LIMIT_REACHED if the kid is already at the open-goal cap, and refused for goals completed through the staff reward path (those need `goals undo-completion`, which reverses the coins)."
|
|
3731
|
+
: "Sets the terminal COMPLETED status. Grants no coins and posts no activity — use `goals complete` (staff only) when the kid should receive the goal's reward. The goal, its history, and its past todos all stay; only the open-goal slot is released. Any already-scheduled future todos on this goal are left alone, matching `goals complete`.",
|
|
3732
|
+
},
|
|
3733
|
+
};
|
|
3734
|
+
return previewBoundWrite(parsed, preview, async () => unwrap(reopen
|
|
3735
|
+
? await api.client.POST("/ai/goals/{goalId}/unarchive", {
|
|
3736
|
+
params: { path: { goalId } },
|
|
3737
|
+
})
|
|
3738
|
+
: await api.client.POST("/ai/goals/{goalId}/archive", {
|
|
3739
|
+
params: { path: { goalId } },
|
|
3740
|
+
})));
|
|
3741
|
+
}
|
|
3562
3742
|
if (verb === "list") {
|
|
3563
3743
|
const userId = flagString(parsed, "student", { required: true });
|
|
3564
3744
|
return unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
|
|
@@ -3727,6 +3907,7 @@ export async function runCommand(argv) {
|
|
|
3727
3907
|
const studentId = flagString(parsed, "student", { required: true });
|
|
3728
3908
|
const patchFile = flagString(parsed, "patch-file", { required: true });
|
|
3729
3909
|
const patch = (await readJsonFile(patchFile, "Goal patch file"));
|
|
3910
|
+
assertGoalPatchValues(patch, patchFile);
|
|
3730
3911
|
patch.delta = flagString(parsed, "delta", { required: true });
|
|
3731
3912
|
const current = unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
|
|
3732
3913
|
params: { path: { userId: studentId } },
|
|
@@ -3752,6 +3933,15 @@ export async function runCommand(argv) {
|
|
|
3752
3933
|
if (verb === "delete") {
|
|
3753
3934
|
const goalId = positional(parsed, 2, "goal ID");
|
|
3754
3935
|
const studentId = flagString(parsed, "student", { required: true });
|
|
3936
|
+
// Deletion is an /admin/* route, so a guardian or guide session can never
|
|
3937
|
+
// complete it. Refuse here, before the preview, rather than rendering a
|
|
3938
|
+
// full and entirely plausible preview — resolved goal, named title,
|
|
3939
|
+
// described soft-delete — that a human approves and the server then
|
|
3940
|
+
// answers 401. A preview is a promise about what --confirm will do.
|
|
3941
|
+
const sessionRole = await resolveSessionRole(api);
|
|
3942
|
+
if (sessionRole && sessionRole !== "ADMIN") {
|
|
3943
|
+
throw new CliError("forbidden", "Deleting a goal is staff-only. To free an open-goal slot for a finished course, use `recess goals archive <goal-id> --student <kid-id>` — it sets the terminal status, keeps the goal and its history, and needs no staff involvement.", 1, { sessionRole, requiredRole: "ADMIN", alternative: "goals archive" });
|
|
3944
|
+
}
|
|
3755
3945
|
const current = unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
|
|
3756
3946
|
params: { path: { userId: studentId } },
|
|
3757
3947
|
})).goals.find((goal) => goal.id === goalId);
|
|
@@ -3869,7 +4059,7 @@ export async function runCommand(argv) {
|
|
|
3869
4059
|
}
|
|
3870
4060
|
throw new CliError("invalid_arguments", "Use goals queue get|set.");
|
|
3871
4061
|
}
|
|
3872
|
-
throw new CliError("invalid_arguments", "Use goals list|create|edit|delete|complete|undo-completion|queue|files|pdf.");
|
|
4062
|
+
throw new CliError("invalid_arguments", "Use goals list|create|edit|delete|archive|unarchive|complete|undo-completion|queue|files|pdf.");
|
|
3873
4063
|
}
|
|
3874
4064
|
if (noun === "students") {
|
|
3875
4065
|
if (verb === "list") {
|
|
@@ -3970,6 +4160,60 @@ export async function runCommand(argv) {
|
|
|
3970
4160
|
body,
|
|
3971
4161
|
})));
|
|
3972
4162
|
}
|
|
4163
|
+
if (verb === "complete") {
|
|
4164
|
+
const todoId = positional(parsed, 2, "todo ID");
|
|
4165
|
+
const targetXp = flagNumber(parsed, "xp");
|
|
4166
|
+
if (targetXp === undefined ||
|
|
4167
|
+
!Number.isInteger(targetXp) ||
|
|
4168
|
+
targetXp <= 0) {
|
|
4169
|
+
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.");
|
|
4170
|
+
}
|
|
4171
|
+
const [currentResponse, detailsResponse] = await Promise.all([
|
|
4172
|
+
api.client.GET("/tutor/browser/todos/{id}/", {
|
|
4173
|
+
params: { path: { id: todoId } },
|
|
4174
|
+
}),
|
|
4175
|
+
api.client.GET("/admin/todos/{id}/details", {
|
|
4176
|
+
params: { path: { id: todoId } },
|
|
4177
|
+
}),
|
|
4178
|
+
]);
|
|
4179
|
+
const current = unwrap(currentResponse);
|
|
4180
|
+
const completionXpAwarded = unwrap(detailsResponse).totalXpAwarded ?? 0;
|
|
4181
|
+
if (current.status === "COMPLETED") {
|
|
4182
|
+
throw new CliError("already_completed", `Todo ${todoId} ("${current.title}") is already completed with ${completionXpAwarded} completion XP credited.`);
|
|
4183
|
+
}
|
|
4184
|
+
const body = {
|
|
4185
|
+
status: "COMPLETED",
|
|
4186
|
+
xpReward: targetXp,
|
|
4187
|
+
expectedUpdatedAt: current.updatedAt,
|
|
4188
|
+
};
|
|
4189
|
+
const newXp = Math.max(0, targetXp - completionXpAwarded);
|
|
4190
|
+
const preview = {
|
|
4191
|
+
action: "complete a todo through the staff reward path and set its total credited XP target",
|
|
4192
|
+
target: {
|
|
4193
|
+
todoId,
|
|
4194
|
+
studentUserId: current.userId,
|
|
4195
|
+
studentName: [current.user.firstName, current.user.lastName]
|
|
4196
|
+
.filter(Boolean)
|
|
4197
|
+
.join(" ") || null,
|
|
4198
|
+
title: current.title,
|
|
4199
|
+
status: current.status,
|
|
4200
|
+
goal: current.goal,
|
|
4201
|
+
},
|
|
4202
|
+
request: body,
|
|
4203
|
+
details: {
|
|
4204
|
+
currentXpReward: current.xpReward,
|
|
4205
|
+
alreadyAwardedXp: completionXpAwarded,
|
|
4206
|
+
targetTotalXp: targetXp,
|
|
4207
|
+
newlyAwardedXp: newXp,
|
|
4208
|
+
configuredCoinReward: current.reward,
|
|
4209
|
+
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.`,
|
|
4210
|
+
},
|
|
4211
|
+
};
|
|
4212
|
+
return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.PATCH("/admin/todos/{id}", {
|
|
4213
|
+
params: { path: { id: todoId } },
|
|
4214
|
+
body,
|
|
4215
|
+
})));
|
|
4216
|
+
}
|
|
3973
4217
|
if (verb === "delete") {
|
|
3974
4218
|
const todoId = positional(parsed, 2, "todo ID");
|
|
3975
4219
|
const current = unwrap(await api.client.GET("/tutor/browser/todos/{id}/", {
|
|
@@ -4038,7 +4282,7 @@ export async function runCommand(argv) {
|
|
|
4038
4282
|
body: targetDueDateISO ? { targetDueDateISO } : {},
|
|
4039
4283
|
})));
|
|
4040
4284
|
}
|
|
4041
|
-
throw new CliError("invalid_arguments", "Use todos create|edit|delete|generate-applet.");
|
|
4285
|
+
throw new CliError("invalid_arguments", "Use todos create|edit|complete|delete|generate-applet.");
|
|
4042
4286
|
}
|
|
4043
4287
|
if (noun === "memories") {
|
|
4044
4288
|
const studentId = flagString(parsed, "student", { required: true });
|