recess-cli 2.1.0 → 2.3.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 +18 -0
- package/dist/api.js +17 -0
- package/dist/auth.js +7 -2
- package/dist/cli.js +206 -8
- package/dist/command-schema.js +1 -0
- package/dist/commands/onboarding.js +360 -0
- package/dist/commands/school.js +727 -0
- package/dist/help.js +93 -5
- package/dist/http.js +45 -2
- package/package.json +1 -1
- package/skill/recess-cli/SKILL.md +3 -1
- package/skill/recess-cli/agents/version.json +1 -1
package/README.md
CHANGED
|
@@ -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"
|
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
|
@@ -9,6 +9,7 @@ import { clearStoredSession, deleteProfile, listProfiles, resolveConfig, savePro
|
|
|
9
9
|
import { agentContext, buildCommandSchema, scopedHelp, validateInvocation, } from "./command-schema.js";
|
|
10
10
|
import { runApplicationsCommand } from "./commands/applications.js";
|
|
11
11
|
import { runOnboardingCommand } from "./commands/onboarding.js";
|
|
12
|
+
import { runSchoolCommand } from "./commands/school.js";
|
|
12
13
|
import { assertChoice, flagIdList, positional, readJsonFile, readJsonValue, } from "./commands/shared.js";
|
|
13
14
|
import { CliError } from "./errors.js";
|
|
14
15
|
import { listFeedback, submitFeedback } from "./feedback.js";
|
|
@@ -23,6 +24,52 @@ import { appendJobEvent, getJob, listJobs, pruneJobs } from "./jobs.js";
|
|
|
23
24
|
function requiredRequestReason(parsed) {
|
|
24
25
|
return requireCliRequestReason(flagString(parsed, "reason", { required: true }));
|
|
25
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
|
+
}
|
|
26
73
|
async function sessionStatus(config) {
|
|
27
74
|
if (!config.sessionCookie) {
|
|
28
75
|
return {
|
|
@@ -130,7 +177,7 @@ async function writeCommand(parsed, preview, execute) {
|
|
|
130
177
|
details: {
|
|
131
178
|
...preview.details,
|
|
132
179
|
operationKey,
|
|
133
|
-
retry: "Rerun the unchanged command with --confirm --operation-key <operationKey>. Reuse that same key after an interrupted invocation.",
|
|
180
|
+
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.",
|
|
134
181
|
},
|
|
135
182
|
};
|
|
136
183
|
await appendJobEvent({
|
|
@@ -144,7 +191,7 @@ async function writeCommand(parsed, preview, execute) {
|
|
|
144
191
|
requireConfirmation(false, boundPreview);
|
|
145
192
|
}
|
|
146
193
|
if (!suppliedOperationKey) {
|
|
147
|
-
throw new CliError("confirmation_required", "A confirmed write requires the
|
|
194
|
+
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" });
|
|
148
195
|
}
|
|
149
196
|
assertOperationKeyMatchesPreview(suppliedOperationKey, fingerprint, preview);
|
|
150
197
|
await appendJobEvent({
|
|
@@ -476,9 +523,26 @@ function approvalTokenFor(preview) {
|
|
|
476
523
|
function operationKeyFor(fingerprint) {
|
|
477
524
|
return `op_${randomUUID().replaceAll("-", "")}_${fingerprint}`;
|
|
478
525
|
}
|
|
526
|
+
/**
|
|
527
|
+
* An operation key is `op_<random>_<fingerprint>`, and the fingerprint is a
|
|
528
|
+
* hash of the whole preview. Only the suffix is checked, which means **any**
|
|
529
|
+
* key minted from a preview of this exact payload works — including one from an
|
|
530
|
+
* earlier preview of the same command. That matters: previewing twice mints two
|
|
531
|
+
* different keys, both valid, and reaching for the newer one is not a mistake.
|
|
532
|
+
*
|
|
533
|
+
* The suffix is also what makes the key safe. It cannot be transplanted onto a
|
|
534
|
+
* different write, because a payload that differs by one character hashes
|
|
535
|
+
* differently and no key from the old preview will match.
|
|
536
|
+
*/
|
|
479
537
|
function assertOperationKeyMatchesPreview(operationKey, fingerprint, preview) {
|
|
480
538
|
if (!operationKey.endsWith(`_${fingerprint}`)) {
|
|
481
|
-
throw new CliError("approval_mismatch",
|
|
539
|
+
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, {
|
|
540
|
+
preview,
|
|
541
|
+
requiredFlag: "--operation-key",
|
|
542
|
+
expectedKeySuffix: `_${fingerprint}`,
|
|
543
|
+
suppliedKey: operationKey,
|
|
544
|
+
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.",
|
|
545
|
+
});
|
|
482
546
|
}
|
|
483
547
|
}
|
|
484
548
|
async function requirePreviewBoundConfirmation(parsed, preview) {
|
|
@@ -508,7 +572,7 @@ async function requirePreviewBoundConfirmation(parsed, preview) {
|
|
|
508
572
|
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" });
|
|
509
573
|
}
|
|
510
574
|
if (!suppliedOperationKey) {
|
|
511
|
-
throw new CliError("confirmation_required", "A confirmed write requires the
|
|
575
|
+
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" });
|
|
512
576
|
}
|
|
513
577
|
assertOperationKeyMatchesPreview(suppliedOperationKey, approvalToken, boundPreview);
|
|
514
578
|
await appendJobEvent({
|
|
@@ -1156,6 +1220,14 @@ function parseGoalTemplateDocument(doc) {
|
|
|
1156
1220
|
(typeof rawSortOrder !== "number" || !Number.isInteger(rawSortOrder))) {
|
|
1157
1221
|
throw new CliError("invalid_arguments", 'Template file field "sortOrder" must be an integer.');
|
|
1158
1222
|
}
|
|
1223
|
+
const rawCoinAmount = doc.coinAmount;
|
|
1224
|
+
if (rawCoinAmount !== undefined &&
|
|
1225
|
+
rawCoinAmount !== null &&
|
|
1226
|
+
(typeof rawCoinAmount !== "number" ||
|
|
1227
|
+
!Number.isInteger(rawCoinAmount) ||
|
|
1228
|
+
rawCoinAmount < 1)) {
|
|
1229
|
+
throw new CliError("invalid_arguments", 'Template file field "coinAmount" must be a positive integer.');
|
|
1230
|
+
}
|
|
1159
1231
|
const kind = assertChoice(optionalDocString(doc, "kind") ?? "SIMPLE", GOAL_TEMPLATE_KINDS, "kind");
|
|
1160
1232
|
const setupAudience = assertChoice(optionalDocString(doc, "setupAudience") ?? "KID_FRIENDLY", GOAL_TEMPLATE_SETUP_AUDIENCES, "setupAudience");
|
|
1161
1233
|
const starterTierRaw = optionalDocString(doc, "starterTier");
|
|
@@ -1175,6 +1247,9 @@ function parseGoalTemplateDocument(doc) {
|
|
|
1175
1247
|
...(optionalDocString(doc, "imageUrl") === undefined
|
|
1176
1248
|
? {}
|
|
1177
1249
|
: { imageUrl: optionalDocString(doc, "imageUrl") }),
|
|
1250
|
+
...(rawCoinAmount === undefined || rawCoinAmount === null
|
|
1251
|
+
? {}
|
|
1252
|
+
: { coinAmount: rawCoinAmount }),
|
|
1178
1253
|
...(optionalDocString(doc, "category") === undefined
|
|
1179
1254
|
? {}
|
|
1180
1255
|
: { category: optionalDocString(doc, "category") }),
|
|
@@ -2248,6 +2323,9 @@ export async function runCommand(argv) {
|
|
|
2248
2323
|
if (noun === "applications" || noun === "quotes") {
|
|
2249
2324
|
return runApplicationsCommand({ parsed, api, writeCommand });
|
|
2250
2325
|
}
|
|
2326
|
+
if (noun === "school") {
|
|
2327
|
+
return runSchoolCommand({ parsed, api, writeCommand });
|
|
2328
|
+
}
|
|
2251
2329
|
if (noun === "cohorts" && verb === "search") {
|
|
2252
2330
|
const search = parsed.positionals.slice(2).join(" ").trim();
|
|
2253
2331
|
if (!search)
|
|
@@ -3084,7 +3162,18 @@ export async function runCommand(argv) {
|
|
|
3084
3162
|
}
|
|
3085
3163
|
if (verb === "create") {
|
|
3086
3164
|
const filePath = flagString(parsed, "file", { required: true });
|
|
3087
|
-
const
|
|
3165
|
+
const authoredDocument = parseGoalTemplateDocument(await readJsonFile(filePath, "Template file"));
|
|
3166
|
+
const coinAmountOverride = flagNumber(parsed, "coin-amount");
|
|
3167
|
+
if (coinAmountOverride !== undefined &&
|
|
3168
|
+
(!Number.isInteger(coinAmountOverride) || coinAmountOverride < 1)) {
|
|
3169
|
+
throw new CliError("invalid_arguments", "--coin-amount must be a positive integer.");
|
|
3170
|
+
}
|
|
3171
|
+
const document = {
|
|
3172
|
+
...authoredDocument,
|
|
3173
|
+
...(coinAmountOverride === undefined
|
|
3174
|
+
? {}
|
|
3175
|
+
: { coinAmount: coinAmountOverride }),
|
|
3176
|
+
};
|
|
3088
3177
|
// C3 read-only preflight, for the same reason `enrollments create` has
|
|
3089
3178
|
// one: the consequences an approver must weigh are resolved SERVER-side.
|
|
3090
3179
|
// Which setupHandler runs, what shape of goal students get, and which
|
|
@@ -3110,6 +3199,7 @@ export async function runCommand(argv) {
|
|
|
3110
3199
|
category: document.category ?? null,
|
|
3111
3200
|
tags: document.tags,
|
|
3112
3201
|
isStarter: document.isStarter ?? false,
|
|
3202
|
+
coinAmount: document.coinAmount ?? null,
|
|
3113
3203
|
},
|
|
3114
3204
|
details: {
|
|
3115
3205
|
resolvedSetupHandler: validation.setupHandler,
|
|
@@ -3206,6 +3296,11 @@ export async function runCommand(argv) {
|
|
|
3206
3296
|
const tags = flagString(parsed, "tags");
|
|
3207
3297
|
const kind = flagString(parsed, "kind");
|
|
3208
3298
|
const sortOrder = flagNumber(parsed, "sort-order");
|
|
3299
|
+
const coinAmount = flagNumber(parsed, "coin-amount");
|
|
3300
|
+
if (coinAmount !== undefined &&
|
|
3301
|
+
(!Number.isInteger(coinAmount) || coinAmount < 1)) {
|
|
3302
|
+
throw new CliError("invalid_arguments", "--coin-amount must be a positive integer.");
|
|
3303
|
+
}
|
|
3209
3304
|
const isStarterRaw = flagString(parsed, "is-starter");
|
|
3210
3305
|
const setupAudienceRaw = flagString(parsed, "setup-audience");
|
|
3211
3306
|
const isStarter = isStarterRaw === undefined
|
|
@@ -3222,6 +3317,10 @@ export async function runCommand(argv) {
|
|
|
3222
3317
|
...(flagString(parsed, "emoji")
|
|
3223
3318
|
? { emoji: flagString(parsed, "emoji") }
|
|
3224
3319
|
: {}),
|
|
3320
|
+
...(flagString(parsed, "image-url")
|
|
3321
|
+
? { imageUrl: flagString(parsed, "image-url") }
|
|
3322
|
+
: {}),
|
|
3323
|
+
...(coinAmount === undefined ? {} : { coinAmount }),
|
|
3225
3324
|
...(flagString(parsed, "category")
|
|
3226
3325
|
? { category: flagString(parsed, "category") }
|
|
3227
3326
|
: {}),
|
|
@@ -3259,7 +3358,7 @@ export async function runCommand(argv) {
|
|
|
3259
3358
|
// shape that caused the template incident; editing an existing spec goes
|
|
3260
3359
|
// through the guarded /ai patch path with its destructive-change token.
|
|
3261
3360
|
if (Object.keys(body).length === 1) {
|
|
3262
|
-
throw new CliError("invalid_arguments", "Pass at least one field to change (--title, --description, --emoji, --category, --tags, --sort-order, --is-starter, --setup-audience, --kind, --agent-instructions-file, --output-template-file).");
|
|
3361
|
+
throw new CliError("invalid_arguments", "Pass at least one field to change (--title, --description, --emoji, --image-url, --coin-amount, --category, --tags, --sort-order, --is-starter, --setup-audience, --kind, --agent-instructions-file, --output-template-file).");
|
|
3263
3362
|
}
|
|
3264
3363
|
return writeCommand(parsed, {
|
|
3265
3364
|
action: "update goal template metadata (never its setupWorkflowSpec)",
|
|
@@ -3270,6 +3369,28 @@ export async function runCommand(argv) {
|
|
|
3270
3369
|
body,
|
|
3271
3370
|
})));
|
|
3272
3371
|
}
|
|
3372
|
+
if (verb === "generate-image") {
|
|
3373
|
+
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
3374
|
+
const current = unwrap(await api.client.GET("/ai/goal-templates/{id}", {
|
|
3375
|
+
params: { path: { id } },
|
|
3376
|
+
}));
|
|
3377
|
+
const prompt = flagString(parsed, "prompt");
|
|
3378
|
+
return writeCommand(parsed, {
|
|
3379
|
+
action: "queue paid GPT-Image-2 art regeneration",
|
|
3380
|
+
target: {
|
|
3381
|
+
templateId: id,
|
|
3382
|
+
slug: current.slug,
|
|
3383
|
+
title: current.title,
|
|
3384
|
+
},
|
|
3385
|
+
request: { prompt: prompt ?? null },
|
|
3386
|
+
details: {
|
|
3387
|
+
costNote: "This queues one paid 1024×1024 GPT-Image-2 generation and replaces the template image when it finishes. New templates already generate art automatically.",
|
|
3388
|
+
},
|
|
3389
|
+
}, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/generate-image", {
|
|
3390
|
+
params: { path: { id } },
|
|
3391
|
+
body: prompt ? { prompt } : {},
|
|
3392
|
+
})));
|
|
3393
|
+
}
|
|
3273
3394
|
if (verb === "delete") {
|
|
3274
3395
|
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
3275
3396
|
const expectedVersion = requiredExpectedVersion(parsed);
|
|
@@ -3476,9 +3597,76 @@ export async function runCommand(argv) {
|
|
|
3476
3597
|
body: { studentUserId, answers },
|
|
3477
3598
|
})));
|
|
3478
3599
|
}
|
|
3479
|
-
throw new CliError("invalid_arguments", "Use goal-templates list|get|versions|validate-spec|create|patch-spec|set-metadata|delete|snapshot-files|capture-snapshot|apply|apply-starter.");
|
|
3600
|
+
throw new CliError("invalid_arguments", "Use goal-templates list|get|versions|validate-spec|create|patch-spec|set-metadata|generate-image|delete|snapshot-files|capture-snapshot|apply|apply-starter.");
|
|
3480
3601
|
}
|
|
3481
3602
|
if (noun === "goals" && verb !== "files" && verb !== "pdf") {
|
|
3603
|
+
if (verb === "complete" || verb === "undo-completion") {
|
|
3604
|
+
const goalId = positional(parsed, 2, "goal ID");
|
|
3605
|
+
const undo = verb === "undo-completion";
|
|
3606
|
+
return writeCommand(parsed, {
|
|
3607
|
+
action: undo
|
|
3608
|
+
? "undo goal completion and reverse its coin reward"
|
|
3609
|
+
: "complete a goal and grant its one-time coin reward",
|
|
3610
|
+
target: { goalId },
|
|
3611
|
+
request: { source: "STAFF" },
|
|
3612
|
+
details: undo
|
|
3613
|
+
? {
|
|
3614
|
+
note: "Undo is refused if the student has already spent enough coins that the reward cannot be reversed.",
|
|
3615
|
+
}
|
|
3616
|
+
: undefined,
|
|
3617
|
+
}, async () => unwrap(undo
|
|
3618
|
+
? await api.client.POST("/ai/goals/{goalId}/undo-completion", {
|
|
3619
|
+
params: { path: { goalId } },
|
|
3620
|
+
})
|
|
3621
|
+
: await api.client.POST("/ai/goals/{goalId}/complete", {
|
|
3622
|
+
params: { path: { goalId } },
|
|
3623
|
+
})));
|
|
3624
|
+
}
|
|
3625
|
+
if (verb === "archive" || verb === "unarchive") {
|
|
3626
|
+
const goalId = positional(parsed, 2, "goal ID");
|
|
3627
|
+
const studentId = flagString(parsed, "student", { required: true });
|
|
3628
|
+
const reopen = verb === "unarchive";
|
|
3629
|
+
const current = unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
|
|
3630
|
+
params: { path: { userId: studentId } },
|
|
3631
|
+
})).goals.find((goal) => goal.id === goalId);
|
|
3632
|
+
if (!current) {
|
|
3633
|
+
throw new CliError("not_found", `Goal ${goalId} was not found for student ${studentId}.`);
|
|
3634
|
+
}
|
|
3635
|
+
// Refuse the no-op before asking a human to approve it. Approving
|
|
3636
|
+
// "archive a goal" only to be told it was already archived teaches an
|
|
3637
|
+
// agent to re-approve noise.
|
|
3638
|
+
if (!reopen && current.status === "COMPLETED") {
|
|
3639
|
+
throw new CliError("already_archived", `Goal ${goalId} ("${current.title}") is already archived and is not holding an open-goal slot.`);
|
|
3640
|
+
}
|
|
3641
|
+
if (reopen && current.status !== "COMPLETED") {
|
|
3642
|
+
throw new CliError("not_archived", `Goal ${goalId} ("${current.title}") is ${current.status}, not archived. There is nothing to reopen.`);
|
|
3643
|
+
}
|
|
3644
|
+
const preview = {
|
|
3645
|
+
action: reopen
|
|
3646
|
+
? "reopen an archived goal (consumes one of the kid's open-goal slots)"
|
|
3647
|
+
: "archive a finished goal (frees one of the kid's open-goal slots)",
|
|
3648
|
+
target: {
|
|
3649
|
+
goalId,
|
|
3650
|
+
studentUserId: studentId,
|
|
3651
|
+
title: current.title,
|
|
3652
|
+
status: current.status,
|
|
3653
|
+
progress: current.progress,
|
|
3654
|
+
},
|
|
3655
|
+
request: { goalId },
|
|
3656
|
+
details: {
|
|
3657
|
+
note: reopen
|
|
3658
|
+
? "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)."
|
|
3659
|
+
: "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`.",
|
|
3660
|
+
},
|
|
3661
|
+
};
|
|
3662
|
+
return previewBoundWrite(parsed, preview, async () => unwrap(reopen
|
|
3663
|
+
? await api.client.POST("/ai/goals/{goalId}/unarchive", {
|
|
3664
|
+
params: { path: { goalId } },
|
|
3665
|
+
})
|
|
3666
|
+
: await api.client.POST("/ai/goals/{goalId}/archive", {
|
|
3667
|
+
params: { path: { goalId } },
|
|
3668
|
+
})));
|
|
3669
|
+
}
|
|
3482
3670
|
if (verb === "list") {
|
|
3483
3671
|
const userId = flagString(parsed, "student", { required: true });
|
|
3484
3672
|
return unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
|
|
@@ -3647,6 +3835,7 @@ export async function runCommand(argv) {
|
|
|
3647
3835
|
const studentId = flagString(parsed, "student", { required: true });
|
|
3648
3836
|
const patchFile = flagString(parsed, "patch-file", { required: true });
|
|
3649
3837
|
const patch = (await readJsonFile(patchFile, "Goal patch file"));
|
|
3838
|
+
assertGoalPatchValues(patch, patchFile);
|
|
3650
3839
|
patch.delta = flagString(parsed, "delta", { required: true });
|
|
3651
3840
|
const current = unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
|
|
3652
3841
|
params: { path: { userId: studentId } },
|
|
@@ -3672,6 +3861,15 @@ export async function runCommand(argv) {
|
|
|
3672
3861
|
if (verb === "delete") {
|
|
3673
3862
|
const goalId = positional(parsed, 2, "goal ID");
|
|
3674
3863
|
const studentId = flagString(parsed, "student", { required: true });
|
|
3864
|
+
// Deletion is an /admin/* route, so a guardian or guide session can never
|
|
3865
|
+
// complete it. Refuse here, before the preview, rather than rendering a
|
|
3866
|
+
// full and entirely plausible preview — resolved goal, named title,
|
|
3867
|
+
// described soft-delete — that a human approves and the server then
|
|
3868
|
+
// answers 401. A preview is a promise about what --confirm will do.
|
|
3869
|
+
const sessionRole = await resolveSessionRole(api);
|
|
3870
|
+
if (sessionRole && sessionRole !== "ADMIN") {
|
|
3871
|
+
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" });
|
|
3872
|
+
}
|
|
3675
3873
|
const current = unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
|
|
3676
3874
|
params: { path: { userId: studentId } },
|
|
3677
3875
|
})).goals.find((goal) => goal.id === goalId);
|
|
@@ -3789,7 +3987,7 @@ export async function runCommand(argv) {
|
|
|
3789
3987
|
}
|
|
3790
3988
|
throw new CliError("invalid_arguments", "Use goals queue get|set.");
|
|
3791
3989
|
}
|
|
3792
|
-
throw new CliError("invalid_arguments", "Use goals list|create|edit|delete|queue|files|pdf.");
|
|
3990
|
+
throw new CliError("invalid_arguments", "Use goals list|create|edit|delete|archive|unarchive|complete|undo-completion|queue|files|pdf.");
|
|
3793
3991
|
}
|
|
3794
3992
|
if (noun === "students") {
|
|
3795
3993
|
if (verb === "list") {
|