recess-cli 2.6.1 → 2.8.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 +9 -1
- package/dist/api.js +13 -8
- package/dist/args.js +0 -1
- package/dist/cli.js +363 -46
- package/dist/command-schema.js +197 -34
- package/dist/commands/apps.js +282 -23
- package/dist/commands/mastery.js +214 -0
- package/dist/commands/onboarding.js +1 -7
- package/dist/commands/school.js +1 -1
- package/dist/commands/village-events.js +26 -3
- package/dist/engine.js +6 -0
- package/dist/errors.js +13 -4
- package/dist/help.js +23 -6
- package/dist/upload-names.js +19 -0
- package/package.json +8 -1
- package/skill/recess-cli/SKILL.md +31 -1
- package/skill/recess-cli/agents/version.json +2 -2
package/dist/commands/apps.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import fs from "node:fs/promises";
|
|
2
3
|
import http from "node:http";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { unwrap } from "../api.js";
|
|
5
|
-
import { flagString, hasFlag } from "../args.js";
|
|
6
|
+
import { flagNumber, flagString, hasFlag } from "../args.js";
|
|
6
7
|
import { CliError } from "../errors.js";
|
|
7
8
|
import { positional } from "./shared.js";
|
|
8
9
|
const PROJECT_FILE = ".recess/app.json";
|
|
@@ -12,6 +13,13 @@ const BRIEF_FILE = "brief.md";
|
|
|
12
13
|
const SKIP_DIRS = new Set([".recess", ".git", "node_modules"]);
|
|
13
14
|
const MAX_FILE_BYTES = 512 * 1024;
|
|
14
15
|
const TERMINAL = new Set(["COMPLETE", "FAILED", "REJECTED"]);
|
|
16
|
+
export const APP_BUNDLE_EDITABLE_FILES = [
|
|
17
|
+
"skill.js",
|
|
18
|
+
"model.js",
|
|
19
|
+
"model.css",
|
|
20
|
+
"params.schema.json",
|
|
21
|
+
"params.json",
|
|
22
|
+
];
|
|
15
23
|
async function readAppLink(dir) {
|
|
16
24
|
try {
|
|
17
25
|
const raw = await fs.readFile(path.join(dir, PROJECT_FILE), "utf8");
|
|
@@ -72,6 +80,22 @@ function resolveDir(ctx, positionalIndex) {
|
|
|
72
80
|
const fromPositional = ctx.parsed.positionals[positionalIndex];
|
|
73
81
|
return path.resolve(flag ?? fromPositional ?? ".");
|
|
74
82
|
}
|
|
83
|
+
function assertRemoteShape(ctx, allowedFlags, positionals) {
|
|
84
|
+
if (ctx.parsed.positionals.length < positionals.min ||
|
|
85
|
+
ctx.parsed.positionals.length > positionals.max) {
|
|
86
|
+
throw new CliError("invalid_arguments", "The hosted command shape does not match its remote usage. Run `help apps`.");
|
|
87
|
+
}
|
|
88
|
+
const allowed = new Set([
|
|
89
|
+
"reason",
|
|
90
|
+
"confirm",
|
|
91
|
+
"operation-key",
|
|
92
|
+
...allowedFlags,
|
|
93
|
+
]);
|
|
94
|
+
const denied = [...ctx.parsed.flags.keys()].find((flag) => !allowed.has(flag));
|
|
95
|
+
if (denied) {
|
|
96
|
+
throw new CliError("remote_flag_unavailable", `--${denied} is not available for this hosted command.`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
75
99
|
async function readContract(dir) {
|
|
76
100
|
const raw = await fs
|
|
77
101
|
.readFile(path.join(dir, CONTRACT_FILE), "utf8")
|
|
@@ -212,6 +236,106 @@ function agentsGuide(docs) {
|
|
|
212
236
|
"",
|
|
213
237
|
].join("\n");
|
|
214
238
|
}
|
|
239
|
+
function editableFilesFrom(files) {
|
|
240
|
+
return Object.fromEntries(APP_BUNDLE_EDITABLE_FILES.map((name) => [name, files[name] ?? ""]));
|
|
241
|
+
}
|
|
242
|
+
function canonicalJson(value) {
|
|
243
|
+
if (Array.isArray(value)) {
|
|
244
|
+
return `[${value.map(canonicalJson).join(",")}]`;
|
|
245
|
+
}
|
|
246
|
+
if (value && typeof value === "object") {
|
|
247
|
+
return `{${Object.entries(value)
|
|
248
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
249
|
+
.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`)
|
|
250
|
+
.join(",")}}`;
|
|
251
|
+
}
|
|
252
|
+
return JSON.stringify(value) ?? "null";
|
|
253
|
+
}
|
|
254
|
+
function parseAppBundle(value) {
|
|
255
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
256
|
+
throw new CliError("invalid_arguments", "input.appBundle must be an AppBundle object.");
|
|
257
|
+
}
|
|
258
|
+
const bundle = value;
|
|
259
|
+
const allowedBundleKeys = new Set([
|
|
260
|
+
"projectId",
|
|
261
|
+
"baseVersion",
|
|
262
|
+
"sourceTodoId",
|
|
263
|
+
"files",
|
|
264
|
+
"contract",
|
|
265
|
+
"manifest",
|
|
266
|
+
]);
|
|
267
|
+
const unexpectedBundleKeys = Object.keys(bundle).filter((key) => !allowedBundleKeys.has(key));
|
|
268
|
+
if (unexpectedBundleKeys.length > 0) {
|
|
269
|
+
throw new CliError("invalid_arguments", "input.appBundle contains unsupported fields.", 1, { unexpected: unexpectedBundleKeys });
|
|
270
|
+
}
|
|
271
|
+
const files = bundle.files;
|
|
272
|
+
if (!files || typeof files !== "object" || Array.isArray(files)) {
|
|
273
|
+
throw new CliError("invalid_arguments", "input.appBundle.files must contain the five editable Studio files.");
|
|
274
|
+
}
|
|
275
|
+
const fileRecord = files;
|
|
276
|
+
const unexpected = Object.keys(fileRecord).filter((name) => !APP_BUNDLE_EDITABLE_FILES.includes(name));
|
|
277
|
+
const missing = APP_BUNDLE_EDITABLE_FILES.filter((name) => typeof fileRecord[name] !== "string");
|
|
278
|
+
if (unexpected.length || missing.length) {
|
|
279
|
+
throw new CliError("invalid_arguments", "An AppBundle accepts exactly skill.js, model.js, model.css, params.schema.json, and params.json.", 1, { unexpected, missing });
|
|
280
|
+
}
|
|
281
|
+
const manifest = bundle.manifest;
|
|
282
|
+
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
|
|
283
|
+
throw new CliError("invalid_arguments", "input.appBundle.manifest is required.");
|
|
284
|
+
}
|
|
285
|
+
const title = manifest.title;
|
|
286
|
+
if (typeof title !== "string" || !title.trim()) {
|
|
287
|
+
throw new CliError("invalid_arguments", "input.appBundle.manifest.title is required.");
|
|
288
|
+
}
|
|
289
|
+
if (!bundle.contract ||
|
|
290
|
+
typeof bundle.contract !== "object" ||
|
|
291
|
+
Array.isArray(bundle.contract)) {
|
|
292
|
+
throw new CliError("invalid_arguments", "input.appBundle.contract must be a BuildContract object.");
|
|
293
|
+
}
|
|
294
|
+
if (bundle.baseVersion !== undefined &&
|
|
295
|
+
(!Number.isInteger(bundle.baseVersion) || Number(bundle.baseVersion) < 1)) {
|
|
296
|
+
throw new CliError("invalid_arguments", "input.appBundle.baseVersion must be a positive integer.");
|
|
297
|
+
}
|
|
298
|
+
return bundle;
|
|
299
|
+
}
|
|
300
|
+
function bundleSummary(bundle) {
|
|
301
|
+
return {
|
|
302
|
+
projectId: bundle.projectId ?? null,
|
|
303
|
+
baseVersion: bundle.baseVersion ?? null,
|
|
304
|
+
sourceTodoId: bundle.sourceTodoId ?? null,
|
|
305
|
+
title: bundle.manifest.title,
|
|
306
|
+
contractSha256: createHash("sha256")
|
|
307
|
+
.update(canonicalJson(bundle.contract))
|
|
308
|
+
.digest("hex"),
|
|
309
|
+
manifestSha256: createHash("sha256")
|
|
310
|
+
.update(canonicalJson(bundle.manifest))
|
|
311
|
+
.digest("hex"),
|
|
312
|
+
files: Object.fromEntries(APP_BUNDLE_EDITABLE_FILES.map((name) => {
|
|
313
|
+
const content = bundle.files[name];
|
|
314
|
+
return [
|
|
315
|
+
name,
|
|
316
|
+
{
|
|
317
|
+
bytes: Buffer.byteLength(content, "utf8"),
|
|
318
|
+
sha256: createHash("sha256").update(content).digest("hex"),
|
|
319
|
+
},
|
|
320
|
+
];
|
|
321
|
+
})),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
async function scaffoldResult(ctx, forTodo) {
|
|
325
|
+
const scaffold = unwrap(await ctx.api.client.GET("/studio/scaffold"));
|
|
326
|
+
const analysis = forTodo
|
|
327
|
+
? unwrap(await ctx.api.client.GET("/studio/todos/{todoId}/analysis", {
|
|
328
|
+
params: { path: { todoId: forTodo } },
|
|
329
|
+
}))
|
|
330
|
+
: null;
|
|
331
|
+
return {
|
|
332
|
+
allFiles: scaffold.files,
|
|
333
|
+
editableFiles: editableFilesFrom(scaffold.files),
|
|
334
|
+
authoringInstructions: agentsGuide(scaffold.docs),
|
|
335
|
+
...(scaffold.docs.forks ? { forkGuidance: scaffold.docs.forks } : {}),
|
|
336
|
+
...(analysis ? { brief: briefFor(analysis), todo: analysis } : {}),
|
|
337
|
+
};
|
|
338
|
+
}
|
|
215
339
|
async function pollBuild(ctx, buildId, timeoutMs) {
|
|
216
340
|
const started = Date.now();
|
|
217
341
|
let delay = 3_000;
|
|
@@ -300,6 +424,143 @@ async function assign(ctx, projectId, body) {
|
|
|
300
424
|
export async function runAppsCommand(ctx) {
|
|
301
425
|
const { parsed, api } = ctx;
|
|
302
426
|
const verb = parsed.positionals[1];
|
|
427
|
+
if (ctx.transport === "remote") {
|
|
428
|
+
if (verb === "scaffold") {
|
|
429
|
+
assertRemoteShape(ctx, ["for-todo"], { min: 2, max: 2 });
|
|
430
|
+
const result = await scaffoldResult(ctx, flagString(parsed, "for-todo"));
|
|
431
|
+
return {
|
|
432
|
+
authoringInstructions: result.authoringInstructions,
|
|
433
|
+
editableFiles: result.editableFiles,
|
|
434
|
+
...(result.forkGuidance ? { forkGuidance: result.forkGuidance } : {}),
|
|
435
|
+
...(result.brief ? { learnerBrief: result.brief } : {}),
|
|
436
|
+
...(result.todo
|
|
437
|
+
? {
|
|
438
|
+
sourceTodoId: result.todo.todo.id,
|
|
439
|
+
studentId: result.todo.todo.studentId,
|
|
440
|
+
}
|
|
441
|
+
: {}),
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
if (verb === "validate") {
|
|
445
|
+
assertRemoteShape(ctx, [], { min: 2, max: 2 });
|
|
446
|
+
const bundle = parseAppBundle(ctx.structuredInput?.appBundle);
|
|
447
|
+
return ctx.writeCommand(parsed, {
|
|
448
|
+
action: bundle.projectId
|
|
449
|
+
? "save a new private Studio app revision and queue validation"
|
|
450
|
+
: "create a private Studio app and queue validation",
|
|
451
|
+
target: bundleSummary(bundle),
|
|
452
|
+
request: { command: "apps validate" },
|
|
453
|
+
details: {
|
|
454
|
+
consequence: "Studio merges these five files into its locked scaffold, stores one immutable revision, and validates it asynchronously. Nothing is published.",
|
|
455
|
+
},
|
|
456
|
+
}, async () => unwrap(await api.client.POST("/studio/apps/validate", {
|
|
457
|
+
body: { appBundle: bundle },
|
|
458
|
+
})));
|
|
459
|
+
}
|
|
460
|
+
if (verb === "pull") {
|
|
461
|
+
assertRemoteShape(ctx, [], { min: 3, max: 3 });
|
|
462
|
+
const projectId = positional(parsed, 2, "project id");
|
|
463
|
+
const project = unwrap(await api.client.GET("/studio/projects/{projectId}/bundle", {
|
|
464
|
+
params: { path: { projectId } },
|
|
465
|
+
}));
|
|
466
|
+
const sourceTodoId = project.appBundle.sourceTodoId;
|
|
467
|
+
if (!sourceTodoId)
|
|
468
|
+
return project;
|
|
469
|
+
const todoContext = unwrap(await api.client.GET("/studio/todos/{todoId}/analysis", {
|
|
470
|
+
params: { path: { todoId: sourceTodoId } },
|
|
471
|
+
}));
|
|
472
|
+
return {
|
|
473
|
+
...project,
|
|
474
|
+
learnerBrief: briefFor(todoContext),
|
|
475
|
+
studentId: todoContext.todo.studentId,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
if (verb === "preview") {
|
|
479
|
+
assertRemoteShape(ctx, ["version"], { min: 3, max: 3 });
|
|
480
|
+
const projectId = positional(parsed, 2, "project id");
|
|
481
|
+
const version = flagNumber(parsed, "version");
|
|
482
|
+
if (!Number.isInteger(version) || Number(version) < 1) {
|
|
483
|
+
throw new CliError("invalid_arguments", "Hosted preview requires --version <positive integer>.");
|
|
484
|
+
}
|
|
485
|
+
return unwrap(await api.client.POST("/studio/projects/{projectId}/preview", {
|
|
486
|
+
params: { path: { projectId } },
|
|
487
|
+
body: { versionNumber: version },
|
|
488
|
+
}));
|
|
489
|
+
}
|
|
490
|
+
if (verb === "publish") {
|
|
491
|
+
assertRemoteShape(ctx, ["version"], { min: 3, max: 3 });
|
|
492
|
+
const projectId = positional(parsed, 2, "project id");
|
|
493
|
+
const version = flagNumber(parsed, "version");
|
|
494
|
+
if (!Number.isInteger(version) || Number(version) < 1) {
|
|
495
|
+
throw new CliError("invalid_arguments", "Hosted publish requires --version <positive integer>.");
|
|
496
|
+
}
|
|
497
|
+
return ctx.writeCommand(parsed, {
|
|
498
|
+
action: "publish an exact validated Studio app revision",
|
|
499
|
+
target: { projectId, versionNumber: version },
|
|
500
|
+
request: { command: "apps publish" },
|
|
501
|
+
details: {
|
|
502
|
+
consequence: "Studio rechecks the named current revision, queues its independent reviewer, and publishes only that exact revision if it passes.",
|
|
503
|
+
},
|
|
504
|
+
}, async () => unwrap(await api.client.POST("/studio/projects/{projectId}/publish", {
|
|
505
|
+
params: { path: { projectId } },
|
|
506
|
+
body: { versionNumber: version },
|
|
507
|
+
})));
|
|
508
|
+
}
|
|
509
|
+
if (verb === "assign") {
|
|
510
|
+
assertRemoteShape(ctx, ["student", "due"], { min: 3, max: 3 });
|
|
511
|
+
const projectId = positional(parsed, 2, "project id");
|
|
512
|
+
const studentId = flagString(parsed, "student", { required: true });
|
|
513
|
+
const dueDate = flagString(parsed, "due");
|
|
514
|
+
if (dueDate && !/^\d{4}-\d{2}-\d{2}$/.test(dueDate)) {
|
|
515
|
+
throw new CliError("invalid_arguments", "--due must be YYYY-MM-DD.");
|
|
516
|
+
}
|
|
517
|
+
const project = unwrap(await api.client.GET("/studio/projects/{projectId}/bundle", {
|
|
518
|
+
params: { path: { projectId } },
|
|
519
|
+
}));
|
|
520
|
+
const sourceTodoId = project.appBundle.sourceTodoId;
|
|
521
|
+
return ctx.writeCommand(parsed, {
|
|
522
|
+
action: "assign a published Studio app to a student",
|
|
523
|
+
target: { projectId, studentId },
|
|
524
|
+
request: {
|
|
525
|
+
dueDate: dueDate ?? null,
|
|
526
|
+
sourceTodoId: sourceTodoId ?? null,
|
|
527
|
+
},
|
|
528
|
+
}, () => assign(ctx, projectId, {
|
|
529
|
+
studentId,
|
|
530
|
+
...(dueDate ? { dueDate } : {}),
|
|
531
|
+
...(sourceTodoId ? { sourceTodoId } : {}),
|
|
532
|
+
}));
|
|
533
|
+
}
|
|
534
|
+
if (verb === "list") {
|
|
535
|
+
assertRemoteShape(ctx, [], { min: 2, max: 2 });
|
|
536
|
+
return unwrap(await api.client.GET("/studio/projects"));
|
|
537
|
+
}
|
|
538
|
+
if (verb === "status") {
|
|
539
|
+
assertRemoteShape(ctx, [], { min: 3, max: 3 });
|
|
540
|
+
const buildId = positional(parsed, 2, "build id");
|
|
541
|
+
return unwrap(await api.client.GET("/studio/builds/{buildId}", {
|
|
542
|
+
params: { path: { buildId } },
|
|
543
|
+
}));
|
|
544
|
+
}
|
|
545
|
+
if (verb !== "standards") {
|
|
546
|
+
throw new CliError("remote_command_unavailable", `apps ${verb ?? ""} is not available over hosted MCP.`);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
if (verb === "scaffold") {
|
|
550
|
+
const result = await scaffoldResult(ctx, flagString(parsed, "for-todo"));
|
|
551
|
+
return {
|
|
552
|
+
authoringInstructions: result.authoringInstructions,
|
|
553
|
+
editableFiles: result.editableFiles,
|
|
554
|
+
...(result.forkGuidance ? { forkGuidance: result.forkGuidance } : {}),
|
|
555
|
+
...(result.brief ? { learnerBrief: result.brief } : {}),
|
|
556
|
+
...(result.todo
|
|
557
|
+
? {
|
|
558
|
+
sourceTodoId: result.todo.todo.id,
|
|
559
|
+
studentId: result.todo.todo.studentId,
|
|
560
|
+
}
|
|
561
|
+
: {}),
|
|
562
|
+
};
|
|
563
|
+
}
|
|
303
564
|
if (verb === "init") {
|
|
304
565
|
const dir = path.resolve(positional(parsed, 2, "app folder"));
|
|
305
566
|
await fs.mkdir(dir, { recursive: true });
|
|
@@ -308,45 +569,40 @@ export async function runAppsCommand(ctx) {
|
|
|
308
569
|
throw new CliError("invalid_arguments", `${dir} is not empty. Use --force to write the scaffold over it.`);
|
|
309
570
|
}
|
|
310
571
|
const forTodo = flagString(parsed, "for-todo");
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
const scaffold = unwrap(await api.client.GET("/studio/scaffold"));
|
|
317
|
-
const written = await writeFiles(dir, scaffold.files);
|
|
318
|
-
await fs.writeFile(path.join(dir, "AGENTS.md"), agentsGuide(scaffold.docs));
|
|
319
|
-
if (scaffold.docs.forks) {
|
|
320
|
-
await fs.writeFile(path.join(dir, "FORKS.md"), scaffold.docs.forks);
|
|
572
|
+
const scaffold = await scaffoldResult(ctx, forTodo);
|
|
573
|
+
const written = await writeFiles(dir, scaffold.allFiles);
|
|
574
|
+
await fs.writeFile(path.join(dir, "AGENTS.md"), scaffold.authoringInstructions);
|
|
575
|
+
if (scaffold.forkGuidance) {
|
|
576
|
+
await fs.writeFile(path.join(dir, "FORKS.md"), scaffold.forkGuidance);
|
|
321
577
|
}
|
|
322
|
-
if (
|
|
323
|
-
await fs.writeFile(path.join(dir, BRIEF_FILE),
|
|
578
|
+
if (scaffold.todo && scaffold.brief) {
|
|
579
|
+
await fs.writeFile(path.join(dir, BRIEF_FILE), scaffold.brief);
|
|
324
580
|
await writeAppLink(dir, {
|
|
325
|
-
forTodoId:
|
|
326
|
-
studentId:
|
|
581
|
+
forTodoId: scaffold.todo.todo.id,
|
|
582
|
+
studentId: scaffold.todo.todo.studentId,
|
|
327
583
|
});
|
|
328
584
|
}
|
|
329
585
|
return {
|
|
330
586
|
dir,
|
|
331
|
-
filesWritten: written + (
|
|
332
|
-
...(
|
|
587
|
+
filesWritten: written + (scaffold.todo ? 2 : 1),
|
|
588
|
+
...(scaffold.todo
|
|
333
589
|
? {
|
|
334
590
|
brief: BRIEF_FILE,
|
|
335
|
-
studentId:
|
|
336
|
-
standards:
|
|
337
|
-
misconceptions:
|
|
591
|
+
studentId: scaffold.todo.todo.studentId,
|
|
592
|
+
standards: scaffold.todo.standards.map((s) => s.notation),
|
|
593
|
+
misconceptions: scaffold.todo.analysis.patterns.map((p) => p.instanceKey ?? slug(p.name)),
|
|
338
594
|
}
|
|
339
595
|
: {}),
|
|
340
596
|
next: [
|
|
341
597
|
`cd ${dir}`,
|
|
342
|
-
|
|
598
|
+
scaffold.todo
|
|
343
599
|
? "Open the folder with your agent: brief.md is the assignment, AGENTS.md the contract."
|
|
344
600
|
: "Open the folder with your agent and describe what the app should teach (AGENTS.md has the contract).",
|
|
345
601
|
"Customizing the wrong-answer detour? FORKS.md explains the fork and how to shape it per misconception.",
|
|
346
602
|
'recess apps preview --reason "See it in my browser"',
|
|
347
603
|
'recess apps validate --reason "Check my app"',
|
|
348
|
-
|
|
349
|
-
? `recess apps publish --assign ${
|
|
604
|
+
scaffold.todo
|
|
605
|
+
? `recess apps publish --assign ${scaffold.todo.todo.studentId} --reason "Ship it to the kid"`
|
|
350
606
|
: 'recess apps publish --reason "Ship my app"',
|
|
351
607
|
],
|
|
352
608
|
};
|
|
@@ -475,6 +731,9 @@ export async function runAppsCommand(ctx) {
|
|
|
475
731
|
throw new CliError("invalid_arguments", "Use: apps primitives list | apps primitives publish <file.js> --name <kebab-name> [--description TEXT].");
|
|
476
732
|
}
|
|
477
733
|
if (verb === "standards") {
|
|
734
|
+
if (ctx.transport === "remote") {
|
|
735
|
+
assertRemoteShape(ctx, [], { min: 3, max: Number.MAX_SAFE_INTEGER });
|
|
736
|
+
}
|
|
478
737
|
const q = parsed.positionals.slice(2).join(" ").trim();
|
|
479
738
|
if (!q) {
|
|
480
739
|
throw new CliError("invalid_arguments", 'Give words or a notation: recess apps standards "grade 4 adding fractions".');
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { unwrap } from "../api.js";
|
|
3
|
+
import { flagString, hasFlag } from "../args.js";
|
|
4
|
+
import { CliError } from "../errors.js";
|
|
5
|
+
import { getJob } from "../jobs.js";
|
|
6
|
+
import { assertChoice, positional, readJsonFile, } from "./shared.js";
|
|
7
|
+
const hash = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
8
|
+
// Replay the exact approved request, without requiring mutable preview sources
|
|
9
|
+
// to remain available after a successful write whose response was lost.
|
|
10
|
+
async function executeApproved(api, preview) {
|
|
11
|
+
const body = preview.request;
|
|
12
|
+
switch (preview.action) {
|
|
13
|
+
case "mastery.link":
|
|
14
|
+
return unwrap(await api.client.POST("/ai/rcs/content-links", {
|
|
15
|
+
body: body,
|
|
16
|
+
}));
|
|
17
|
+
case "mastery.unlink":
|
|
18
|
+
return unwrap(await api.client.DELETE("/ai/rcs/content-links/{id}", {
|
|
19
|
+
params: { path: { id: String(body.id) } },
|
|
20
|
+
}));
|
|
21
|
+
case "mastery.publish":
|
|
22
|
+
return unwrap(await api.client.POST("/ai/rcs/drafts/{id}/promote", {
|
|
23
|
+
params: { path: { id: String(preview.target.draftId) } },
|
|
24
|
+
body,
|
|
25
|
+
}));
|
|
26
|
+
case "mastery.edit":
|
|
27
|
+
return "graphJson" in body
|
|
28
|
+
? unwrap(await api.client.POST("/ai/rcs/drafts", {
|
|
29
|
+
body: body,
|
|
30
|
+
}))
|
|
31
|
+
: unwrap(await api.client.POST("/ai/rcs/edits/propose", {
|
|
32
|
+
body: body,
|
|
33
|
+
}));
|
|
34
|
+
default:
|
|
35
|
+
throw new CliError("approval_mismatch", "Unknown approved mastery action.");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export async function runMasteryCommand({ parsed, api, writeCommand: gate, }) {
|
|
39
|
+
// These commands curate shared school curriculum, not a family's private plan.
|
|
40
|
+
const session = unwrap(await api.client.GET("/auth/admin-cli/session/"));
|
|
41
|
+
if (session.user.role !== "ADMIN" || session.cliScope !== "full_admin")
|
|
42
|
+
throw new CliError("forbidden", "Mastery curriculum commands require a full-admin session.");
|
|
43
|
+
const verb = parsed.positionals[1];
|
|
44
|
+
const isWrite = ["edit", "publish", "link", "unlink"].includes(verb ?? "");
|
|
45
|
+
const file = isWrite ? flagString(parsed, "file") : undefined;
|
|
46
|
+
const invocation = hash({
|
|
47
|
+
actorId: session.user.id,
|
|
48
|
+
apiOrigin: api.config.apiOrigin,
|
|
49
|
+
positionals: parsed.positionals,
|
|
50
|
+
flags: [...parsed.flags]
|
|
51
|
+
.filter(([key]) => !["confirm", "operation-key", "json", "reason"].includes(key))
|
|
52
|
+
.sort(([a], [b]) => a.localeCompare(b)),
|
|
53
|
+
...(file
|
|
54
|
+
? { graphJson: await readJsonFile(file, "Edited graph JSON") }
|
|
55
|
+
: {}),
|
|
56
|
+
});
|
|
57
|
+
const writeCommand = (args, preview, execute) => gate(args, { ...preview, details: { ...preview.details, invocation } }, execute);
|
|
58
|
+
if (isWrite && hasFlag(parsed, "confirm")) {
|
|
59
|
+
const key = flagString(parsed, "operation-key", { required: true });
|
|
60
|
+
const { history } = await getJob(key);
|
|
61
|
+
const approved = history.find((event) => event.status === "awaiting_confirmation")?.preview;
|
|
62
|
+
if (!approved || approved.details?.invocation !== invocation)
|
|
63
|
+
throw new CliError("approval_mismatch", "Run an unchanged preview on this machine before confirming. The command, file, account, and API must match.");
|
|
64
|
+
return gate(parsed, approved, () => executeApproved(api, approved));
|
|
65
|
+
}
|
|
66
|
+
if (verb === "graphs") {
|
|
67
|
+
return unwrap(await api.client.GET("/ai/rcs/graphs", {
|
|
68
|
+
params: { query: { domainSlug: flagString(parsed, "domain") } },
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
if (verb === "get" || verb === "edit" || verb === "publish") {
|
|
72
|
+
const id = positional(parsed, 2, "graph artifact ID");
|
|
73
|
+
const kind = verb === "publish"
|
|
74
|
+
? "draft"
|
|
75
|
+
: assertChoice(flagString(parsed, "kind") ?? "release", ["seed", "draft", "release"], "--kind");
|
|
76
|
+
const artifact = unwrap(await api.client.GET("/ai/rcs/graphs/artifact", {
|
|
77
|
+
params: { query: { kind, id } },
|
|
78
|
+
}));
|
|
79
|
+
if (verb === "get")
|
|
80
|
+
return artifact;
|
|
81
|
+
const graph = artifact.draft ?? artifact.release ?? artifact.seed;
|
|
82
|
+
if (!graph)
|
|
83
|
+
throw new CliError("not_found", "Graph artifact not found.");
|
|
84
|
+
if (verb === "publish") {
|
|
85
|
+
const expected = flagString(parsed, "expected-active-release", {
|
|
86
|
+
required: true,
|
|
87
|
+
});
|
|
88
|
+
const body = {
|
|
89
|
+
expectedActiveReleaseId: expected === "none" ? null : expected,
|
|
90
|
+
expectedDraftUpdatedAt: artifact.draft.updatedAt,
|
|
91
|
+
};
|
|
92
|
+
return writeCommand(parsed, {
|
|
93
|
+
action: "mastery.publish",
|
|
94
|
+
target: {
|
|
95
|
+
draftId: id,
|
|
96
|
+
title: graph.title,
|
|
97
|
+
domainSlug: graph.domainSlug,
|
|
98
|
+
},
|
|
99
|
+
request: body,
|
|
100
|
+
details: {
|
|
101
|
+
graphSha256: hash(graph.graphJson),
|
|
102
|
+
consequence: "Makes this draft the active curriculum graph. Retires the previous release. Existing content links stay on their original release and need review/relinking. First publication may queue historical todo evidence backfill.",
|
|
103
|
+
},
|
|
104
|
+
}, async () => unwrap(await api.client.POST("/ai/rcs/drafts/{id}/promote", {
|
|
105
|
+
params: { path: { id } },
|
|
106
|
+
body,
|
|
107
|
+
})));
|
|
108
|
+
}
|
|
109
|
+
const file = flagString(parsed, "file");
|
|
110
|
+
const instruction = flagString(parsed, "instruction");
|
|
111
|
+
const node = flagString(parsed, "node");
|
|
112
|
+
if (Boolean(file) === Boolean(instruction))
|
|
113
|
+
throw new CliError("invalid_arguments", "Provide exactly one of --file (edited graph JSON) or --instruction.");
|
|
114
|
+
const target = {
|
|
115
|
+
kind,
|
|
116
|
+
id,
|
|
117
|
+
title: graph.title,
|
|
118
|
+
domainSlug: graph.domainSlug,
|
|
119
|
+
};
|
|
120
|
+
if (file) {
|
|
121
|
+
if (node)
|
|
122
|
+
throw new CliError("invalid_arguments", "--node is only used with --instruction.");
|
|
123
|
+
const graphJson = await readJsonFile(file, "Edited graph JSON");
|
|
124
|
+
if (graphJson.domainSlug !== graph.domainSlug)
|
|
125
|
+
throw new CliError("invalid_arguments", "The edited graph must keep the source domainSlug.");
|
|
126
|
+
if (typeof graphJson.title !== "string" || !graphJson.title.trim())
|
|
127
|
+
throw new CliError("invalid_arguments", "The edited graph requires a title.");
|
|
128
|
+
const body = {
|
|
129
|
+
domainSlug: graph.domainSlug,
|
|
130
|
+
title: graphJson.title,
|
|
131
|
+
graphJson,
|
|
132
|
+
};
|
|
133
|
+
return writeCommand(parsed, {
|
|
134
|
+
action: "mastery.edit",
|
|
135
|
+
target,
|
|
136
|
+
request: body,
|
|
137
|
+
details: {
|
|
138
|
+
sourceGraphSha256: hash(graph.graphJson),
|
|
139
|
+
consequence: "Saves a NEW draft; does not overwrite or publish the current graph. No AI generation.",
|
|
140
|
+
},
|
|
141
|
+
}, async () => unwrap(await api.client.POST("/ai/rcs/drafts", { body })));
|
|
142
|
+
}
|
|
143
|
+
if (kind === "seed")
|
|
144
|
+
throw new CliError("invalid_arguments", "Use --file for seed graphs; instruction editing requires a draft or release.");
|
|
145
|
+
const body = {
|
|
146
|
+
...(kind === "draft" ? { draftId: id } : { releaseId: id }),
|
|
147
|
+
feedback: [
|
|
148
|
+
{ instruction: instruction, ...(node ? { objectiveId: node } : {}) },
|
|
149
|
+
],
|
|
150
|
+
};
|
|
151
|
+
return writeCommand(parsed, {
|
|
152
|
+
action: "mastery.edit",
|
|
153
|
+
target,
|
|
154
|
+
request: body,
|
|
155
|
+
details: {
|
|
156
|
+
sourceGraphSha256: hash(graph.graphJson),
|
|
157
|
+
consequence: "Runs paid AI editing and saves a proposed draft for review. Does not publish. A node is context, not a strict boundary: related nodes may change.",
|
|
158
|
+
},
|
|
159
|
+
}, async () => unwrap(await api.client.POST("/ai/rcs/edits/propose", { body })));
|
|
160
|
+
}
|
|
161
|
+
if (verb === "links") {
|
|
162
|
+
const graphReleaseId = flagString(parsed, "graph", { required: true });
|
|
163
|
+
return unwrap(await api.client.GET("/ai/rcs/content-links", {
|
|
164
|
+
params: {
|
|
165
|
+
query: {
|
|
166
|
+
graphReleaseId,
|
|
167
|
+
objectiveId: flagString(parsed, "node"),
|
|
168
|
+
cursor: flagString(parsed, "cursor"),
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
}));
|
|
172
|
+
}
|
|
173
|
+
if (verb === "link") {
|
|
174
|
+
const contentType = assertChoice(flagString(parsed, "type", { required: true }), ["applet", "goal"], "--type");
|
|
175
|
+
const body = {
|
|
176
|
+
graphReleaseId: flagString(parsed, "graph", { required: true }),
|
|
177
|
+
contentType: contentType === "applet" ? "APPLET" : "GOAL",
|
|
178
|
+
contentId: positional(parsed, 2, "content ID"),
|
|
179
|
+
objectiveId: flagString(parsed, "node"),
|
|
180
|
+
};
|
|
181
|
+
const preview = unwrap(await api.client.GET("/ai/rcs/content-links/preview", {
|
|
182
|
+
params: { query: body },
|
|
183
|
+
}));
|
|
184
|
+
return writeCommand(parsed, {
|
|
185
|
+
action: "mastery.link",
|
|
186
|
+
target: {
|
|
187
|
+
graphReleaseId: body.graphReleaseId,
|
|
188
|
+
contentId: body.contentId,
|
|
189
|
+
},
|
|
190
|
+
request: body,
|
|
191
|
+
details: {
|
|
192
|
+
...preview,
|
|
193
|
+
consequence: "Adds a curriculum association on this release only. Does not assign work, grant XP, or change mastery.",
|
|
194
|
+
},
|
|
195
|
+
}, async () => unwrap(await api.client.POST("/ai/rcs/content-links", { body })));
|
|
196
|
+
}
|
|
197
|
+
if (verb === "unlink") {
|
|
198
|
+
const id = positional(parsed, 2, "link ID");
|
|
199
|
+
// The immutable link ID is obtained from `mastery links`; deletion is
|
|
200
|
+
// idempotent so an interrupted confirmed invocation can safely be retried.
|
|
201
|
+
return writeCommand(parsed, {
|
|
202
|
+
action: "mastery.unlink",
|
|
203
|
+
target: { linkId: id },
|
|
204
|
+
request: { id },
|
|
205
|
+
details: {
|
|
206
|
+
consequence: "Removes only this curriculum link, not the applet, goal, graph, or learner evidence.",
|
|
207
|
+
},
|
|
208
|
+
}, async () => unwrap(await api.client.DELETE("/ai/rcs/content-links/{id}", {
|
|
209
|
+
params: { path: { id } },
|
|
210
|
+
})));
|
|
211
|
+
}
|
|
212
|
+
throw new CliError("unknown_command", "Use recess help mastery.");
|
|
213
|
+
}
|
|
214
|
+
//# sourceMappingURL=mastery.js.map
|
|
@@ -95,10 +95,6 @@ function suggestedCommandsForQueueAction(action, familyId) {
|
|
|
95
95
|
`recess onboarding active-tutors ${familyId}`,
|
|
96
96
|
`recess onboarding set-primary-tutor ${familyId} --tutor <user-id>`,
|
|
97
97
|
];
|
|
98
|
-
case "attest":
|
|
99
|
-
return [
|
|
100
|
-
`recess onboarding attest ${familyId} --condition <app_downloaded|tutor_met|goals_loaded|ma_diagnostic>`,
|
|
101
|
-
];
|
|
102
98
|
case "clear_for_cohort":
|
|
103
99
|
return [`recess onboarding clear-for-cohort ${familyId}`];
|
|
104
100
|
case "register_cohort":
|
|
@@ -111,8 +107,6 @@ function suggestedCommandsForQueueAction(action, familyId) {
|
|
|
111
107
|
`recess onboarding readiness ${familyId}`,
|
|
112
108
|
`recess onboarding timeline ${familyId}`,
|
|
113
109
|
];
|
|
114
|
-
case "mark_complete":
|
|
115
|
-
return [`recess onboarding set-stage ${familyId} --stage COMPLETE`];
|
|
116
110
|
default:
|
|
117
111
|
return [];
|
|
118
112
|
}
|
|
@@ -478,7 +472,7 @@ export async function runOnboardingCommand({ parsed, api, writeCommand, }) {
|
|
|
478
472
|
if (verb === "clear-for-cohort") {
|
|
479
473
|
const familyId = positional(parsed, 2, "family ID");
|
|
480
474
|
return writeCommand(parsed, {
|
|
481
|
-
action: "clear the family for cohort placement after the server rechecks stage, account, kids
|
|
475
|
+
action: "clear the family for cohort placement after the server rechecks stage, account state, and that it has kids (goals, tutor and gate notes are not required)",
|
|
482
476
|
target: { familyId },
|
|
483
477
|
request: {},
|
|
484
478
|
}, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/clear-for-cohort", { params: { path: { familyId } } })));
|
package/dist/commands/school.js
CHANGED
|
@@ -268,7 +268,7 @@ export async function runSchoolCommand({ parsed, api, writeCommand, }) {
|
|
|
268
268
|
request: { familyId, kidIds },
|
|
269
269
|
details: {
|
|
270
270
|
kids: kidIds.map((id) => kidName(known.get(id)?.firstName ?? null, null, id)),
|
|
271
|
-
billing: "Default membership price unless the family is grandfathered onto one, with a 1-
|
|
271
|
+
billing: "Default membership price unless the family is grandfathered onto one, with a 1-hour trial for restarts, confirmed against the customer's saved default payment method.",
|
|
272
272
|
note: "A kid whose membership is already ACTIVE comes back `skipped_active` rather than being charged twice; per-kid failures are reported in `results`, not thrown.",
|
|
273
273
|
},
|
|
274
274
|
}, async () => unwrap(await api.client.POST("/admin/memberships/start", {
|