recess-cli 1.0.1 → 1.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 +56 -3
- package/dist/args.js +43 -2
- package/dist/cli.js +1090 -2
- package/dist/setup.js +40 -2
- package/dist/skill-update.js +150 -0
- package/dist/skills-cache.js +0 -0
- package/package.json +9 -2
- package/scripts/postinstall.mjs +44 -0
- package/skill/recess-cli/SKILL.md +204 -14
- package/skill/recess-cli/reference/cancellation-credits.md +1 -1
- package/skill/recess-cli/reference/class-ops-reschedule.md +1 -1
- package/skill/recess-cli/reference/goal-authoring.md +274 -0
package/dist/cli.js
CHANGED
|
@@ -2,15 +2,18 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { RecessAdminApi, unwrap } from "./api.js";
|
|
5
|
-
import { flagNumber, flagString, hasFlag, parseArgs, } from "./args.js";
|
|
5
|
+
import { flagList, flagNumber, flagString, hasFlag, parseArgs, } from "./args.js";
|
|
6
6
|
import { login, pollDeviceAuth, requestDeviceAuth } from "./auth.js";
|
|
7
7
|
import { clearStoredSession, resolveConfig, } from "./config.js";
|
|
8
8
|
import { CliError } from "./errors.js";
|
|
9
9
|
import { requireConfirmation } from "./safety.js";
|
|
10
|
-
import { installSkill, isEphemeralInstall } from "./setup.js";
|
|
10
|
+
import { installSkill, isEphemeralInstall, readBundledSkillVersion, readCliVersion, } from "./setup.js";
|
|
11
|
+
import { compareVersions, updateSkillFromServer } from "./skill-update.js";
|
|
12
|
+
import { readSkillCache, writeSkillCache } from "./skills-cache.js";
|
|
11
13
|
export const HELP = `recess — safe Recess administration from the command line
|
|
12
14
|
|
|
13
15
|
Usage:
|
|
16
|
+
recess [--json] --version
|
|
14
17
|
recess [--json] setup [--skill-only]
|
|
15
18
|
recess [--json] doctor
|
|
16
19
|
recess [--json] auth login [--client-id ID] [--callback-port 8765]
|
|
@@ -35,6 +38,9 @@ Usage:
|
|
|
35
38
|
recess [--json] invoices refund --invoice <id> --line-item <id>
|
|
36
39
|
--method refund|credit|tokens [--full | --amount-cents N]
|
|
37
40
|
[--who-pays guide|recess] [--reason TEXT] [--confirm]
|
|
41
|
+
recess [--json] applications enroll <application-id> --quote <quote-id>
|
|
42
|
+
--school <institution-slug> --kid "<quoteLineId>:<firstName>[:<age>]" (repeat per line)
|
|
43
|
+
[--family <family-id>] [--unassign <kid-id>] (repeat) [--note TEXT] [--confirm]
|
|
38
44
|
recess [--json] cohorts search <query>
|
|
39
45
|
recess [--json] enrollments create --user <kid-id> --cohort <id>
|
|
40
46
|
[--first-charge-at ISO_DATETIME] [--send-email] [--force] [--confirm]
|
|
@@ -107,6 +113,56 @@ Usage:
|
|
|
107
113
|
recess [--json] village models remove <placement-id> [--world village-1] [--confirm]
|
|
108
114
|
recess [--json] village render --min-x N --min-z N --max-x N --max-z N
|
|
109
115
|
[--world village-1]
|
|
116
|
+
recess [--json] skills list [--query TEXT] [--category TEXT]
|
|
117
|
+
recess [--json] skills get <skill-name> [--reference NAME | --all-references]
|
|
118
|
+
[--refresh]
|
|
119
|
+
recess [--json] goal-templates list [--query TEXT] [--category TEXT]
|
|
120
|
+
[--kind SIMPLE|BLUEPRINT] [--starter-only] [--include-deleted]
|
|
121
|
+
recess [--json] goal-templates get <template-id|slug> [--spec-only]
|
|
122
|
+
recess [--json] goal-templates versions <template-id> [--version N]
|
|
123
|
+
recess [--json] goal-templates validate-spec --file <path/template.json>
|
|
124
|
+
recess [--json] goal-templates create --file <path/template.json> [--confirm]
|
|
125
|
+
recess [--json] goal-templates patch-spec <template-id|slug> --expected-version N
|
|
126
|
+
--patches-file <path/patches.json> [--confirm]
|
|
127
|
+
[--confirm-destructive-changes --destructive-change-token TOKEN]
|
|
128
|
+
recess [--json] goal-templates set-metadata <template-id> --expected-version N
|
|
129
|
+
[--title TEXT] [--description TEXT] [--emoji X] [--category TEXT] [--tags A,B]
|
|
130
|
+
[--sort-order N] [--kind SIMPLE|BLUEPRINT] [--agent-instructions-file <path>]
|
|
131
|
+
[--confirm]
|
|
132
|
+
recess [--json] goal-templates delete <template-id> --expected-version N [--confirm]
|
|
133
|
+
recess [--json] goal-templates snapshot-files <template-id> [--path P]
|
|
134
|
+
recess [--json] goal-templates capture-snapshot <template-id|slug>
|
|
135
|
+
(--source-goal <goal-id> | --source-draft <draft-slug> --student <kid-id>)
|
|
136
|
+
[--dry-run] [--confirm]
|
|
137
|
+
recess [--json] goal-templates apply <template-id> --answers-file <path>
|
|
138
|
+
[--dry-run] [--confirm]
|
|
139
|
+
recess [--json] goal-templates apply-starter <template-id> --student <kid-id>
|
|
140
|
+
[--answers-file <path>] [--confirm]
|
|
141
|
+
recess [--json] goals list --student <kid-id>
|
|
142
|
+
recess [--json] goals create --student <kid-id> --title TEXT
|
|
143
|
+
(--description TEXT | --description-file <path>) [--target-date <iso>]
|
|
144
|
+
[--schedule TEXT] [--confirm]
|
|
145
|
+
recess [--json] mesa files list --student <kid-id> --goal <goal-id>
|
|
146
|
+
recess [--json] mesa files read --student <kid-id> --goal <goal-id> --path P
|
|
147
|
+
recess [--json] mesa files write --student <kid-id>
|
|
148
|
+
(--goal <goal-id> | --draft <draft-slug>)
|
|
149
|
+
(--source-dir <local-dir> | --source-file <local-file> --path P)
|
|
150
|
+
[--message TEXT] [--confirm]
|
|
151
|
+
|
|
152
|
+
Authoring notes: "skills" serves the in-product tutor skills (the PRIVATE
|
|
153
|
+
packages/skills submodule) read-only over your admin session — they are never
|
|
154
|
+
bundled into this npm package. Load os-v2-goal-template-builder and its
|
|
155
|
+
references/deterministic-workflow-setup.md BEFORE authoring a template; that is
|
|
156
|
+
the same guidance the recess.gg/ai agent follows, so there is exactly one
|
|
157
|
+
standard. Responses cache under ~/.recess-cli/skills-cache/ (--refresh re-fetches).
|
|
158
|
+
Every template created here is setupMode DETERMINISTIC_WORKFLOW and CANNOT be
|
|
159
|
+
converted back, so "validate-spec" against the same file until it passes, then
|
|
160
|
+
"create". "create" runs a real server-side validation before its gate, so the
|
|
161
|
+
preview shows the handler/goalShape/step keys the SERVER resolved. "apply" runs
|
|
162
|
+
the backend's own dryRun before the gate and previews the per-student outcome.
|
|
163
|
+
"set-metadata" and "delete" require --expected-version (from "get"); a stale one
|
|
164
|
+
409s STALE_WRITE and writes nothing. The spec is unreachable from "set-metadata"
|
|
165
|
+
by design — an existing spec is edited only through the guarded /ai patch path.
|
|
110
166
|
|
|
111
167
|
Onboarding notes: "status" and "intake-session" are reads — "intake-session"
|
|
112
168
|
looks up the current IN_PROGRESS session without creating one (prints a "none
|
|
@@ -136,6 +192,13 @@ cloud agent, "auth request" prints an approval URL to hand a Recess admin; after
|
|
|
136
192
|
approve it in a browser, "auth poll" collects the 12h session. When it lapses, run
|
|
137
193
|
"auth request" again for a fresh link. Both paths yield the same session.
|
|
138
194
|
|
|
195
|
+
Skill notes: this CLI's own agent skill ships inside the npm package AND is served
|
|
196
|
+
by the server, so wording/Gotcha updates arrive without an npm release. "setup"
|
|
197
|
+
(or "setup --skill-only") installs the bundled copy, then upgrades it from the
|
|
198
|
+
server when the served bundle's minCliVersion allows — an older binary keeps the
|
|
199
|
+
bundled copy, and an unreachable server is not an error. "doctor" reports whether
|
|
200
|
+
a newer skill exists and names the command; it never writes.
|
|
201
|
+
|
|
139
202
|
Writes preview and exit 2 unless --confirm is supplied after explicit human approval.
|
|
140
203
|
Environment overrides: RECESS_CLI_API_ORIGIN, RECESS_CLI_WEB_ORIGIN,
|
|
141
204
|
RECESS_CLI_OAUTH_CLIENT_ID, RECESS_CLI_COOKIE, RECESS_CLI_CONFIG.`;
|
|
@@ -189,12 +252,139 @@ async function doctor(config) {
|
|
|
189
252
|
message: error instanceof Error ? error.message : String(error),
|
|
190
253
|
};
|
|
191
254
|
}
|
|
255
|
+
checks.skill = await skillStatus(config);
|
|
192
256
|
return checks;
|
|
193
257
|
}
|
|
258
|
+
/**
|
|
259
|
+
* Report whether a newer agent skill is available. Deliberately READ-ONLY:
|
|
260
|
+
* `doctor` is run reflexively at the start of a session, and a diagnostic that
|
|
261
|
+
* silently rewrote `~/.claude/skills` mid-session would change what the agent is
|
|
262
|
+
* reading out from under it. It names the command instead.
|
|
263
|
+
*/
|
|
264
|
+
async function skillStatus(config) {
|
|
265
|
+
const cliVersion = await readCliVersion();
|
|
266
|
+
const installedVersion = await readBundledSkillVersion();
|
|
267
|
+
const base = { cliVersion, bundledSkillVersion: installedVersion };
|
|
268
|
+
if (!config.sessionCookie) {
|
|
269
|
+
return { ...base, checked: false, reason: "no session" };
|
|
270
|
+
}
|
|
271
|
+
try {
|
|
272
|
+
const response = await fetch(new URL("/admin/cli-skill/", config.apiOrigin), {
|
|
273
|
+
headers: { cookie: config.sessionCookie },
|
|
274
|
+
signal: AbortSignal.timeout(5000),
|
|
275
|
+
});
|
|
276
|
+
if (!response.ok) {
|
|
277
|
+
return { ...base, checked: false, reason: `server ${response.status}` };
|
|
278
|
+
}
|
|
279
|
+
const bundle = (await response.json());
|
|
280
|
+
const serverVersion = bundle.version ?? "0.0.0";
|
|
281
|
+
const minCliVersion = bundle.minCliVersion ?? "0.0.0";
|
|
282
|
+
const cliTooOld = compareVersions(cliVersion, minCliVersion) < 0;
|
|
283
|
+
const upToDate = compareVersions(installedVersion, serverVersion) >= 0;
|
|
284
|
+
return {
|
|
285
|
+
...base,
|
|
286
|
+
checked: true,
|
|
287
|
+
serverSkillVersion: serverVersion,
|
|
288
|
+
minCliVersion,
|
|
289
|
+
upToDate,
|
|
290
|
+
...(upToDate
|
|
291
|
+
? {}
|
|
292
|
+
: cliTooOld
|
|
293
|
+
? {
|
|
294
|
+
action: `Server skill ${serverVersion} needs recess >= ${minCliVersion}; upgrade with \`npm install -g recess-cli\`.`,
|
|
295
|
+
}
|
|
296
|
+
: { action: "Run `recess setup --skill-only` to install it." }),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
return {
|
|
301
|
+
...base,
|
|
302
|
+
checked: false,
|
|
303
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
}
|
|
194
307
|
async function writeCommand(parsed, preview, execute) {
|
|
195
308
|
requireConfirmation(hasFlag(parsed, "confirm"), preview);
|
|
196
309
|
return execute();
|
|
197
310
|
}
|
|
311
|
+
const MESA_WRITE_MAX_FILES = 1_000;
|
|
312
|
+
const MESA_WRITE_MAX_TOTAL_BYTES = 20 * 1024 * 1024;
|
|
313
|
+
async function readMesaWriteSource(parsed) {
|
|
314
|
+
const sourceDirectory = flagString(parsed, "source-dir");
|
|
315
|
+
const sourceFile = flagString(parsed, "source-file");
|
|
316
|
+
if (Boolean(sourceDirectory) === Boolean(sourceFile)) {
|
|
317
|
+
throw new CliError("invalid_arguments", "Pass exactly one of --source-dir or --source-file.");
|
|
318
|
+
}
|
|
319
|
+
const files = [];
|
|
320
|
+
const hash = createHash("sha256");
|
|
321
|
+
let sizeBytes = 0;
|
|
322
|
+
let source;
|
|
323
|
+
const addFile = async (absolutePath, workspacePath) => {
|
|
324
|
+
if (files.length >= MESA_WRITE_MAX_FILES) {
|
|
325
|
+
throw new CliError("invalid_arguments", `Mesa workspace writes are limited to ${MESA_WRITE_MAX_FILES} files.`);
|
|
326
|
+
}
|
|
327
|
+
const fileStat = await fs.stat(absolutePath);
|
|
328
|
+
if (sizeBytes + fileStat.size > MESA_WRITE_MAX_TOTAL_BYTES) {
|
|
329
|
+
throw new CliError("invalid_arguments", `Mesa workspace writes are limited to ${MESA_WRITE_MAX_TOTAL_BYTES} decoded bytes.`);
|
|
330
|
+
}
|
|
331
|
+
const bytes = await fs.readFile(absolutePath);
|
|
332
|
+
sizeBytes += bytes.byteLength;
|
|
333
|
+
files.push({
|
|
334
|
+
path: workspacePath.replaceAll(path.sep, "/"),
|
|
335
|
+
content: bytes.toString("base64"),
|
|
336
|
+
contentEncoding: "base64",
|
|
337
|
+
});
|
|
338
|
+
};
|
|
339
|
+
if (sourceDirectory) {
|
|
340
|
+
const root = path.resolve(sourceDirectory);
|
|
341
|
+
const rootStat = await fs.lstat(root).catch(() => null);
|
|
342
|
+
if (!rootStat?.isDirectory() || rootStat.isSymbolicLink()) {
|
|
343
|
+
throw new CliError("invalid_arguments", `--source-dir is not a directory: ${root}`);
|
|
344
|
+
}
|
|
345
|
+
source = { kind: "directory", absolutePath: root };
|
|
346
|
+
const walk = async (directory, relativeRoot = "") => {
|
|
347
|
+
const entries = await fs.readdir(directory, { withFileTypes: true });
|
|
348
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
349
|
+
for (const entry of entries) {
|
|
350
|
+
const relativePath = relativeRoot
|
|
351
|
+
? path.join(relativeRoot, entry.name)
|
|
352
|
+
: entry.name;
|
|
353
|
+
const absolutePath = path.join(directory, entry.name);
|
|
354
|
+
if (entry.isSymbolicLink()) {
|
|
355
|
+
throw new CliError("invalid_arguments", `Refusing symbolic link in --source-dir: ${absolutePath}`);
|
|
356
|
+
}
|
|
357
|
+
if (entry.isDirectory()) {
|
|
358
|
+
await walk(absolutePath, relativePath);
|
|
359
|
+
}
|
|
360
|
+
else if (entry.isFile()) {
|
|
361
|
+
await addFile(absolutePath, relativePath);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
await walk(root);
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
const absolutePath = path.resolve(sourceFile);
|
|
369
|
+
const fileStat = await fs.lstat(absolutePath).catch(() => null);
|
|
370
|
+
if (!fileStat?.isFile() || fileStat.isSymbolicLink()) {
|
|
371
|
+
throw new CliError("invalid_arguments", `--source-file is not a file: ${absolutePath}`);
|
|
372
|
+
}
|
|
373
|
+
const workspacePath = flagString(parsed, "path", { required: true });
|
|
374
|
+
source = { kind: "file", absolutePath };
|
|
375
|
+
await addFile(absolutePath, workspacePath);
|
|
376
|
+
}
|
|
377
|
+
if (files.length === 0) {
|
|
378
|
+
throw new CliError("invalid_arguments", "The Mesa write source contains no files.");
|
|
379
|
+
}
|
|
380
|
+
for (const file of files) {
|
|
381
|
+
hash.update(file.path);
|
|
382
|
+
hash.update("\0");
|
|
383
|
+
hash.update(file.content, "base64");
|
|
384
|
+
hash.update("\0");
|
|
385
|
+
}
|
|
386
|
+
return { files, source, sizeBytes, sha256: hash.digest("hex") };
|
|
387
|
+
}
|
|
198
388
|
function assertChoice(value, choices, label) {
|
|
199
389
|
if (!choices.includes(value)) {
|
|
200
390
|
throw new CliError("invalid_arguments", `${label} must be one of: ${choices.join(", ")}.`);
|
|
@@ -263,6 +453,220 @@ const SCHOOL_TIER_OPTIONS = [
|
|
|
263
453
|
];
|
|
264
454
|
const SCHOOL_TIER_IDS = SCHOOL_TIER_OPTIONS.map((tier) => tier.id);
|
|
265
455
|
const MAX_MAP_PDF_BYTES = 15 * 1024 * 1024;
|
|
456
|
+
const GOAL_TEMPLATE_KINDS = ["SIMPLE", "BLUEPRINT"];
|
|
457
|
+
const GOAL_TEMPLATE_SETUP_AUDIENCES = ["KID_FRIENDLY", "PARENT_SETUP"];
|
|
458
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
459
|
+
/**
|
|
460
|
+
* Read and parse a JSON file supplied by an authoring agent. Large payloads —
|
|
461
|
+
* a setupWorkflowSpec, an answers map, a goal description — go through files
|
|
462
|
+
* rather than argv: a shell mangles embedded quotes and newlines, and a spec
|
|
463
|
+
* that survived a round trip through `--json '<...>'` is not the spec that was
|
|
464
|
+
* reviewed.
|
|
465
|
+
*/
|
|
466
|
+
async function readJsonValue(filePath, label) {
|
|
467
|
+
const absolutePath = path.resolve(filePath);
|
|
468
|
+
let raw;
|
|
469
|
+
try {
|
|
470
|
+
raw = await fs.readFile(absolutePath, "utf8");
|
|
471
|
+
}
|
|
472
|
+
catch (error) {
|
|
473
|
+
if (error.code === "ENOENT") {
|
|
474
|
+
throw new CliError("invalid_arguments", `${label} does not exist: ${absolutePath}`);
|
|
475
|
+
}
|
|
476
|
+
throw error;
|
|
477
|
+
}
|
|
478
|
+
let parsed;
|
|
479
|
+
try {
|
|
480
|
+
parsed = JSON.parse(raw);
|
|
481
|
+
}
|
|
482
|
+
catch (error) {
|
|
483
|
+
throw new CliError("invalid_arguments", `${label} is not valid JSON (${absolutePath}): ${error instanceof Error ? error.message : String(error)}`);
|
|
484
|
+
}
|
|
485
|
+
return { absolutePath, raw, parsed };
|
|
486
|
+
}
|
|
487
|
+
async function readJsonFile(filePath, label) {
|
|
488
|
+
const { absolutePath, parsed } = await readJsonValue(filePath, label);
|
|
489
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
490
|
+
throw new CliError("invalid_arguments", `${label} must be a JSON object (${absolutePath}).`);
|
|
491
|
+
}
|
|
492
|
+
return parsed;
|
|
493
|
+
}
|
|
494
|
+
async function readGoalTemplateSpecPatches(filePath) {
|
|
495
|
+
const { absolutePath, raw, parsed } = await readJsonValue(filePath, "Goal-template patch file");
|
|
496
|
+
if (!Array.isArray(parsed) || parsed.length === 0) {
|
|
497
|
+
throw new CliError("invalid_arguments", `Goal-template patch file must be a non-empty JSON array (${absolutePath}).`);
|
|
498
|
+
}
|
|
499
|
+
if (parsed.length > 50) {
|
|
500
|
+
throw new CliError("invalid_arguments", "Goal-template patch files support at most 50 operations.");
|
|
501
|
+
}
|
|
502
|
+
const patches = parsed.map((entry, index) => {
|
|
503
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
|
504
|
+
throw new CliError("invalid_arguments", `Patch operation ${index} must be an object.`);
|
|
505
|
+
}
|
|
506
|
+
const patch = entry;
|
|
507
|
+
const op = patch.op;
|
|
508
|
+
if (op !== "add" && op !== "copy" && op !== "replace" && op !== "remove") {
|
|
509
|
+
throw new CliError("invalid_arguments", `Patch operation ${index}.op must be add, copy, replace, or remove.`);
|
|
510
|
+
}
|
|
511
|
+
if (typeof patch.path !== "string" || !patch.path.startsWith("/")) {
|
|
512
|
+
throw new CliError("invalid_arguments", `Patch operation ${index}.path must be a JSON Pointer starting with "/".`);
|
|
513
|
+
}
|
|
514
|
+
const hasValue = Object.prototype.hasOwnProperty.call(patch, "value");
|
|
515
|
+
if ((op === "add" || op === "replace") && !hasValue) {
|
|
516
|
+
throw new CliError("invalid_arguments", `Patch operation ${index}.value is required for ${op}.`);
|
|
517
|
+
}
|
|
518
|
+
if ((op === "copy" || op === "remove") && hasValue) {
|
|
519
|
+
throw new CliError("invalid_arguments", `Patch operation ${index}.value is not allowed for ${op}.`);
|
|
520
|
+
}
|
|
521
|
+
if (op === "copy" &&
|
|
522
|
+
(typeof patch.from !== "string" || !patch.from.startsWith("/"))) {
|
|
523
|
+
throw new CliError("invalid_arguments", `Patch operation ${index}.from must be a JSON Pointer starting with "/" for copy.`);
|
|
524
|
+
}
|
|
525
|
+
return {
|
|
526
|
+
op,
|
|
527
|
+
path: patch.path,
|
|
528
|
+
...(op === "copy" ? { from: patch.from } : {}),
|
|
529
|
+
...(hasValue ? { value: patch.value } : {}),
|
|
530
|
+
};
|
|
531
|
+
});
|
|
532
|
+
return {
|
|
533
|
+
absolutePath,
|
|
534
|
+
sizeBytes: Buffer.byteLength(raw),
|
|
535
|
+
sha256: createHash("sha256").update(raw).digest("hex"),
|
|
536
|
+
patches,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
function requiredDocString(doc, key) {
|
|
540
|
+
const value = doc[key];
|
|
541
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
542
|
+
throw new CliError("invalid_arguments", `Template file is missing required string "${key}".`);
|
|
543
|
+
}
|
|
544
|
+
return value;
|
|
545
|
+
}
|
|
546
|
+
function optionalDocString(doc, key) {
|
|
547
|
+
const value = doc[key];
|
|
548
|
+
if (value === undefined || value === null)
|
|
549
|
+
return undefined;
|
|
550
|
+
if (typeof value !== "string") {
|
|
551
|
+
throw new CliError("invalid_arguments", `Template file field "${key}" must be a string.`);
|
|
552
|
+
}
|
|
553
|
+
return value;
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Coerce an authored template file into the exact `POST /admin/goal-templates/`
|
|
557
|
+
* body. Every unknown key is dropped rather than forwarded, so a stale field
|
|
558
|
+
* copied from an old export cannot ride along into a create, and the shape
|
|
559
|
+
* failures an agent actually makes (missing slug, spec as a string, tags as a
|
|
560
|
+
* comma list) are named locally before any network call.
|
|
561
|
+
*
|
|
562
|
+
* `setupMode` is deliberately absent: the route accepts only
|
|
563
|
+
* DETERMINISTIC_WORKFLOW and a deterministic template can never be converted
|
|
564
|
+
* back, so there is no choice to express.
|
|
565
|
+
*/
|
|
566
|
+
function parseGoalTemplateDocument(doc) {
|
|
567
|
+
if (doc.setupMode !== undefined &&
|
|
568
|
+
doc.setupMode !== "DETERMINISTIC_WORKFLOW") {
|
|
569
|
+
throw new CliError("invalid_arguments", `Template file sets setupMode "${String(doc.setupMode)}". New templates are DETERMINISTIC_WORKFLOW only — omit the field.`);
|
|
570
|
+
}
|
|
571
|
+
const spec = doc.setupWorkflowSpec;
|
|
572
|
+
if (spec === null || typeof spec !== "object" || Array.isArray(spec)) {
|
|
573
|
+
throw new CliError("invalid_arguments", 'Template file requires an object "setupWorkflowSpec" (the fixed wizard). Author it with the os-v2-goal-template-builder skill: `recess --json skills get os-v2-goal-template-builder --all-references`.');
|
|
574
|
+
}
|
|
575
|
+
const rawTags = doc.tags;
|
|
576
|
+
let tags = [];
|
|
577
|
+
if (Array.isArray(rawTags)) {
|
|
578
|
+
if (!rawTags.every((tag) => typeof tag === "string")) {
|
|
579
|
+
throw new CliError("invalid_arguments", 'Template file field "tags" must be an array of strings.');
|
|
580
|
+
}
|
|
581
|
+
tags = rawTags;
|
|
582
|
+
}
|
|
583
|
+
else if (typeof rawTags === "string") {
|
|
584
|
+
// A comma list is what a human writes by hand; accept it rather than
|
|
585
|
+
// failing an otherwise-valid template on punctuation.
|
|
586
|
+
tags = rawTags
|
|
587
|
+
.split(",")
|
|
588
|
+
.map((tag) => tag.trim())
|
|
589
|
+
.filter(Boolean);
|
|
590
|
+
}
|
|
591
|
+
else if (rawTags !== undefined && rawTags !== null) {
|
|
592
|
+
throw new CliError("invalid_arguments", 'Template file field "tags" must be an array of strings.');
|
|
593
|
+
}
|
|
594
|
+
const rawSortOrder = doc.sortOrder;
|
|
595
|
+
if (rawSortOrder !== undefined &&
|
|
596
|
+
rawSortOrder !== null &&
|
|
597
|
+
(typeof rawSortOrder !== "number" || !Number.isInteger(rawSortOrder))) {
|
|
598
|
+
throw new CliError("invalid_arguments", 'Template file field "sortOrder" must be an integer.');
|
|
599
|
+
}
|
|
600
|
+
const kind = assertChoice(optionalDocString(doc, "kind") ?? "SIMPLE", GOAL_TEMPLATE_KINDS, "kind");
|
|
601
|
+
const setupAudience = assertChoice(optionalDocString(doc, "setupAudience") ?? "KID_FRIENDLY", GOAL_TEMPLATE_SETUP_AUDIENCES, "setupAudience");
|
|
602
|
+
const starterTierRaw = optionalDocString(doc, "starterTier");
|
|
603
|
+
const parentSetupSpec = doc.parentSetupSpec;
|
|
604
|
+
if (parentSetupSpec !== undefined &&
|
|
605
|
+
parentSetupSpec !== null &&
|
|
606
|
+
(typeof parentSetupSpec !== "object" || Array.isArray(parentSetupSpec))) {
|
|
607
|
+
throw new CliError("invalid_arguments", 'Template file field "parentSetupSpec" must be an object.');
|
|
608
|
+
}
|
|
609
|
+
return {
|
|
610
|
+
slug: requiredDocString(doc, "slug"),
|
|
611
|
+
title: requiredDocString(doc, "title"),
|
|
612
|
+
description: requiredDocString(doc, "description"),
|
|
613
|
+
...(optionalDocString(doc, "emoji") === undefined
|
|
614
|
+
? {}
|
|
615
|
+
: { emoji: optionalDocString(doc, "emoji") }),
|
|
616
|
+
...(optionalDocString(doc, "imageUrl") === undefined
|
|
617
|
+
? {}
|
|
618
|
+
: { imageUrl: optionalDocString(doc, "imageUrl") }),
|
|
619
|
+
...(optionalDocString(doc, "category") === undefined
|
|
620
|
+
? {}
|
|
621
|
+
: { category: optionalDocString(doc, "category") }),
|
|
622
|
+
tags,
|
|
623
|
+
sortOrder: rawSortOrder ?? 0,
|
|
624
|
+
...(doc.isStarter === undefined
|
|
625
|
+
? {}
|
|
626
|
+
: { isStarter: Boolean(doc.isStarter) }),
|
|
627
|
+
...(starterTierRaw === undefined
|
|
628
|
+
? {}
|
|
629
|
+
: {
|
|
630
|
+
starterTier: assertChoice(starterTierRaw, ["CORE", "EXTRA"], "starterTier"),
|
|
631
|
+
}),
|
|
632
|
+
kind,
|
|
633
|
+
setupAudience,
|
|
634
|
+
setupWorkflowSpec: spec,
|
|
635
|
+
...(parentSetupSpec === undefined || parentSetupSpec === null
|
|
636
|
+
? {}
|
|
637
|
+
: { parentSetupSpec: parentSetupSpec }),
|
|
638
|
+
agentInstructions: requiredDocString(doc, "agentInstructions"),
|
|
639
|
+
...(optionalDocString(doc, "outputTemplate") === undefined
|
|
640
|
+
? {}
|
|
641
|
+
: { outputTemplate: optionalDocString(doc, "outputTemplate") }),
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Resolve a template reference that may be a UUID or a slug. Slugs are what an
|
|
646
|
+
* author types and what the skill's prose uses; the routes take a UUID only.
|
|
647
|
+
*/
|
|
648
|
+
async function resolveGoalTemplateId(api, reference) {
|
|
649
|
+
if (UUID_RE.test(reference))
|
|
650
|
+
return reference;
|
|
651
|
+
const list = unwrap(await api.client.GET("/admin/goal-templates/", {
|
|
652
|
+
params: { query: { includeDeleted: "true" } },
|
|
653
|
+
}));
|
|
654
|
+
const match = list.items.find((item) => item.slug === reference);
|
|
655
|
+
if (!match) {
|
|
656
|
+
throw new CliError("invalid_arguments", `No goal template with id or slug "${reference}".`);
|
|
657
|
+
}
|
|
658
|
+
return match.id;
|
|
659
|
+
}
|
|
660
|
+
function requiredExpectedVersion(parsed) {
|
|
661
|
+
const value = flagNumber(parsed, "expected-version");
|
|
662
|
+
if (value === undefined) {
|
|
663
|
+
throw new CliError("invalid_arguments", "Missing required --expected-version. Read it from `goal-templates get <id>` (the `version` field); a stale value 409s without writing.");
|
|
664
|
+
}
|
|
665
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
666
|
+
throw new CliError("invalid_arguments", "--expected-version must be a positive integer.");
|
|
667
|
+
}
|
|
668
|
+
return value;
|
|
669
|
+
}
|
|
266
670
|
async function readMapScorePdf(filePath) {
|
|
267
671
|
const absolutePath = path.resolve(filePath);
|
|
268
672
|
if (path.extname(absolutePath).toLowerCase() !== ".pdf") {
|
|
@@ -308,6 +712,34 @@ function flagStatusList(parsed, name, choices) {
|
|
|
308
712
|
}
|
|
309
713
|
return values.map((value) => assertChoice(value, choices, `--${name}`));
|
|
310
714
|
}
|
|
715
|
+
/**
|
|
716
|
+
* Parse one `--kid` spec into an enroll roster entry.
|
|
717
|
+
*
|
|
718
|
+
* Shape: `<quoteLineId>:<firstName>[:<age>]` — the quote line FIRST, because
|
|
719
|
+
* the line id is the identity the enrollment matches on and the name is only a
|
|
720
|
+
* label. A staffer typing these reads them off the quote, in order.
|
|
721
|
+
*
|
|
722
|
+
* ⚠️ THE NAME MAY CONTAIN NOTHING SURPRISING, but an age must be a real number:
|
|
723
|
+
* age decides `birthdate` at creation AND rides in the frozen payment snapshot
|
|
724
|
+
* that a later takeover is compared against, so a silently-dropped or
|
|
725
|
+
* mistyped age is a money-adjacent error, not a cosmetic one. Absent is fine
|
|
726
|
+
* (the server treats it as "not stated"); unparseable is refused here.
|
|
727
|
+
*/
|
|
728
|
+
function parseEnrollKidSpec(raw) {
|
|
729
|
+
const parts = raw.split(":").map((part) => part.trim());
|
|
730
|
+
const [quoteLineId, firstName, ageRaw, ...extra] = parts;
|
|
731
|
+
if (!quoteLineId || !firstName || extra.length > 0) {
|
|
732
|
+
throw new CliError("invalid_arguments", `--kid must be "<quoteLineId>:<firstName>[:<age>]"; got "${raw}".`);
|
|
733
|
+
}
|
|
734
|
+
if (ageRaw === undefined || ageRaw === "") {
|
|
735
|
+
return { quoteLineId, firstName };
|
|
736
|
+
}
|
|
737
|
+
const age = Number(ageRaw);
|
|
738
|
+
if (!Number.isInteger(age) || age < 1 || age > 25) {
|
|
739
|
+
throw new CliError("invalid_arguments", `--kid age must be a whole number from 1 to 25; got "${ageRaw}".`);
|
|
740
|
+
}
|
|
741
|
+
return { quoteLineId, firstName, age };
|
|
742
|
+
}
|
|
311
743
|
function flagCents(parsed, name, options = {}) {
|
|
312
744
|
const value = flagNumber(parsed, name);
|
|
313
745
|
if (value === undefined) {
|
|
@@ -407,6 +839,14 @@ function tierSlots(parsed) {
|
|
|
407
839
|
export async function runCommand(argv) {
|
|
408
840
|
const parsed = parseArgs(argv);
|
|
409
841
|
const [noun, verb] = parsed.positionals;
|
|
842
|
+
// Before the help branch: `--version` parses as a FLAG, so `noun` is
|
|
843
|
+
// undefined and `!noun` would return help instead. (Found by running it.)
|
|
844
|
+
if (noun === "version" || hasFlag(parsed, "version")) {
|
|
845
|
+
return {
|
|
846
|
+
cliVersion: await readCliVersion(),
|
|
847
|
+
skillVersion: await readBundledSkillVersion(),
|
|
848
|
+
};
|
|
849
|
+
}
|
|
410
850
|
if (!noun || noun === "help" || hasFlag(parsed, "help")) {
|
|
411
851
|
return { help: HELP };
|
|
412
852
|
}
|
|
@@ -414,7 +854,15 @@ export async function runCommand(argv) {
|
|
|
414
854
|
if (noun === "doctor")
|
|
415
855
|
return doctor(config);
|
|
416
856
|
if (noun === "setup") {
|
|
857
|
+
// Always lay down the bundled copy first: it is the floor, and it is the
|
|
858
|
+
// only copy guaranteed to match this binary. The served upgrade below is
|
|
859
|
+
// strictly additive on top of it.
|
|
417
860
|
const skills = await installSkill();
|
|
861
|
+
const skillUpdate = await updateSkillFromServer({
|
|
862
|
+
apiOrigin: config.apiOrigin,
|
|
863
|
+
sessionCookie: config.sessionCookie,
|
|
864
|
+
cliVersion: await readCliVersion(),
|
|
865
|
+
});
|
|
418
866
|
const ephemeral = isEphemeralInstall();
|
|
419
867
|
// Reuse a session that is still live; a missing or already-expired one is
|
|
420
868
|
// worth spending the browser round trip on now rather than at first use.
|
|
@@ -430,6 +878,7 @@ export async function runCommand(argv) {
|
|
|
430
878
|
});
|
|
431
879
|
return {
|
|
432
880
|
skills,
|
|
881
|
+
skillUpdate,
|
|
433
882
|
session: loggedIn ?? (sessionIsLive ? "existing" : null),
|
|
434
883
|
...(ephemeral
|
|
435
884
|
? {
|
|
@@ -743,6 +1192,48 @@ export async function runCommand(argv) {
|
|
|
743
1192
|
params: { query: { subscriptionId } },
|
|
744
1193
|
}));
|
|
745
1194
|
}
|
|
1195
|
+
if (noun === "applications" && verb === "enroll") {
|
|
1196
|
+
const applicationId = positional(parsed, 2, "application ID");
|
|
1197
|
+
const quoteId = flagString(parsed, "quote", { required: true });
|
|
1198
|
+
const institutionSlug = flagString(parsed, "school", { required: true });
|
|
1199
|
+
const familyId = flagString(parsed, "family");
|
|
1200
|
+
const note = flagString(parsed, "note");
|
|
1201
|
+
// Repeatable --kid, one per PRICED LINE on the quote. The server refuses a
|
|
1202
|
+
// partial roster (every priced student must be enrolled), so this is
|
|
1203
|
+
// deliberately not a convenience list — it is the whole quote, echoed back.
|
|
1204
|
+
const kidSpecs = flagList(parsed, "kid");
|
|
1205
|
+
if (kidSpecs.length === 0) {
|
|
1206
|
+
throw new CliError("invalid_arguments", 'Missing --kid. Pass one per quote line: --kid "<quoteLineId>:<firstName>[:<age>]".');
|
|
1207
|
+
}
|
|
1208
|
+
const kids = kidSpecs.map(parseEnrollKidSpec);
|
|
1209
|
+
// Every OTHER live child in the family must be named explicitly. The server
|
|
1210
|
+
// refuses the whole enrollment otherwise, listing who was unlisted — so the
|
|
1211
|
+
// failure is legible either way, but naming them here is how a staffer says
|
|
1212
|
+
// "yes, I know, they are not enrolling".
|
|
1213
|
+
const dispositions = flagList(parsed, "unassign").map((kidUserId) => ({
|
|
1214
|
+
kidUserId,
|
|
1215
|
+
action: "unassigned",
|
|
1216
|
+
}));
|
|
1217
|
+
return writeCommand(parsed, {
|
|
1218
|
+
// One short clause, like every other preview in this file. The
|
|
1219
|
+
// consequences are enumerated in `request` below, which is what the
|
|
1220
|
+
// confirmation prompt prints in full — restating them here would make
|
|
1221
|
+
// this the only preview a staffer has to read twice.
|
|
1222
|
+
action: "enroll an application from its accepted quote (creates children, charges the first month, cancels marketplace subscriptions)",
|
|
1223
|
+
target: { applicationId, quoteId, institutionSlug, familyId },
|
|
1224
|
+
request: { kids, dispositions, note },
|
|
1225
|
+
}, async () => unwrap(await api.client.POST("/admin/applications/{applicationId}/enroll", {
|
|
1226
|
+
params: { path: { applicationId } },
|
|
1227
|
+
body: {
|
|
1228
|
+
quoteId,
|
|
1229
|
+
institutionSlug,
|
|
1230
|
+
kids,
|
|
1231
|
+
...(familyId ? { familyId } : {}),
|
|
1232
|
+
...(dispositions.length > 0 ? { dispositions } : {}),
|
|
1233
|
+
...(note ? { note } : {}),
|
|
1234
|
+
},
|
|
1235
|
+
})));
|
|
1236
|
+
}
|
|
746
1237
|
if (noun === "cohorts" && verb === "search") {
|
|
747
1238
|
const search = parsed.positionals.slice(2).join(" ").trim();
|
|
748
1239
|
if (!search)
|
|
@@ -1529,6 +2020,603 @@ export async function runCommand(argv) {
|
|
|
1529
2020
|
}
|
|
1530
2021
|
throw new CliError("invalid_arguments", "Use onboarding status|kids|intake-session|intake-session-create|set-stage|set-account-state|attest|set-intake|extract.");
|
|
1531
2022
|
}
|
|
2023
|
+
if (noun === "skills") {
|
|
2024
|
+
// Reads only — no gate. The value of this noun is that CLI agents author
|
|
2025
|
+
// against the SAME documents the in-product tutor loads; nothing is copied
|
|
2026
|
+
// into this package, so a skills-repo change reaches agents with no release.
|
|
2027
|
+
const refresh = hasFlag(parsed, "refresh");
|
|
2028
|
+
if (verb === "list") {
|
|
2029
|
+
const query = flagString(parsed, "query");
|
|
2030
|
+
const category = flagString(parsed, "category");
|
|
2031
|
+
const cacheKey = `list:${query ?? ""}:${category ?? ""}`;
|
|
2032
|
+
if (!refresh) {
|
|
2033
|
+
const cached = await readSkillCache(config.apiOrigin, cacheKey);
|
|
2034
|
+
if (cached)
|
|
2035
|
+
return { ...cached, cached: true };
|
|
2036
|
+
}
|
|
2037
|
+
const data = unwrap(await api.client.GET("/admin/skills/", {
|
|
2038
|
+
params: {
|
|
2039
|
+
query: {
|
|
2040
|
+
...(query ? { query } : {}),
|
|
2041
|
+
...(category ? { category } : {}),
|
|
2042
|
+
},
|
|
2043
|
+
},
|
|
2044
|
+
}));
|
|
2045
|
+
await writeSkillCache(config.apiOrigin, cacheKey, data);
|
|
2046
|
+
return { ...data, cached: false };
|
|
2047
|
+
}
|
|
2048
|
+
if (verb === "get") {
|
|
2049
|
+
const name = positional(parsed, 2, "skill name");
|
|
2050
|
+
const reference = flagString(parsed, "reference");
|
|
2051
|
+
const allReferences = hasFlag(parsed, "all-references");
|
|
2052
|
+
if (reference && allReferences) {
|
|
2053
|
+
throw new CliError("invalid_arguments", "Pass --reference <name> for one reference or --all-references for the whole tree, not both.");
|
|
2054
|
+
}
|
|
2055
|
+
const cacheKey = `get:${name}:${reference ?? ""}:${allReferences}`;
|
|
2056
|
+
if (!refresh) {
|
|
2057
|
+
const cached = await readSkillCache(config.apiOrigin, cacheKey);
|
|
2058
|
+
if (cached)
|
|
2059
|
+
return { ...cached, cached: true };
|
|
2060
|
+
}
|
|
2061
|
+
const data = unwrap(await api.client.GET("/admin/skills/{name}", {
|
|
2062
|
+
params: {
|
|
2063
|
+
path: { name },
|
|
2064
|
+
query: {
|
|
2065
|
+
...(reference ? { reference } : {}),
|
|
2066
|
+
...(allReferences ? { references: "all" } : {}),
|
|
2067
|
+
},
|
|
2068
|
+
},
|
|
2069
|
+
}));
|
|
2070
|
+
await writeSkillCache(config.apiOrigin, cacheKey, data);
|
|
2071
|
+
return { ...data, cached: false };
|
|
2072
|
+
}
|
|
2073
|
+
throw new CliError("invalid_arguments", "Use skills list|get.");
|
|
2074
|
+
}
|
|
2075
|
+
if (noun === "goal-templates") {
|
|
2076
|
+
if (verb === "list") {
|
|
2077
|
+
const query = flagString(parsed, "query")?.toLowerCase();
|
|
2078
|
+
const kind = flagString(parsed, "kind");
|
|
2079
|
+
const data = unwrap(await api.client.GET("/admin/goal-templates/", {
|
|
2080
|
+
params: {
|
|
2081
|
+
query: {
|
|
2082
|
+
...(flagString(parsed, "category")
|
|
2083
|
+
? { category: flagString(parsed, "category") }
|
|
2084
|
+
: {}),
|
|
2085
|
+
includeDeleted: hasFlag(parsed, "include-deleted")
|
|
2086
|
+
? "true"
|
|
2087
|
+
: "false",
|
|
2088
|
+
starterOnly: hasFlag(parsed, "starter-only")
|
|
2089
|
+
? "true"
|
|
2090
|
+
: "false",
|
|
2091
|
+
},
|
|
2092
|
+
},
|
|
2093
|
+
}));
|
|
2094
|
+
// The route filters by category/starter/deleted only; `--query` and
|
|
2095
|
+
// `--kind` narrow the returned page here rather than pretending the
|
|
2096
|
+
// backend supports them.
|
|
2097
|
+
const wantedKind = kind
|
|
2098
|
+
? assertChoice(kind, GOAL_TEMPLATE_KINDS, "--kind")
|
|
2099
|
+
: undefined;
|
|
2100
|
+
const items = data.items.filter((item) => {
|
|
2101
|
+
if (wantedKind && item.kind !== wantedKind)
|
|
2102
|
+
return false;
|
|
2103
|
+
if (!query)
|
|
2104
|
+
return true;
|
|
2105
|
+
return [item.title, item.slug, item.description, item.category ?? ""]
|
|
2106
|
+
.join("\n")
|
|
2107
|
+
.toLowerCase()
|
|
2108
|
+
.includes(query);
|
|
2109
|
+
});
|
|
2110
|
+
return { items, totalBeforeFilter: data.items.length };
|
|
2111
|
+
}
|
|
2112
|
+
if (verb === "get") {
|
|
2113
|
+
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
2114
|
+
const template = unwrap(await api.client.GET("/admin/goal-templates/{id}", {
|
|
2115
|
+
params: { path: { id } },
|
|
2116
|
+
}));
|
|
2117
|
+
// A full spec is large; `--spec-only` is what an agent pipes into a file
|
|
2118
|
+
// before editing, without the surrounding metadata noise.
|
|
2119
|
+
if (hasFlag(parsed, "spec-only")) {
|
|
2120
|
+
return {
|
|
2121
|
+
id: template.id,
|
|
2122
|
+
slug: template.slug,
|
|
2123
|
+
version: template.version,
|
|
2124
|
+
setupWorkflowSpec: template.setupWorkflowSpec,
|
|
2125
|
+
};
|
|
2126
|
+
}
|
|
2127
|
+
return template;
|
|
2128
|
+
}
|
|
2129
|
+
if (verb === "versions") {
|
|
2130
|
+
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
2131
|
+
const data = unwrap(await api.client.GET("/admin/goal-templates/{id}/versions", {
|
|
2132
|
+
params: { path: { id } },
|
|
2133
|
+
}));
|
|
2134
|
+
const wanted = flagNumber(parsed, "version");
|
|
2135
|
+
if (wanted === undefined) {
|
|
2136
|
+
// Version bodies carry the whole frozen spec; the default listing is
|
|
2137
|
+
// metadata so history stays readable, and `--version N` fetches one.
|
|
2138
|
+
return {
|
|
2139
|
+
versions: data.versions.map(({ setupWorkflowSpec, agentInstructions, ...rest }) => ({
|
|
2140
|
+
...rest,
|
|
2141
|
+
hasSetupWorkflowSpec: setupWorkflowSpec !== null,
|
|
2142
|
+
agentInstructionsChars: agentInstructions.length,
|
|
2143
|
+
})),
|
|
2144
|
+
};
|
|
2145
|
+
}
|
|
2146
|
+
const version = data.versions.find((entry) => entry.version === wanted);
|
|
2147
|
+
if (!version) {
|
|
2148
|
+
throw new CliError("invalid_arguments", `Template has no version ${wanted}.`);
|
|
2149
|
+
}
|
|
2150
|
+
return version;
|
|
2151
|
+
}
|
|
2152
|
+
if (verb === "validate-spec") {
|
|
2153
|
+
const document = parseGoalTemplateDocument(await readJsonFile(flagString(parsed, "file", { required: true }), "Template file"));
|
|
2154
|
+
// A read-only validation: no gate, and iterating on it is the whole point.
|
|
2155
|
+
return {
|
|
2156
|
+
slug: document.slug,
|
|
2157
|
+
kind: document.kind,
|
|
2158
|
+
...unwrap(await api.client.POST("/admin/goal-templates/validate-spec", {
|
|
2159
|
+
body: {
|
|
2160
|
+
setupWorkflowSpec: document.setupWorkflowSpec,
|
|
2161
|
+
kind: document.kind,
|
|
2162
|
+
},
|
|
2163
|
+
})),
|
|
2164
|
+
};
|
|
2165
|
+
}
|
|
2166
|
+
if (verb === "create") {
|
|
2167
|
+
const filePath = flagString(parsed, "file", { required: true });
|
|
2168
|
+
const document = parseGoalTemplateDocument(await readJsonFile(filePath, "Template file"));
|
|
2169
|
+
// C3 read-only preflight, for the same reason `enrollments create` has
|
|
2170
|
+
// one: the consequences an approver must weigh are resolved SERVER-side.
|
|
2171
|
+
// Which setupHandler runs, what shape of goal students get, and which
|
|
2172
|
+
// answer keys `apply` will demand all come out of the spec's own
|
|
2173
|
+
// validation — a locally guessed preview could name a different handler
|
|
2174
|
+
// than the one that lands. Validation writes nothing.
|
|
2175
|
+
const validation = unwrap(await api.client.POST("/admin/goal-templates/validate-spec", {
|
|
2176
|
+
body: {
|
|
2177
|
+
setupWorkflowSpec: document.setupWorkflowSpec,
|
|
2178
|
+
kind: document.kind,
|
|
2179
|
+
},
|
|
2180
|
+
}));
|
|
2181
|
+
if (!validation.valid) {
|
|
2182
|
+
throw new CliError("invalid_spec", `The setupWorkflowSpec is invalid, so nothing was created: ${validation.error}`, 1, { file: path.resolve(filePath) });
|
|
2183
|
+
}
|
|
2184
|
+
return writeCommand(parsed, {
|
|
2185
|
+
action: "create a NEW global goal template (DETERMINISTIC_WORKFLOW — this cannot be converted back)",
|
|
2186
|
+
target: { slug: document.slug, title: document.title },
|
|
2187
|
+
request: {
|
|
2188
|
+
file: path.resolve(filePath),
|
|
2189
|
+
kind: document.kind,
|
|
2190
|
+
setupAudience: document.setupAudience,
|
|
2191
|
+
category: document.category ?? null,
|
|
2192
|
+
tags: document.tags,
|
|
2193
|
+
isStarter: document.isStarter ?? false,
|
|
2194
|
+
},
|
|
2195
|
+
details: {
|
|
2196
|
+
resolvedSetupHandler: validation.setupHandler,
|
|
2197
|
+
resolvedGoalShape: validation.goalShape,
|
|
2198
|
+
wizardStepKeys: validation.stepKeys,
|
|
2199
|
+
specSha256: validation.sha256,
|
|
2200
|
+
specInventory: validation.totals,
|
|
2201
|
+
note: "Setup mode is DETERMINISTIC_WORKFLOW and is one-way: this template can never be converted back to a chat-driven setup.",
|
|
2202
|
+
},
|
|
2203
|
+
}, async () => unwrap(await api.client.POST("/admin/goal-templates/", {
|
|
2204
|
+
body: document,
|
|
2205
|
+
})));
|
|
2206
|
+
}
|
|
2207
|
+
if (verb === "patch-spec") {
|
|
2208
|
+
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
2209
|
+
const expectedVersion = requiredExpectedVersion(parsed);
|
|
2210
|
+
const patchFile = await readGoalTemplateSpecPatches(flagString(parsed, "patches-file", { required: true }));
|
|
2211
|
+
// The server owns JSON Pointer semantics, strict final-spec validation,
|
|
2212
|
+
// protected-inventory counting, and the token binding the approved loss
|
|
2213
|
+
// to this exact version + result hash. Always ask it for a fresh preview,
|
|
2214
|
+
// including on a confirmed run, before permitting the write request.
|
|
2215
|
+
const preflight = unwrap(await api.client.POST("/admin/goal-templates/{id}/setup-workflow-spec/patch", {
|
|
2216
|
+
params: { path: { id } },
|
|
2217
|
+
body: {
|
|
2218
|
+
expectedVersion,
|
|
2219
|
+
patches: patchFile.patches,
|
|
2220
|
+
dryRun: true,
|
|
2221
|
+
},
|
|
2222
|
+
}));
|
|
2223
|
+
if (preflight.action !== "preview_setup_workflow_spec_patch") {
|
|
2224
|
+
throw new CliError("unexpected_response", "The goal-template patch preflight did not return a preview; nothing was changed.");
|
|
2225
|
+
}
|
|
2226
|
+
const preview = {
|
|
2227
|
+
action: preflight.preview.destructiveChanges
|
|
2228
|
+
? "patch a global goal template setupWorkflowSpec (REMOVES protected template data)"
|
|
2229
|
+
: "patch a global goal template setupWorkflowSpec",
|
|
2230
|
+
target: {
|
|
2231
|
+
templateId: preflight.template.id,
|
|
2232
|
+
slug: preflight.template.slug,
|
|
2233
|
+
title: preflight.template.title,
|
|
2234
|
+
version: preflight.template.version,
|
|
2235
|
+
},
|
|
2236
|
+
request: {
|
|
2237
|
+
expectedVersion,
|
|
2238
|
+
patchesFile: patchFile.absolutePath,
|
|
2239
|
+
patchesFileSizeBytes: patchFile.sizeBytes,
|
|
2240
|
+
patchesFileSha256: patchFile.sha256,
|
|
2241
|
+
patches: patchFile.patches,
|
|
2242
|
+
},
|
|
2243
|
+
details: {
|
|
2244
|
+
patchesApplied: preflight.patchesApplied,
|
|
2245
|
+
safety: preflight.preview,
|
|
2246
|
+
...(preflight.preview.destructiveChanges
|
|
2247
|
+
? {
|
|
2248
|
+
destructiveApproval: "After explicit approval of the exact removed inventory, rerun with --confirm --confirm-destructive-changes and --destructive-change-token set to this preview's token.",
|
|
2249
|
+
}
|
|
2250
|
+
: {}),
|
|
2251
|
+
},
|
|
2252
|
+
};
|
|
2253
|
+
requireConfirmation(hasFlag(parsed, "confirm"), preview);
|
|
2254
|
+
const destructiveChangeToken = flagString(parsed, "destructive-change-token");
|
|
2255
|
+
if (preflight.preview.destructiveChanges &&
|
|
2256
|
+
(!hasFlag(parsed, "confirm-destructive-changes") ||
|
|
2257
|
+
!destructiveChangeToken ||
|
|
2258
|
+
destructiveChangeToken !== preflight.preview.destructiveChangeToken)) {
|
|
2259
|
+
throw new CliError("destructive_confirmation_required", "This patch removes protected template data. Review the fresh preview and rerun with --confirm-destructive-changes plus its exact --destructive-change-token; nothing was changed.", 2, {
|
|
2260
|
+
preview,
|
|
2261
|
+
requiredFlags: [
|
|
2262
|
+
"--confirm",
|
|
2263
|
+
"--confirm-destructive-changes",
|
|
2264
|
+
"--destructive-change-token",
|
|
2265
|
+
],
|
|
2266
|
+
expectedDestructiveChangeToken: preflight.preview.destructiveChangeToken,
|
|
2267
|
+
});
|
|
2268
|
+
}
|
|
2269
|
+
return unwrap(await api.client.POST("/admin/goal-templates/{id}/setup-workflow-spec/patch", {
|
|
2270
|
+
params: { path: { id } },
|
|
2271
|
+
body: {
|
|
2272
|
+
expectedVersion,
|
|
2273
|
+
patches: patchFile.patches,
|
|
2274
|
+
dryRun: false,
|
|
2275
|
+
confirmDestructiveChanges: preflight.preview.destructiveChanges,
|
|
2276
|
+
...(destructiveChangeToken ? { destructiveChangeToken } : {}),
|
|
2277
|
+
},
|
|
2278
|
+
}));
|
|
2279
|
+
}
|
|
2280
|
+
if (verb === "set-metadata") {
|
|
2281
|
+
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
2282
|
+
const expectedVersion = requiredExpectedVersion(parsed);
|
|
2283
|
+
const agentInstructionsFile = flagString(parsed, "agent-instructions-file");
|
|
2284
|
+
const tags = flagString(parsed, "tags");
|
|
2285
|
+
const kind = flagString(parsed, "kind");
|
|
2286
|
+
const sortOrder = flagNumber(parsed, "sort-order");
|
|
2287
|
+
const body = {
|
|
2288
|
+
expectedVersion,
|
|
2289
|
+
...(flagString(parsed, "title")
|
|
2290
|
+
? { title: flagString(parsed, "title") }
|
|
2291
|
+
: {}),
|
|
2292
|
+
...(flagString(parsed, "description")
|
|
2293
|
+
? { description: flagString(parsed, "description") }
|
|
2294
|
+
: {}),
|
|
2295
|
+
...(flagString(parsed, "emoji")
|
|
2296
|
+
? { emoji: flagString(parsed, "emoji") }
|
|
2297
|
+
: {}),
|
|
2298
|
+
...(flagString(parsed, "category")
|
|
2299
|
+
? { category: flagString(parsed, "category") }
|
|
2300
|
+
: {}),
|
|
2301
|
+
...(tags
|
|
2302
|
+
? {
|
|
2303
|
+
tags: tags
|
|
2304
|
+
.split(",")
|
|
2305
|
+
.map((tag) => tag.trim())
|
|
2306
|
+
.filter(Boolean),
|
|
2307
|
+
}
|
|
2308
|
+
: {}),
|
|
2309
|
+
...(sortOrder === undefined ? {} : { sortOrder }),
|
|
2310
|
+
...(kind
|
|
2311
|
+
? { kind: assertChoice(kind, GOAL_TEMPLATE_KINDS, "--kind") }
|
|
2312
|
+
: {}),
|
|
2313
|
+
...(agentInstructionsFile
|
|
2314
|
+
? {
|
|
2315
|
+
agentInstructions: await fs.readFile(path.resolve(agentInstructionsFile), "utf8"),
|
|
2316
|
+
}
|
|
2317
|
+
: {}),
|
|
2318
|
+
};
|
|
2319
|
+
// `setupWorkflowSpec` is unreachable from this command by construction.
|
|
2320
|
+
// The route still accepts one, but a wholesale spec replacement is the
|
|
2321
|
+
// shape that caused the template incident; editing an existing spec goes
|
|
2322
|
+
// through the guarded /ai patch path with its destructive-change token.
|
|
2323
|
+
if (Object.keys(body).length === 1) {
|
|
2324
|
+
throw new CliError("invalid_arguments", "Pass at least one field to change (--title, --description, --emoji, --category, --tags, --sort-order, --kind, --agent-instructions-file).");
|
|
2325
|
+
}
|
|
2326
|
+
return writeCommand(parsed, {
|
|
2327
|
+
action: "update goal template metadata (never its setupWorkflowSpec)",
|
|
2328
|
+
target: { templateId: id, expectedVersion },
|
|
2329
|
+
request: body,
|
|
2330
|
+
}, async () => unwrap(await api.client.PUT("/admin/goal-templates/{id}", {
|
|
2331
|
+
params: { path: { id } },
|
|
2332
|
+
body,
|
|
2333
|
+
})));
|
|
2334
|
+
}
|
|
2335
|
+
if (verb === "delete") {
|
|
2336
|
+
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
2337
|
+
const expectedVersion = requiredExpectedVersion(parsed);
|
|
2338
|
+
// Read the current version so the gate refuses a stale delete locally,
|
|
2339
|
+
// and so the preview names the template a human is being asked to approve
|
|
2340
|
+
// rather than only its UUID.
|
|
2341
|
+
const current = unwrap(await api.client.GET("/admin/goal-templates/{id}", {
|
|
2342
|
+
params: { path: { id } },
|
|
2343
|
+
}));
|
|
2344
|
+
if (current.version !== expectedVersion) {
|
|
2345
|
+
throw new CliError("stale_write", `Goal template is at version ${current.version}, not ${expectedVersion}. Re-read it before deleting; nothing was deleted.`, 1, { templateId: id, currentVersion: current.version });
|
|
2346
|
+
}
|
|
2347
|
+
return writeCommand(parsed, {
|
|
2348
|
+
action: "soft-delete a global goal template",
|
|
2349
|
+
target: {
|
|
2350
|
+
templateId: id,
|
|
2351
|
+
slug: current.slug,
|
|
2352
|
+
title: current.title,
|
|
2353
|
+
version: current.version,
|
|
2354
|
+
},
|
|
2355
|
+
request: { expectedVersion },
|
|
2356
|
+
details: {
|
|
2357
|
+
note: "Soft delete: the row keeps its deletedAt and drops out of every list. Existing goals already applied from it are unaffected.",
|
|
2358
|
+
},
|
|
2359
|
+
}, async () => unwrap(await api.client.DELETE("/admin/goal-templates/{id}", {
|
|
2360
|
+
params: { path: { id } },
|
|
2361
|
+
})));
|
|
2362
|
+
}
|
|
2363
|
+
if (verb === "snapshot-files") {
|
|
2364
|
+
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
2365
|
+
const filePath = flagString(parsed, "path");
|
|
2366
|
+
if (filePath) {
|
|
2367
|
+
return unwrap(await api.client.GET("/admin/goal-templates/{id}/snapshot/file", {
|
|
2368
|
+
params: { path: { id }, query: { path: filePath } },
|
|
2369
|
+
}));
|
|
2370
|
+
}
|
|
2371
|
+
return unwrap(await api.client.GET("/admin/goal-templates/{id}/snapshot/files", {
|
|
2372
|
+
params: { path: { id } },
|
|
2373
|
+
}));
|
|
2374
|
+
}
|
|
2375
|
+
if (verb === "capture-snapshot") {
|
|
2376
|
+
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
2377
|
+
const sourceGoalId = flagString(parsed, "source-goal");
|
|
2378
|
+
const sourceDraft = flagString(parsed, "source-draft");
|
|
2379
|
+
if (Boolean(sourceGoalId) === Boolean(sourceDraft)) {
|
|
2380
|
+
throw new CliError("invalid_arguments", "Pass exactly one of --source-goal or --source-draft.");
|
|
2381
|
+
}
|
|
2382
|
+
const sourceStudentUserId = sourceDraft
|
|
2383
|
+
? flagString(parsed, "student", { required: true })
|
|
2384
|
+
: undefined;
|
|
2385
|
+
const source = sourceGoalId
|
|
2386
|
+
? { sourceGoalId }
|
|
2387
|
+
: {
|
|
2388
|
+
sourceWorkspacePath: `drafts/${sourceDraft}/workspace`,
|
|
2389
|
+
sourceStudentUserId,
|
|
2390
|
+
};
|
|
2391
|
+
const preflight = unwrap(await api.client.POST("/admin/goal-templates/{id}/snapshot/capture", {
|
|
2392
|
+
params: { path: { id } },
|
|
2393
|
+
body: { ...source, dryRun: true },
|
|
2394
|
+
}));
|
|
2395
|
+
if (preflight.action !== "preview_capture_snapshot") {
|
|
2396
|
+
throw new CliError("unexpected_response", "Snapshot capture did not return a preview; nothing was captured.");
|
|
2397
|
+
}
|
|
2398
|
+
if (hasFlag(parsed, "dry-run"))
|
|
2399
|
+
return preflight;
|
|
2400
|
+
const preview = {
|
|
2401
|
+
action: "capture a validated Mesa workspace as this global template's instant-apply snapshot",
|
|
2402
|
+
target: {
|
|
2403
|
+
templateId: preflight.template.id,
|
|
2404
|
+
slug: preflight.template.slug,
|
|
2405
|
+
title: preflight.template.title,
|
|
2406
|
+
version: preflight.template.version,
|
|
2407
|
+
},
|
|
2408
|
+
request: {
|
|
2409
|
+
source: preflight.source,
|
|
2410
|
+
moduleCount: preflight.snapshot.moduleCount,
|
|
2411
|
+
fileCount: preflight.snapshot.fileCount,
|
|
2412
|
+
sizeBytes: preflight.snapshot.sizeBytes,
|
|
2413
|
+
sha256: preflight.snapshot.sha256,
|
|
2414
|
+
},
|
|
2415
|
+
details: {
|
|
2416
|
+
modules: preflight.snapshot.modules,
|
|
2417
|
+
paths: preflight.snapshot.paths,
|
|
2418
|
+
warnings: preflight.warnings,
|
|
2419
|
+
note: "The confirmed request is fenced to both this template version and this exact Mesa source change. Capturing bumps the template version unless the snapshot is unchanged.",
|
|
2420
|
+
},
|
|
2421
|
+
};
|
|
2422
|
+
requireConfirmation(hasFlag(parsed, "confirm"), preview);
|
|
2423
|
+
return unwrap(await api.client.POST("/admin/goal-templates/{id}/snapshot/capture", {
|
|
2424
|
+
params: { path: { id } },
|
|
2425
|
+
body: {
|
|
2426
|
+
...source,
|
|
2427
|
+
dryRun: false,
|
|
2428
|
+
expectedTemplateVersion: preflight.template.version,
|
|
2429
|
+
expectedSourceChangeId: preflight.source.changeId,
|
|
2430
|
+
},
|
|
2431
|
+
}));
|
|
2432
|
+
}
|
|
2433
|
+
if (verb === "apply") {
|
|
2434
|
+
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
2435
|
+
const answers = await readJsonFile(flagString(parsed, "answers-file", { required: true }), "Answers file");
|
|
2436
|
+
// `--dry-run` is a read: it resolves the whole plan and writes nothing,
|
|
2437
|
+
// so it does not gate. Run it, read the per-student results, then confirm.
|
|
2438
|
+
if (hasFlag(parsed, "dry-run")) {
|
|
2439
|
+
return unwrap(await api.client.POST("/ai/goal-templates/{id}/apply-workflow", {
|
|
2440
|
+
params: { path: { id } },
|
|
2441
|
+
body: { answers, dryRun: true },
|
|
2442
|
+
}));
|
|
2443
|
+
}
|
|
2444
|
+
// The real consequence — which students get which goals, which are
|
|
2445
|
+
// skipped as already-existing, and what coverage is missing — is only
|
|
2446
|
+
// knowable from the backend's own planner. Run its dryRun before the gate
|
|
2447
|
+
// (writes nothing) so the approver sees the actual roster.
|
|
2448
|
+
const preflight = unwrap(await api.client.POST("/ai/goal-templates/{id}/apply-workflow", {
|
|
2449
|
+
params: { path: { id } },
|
|
2450
|
+
body: { answers, dryRun: true },
|
|
2451
|
+
}));
|
|
2452
|
+
const counts = preflight.results.reduce((totals, result) => ({
|
|
2453
|
+
...totals,
|
|
2454
|
+
[result.action]: (totals[result.action] ?? 0) + 1,
|
|
2455
|
+
}), {});
|
|
2456
|
+
return writeCommand(parsed, {
|
|
2457
|
+
action: "apply a goal template to students (creates goals and todos)",
|
|
2458
|
+
target: { templateId: id, students: preflight.results.length },
|
|
2459
|
+
request: { answers },
|
|
2460
|
+
details: {
|
|
2461
|
+
counts,
|
|
2462
|
+
results: preflight.results.map((result) => ({
|
|
2463
|
+
student: result.studentName,
|
|
2464
|
+
studentUserId: result.studentUserId,
|
|
2465
|
+
action: result.action,
|
|
2466
|
+
goalTitle: result.goalTitle,
|
|
2467
|
+
todoTitle: result.todoTitle,
|
|
2468
|
+
warnings: result.warnings,
|
|
2469
|
+
})),
|
|
2470
|
+
missingCoverage: preflight.missingCoverage,
|
|
2471
|
+
note: "Counts come from the backend's own dry run. `skipped_existing` items are idempotent — re-applying does not duplicate them.",
|
|
2472
|
+
},
|
|
2473
|
+
}, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/apply-workflow", {
|
|
2474
|
+
params: { path: { id } },
|
|
2475
|
+
body: { answers, dryRun: false },
|
|
2476
|
+
})));
|
|
2477
|
+
}
|
|
2478
|
+
if (verb === "apply-starter") {
|
|
2479
|
+
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
2480
|
+
const studentUserId = flagString(parsed, "student", { required: true });
|
|
2481
|
+
const answersFile = flagString(parsed, "answers-file");
|
|
2482
|
+
const answers = answersFile
|
|
2483
|
+
? await readJsonFile(answersFile, "Answers file")
|
|
2484
|
+
: {};
|
|
2485
|
+
// apply-starter has no dryRun — it is the one-tap starter path — so this
|
|
2486
|
+
// preview is offline. It is also flag-gated per actor server-side
|
|
2487
|
+
// (school-onboarding-v1); a flag-off admin gets a 404 rather than a write.
|
|
2488
|
+
return writeCommand(parsed, {
|
|
2489
|
+
action: "apply ONE starter goal template to ONE student (no dry run exists for this route)",
|
|
2490
|
+
target: { templateId: id, studentUserId },
|
|
2491
|
+
request: { answers },
|
|
2492
|
+
details: {
|
|
2493
|
+
note: "Gated behind the school-onboarding-v1 flag for the acting admin; a 404 means the flag is off, not that the template is missing. Prefer `goal-templates apply --dry-run` when the template supports it.",
|
|
2494
|
+
},
|
|
2495
|
+
}, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/apply-starter", {
|
|
2496
|
+
params: { path: { id } },
|
|
2497
|
+
body: { studentUserId, answers },
|
|
2498
|
+
})));
|
|
2499
|
+
}
|
|
2500
|
+
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.");
|
|
2501
|
+
}
|
|
2502
|
+
if (noun === "goals") {
|
|
2503
|
+
if (verb === "list") {
|
|
2504
|
+
const userId = flagString(parsed, "student", { required: true });
|
|
2505
|
+
return unwrap(await api.client.GET("/admin/browser/students/{userId}/goals/", {
|
|
2506
|
+
params: { path: { userId } },
|
|
2507
|
+
}));
|
|
2508
|
+
}
|
|
2509
|
+
if (verb === "create") {
|
|
2510
|
+
const userId = flagString(parsed, "student", { required: true });
|
|
2511
|
+
const title = flagString(parsed, "title", { required: true });
|
|
2512
|
+
const descriptionFile = flagString(parsed, "description-file");
|
|
2513
|
+
const descriptionFlag = flagString(parsed, "description");
|
|
2514
|
+
if ((descriptionFile && descriptionFlag) ||
|
|
2515
|
+
(!descriptionFile && !descriptionFlag)) {
|
|
2516
|
+
throw new CliError("invalid_arguments", "Pass exactly one of --description <text> or --description-file <path>.");
|
|
2517
|
+
}
|
|
2518
|
+
const description = descriptionFile
|
|
2519
|
+
? await fs.readFile(path.resolve(descriptionFile), "utf8")
|
|
2520
|
+
: descriptionFlag;
|
|
2521
|
+
const targetDate = flagString(parsed, "target-date");
|
|
2522
|
+
if (targetDate !== undefined && Number.isNaN(Date.parse(targetDate))) {
|
|
2523
|
+
throw new CliError("invalid_arguments", "--target-date must be an ISO 8601 datetime.");
|
|
2524
|
+
}
|
|
2525
|
+
const schedule = flagString(parsed, "schedule");
|
|
2526
|
+
const body = {
|
|
2527
|
+
title,
|
|
2528
|
+
description,
|
|
2529
|
+
...(targetDate ? { targetDate } : {}),
|
|
2530
|
+
...(schedule ? { schedule } : {}),
|
|
2531
|
+
};
|
|
2532
|
+
return writeCommand(parsed, {
|
|
2533
|
+
action: "create a goal directly on a kid (visible to them immediately)",
|
|
2534
|
+
target: { studentUserId: userId },
|
|
2535
|
+
request: {
|
|
2536
|
+
title,
|
|
2537
|
+
descriptionChars: description.length,
|
|
2538
|
+
targetDate: targetDate ?? null,
|
|
2539
|
+
schedule: schedule ?? null,
|
|
2540
|
+
},
|
|
2541
|
+
details: {
|
|
2542
|
+
note: "Creates a description-only goal with no GoalModules and no Mesa workspace. For a module-backed course, apply a BLUEPRINT template instead. A 409 GOAL_LIMIT_REACHED means the kid is at capacity and nothing was created.",
|
|
2543
|
+
},
|
|
2544
|
+
}, async () => unwrap(await api.client.POST("/admin/browser/students/{userId}/goals/", {
|
|
2545
|
+
params: { path: { userId } },
|
|
2546
|
+
body,
|
|
2547
|
+
})));
|
|
2548
|
+
}
|
|
2549
|
+
throw new CliError("invalid_arguments", "Use goals list|create.");
|
|
2550
|
+
}
|
|
2551
|
+
if (noun === "mesa" && verb === "files") {
|
|
2552
|
+
const action = positional(parsed, 2, "mesa files action");
|
|
2553
|
+
const studentId = flagString(parsed, "student", { required: true });
|
|
2554
|
+
if (action === "list") {
|
|
2555
|
+
const goalId = flagString(parsed, "goal", { required: true });
|
|
2556
|
+
return unwrap(await api.client.GET("/tutor/students/{studentId}/goals/{goalId}/workspace/files", { params: { path: { studentId, goalId } } }));
|
|
2557
|
+
}
|
|
2558
|
+
if (action === "read") {
|
|
2559
|
+
const goalId = flagString(parsed, "goal", { required: true });
|
|
2560
|
+
return unwrap(await api.client.GET("/tutor/students/{studentId}/goals/{goalId}/workspace/file", {
|
|
2561
|
+
params: {
|
|
2562
|
+
path: { studentId, goalId },
|
|
2563
|
+
query: { path: flagString(parsed, "path", { required: true }) },
|
|
2564
|
+
},
|
|
2565
|
+
}));
|
|
2566
|
+
}
|
|
2567
|
+
if (action === "write") {
|
|
2568
|
+
const goalId = flagString(parsed, "goal");
|
|
2569
|
+
const draftSlug = flagString(parsed, "draft");
|
|
2570
|
+
if (Boolean(goalId) === Boolean(draftSlug)) {
|
|
2571
|
+
throw new CliError("invalid_arguments", "Pass exactly one of --goal or --draft for mesa files write.");
|
|
2572
|
+
}
|
|
2573
|
+
const input = await readMesaWriteSource(parsed);
|
|
2574
|
+
const target = goalId
|
|
2575
|
+
? { kind: "goal", goalId }
|
|
2576
|
+
: { kind: "draft", draftSlug: draftSlug };
|
|
2577
|
+
const message = flagString(parsed, "message") ??
|
|
2578
|
+
`recess-cli: write ${input.files.length} workspace file${input.files.length === 1 ? "" : "s"}`;
|
|
2579
|
+
const body = {
|
|
2580
|
+
studentUserId: studentId,
|
|
2581
|
+
target,
|
|
2582
|
+
message,
|
|
2583
|
+
files: input.files,
|
|
2584
|
+
};
|
|
2585
|
+
const preflight = unwrap(await api.client.POST("/admin/mesa/workspace-files", {
|
|
2586
|
+
body: { ...body, dryRun: true },
|
|
2587
|
+
}));
|
|
2588
|
+
if (preflight.action !== "preview_workspace_files_write") {
|
|
2589
|
+
throw new CliError("unexpected_response", "Mesa workspace write did not return a preview; nothing was written.");
|
|
2590
|
+
}
|
|
2591
|
+
const preview = {
|
|
2592
|
+
action: "batch-upsert files in a student's Mesa workspace",
|
|
2593
|
+
target: preflight.target,
|
|
2594
|
+
request: {
|
|
2595
|
+
source: input.source,
|
|
2596
|
+
sourceSha256: input.sha256,
|
|
2597
|
+
fileCount: input.files.length,
|
|
2598
|
+
sizeBytes: input.sizeBytes,
|
|
2599
|
+
message,
|
|
2600
|
+
},
|
|
2601
|
+
details: {
|
|
2602
|
+
currentChangeId: preflight.currentChangeId,
|
|
2603
|
+
files: preflight.files,
|
|
2604
|
+
note: target.kind === "draft"
|
|
2605
|
+
? "This writes a complete authoring tree under drafts/<slug>/workspace. Capture it only after the server's goal-workspace validation passes."
|
|
2606
|
+
: "Live goal modules/ and state/ are blocked here because they have database projections; use a draft + template capture for structural course changes.",
|
|
2607
|
+
},
|
|
2608
|
+
};
|
|
2609
|
+
requireConfirmation(hasFlag(parsed, "confirm"), preview);
|
|
2610
|
+
return unwrap(await api.client.POST("/admin/mesa/workspace-files", {
|
|
2611
|
+
body: {
|
|
2612
|
+
...body,
|
|
2613
|
+
dryRun: false,
|
|
2614
|
+
expectedChangeId: preflight.currentChangeId,
|
|
2615
|
+
},
|
|
2616
|
+
}));
|
|
2617
|
+
}
|
|
2618
|
+
throw new CliError("invalid_arguments", "Use mesa files list|read|write.");
|
|
2619
|
+
}
|
|
1532
2620
|
if (noun === "request" && verb === "get") {
|
|
1533
2621
|
return api.rawGet(positional(parsed, 2, "request path"));
|
|
1534
2622
|
}
|