recess-cli 3.3.1 → 3.4.1

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/dist/api.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import createClient from "openapi-fetch";
3
3
  import { apiError, CliError } from "./errors.js";
4
- import { cliRequestHeaders, markCliRequest, RECESS_CLIENT_CLI, } from "./http.js";
4
+ import { cliRequestHeaders, markCliRequest, noteServerCliVersions, RECESS_CLIENT_CLI, } from "./http.js";
5
5
  const idempotencyContext = new AsyncLocalStorage();
6
6
  export function withIdempotencyContext(context, execute) {
7
7
  return idempotencyContext.run(context, execute);
@@ -40,6 +40,10 @@ export class RecessAdminApi {
40
40
  applyIdempotencyHeaders(request.headers);
41
41
  return request;
42
42
  },
43
+ onResponse({ response }) {
44
+ noteServerCliVersions(response.headers);
45
+ return response;
46
+ },
43
47
  });
44
48
  }
45
49
  requireAuth() {
@@ -55,6 +59,7 @@ export class RecessAdminApi {
55
59
  const response = await fetch(new URL(path, this.config.apiOrigin), {
56
60
  headers: cliRequestHeaders({ cookie: this.config.sessionCookie }, this.reason, this.clientTag),
57
61
  });
62
+ noteServerCliVersions(response.headers);
58
63
  const text = await response.text();
59
64
  let body = text;
60
65
  try {
package/dist/args.js CHANGED
@@ -6,6 +6,7 @@ import { CliError } from "./errors.js";
6
6
  */
7
7
  export const REPEATABLE_FLAGS = new Set(["kid", "unassign"]);
8
8
  const BOOLEAN_FLAGS = new Set([
9
+ "clear",
9
10
  "zero-balance",
10
11
  "allow-assigned-edits",
11
12
  "all-references",
package/dist/cli.js CHANGED
@@ -21,7 +21,8 @@ import { CliError } from "./errors.js";
21
21
  import { listFeedback, submitFeedback } from "./feedback.js";
22
22
  import { HELP } from "./help.js";
23
23
  export { HELP };
24
- import { cliRequestHeaders, requireCliRequestReason } from "./http.js";
24
+ import { cliRequestHeaders, noteServerCliVersions, requireCliRequestReason, } from "./http.js";
25
+ import { runSelfUpdate, UPGRADE_COMMAND, updateNoticeText, updateStatus, } from "./update-notice.js";
25
26
  import { requireConfirmation } from "./safety.js";
26
27
  import { installSkill, isEphemeralInstall, readBundledSkillVersion, readCliVersion, } from "./setup.js";
27
28
  import { compareVersions, updateSkillFromServer } from "./skill-update.js";
@@ -174,6 +175,7 @@ async function doctor(config, reason) {
174
175
  headers: cliRequestHeaders(undefined, reason),
175
176
  signal: AbortSignal.timeout(5000),
176
177
  });
178
+ noteServerCliVersions(response.headers);
177
179
  checks.api = { reachable: response.ok, status: response.status };
178
180
  }
179
181
  catch (error) {
@@ -183,6 +185,9 @@ async function doctor(config, reason) {
183
185
  };
184
186
  }
185
187
  checks.skill = await skillStatus(config, reason);
188
+ const update = updateStatus(await readCliVersion());
189
+ const updateAction = updateNoticeText(update);
190
+ checks.update = updateAction ? { ...update, action: updateAction } : update;
186
191
  return checks;
187
192
  }
188
193
  /**
@@ -1438,6 +1443,19 @@ function parseGoalTemplateDocument(doc) {
1438
1443
  * Resolve a template reference that may be a UUID or a slug. Slugs are what an
1439
1444
  * author types and what the skill's prose uses; the routes take a UUID only.
1440
1445
  */
1446
+ /**
1447
+ * `/challenges-v2/{id}/preview` on the web app: the template as the card a
1448
+ * kid would see, with no chat around it. Returned by `goal-templates get`
1449
+ * and `create` so an agent can open it, or hand it to the human, right after
1450
+ * authoring.
1451
+ */
1452
+ function goalTemplatePreviewUrl(webOrigin, templateId) {
1453
+ return new URL(`/challenges-v2/${encodeURIComponent(templateId)}/preview`, webOrigin).toString();
1454
+ }
1455
+ /** Runs the template with Rocky as a kid would (staff only; ephemeral, never touches real todos). */
1456
+ function goalTemplatePlayTestUrl(webOrigin, templateId) {
1457
+ return new URL(`/challenges-v2/${encodeURIComponent(templateId)}?play=1`, webOrigin).toString();
1458
+ }
1441
1459
  async function resolveGoalTemplateId(api, reference) {
1442
1460
  if (UUID_RE.test(reference))
1443
1461
  return reference;
@@ -1910,7 +1928,11 @@ export async function executeRecessCommand(argv, options = {}) {
1910
1928
  }
1911
1929
  }
1912
1930
  const config = options.config ?? (await resolveConfig(flagString(parsed, "profile")));
1913
- const requestReason = noun === "auth" ? undefined : requiredRequestReason(parsed);
1931
+ const requestReason = noun === "auth"
1932
+ ? undefined
1933
+ : noun === "update"
1934
+ ? requireCliRequestReason(flagString(parsed, "reason") ?? "check for a recess CLI update")
1935
+ : requiredRequestReason(parsed);
1914
1936
  if (noun === "doctor" && isRemote) {
1915
1937
  return {
1916
1938
  transport: "streamable-http-mcp",
@@ -1922,6 +1944,31 @@ export async function executeRecessCommand(argv, options = {}) {
1922
1944
  }
1923
1945
  if (noun === "doctor")
1924
1946
  return doctor(config, requestReason);
1947
+ if (noun === "update") {
1948
+ if (isRemote) {
1949
+ throw new CliError("remote_command_unavailable", "The update command is not exposed by the hosted Recess CLI.");
1950
+ }
1951
+ const cliVersion = await readCliVersion();
1952
+ const probe = await fetch(new URL("/health", config.apiOrigin), {
1953
+ headers: cliRequestHeaders(undefined, requestReason),
1954
+ signal: AbortSignal.timeout(5000),
1955
+ }).catch(() => null);
1956
+ if (probe)
1957
+ noteServerCliVersions(probe.headers);
1958
+ const status = updateStatus(cliVersion);
1959
+ if (status.state === "current")
1960
+ return { ...status, updated: false };
1961
+ if (isEphemeralInstall()) {
1962
+ throw new CliError("invalid_arguments", "This recess is running from a temporary npx cache. Run `npx recess-cli@latest` or install it with `npm install -g recess-cli@latest`.");
1963
+ }
1964
+ requireConfirmation(hasFlag(parsed, "confirm"), {
1965
+ action: "install the latest recess CLI globally with npm",
1966
+ target: { ...status },
1967
+ request: { command: UPGRADE_COMMAND },
1968
+ });
1969
+ const result = await runSelfUpdate();
1970
+ return { ...status, updated: true, ...result };
1971
+ }
1925
1972
  if (noun === "setup") {
1926
1973
  // Always lay down the bundled copy first: it is the floor, and it is the
1927
1974
  // only copy guaranteed to match this binary. The served upgrade below is
@@ -2484,6 +2531,145 @@ export async function executeRecessCommand(argv, options = {}) {
2484
2531
  body,
2485
2532
  })));
2486
2533
  }
2534
+ if (noun === "users" && verb === "school") {
2535
+ const action = parsed.positionals[2];
2536
+ const userId = positional(parsed, 3, "user ID");
2537
+ if (action === "status")
2538
+ return unwrap(await api.client.GET("/admin/users/{userId}/school-status", {
2539
+ params: { path: { userId } },
2540
+ }));
2541
+ if (action === "history")
2542
+ return unwrap(await api.client.GET("/admin/users/{userId}/school-history", {
2543
+ params: {
2544
+ path: { userId },
2545
+ query: {
2546
+ limit: flagNumber(parsed, "limit"),
2547
+ cursor: flagNumber(parsed, "cursor"),
2548
+ },
2549
+ },
2550
+ }));
2551
+ if (action === "complete-action") {
2552
+ const actionId = positional(parsed, 4, "action ID");
2553
+ const body = { reason: flagString(parsed, "note", { required: true }) };
2554
+ return writeCommand(parsed, {
2555
+ action: "record verified provider cancellation or deactivation; does not contact the provider",
2556
+ target: { userId, actionId },
2557
+ request: body,
2558
+ }, async () => unwrap(await api.client.POST("/admin/users/{userId}/school-actions/{actionId}/complete", { params: { path: { userId, actionId } }, body })));
2559
+ }
2560
+ const status = action === "withdraw" ? "WITHDRAWN" : "ACTIVE";
2561
+ const tierId = action === "restore"
2562
+ ? assertChoice(flagString(parsed, "tier", { required: true }), SCHOOL_TIER_IDS, "--tier")
2563
+ : undefined;
2564
+ const body = {
2565
+ status,
2566
+ reason: flagString(parsed, "note", { required: true }),
2567
+ expectedUpdatedAt: flagString(parsed, "expected-updated-at", {
2568
+ required: true,
2569
+ }),
2570
+ ...(tierId ? { tierId } : {}),
2571
+ };
2572
+ return writeCommand(parsed, {
2573
+ action: status === "WITHDRAWN"
2574
+ ? "withdraw SCHOOL student, cancel subscriptions immediately without refund, and revoke external learning access"
2575
+ : "restore Recess access; billing, classes, and external accounts are not restarted",
2576
+ target: { userId },
2577
+ request: body,
2578
+ details: status === "WITHDRAWN"
2579
+ ? {
2580
+ cancelSubscriptionsImmediately: true,
2581
+ refunds: false,
2582
+ revokeExternalAccess: true,
2583
+ ixlRemoval: "nightly roster import",
2584
+ manualProviderFollowUpMayBeRequired: true,
2585
+ }
2586
+ : { restartsBilling: false, restoresExternalAccounts: false },
2587
+ }, async () => unwrap(await api.client.PATCH("/admin/users/{userId}/school-status", {
2588
+ params: { path: { userId } },
2589
+ body,
2590
+ })));
2591
+ }
2592
+ if (noun === "users" && verb === "enrollment") {
2593
+ if (parsed.positionals[2] === "set-partner-school-cc") {
2594
+ const school = positional(parsed, 3, "partner school ID or slug");
2595
+ const email = flagString(parsed, "email");
2596
+ const clear = hasFlag(parsed, "clear");
2597
+ if (!!email === clear) {
2598
+ throw new CliError("invalid_arguments", "Specify either --email <address> or --clear.");
2599
+ }
2600
+ const { schools } = unwrap(await api.client.GET("/admin/users/enrollment-partner-schools"));
2601
+ const partnerSchool = schools.find((entry) => entry.id === school || entry.slug === school);
2602
+ if (!partnerSchool)
2603
+ throw new CliError("invalid_arguments", "Unknown partner school. Use users enrollment list-partner-schools.");
2604
+ const body = {
2605
+ communicationsCcEmail: email?.trim().toLowerCase() ?? null,
2606
+ };
2607
+ return writeCommand(parsed, {
2608
+ action: "set partner school communications CC",
2609
+ target: {
2610
+ id: partnerSchool.id,
2611
+ slug: partnerSchool.slug,
2612
+ name: partnerSchool.name,
2613
+ },
2614
+ request: body,
2615
+ }, async () => unwrap(await api.client.PATCH("/admin/users/enrollment-partner-schools/{schoolId}/communications", {
2616
+ params: { path: { schoolId: partnerSchool.id } },
2617
+ body,
2618
+ })));
2619
+ }
2620
+ if (parsed.positionals[2] === "history") {
2621
+ const userId = positional(parsed, 3, "user ID");
2622
+ return unwrap(await api.client.GET("/admin/users/{userId}/enrollment-history", {
2623
+ params: {
2624
+ path: { userId },
2625
+ query: {
2626
+ limit: flagNumber(parsed, "limit"),
2627
+ cursor: flagNumber(parsed, "cursor"),
2628
+ },
2629
+ },
2630
+ }));
2631
+ }
2632
+ if (parsed.positionals[2] === "list-partner-schools") {
2633
+ return unwrap(await api.client.GET("/admin/users/enrollment-partner-schools"));
2634
+ }
2635
+ const userId = positional(parsed, 3, "user ID");
2636
+ const entities = {
2637
+ "online-learning-program": "ONLINE_LEARNING_PROGRAM",
2638
+ "recess-academy-school": "RECESS_ACADEMY_SCHOOL",
2639
+ "partner-school": "PARTNER_SCHOOL",
2640
+ unset: null,
2641
+ };
2642
+ const entity = assertChoice(flagString(parsed, "entity", { required: true }), [
2643
+ "online-learning-program",
2644
+ "recess-academy-school",
2645
+ "partner-school",
2646
+ "unset",
2647
+ ], "--entity");
2648
+ const partner = flagString(parsed, "partner-school");
2649
+ if ((entity === "partner-school") !== !!partner) {
2650
+ throw new CliError("invalid_arguments", "--partner-school is required only with --entity partner-school.");
2651
+ }
2652
+ const schools = partner
2653
+ ? unwrap(await api.client.GET("/admin/users/enrollment-partner-schools"))
2654
+ .schools
2655
+ : [];
2656
+ const partnerSchool = schools.find((school) => school.id === partner || school.slug === partner);
2657
+ if (partner && !partnerSchool) {
2658
+ throw new CliError("invalid_arguments", "Unknown partner school. Use users enrollment list-partner-schools to find its ID or slug.");
2659
+ }
2660
+ const body = {
2661
+ enrollmentEntity: entities[entity],
2662
+ enrollmentPartnerSchoolId: partnerSchool?.id ?? null,
2663
+ };
2664
+ return writeCommand(parsed, {
2665
+ action: "set user enrollment entity",
2666
+ target: { userId, partnerSchool: partnerSchool ?? null },
2667
+ request: body,
2668
+ }, async () => unwrap(await api.client.PATCH("/admin/users/{userId}/enrollment-entity", {
2669
+ params: { path: { userId } },
2670
+ body,
2671
+ })));
2672
+ }
2487
2673
  if (noun === "users" && verb === "get") {
2488
2674
  const userId = positional(parsed, 2, "user ID");
2489
2675
  return unwrap(await api.client.GET("/admin/users/{userId}", {
@@ -3708,7 +3894,13 @@ export async function executeRecessCommand(argv, options = {}) {
3708
3894
  setupWorkflowSpec: template.setupWorkflowSpec,
3709
3895
  };
3710
3896
  }
3711
- return template;
3897
+ // The card a kid would see, with no chat around it: the page to open
3898
+ // (or hand to the human) after authoring.
3899
+ return {
3900
+ ...template,
3901
+ previewUrl: goalTemplatePreviewUrl(config.webOrigin, template.id),
3902
+ playTestUrl: goalTemplatePlayTestUrl(config.webOrigin, template.id),
3903
+ };
3712
3904
  }
3713
3905
  if (verb === "versions") {
3714
3906
  const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
@@ -3800,9 +3992,16 @@ export async function executeRecessCommand(argv, options = {}) {
3800
3992
  specInventory: validation.totals,
3801
3993
  note: "Setup mode is DETERMINISTIC_WORKFLOW and is one-way: this template can never be converted back to a chat-driven setup.",
3802
3994
  },
3803
- }, async () => unwrap(await api.client.POST("/ai/goal-templates/", {
3804
- body: document,
3805
- })));
3995
+ }, async () => {
3996
+ const created = unwrap(await api.client.POST("/ai/goal-templates/", {
3997
+ body: document,
3998
+ }));
3999
+ return {
4000
+ ...created,
4001
+ previewUrl: goalTemplatePreviewUrl(config.webOrigin, created.id),
4002
+ playTestUrl: goalTemplatePlayTestUrl(config.webOrigin, created.id),
4003
+ };
4004
+ });
3806
4005
  }
3807
4006
  if (verb === "policy") {
3808
4007
  const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
@@ -4060,6 +4259,7 @@ export async function executeRecessCommand(argv, options = {}) {
4060
4259
  (!Number.isInteger(coinAmount) || coinAmount < 1)) {
4061
4260
  throw new CliError("invalid_arguments", "--coin-amount must be a positive integer.");
4062
4261
  }
4262
+ const visibility = flagString(parsed, "visibility");
4063
4263
  const isStarterRaw = flagString(parsed, "is-starter");
4064
4264
  const setupAudienceRaw = flagString(parsed, "setup-audience");
4065
4265
  const isStarter = isStarterRaw === undefined
@@ -4101,6 +4301,11 @@ export async function executeRecessCommand(argv, options = {}) {
4101
4301
  ...(kind
4102
4302
  ? { kind: assertChoice(kind, GOAL_TEMPLATE_KINDS, "--kind") }
4103
4303
  : {}),
4304
+ ...(visibility
4305
+ ? {
4306
+ visibility: assertChoice(visibility, ["PUBLIC", "PRIVATE", "STAFF_ONLY"], "--visibility"),
4307
+ }
4308
+ : {}),
4104
4309
  ...(agentInstructionsFile
4105
4310
  ? {
4106
4311
  agentInstructions: await fs.readFile(path.resolve(agentInstructionsFile), "utf8"),
@@ -4117,7 +4322,7 @@ export async function executeRecessCommand(argv, options = {}) {
4117
4322
  // shape that caused the template incident; editing an existing spec goes
4118
4323
  // through the guarded /ai patch path with its destructive-change token.
4119
4324
  if (Object.keys(body).length === 1) {
4120
- throw new CliError("invalid_arguments", "Pass at least one field to change (--title, --description, --emoji, --image-url, --coin-amount, --category, --tags, --sort-order, --is-starter, --setup-audience, --kind, --agent-instructions-file, --output-template-file).");
4325
+ throw new CliError("invalid_arguments", "Pass at least one field to change (--title, --description, --emoji, --image-url, --coin-amount, --category, --tags, --sort-order, --is-starter, --setup-audience, --kind, --visibility, --agent-instructions-file, --output-template-file).");
4121
4326
  }
4122
4327
  return writeCommand(parsed, {
4123
4328
  action: "update goal template metadata (never its setupWorkflowSpec)",
@@ -4358,6 +4563,25 @@ export async function executeRecessCommand(argv, options = {}) {
4358
4563
  }
4359
4564
  throw new CliError("invalid_arguments", "Use goal-templates list|get|versions|validate-spec|create|patch-spec|set-metadata|generate-image|delete|snapshot-files|capture-snapshot|apply|apply-starter.");
4360
4565
  }
4566
+ if (noun === "goals" && verb === "adaptations") {
4567
+ const action = positional(parsed, 2, "goals adaptations action");
4568
+ const studentId = flagString(parsed, "student", { required: true });
4569
+ const goalId = flagString(parsed, "goal", { required: true });
4570
+ if (action === "list") {
4571
+ return unwrap(await api.client.GET("/tutor/students/{studentId}/goals/{goalId}/adaptations", { params: { path: { studentId, goalId } } }));
4572
+ }
4573
+ if (action === "diff") {
4574
+ const runId = positional(parsed, 3, "adaptation run ID");
4575
+ const path = flagString(parsed, "path");
4576
+ return unwrap(await api.client.GET("/tutor/students/{studentId}/goals/{goalId}/adaptations/{runId}/diff", {
4577
+ params: {
4578
+ path: { studentId, goalId, runId },
4579
+ query: path ? { path } : {},
4580
+ },
4581
+ }));
4582
+ }
4583
+ throw new CliError("invalid_arguments", "Use goals adaptations list|diff.");
4584
+ }
4361
4585
  if (noun === "goals" && verb !== "files" && verb !== "pdf") {
4362
4586
  if (verb === "complete" || verb === "undo-completion") {
4363
4587
  const goalId = positional(parsed, 2, "goal ID");
@@ -135,6 +135,7 @@ const LOCAL_COMMAND_NOUNS = new Set([
135
135
  "jobs",
136
136
  "profile",
137
137
  "setup",
138
+ "update",
138
139
  ]);
139
140
  const AUTHENTICATED_COMMAND_PREFIXES = [
140
141
  "village build",
@@ -164,6 +165,8 @@ const FAMILY_AI_COMMANDS = new Set([
164
165
  "goal-templates list",
165
166
  "goal-templates snapshot-files",
166
167
  "goal-templates validate-spec",
168
+ "goals adaptations diff",
169
+ "goals adaptations list",
167
170
  "goals archive",
168
171
  "goals create",
169
172
  "goals edit",
@@ -212,7 +212,7 @@ function agentsGuide(docs) {
212
212
  "widgets.js, engine.js, key.js — a modified locked file fails validation.",
213
213
  "",
214
214
  "Before publishing, write two files at the root:",
215
- "- contract.json — the BuildContract described below (copy the shape in the template contract).",
215
+ "- contract.json — the BuildContract: copy the example in the contract.json section below.",
216
216
  '- manifest.json — { "title": "...", "description": "...", "template": { "ccss": "5.NF.A.1", "modes": ["prepare","remediate"], "misconceptions": ["..."] } }',
217
217
  "",
218
218
  'Then: `recess apps validate --reason "..."` (reports without publishing) and',
@@ -228,6 +228,10 @@ function agentsGuide(docs) {
228
228
  "",
229
229
  "---",
230
230
  "",
231
+ docs.contract ?? "",
232
+ "",
233
+ "---",
234
+ "",
231
235
  docs.pedagogy ? `# Pedagogy\n\n${docs.pedagogy}` : "",
232
236
  "",
233
237
  "---",
package/dist/help.js CHANGED
@@ -17,6 +17,7 @@ Usage:
17
17
  recess [--json] agent-context
18
18
  recess [--json] setup [--skill-only]
19
19
  recess [--json] doctor
20
+ recess [--json] update [--confirm]
20
21
  recess [--json] profile list
21
22
  recess [--json] profile show <name>
22
23
  recess [--json] profile save <name> [--api-origin URL] [--web-origin URL]
@@ -34,6 +35,18 @@ Usage:
34
35
  recess [--json] auth status|logout
35
36
  recess [--json] users search <name-or-id> [--limit 10]
36
37
  recess [--json] users get <user-id>
38
+ recess [--json] users school complete-action <user-id> <action-id> --note <text> [--confirm]
39
+ recess [--json] users school status <user-id>
40
+ recess [--json] users school history <user-id> [--limit 25] [--cursor <id>]
41
+ recess [--json] users school withdraw <user-id> --note <text> --expected-updated-at <iso> [--confirm]
42
+ recess [--json] users school restore <user-id> --tier <tier-id> --note <text> --expected-updated-at <iso> [--confirm]
43
+ recess [--json] users enrollment history <user-id> [--limit 25] [--cursor <id>]
44
+ recess [--json] users enrollment set-partner-school-cc <school-id-or-slug>
45
+ [--email <address> | --clear] [--confirm]
46
+ recess [--json] users enrollment list-partner-schools
47
+ recess [--json] users enrollment set <user-id>
48
+ --entity online-learning-program|recess-academy-school|partner-school|unset
49
+ [--partner-school <school-id-or-slug>] [--confirm]
37
50
  recess [--json] users lock <user-id> [--confirm]
38
51
  recess [--json] users unlock <user-id> [--confirm]
39
52
  recess [--json] users disable <user-id> [--whole-family] [--allow-billing] [--confirm]
@@ -378,7 +391,7 @@ Usage:
378
391
  [--title TEXT] [--description TEXT] [--emoji X] [--category TEXT] [--tags A,B]
379
392
  [--image-url URL] [--coin-amount N] [--sort-order N] [--is-starter true|false]
380
393
  [--setup-audience KID_FRIENDLY|PARENT_SETUP] [--kind SIMPLE|BLUEPRINT]
381
- [--agent-instructions-file <path>]
394
+ [--visibility PUBLIC|PRIVATE|STAFF_ONLY] [--agent-instructions-file <path>]
382
395
  [--output-template-file <path>] [--confirm]
383
396
  recess [--json] goal-templates delete <template-id> --expected-version N [--confirm]
384
397
  recess [--json] goal-templates generate-image <template-id|slug>
@@ -455,6 +468,9 @@ Usage:
455
468
  [--lessons-dir <dir>] [--emoji TEXT] [--grade-min N] [--grade-max N]
456
469
  [--visibility PUBLIC|STAFF] [--confirm]
457
470
  recess [--json] tutor-templates delete <slug> [--confirm]
471
+ recess [--json] goals adaptations list --student <goal-owner-id> --goal <goal-id>
472
+ recess [--json] goals adaptations diff <run-id> --student <goal-owner-id>
473
+ --goal <goal-id> [--path P]
458
474
  recess [--json] goals files list --student <goal-owner-id> --goal <goal-id>
459
475
  recess [--json] goals files read --student <goal-owner-id> --goal <goal-id> --path P
460
476
  recess [--json] goals files init --student <goal-owner-id> --draft <draft-slug>
package/dist/http.js CHANGED
@@ -10,6 +10,25 @@ export const RECESS_REASON_HEADER = "x-recess-reason";
10
10
  */
11
11
  export const RECESS_REASON_ENCODING_HEADER = "x-recess-reason-encoding";
12
12
  export const RECESS_REASON_ENCODING_UTF8 = "utf-8-percent";
13
+ export const RECESS_CLI_VERSION_HEADER = "x-recess-cli-version";
14
+ export const RECESS_CLI_LATEST_HEADER = "x-recess-cli-latest";
15
+ export const RECESS_CLI_MINIMUM_HEADER = "x-recess-cli-minimum";
16
+ let requestCliVersion;
17
+ const observedCliVersions = {};
18
+ export function setRequestCliVersion(version) {
19
+ requestCliVersion = version;
20
+ }
21
+ export function noteServerCliVersions(headers) {
22
+ const latest = headers.get(RECESS_CLI_LATEST_HEADER)?.trim();
23
+ const minimum = headers.get(RECESS_CLI_MINIMUM_HEADER)?.trim();
24
+ if (latest)
25
+ observedCliVersions.latest = latest;
26
+ if (minimum)
27
+ observedCliVersions.minimum = minimum;
28
+ }
29
+ export function serverCliVersions() {
30
+ return { ...observedCliVersions };
31
+ }
13
32
  const ASCII_PRINTABLE = /^[\x20-\x7E]*$/;
14
33
  /**
15
34
  * Make a human-written reason safe to put in an HTTP header.
@@ -53,6 +72,8 @@ export function requireCliRequestReason(value) {
53
72
  }
54
73
  export function markCliRequest(headers, reason, client = RECESS_CLIENT_CLI) {
55
74
  headers.set(RECESS_CLIENT_HEADER, client);
75
+ if (requestCliVersion)
76
+ headers.set(RECESS_CLI_VERSION_HEADER, requestCliVersion);
56
77
  if (reason) {
57
78
  const encoded = encodeReasonHeader(reason);
58
79
  headers.set(RECESS_REASON_HEADER, encoded.value);
package/dist/index.js CHANGED
@@ -4,6 +4,9 @@ import { parseArgs } from "./args.js";
4
4
  import { runCommand } from "./cli.js";
5
5
  import { deliverJson } from "./delivery.js";
6
6
  import { CliError } from "./errors.js";
7
+ import { setRequestCliVersion } from "./http.js";
8
+ import { readCliVersion } from "./setup.js";
9
+ import { printUpdateNotice } from "./update-notice.js";
7
10
  async function withInteractiveAdmissionStage(args) {
8
11
  const parsed = parseArgs(args);
9
12
  if (parsed.flags.has("json") ||
@@ -41,6 +44,8 @@ async function withInteractiveAdmissionStage(args) {
41
44
  const argv = await withInteractiveAdmissionStage(process.argv.slice(2));
42
45
  const parsedArgv = parseArgs(argv);
43
46
  const json = parsedArgv.flags.has("json");
47
+ const cliVersion = await readCliVersion();
48
+ setRequestCliVersion(cliVersion);
44
49
  // The interactive console owns the terminal, so it runs before the JSON
45
50
  // envelope machinery rather than through it.
46
51
  if (argv[0] === "ui") {
@@ -103,4 +108,7 @@ catch (error) {
103
108
  }
104
109
  process.exitCode = cliError.exitCode;
105
110
  }
111
+ if (parsedArgv.positionals[0] !== "update") {
112
+ await printUpdateNotice(cliVersion);
113
+ }
106
114
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,85 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { defaultConfigPath } from "./config.js";
5
+ import { CliError } from "./errors.js";
6
+ import { serverCliVersions } from "./http.js";
7
+ import { compareVersions } from "./skill-update.js";
8
+ export const UPGRADE_COMMAND = "npm install -g recess-cli@latest";
9
+ const NOTICE_INTERVAL_MS = 24 * 60 * 60 * 1000;
10
+ export function updateStatus(cliVersion, versions = serverCliVersions()) {
11
+ const { latest, minimum } = versions;
12
+ if (minimum && compareVersions(cliVersion, minimum) < 0) {
13
+ return { state: "required", cliVersion, latest, minimum };
14
+ }
15
+ if (!latest)
16
+ return { state: "unknown", cliVersion };
17
+ if (compareVersions(cliVersion, latest) < 0) {
18
+ return { state: "available", cliVersion, latest, minimum };
19
+ }
20
+ return { state: "current", cliVersion, latest };
21
+ }
22
+ export function updateNoticeText(status) {
23
+ if (status.state === "required") {
24
+ return `Warning: recess ${status.cliVersion} is older than the minimum supported version ${status.minimum}, so some commands may fail. Update with \`recess update --confirm\` or \`${UPGRADE_COMMAND}\`.`;
25
+ }
26
+ if (status.state === "available") {
27
+ return `recess ${status.latest} is available (you have ${status.cliVersion}). Update with \`recess update --confirm\` or \`${UPGRADE_COMMAND}\`.`;
28
+ }
29
+ return null;
30
+ }
31
+ function noticeStampPath() {
32
+ return path.join(path.dirname(defaultConfigPath()), "update-notice.json");
33
+ }
34
+ async function recentlyNotified(latest) {
35
+ try {
36
+ const stamp = JSON.parse(await fs.readFile(noticeStampPath(), "utf8"));
37
+ if (stamp.latest !== latest || !stamp.notifiedAt)
38
+ return false;
39
+ return Date.now() - Date.parse(stamp.notifiedAt) < NOTICE_INTERVAL_MS;
40
+ }
41
+ catch {
42
+ return false;
43
+ }
44
+ }
45
+ async function recordNotice(latest) {
46
+ try {
47
+ await fs.mkdir(path.dirname(noticeStampPath()), { recursive: true });
48
+ await fs.writeFile(noticeStampPath(), JSON.stringify({ latest, notifiedAt: new Date().toISOString() }));
49
+ }
50
+ catch {
51
+ return;
52
+ }
53
+ }
54
+ export async function printUpdateNotice(cliVersion, write = (text) => process.stderr.write(text)) {
55
+ if (process.env.RECESS_CLI_NO_UPDATE_NOTICE === "1")
56
+ return;
57
+ const status = updateStatus(cliVersion);
58
+ const text = updateNoticeText(status);
59
+ if (!text)
60
+ return;
61
+ if (status.state === "available") {
62
+ if (await recentlyNotified(status.latest))
63
+ return;
64
+ await recordNotice(status.latest);
65
+ }
66
+ write(`\n${text}\n`);
67
+ }
68
+ export async function runSelfUpdate() {
69
+ const [bin, ...args] = UPGRADE_COMMAND.split(" ");
70
+ const exitCode = await new Promise((resolve, reject) => {
71
+ const child = spawn(bin, args, {
72
+ stdio: ["ignore", process.stderr, process.stderr],
73
+ shell: process.platform === "win32",
74
+ });
75
+ child.on("error", reject);
76
+ child.on("close", (code) => resolve(code ?? 1));
77
+ }).catch((error) => {
78
+ throw new CliError("update_failed", `Could not run \`${UPGRADE_COMMAND}\`: ${error instanceof Error ? error.message : String(error)}`);
79
+ });
80
+ if (exitCode !== 0) {
81
+ throw new CliError("update_failed", `\`${UPGRADE_COMMAND}\` exited with code ${exitCode}. Run it yourself to see the full npm output; a permissions error usually means the global npm directory needs sudo or a Node version manager.`);
82
+ }
83
+ return { command: UPGRADE_COMMAND, exitCode };
84
+ }
85
+ //# sourceMappingURL=update-notice.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "recess-cli",
3
- "version": "3.3.1",
3
+ "version": "3.4.1",
4
4
  "description": "Safe Recess administration and family AI tools from the command line.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -107,6 +107,9 @@ prompts a model for them.
107
107
  findings — fix them and publish again. Both commands wait for the result (`--no-wait` to return the
108
108
  build id and poll with `apps status`).
109
109
  - `recess --json apps pull <project-id> [dir]` downloads a project you own to keep editing it.
110
+ - A published `appUrl` is also a valid goal queue entry (`goals queue set`, or a template's
111
+ external practice queue): each app becomes one module. Build interactive content this way rather
112
+ than linking a chat artifact the kid cannot open inside Recess.
110
113
  - `recess --json apps standards "<words or notation>"` finds the CCSS standard for `manifest.json`
111
114
  (`"grade 4 adding fractions"` → `4.NF.B.3a`…); guides rarely know the codes, look them up.
112
115