recess-cli 2.7.0 → 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.
@@ -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 analysis = forTodo
312
- ? unwrap(await api.client.GET("/studio/todos/{todoId}/analysis", {
313
- params: { path: { todoId: forTodo } },
314
- }))
315
- : null;
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 (analysis) {
323
- await fs.writeFile(path.join(dir, BRIEF_FILE), briefFor(analysis));
578
+ if (scaffold.todo && scaffold.brief) {
579
+ await fs.writeFile(path.join(dir, BRIEF_FILE), scaffold.brief);
324
580
  await writeAppLink(dir, {
325
- forTodoId: analysis.todo.id,
326
- studentId: analysis.todo.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 + (analysis ? 2 : 1),
332
- ...(analysis
587
+ filesWritten: written + (scaffold.todo ? 2 : 1),
588
+ ...(scaffold.todo
333
589
  ? {
334
590
  brief: BRIEF_FILE,
335
- studentId: analysis.todo.studentId,
336
- standards: analysis.standards.map((s) => s.notation),
337
- misconceptions: analysis.analysis.patterns.map((p) => p.instanceKey ?? slug(p.name)),
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
- analysis
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
- analysis
349
- ? `recess apps publish --assign ${analysis.todo.studentId} --reason "Ship it to the kid"`
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, goals, tutor, and attestations",
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 } } })));
@@ -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-day trial, confirmed against the customer's saved default payment method.",
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", {
package/dist/engine.js ADDED
@@ -0,0 +1,6 @@
1
+ export { executeRecessCommand, } from "./cli.js";
2
+ export { buildCommandSchema, findCommandSchema, RECESS_MCP_SCOPES, } from "./command-schema.js";
3
+ export { RecessAdminApi } from "./api.js";
4
+ export { CliError } from "./errors.js";
5
+ export { APP_BUNDLE_EDITABLE_FILES } from "./commands/apps.js";
6
+ //# sourceMappingURL=engine.js.map
package/dist/errors.js CHANGED
@@ -10,9 +10,18 @@ export class CliError extends Error {
10
10
  }
11
11
  }
12
12
  export function apiError(status, body) {
13
- const message = typeof body === "object" && body && "message" in body
14
- ? String(body.message)
15
- : `Recess API request failed with status ${status}`;
16
- return new CliError("api_error", message, 1, { status, body });
13
+ const message = typeof body === "object" && body && "error" in body
14
+ ? String(body.error)
15
+ : typeof body === "object" && body && "message" in body
16
+ ? String(body.message)
17
+ : `Recess API request failed with status ${status}`;
18
+ const code = status === 404
19
+ ? "not_found"
20
+ : status === 409
21
+ ? "conflict"
22
+ : status === 403
23
+ ? "forbidden"
24
+ : "api_error";
25
+ return new CliError(code, message, 1, { status, body });
17
26
  }
18
27
  //# sourceMappingURL=errors.js.map