recess-cli 2.10.0 → 3.0.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 CHANGED
@@ -9,6 +9,13 @@ authenticated Village home building. It uses the web-server OpenAPI document, au
9
9
  through Recess SSO, emits stable JSON, and refuses live writes until the exact command is rerun with
10
10
  `--confirm` plus the preview's operation key after human approval.
11
11
 
12
+ ## Version 3 changes
13
+
14
+ `goals list` now shows ACTIVE and PAUSED goals by default. Add `--include-archived` to
15
+ include COMPLETED goals (both archived and rewarded completions), and `--query` to search.
16
+ Staff gain `cohorts schedule` and versioned `memories` file commands. Deploy their backend
17
+ and database support before publishing this CLI; writes require a server preview.
18
+
12
19
  ## Install (no checkout needed)
13
20
 
14
21
  Published to npm as [`recess-cli`](https://www.npmjs.com/package/recess-cli). On any machine with Node 20+:
package/dist/args.js CHANGED
@@ -19,6 +19,7 @@ const BOOLEAN_FLAGS = new Set([
19
19
  "help",
20
20
  "immediate",
21
21
  "include-deleted",
22
+ "include-archived",
22
23
  "mirrored",
23
24
  "no-invite",
24
25
  "no-collision",
package/dist/cli.js CHANGED
@@ -12,6 +12,7 @@ import { runAppsCommand } from "./commands/apps.js";
12
12
  import { runChatLogsCommand } from "./commands/chat-logs.js";
13
13
  import { runMasteryCommand } from "./commands/mastery.js";
14
14
  import { runOnboardingCommand } from "./commands/onboarding.js";
15
+ import { runScheduleCommand, runMemoryFilesCommand, } from "./commands/safe-staff-operations.js";
15
16
  import { runSchoolCommand } from "./commands/school.js";
16
17
  import { runVillageEventsCommand } from "./commands/village-events.js";
17
18
  import { assertChoice, flagIdList, positional, readJsonFile, readJsonValue, } from "./commands/shared.js";
@@ -252,7 +253,10 @@ async function localWriteCommand(parsed, preview, execute) {
252
253
  action: preview.action,
253
254
  fingerprint,
254
255
  target: preview.target,
255
- ...(preview.action.startsWith("mastery.") ? { preview } : {}),
256
+ ...(preview.action.startsWith("mastery.") ||
257
+ preview.request.attendanceTakenAt
258
+ ? { preview }
259
+ : {}),
256
260
  });
257
261
  requireConfirmation(false, boundPreview);
258
262
  }
@@ -657,7 +661,12 @@ async function previewBoundWrite(parsed, preview, execute) {
657
661
  const result = await withIdempotencyContext(context, execute);
658
662
  await appendJobEvent({
659
663
  jobId: context.operationKey,
660
- status: "completed",
664
+ status: typeof result === "object" &&
665
+ result !== null &&
666
+ "outcome" in result &&
667
+ result.outcome === "outcome_unknown"
668
+ ? "unknown"
669
+ : "completed",
661
670
  timestamp: new Date().toISOString(),
662
671
  action: preview.action,
663
672
  fingerprint: context.fingerprint,
@@ -2677,6 +2686,14 @@ export async function executeRecessCommand(argv, options = {}) {
2677
2686
  if (noun === "school") {
2678
2687
  return runSchoolCommand({ parsed, api, writeCommand });
2679
2688
  }
2689
+ if (noun === "cohorts" && verb === "schedule")
2690
+ return runScheduleCommand({ parsed, api, writeCommand: previewBoundWrite });
2691
+ if (noun === "memories" && verb !== "context" && verb !== "log")
2692
+ return runMemoryFilesCommand({
2693
+ parsed,
2694
+ api,
2695
+ writeCommand: previewBoundWrite,
2696
+ });
2680
2697
  if (noun === "cohorts" && verb === "search") {
2681
2698
  const search = parsed.positionals.slice(2).join(" ").trim();
2682
2699
  if (!search)
@@ -3159,7 +3176,16 @@ export async function executeRecessCommand(argv, options = {}) {
3159
3176
  excused: true,
3160
3177
  })),
3161
3178
  ];
3162
- const body = { attendanceTakenAt: new Date().toISOString(), attendance };
3179
+ const operationKey = flagString(parsed, "operation-key");
3180
+ const stored = operationKey ? await getJob(operationKey) : undefined;
3181
+ const preparedAt = stored?.history.find((event) => event.preview?.request.attendanceTakenAt)?.preview?.request.attendanceTakenAt;
3182
+ if (hasFlag(parsed, "confirm") && typeof preparedAt !== "string") {
3183
+ throw new CliError("confirmation_required", "The saved attendance preview is unavailable. Request a new preview before confirming.");
3184
+ }
3185
+ const body = {
3186
+ attendanceTakenAt: typeof preparedAt === "string" ? preparedAt : new Date().toISOString(),
3187
+ attendance,
3188
+ };
3163
3189
  return writeCommand(parsed, {
3164
3190
  action: "take attendance (non-excused absentees may trigger automatic 'we missed you' emails to parents)",
3165
3191
  target: { eventId },
@@ -4048,9 +4074,21 @@ export async function executeRecessCommand(argv, options = {}) {
4048
4074
  }
4049
4075
  if (verb === "list") {
4050
4076
  const userId = flagString(parsed, "student", { required: true });
4051
- return unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
4077
+ const result = unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
4052
4078
  params: { path: { userId } },
4053
4079
  }));
4080
+ const query = flagString(parsed, "query")?.toLocaleLowerCase();
4081
+ return {
4082
+ ...result,
4083
+ goals: result.goals.filter((goal) => (goal.status === "ACTIVE" ||
4084
+ goal.status === "PAUSED" ||
4085
+ (hasFlag(parsed, "include-archived") &&
4086
+ goal.status === "COMPLETED")) &&
4087
+ (!query ||
4088
+ `${goal.title}\n${goal.description ?? ""}`
4089
+ .toLocaleLowerCase()
4090
+ .includes(query))),
4091
+ };
4054
4092
  }
4055
4093
  if (verb === "create") {
4056
4094
  const requestedStudentId = flagString(parsed, "student");
@@ -22,6 +22,7 @@ const BOOLEAN_FLAGS = new Set([
22
22
  "immediate",
23
23
  "keep-tokens",
24
24
  "include-deleted",
25
+ "include-archived",
25
26
  "json",
26
27
  "mirrored",
27
28
  "no-collision",
@@ -180,6 +181,16 @@ const FAMILY_AI_COMMANDS = new Set([
180
181
  "todos edit",
181
182
  ]);
182
183
  const STAFF_COMMANDS = new Set([
184
+ "cohorts schedule get",
185
+ "cohorts schedule set",
186
+ "cohorts schedule changes",
187
+ "cohorts schedule change",
188
+ "memories files",
189
+ "memories read",
190
+ "memories edit",
191
+ "memories history",
192
+ "memories revision",
193
+ "memories restore",
183
194
  "apps assign",
184
195
  "apps list",
185
196
  "apps preview",
@@ -0,0 +1,183 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { unwrap } from "../api.js";
5
+ import { flagString, hasFlag } from "../args.js";
6
+ import { CliError } from "../errors.js";
7
+ import { appendJobEvent } from "../jobs.js";
8
+ import { assertChoice, positional } from "./shared.js";
9
+ const hash = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
10
+ function recoveryKey(ctx) {
11
+ if (!hasFlag(ctx.parsed, "confirm"))
12
+ return undefined;
13
+ const key = flagString(ctx.parsed, "operation-key");
14
+ const token = flagString(ctx.parsed, "approval-token");
15
+ if (key && (!token || !key.endsWith(`_${token}`)))
16
+ throw new CliError("approval_mismatch", "Recovery requires the original operation key and approval token.");
17
+ return key;
18
+ }
19
+ async function recovered(ctx, row, expectedInputHash) {
20
+ if (row.inputHash !== expectedInputHash)
21
+ throw new CliError("approval_mismatch", "This operation key belongs to different input. No operation was retried.");
22
+ await appendJobEvent({
23
+ jobId: row.operationKey,
24
+ status: row.outcome === "outcome_unknown" ? "unknown" : "completed",
25
+ timestamp: new Date().toISOString(),
26
+ action: "recover staff operation",
27
+ fingerprint: flagString(ctx.parsed, "approval-token"),
28
+ target: { apiOrigin: ctx.api.config.apiOrigin },
29
+ result: row,
30
+ });
31
+ return row;
32
+ }
33
+ export async function runScheduleCommand(ctx) {
34
+ const { api, parsed, writeCommand } = ctx;
35
+ const action = positional(parsed, 2, "schedule action");
36
+ const id = positional(parsed, 3, "cohort or change ID");
37
+ if (action === "change")
38
+ return unwrap(await api.client.GET("/admin/cohorts/schedule-changes/{changeId}", {
39
+ params: { path: { changeId: id } },
40
+ }));
41
+ const params = { path: { id } };
42
+ if (action === "get")
43
+ return unwrap(await api.client.GET("/admin/cohorts/{id}/schedule/", { params }));
44
+ if (action === "changes")
45
+ return unwrap(await api.client.GET("/admin/cohorts/{id}/schedule/changes", {
46
+ params: { ...params, query: { cursor: flagString(parsed, "cursor") } },
47
+ }));
48
+ if (action !== "set")
49
+ throw new CliError("invalid_arguments", "Use cohorts schedule get|set|changes|change.");
50
+ const input = {
51
+ days: flagString(parsed, "days", { required: true })
52
+ .split(",")
53
+ .map((d) => assertChoice(d.trim().toUpperCase(), ["MO", "TU", "WE", "TH", "FR", "SA", "SU"], "weekday")),
54
+ time: flagString(parsed, "time", { required: true }),
55
+ timezone: flagString(parsed, "timezone"),
56
+ effectiveDate: flagString(parsed, "effective-date", { required: true }),
57
+ notify: assertChoice(flagString(parsed, "notify", { required: true }), ["none", "parents"], "notify"),
58
+ reason: flagString(parsed, "reason", { required: true }).trim(),
59
+ };
60
+ const key = recoveryKey(ctx);
61
+ if (key) {
62
+ const history = unwrap(await api.client.GET("/admin/cohorts/{id}/schedule/changes", {
63
+ params: { ...params, query: { operationKey: key } },
64
+ }));
65
+ if (history.changes[0])
66
+ return recovered(ctx, history.changes[0], hash({ id, input }));
67
+ }
68
+ const capability = unwrap(await api.client.GET("/admin/cohorts/{id}/schedule/", { params }));
69
+ if (capability.capabilityVersion !== 1 || !capability.supported)
70
+ throw new CliError("unsupported_backend", capability.unsupportedReason ??
71
+ "Backend does not support safe schedule changes.");
72
+ const preview = unwrap(await api.client.POST("/admin/cohorts/{id}/schedule/preview", {
73
+ params,
74
+ body: input,
75
+ }));
76
+ return writeCommand(parsed, {
77
+ action: "change recurring cohort schedule",
78
+ target: { cohortId: id, apiOrigin: api.config.apiOrigin },
79
+ request: input,
80
+ details: preview,
81
+ }, async () => {
82
+ const result = unwrap(await api.client.POST("/admin/cohorts/{id}/schedule/apply", {
83
+ params,
84
+ body: {
85
+ ...input,
86
+ operationKey: flagString(parsed, "operation-key", {
87
+ required: true,
88
+ }),
89
+ fingerprint: preview.fingerprint,
90
+ },
91
+ }));
92
+ return unwrap(await api.client.GET("/admin/cohorts/schedule-changes/{changeId}", {
93
+ params: { path: { changeId: result.id } },
94
+ }));
95
+ });
96
+ }
97
+ export async function runMemoryFilesCommand(ctx) {
98
+ const { parsed, api, writeCommand } = ctx;
99
+ const action = positional(parsed, 1, "memory action");
100
+ const studentId = flagString(parsed, "student", { required: true });
101
+ const params = { path: { studentId } };
102
+ if (action === "files")
103
+ return unwrap(await api.client.GET("/tutor/students/{studentId}/memory-files", {
104
+ params,
105
+ }));
106
+ const memoryPath = flagString(parsed, "path", { required: true });
107
+ if (action === "read")
108
+ return unwrap(await api.client.GET("/tutor/students/{studentId}/memory-files/read", {
109
+ params: { ...params, query: { path: memoryPath } },
110
+ }));
111
+ if (action === "history")
112
+ return unwrap(await api.client.GET("/tutor/students/{studentId}/memory-files/history", {
113
+ params: {
114
+ ...params,
115
+ query: { path: memoryPath, cursor: flagString(parsed, "cursor") },
116
+ },
117
+ }));
118
+ if (action === "revision")
119
+ return unwrap(await api.client.GET("/tutor/students/{studentId}/memory-files/revision", {
120
+ params: {
121
+ ...params,
122
+ query: {
123
+ path: memoryPath,
124
+ revision: flagString(parsed, "revision", { required: true }),
125
+ },
126
+ },
127
+ }));
128
+ if (action !== "edit" && action !== "restore")
129
+ throw new CliError("invalid_arguments", "Use memories files|read|edit|history|revision|restore.");
130
+ let content;
131
+ let contentFile;
132
+ if (action === "edit") {
133
+ contentFile = path.resolve(flagString(parsed, "content-file", { required: true }));
134
+ const info = await fs.lstat(contentFile);
135
+ if (!info.isFile() ||
136
+ info.isSymbolicLink() ||
137
+ (await fs.realpath(contentFile)) !== contentFile)
138
+ throw new CliError("invalid_file", "Memory input must be a regular file without symlink components.");
139
+ content = new TextDecoder("utf-8", { fatal: true }).decode(await fs.readFile(contentFile));
140
+ }
141
+ const input = {
142
+ path: memoryPath,
143
+ content,
144
+ revision: action === "restore"
145
+ ? flagString(parsed, "revision", { required: true })
146
+ : undefined,
147
+ action,
148
+ reason: flagString(parsed, "reason", { required: true }).trim(),
149
+ };
150
+ const key = recoveryKey(ctx);
151
+ if (key) {
152
+ const result = unwrap(await api.client.GET("/tutor/students/{studentId}/memory-files/operation", { params: { ...params, query: { operationKey: key } } }));
153
+ if (result.operation)
154
+ return recovered(ctx, result.operation, hash({ studentId, input }));
155
+ }
156
+ const preview = unwrap(await api.client.POST("/tutor/students/{studentId}/memory-files/preview", {
157
+ params,
158
+ body: input,
159
+ }));
160
+ if (preview.capabilityVersion !== 1 || preview.writeSupported !== true)
161
+ throw new CliError("unsupported_backend", preview.writeUnsupportedReason ??
162
+ "Backend does not support safe staff memory edits and restores.");
163
+ return writeCommand(parsed, {
164
+ action: `${action} student memory file`,
165
+ target: { studentId, path: memoryPath, apiOrigin: api.config.apiOrigin },
166
+ request: { ...input, contentFile },
167
+ details: {
168
+ ...preview,
169
+ before: { ...preview.before, revision: undefined },
170
+ },
171
+ }, async () => unwrap(await api.client.POST("/tutor/students/{studentId}/memory-files/apply", {
172
+ params,
173
+ body: {
174
+ ...input,
175
+ expectedHash: preview.before.contentSha,
176
+ fingerprint: preview.fingerprint,
177
+ operationKey: flagString(parsed, "operation-key", {
178
+ required: true,
179
+ }),
180
+ },
181
+ })));
182
+ }
183
+ //# sourceMappingURL=safe-staff-operations.js.map
package/dist/help.js CHANGED
@@ -143,6 +143,10 @@ Usage:
143
143
  [--description TEXT] [--date YYYY-MM-DD] [--confirm]
144
144
  recess [--json] payout items delete <item-id> [--confirm]
145
145
  recess [--json] cohorts get <cohort-id> [--events-tab ACTIVE|ENDED|CANCELED|ARCHIVED]
146
+ recess [--json] cohorts schedule get <cohort-id>
147
+ recess [--json] cohorts schedule set <cohort-id> --days <MO,WE> --time <09:30> [--timezone <zone>] --effective-date <YYYY-MM-DD> --notify none|parents [--confirm] [--approval-token <token>]
148
+ recess [--json] cohorts schedule changes <cohort-id> [--cursor <cursor>]
149
+ recess [--json] cohorts schedule change <change-id>
146
150
  recess [--json] cohorts parent-emails <cohort-id>
147
151
  recess [--json] cohorts end <cohort-id> [--cancel-subscriptions] [--confirm]
148
152
  recess [--json] cohorts pause-billing <cohort-id> --weeks 1..6 [--confirm]
@@ -360,7 +364,7 @@ Usage:
360
364
  [--dry-run] [--confirm --approval-token TOKEN]
361
365
  recess [--json] goal-templates apply-starter <template-id> --student <kid-id>
362
366
  [--answers-file <path>] [--confirm]
363
- recess [--json] goals list --student <kid-id>
367
+ recess [--json] goals list --student <kid-id> [--query <text>] [--include-archived]
364
368
  recess [--json] goals create [--student <kid-id>] --title TEXT
365
369
  (--description TEXT | --description-file <path>) [--target-date <iso>]
366
370
  [--schedule TEXT] [(--draft <draft-slug> | --source-dir <local-checkout>)
@@ -395,6 +399,14 @@ Usage:
395
399
  recess [--json] todos generate-learning-analysis <todo-id> [--confirm]
396
400
  recess [--json] todos generate-applet <todo-id> --student <kid-id>
397
401
  [--due-date YYYY-MM-DD] [--confirm --approval-token TOKEN]
402
+ recess [--json] memories files --student <kid-id>
403
+ recess [--json] memories read --student <kid-id> --path <path>
404
+ recess [--json] memories edit --student <kid-id> --path <path> --content-file <file> [--confirm] [--approval-token <token>]
405
+ recess [--json] memories history --student <kid-id> --path <path> [--cursor <cursor>]
406
+ recess [--json] memories revision --student <kid-id> --path <path> --revision <revision>
407
+ recess [--json] memories restore --student <kid-id> --path <path> --revision <revision> [--confirm] [--approval-token <token>]
408
+ Staff memory edit/restore publication is disabled pending recoverable immutable history.
409
+ Reads, history, and recovery of existing operations remain available.
398
410
  recess [--json] memories context --student <kid-id>
399
411
  recess [--json] memories log --student <kid-id> --date YYYY-MM-DD
400
412
  recess [--json] rocky get --student <kid-id>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "recess-cli",
3
- "version": "2.10.0",
3
+ "version": "3.0.0",
4
4
  "description": "Safe Recess administration and family AI tools from the command line.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -72,18 +72,21 @@ Any changed target, payload, local file, server revision, amount, recipient, or
72
72
 
73
73
  ### Hosted MCP transport
74
74
 
75
- When this skill is reached through the hosted Recess connector, use its only tool,
76
- `recess_exec({argv,input?})`. `argv` starts with the command noun, omits both `recess` and `--json`,
77
- and still carries `--reason`. The CLI envelope is returned in MCP `structuredContent`; a
78
- `confirmation_required` envelope is expected control flow, while other failures are tool errors.
79
- Start with `argv:["agent-context"]` or `argv:["help","apps"]` for the remote-only catalog. OAuth
80
- already supplies the live user and scopes: never ask for cookies, profiles, local paths, or
81
- `--deliver`.
82
-
83
- Hosted writes stage the exact argv and structured payload on the server. Preview without
84
- `--confirm`, show the human `error.details.preview`, then confirm with the same command path plus
85
- `--confirm --operation-key <key>` and **omit `input`**. The server executes the stored bytes; changed
86
- source, flags, project/version, or operation keys require a fresh preview.
75
+ Use the connector's individually named tools and their discovered argument schemas, for example
76
+ `recess_students_todos({student:"…",reason:"…"})`. Discovery replaces CLI help. OAuth supplies the
77
+ live user and consented scopes; never ask for cookies, local paths or profiles.
78
+
79
+ Preview writes with named arguments and `reason`, show `error.details.preview`, then confirm
80
+ through the same tool with **only** `{confirm:true,operationKey:"…"}`. Stored arguments and source
81
+ bytes are restored server-side. Changed input or consequences require a new preview. Retain the
82
+ key on interruption and inspect `recess_jobs_get`; never start a replacement for an uncertain
83
+ write. `confirmation_required` is normal control flow in the returned `structuredContent`.
84
+
85
+ Supply JSON/text directly, workspaces as file bundles with `baseRevision`, and binaries as
86
+ `{name,content,encoding}` or scoped `{uploadHandle}` references created by `recess_upload_create`.
87
+ Upload bytes to its signed URL(s); size/hash verification happens before use. Download returned
88
+ bundles/authorized URLs. `/mcp/legacy` temporarily retains `recess_exec({command,input?})` for
89
+ 30 days after rollout. Refresh connector metadata and consent for expanded capabilities.
87
90
 
88
91
  ## Building Studio apps (`recess apps`)
89
92
 
@@ -106,20 +109,16 @@ prompts a model for them.
106
109
  - `recess --json apps standards "<words or notation>"` finds the CCSS standard for `manifest.json`
107
110
  (`"grade 4 adding fractions"` → `4.NF.B.3a`…); guides rarely know the codes, look them up.
108
111
 
109
- Hosted authoring keeps those same contracts but carries no folder:
110
-
111
- - `apps scaffold [--for-todo <id>]` returns authoring instructions, the five editable starter
112
- files, fork guidance, and an optional learner brief. It performs no write.
113
- - Generate `input.appBundle` with exactly `skill.js`, `model.js`, `model.css`,
114
- `params.schema.json`, and `params.json`, plus `contract` and `manifest`. Preview and confirm
115
- `apps validate`; keep the returned project/version/build IDs and poll `apps status <build-id>`.
116
- - Fix findings in the bundle. For an existing project, first `apps pull <project-id>` and preserve
117
- its `baseVersion`; a stale version is a conflict, never an overwrite.
118
- - `apps preview <project-id> --version <n>` returns the short-lived capability URL.
119
- Preview/confirm `apps publish <project-id> --version <n>`, then poll its build. Publication sends
120
- no source and reviews exactly that validated version.
121
- - After publication, preview/confirm `apps assign <project-id> --student <id> [--due YYYY-MM-DD]`.
122
- Assignment is intentionally separate from hosted publish.
112
+ Hosted authoring keeps those same contracts with named tools:
113
+
114
+ - `recess_apps_scaffold` returns instructions, editable files, and an optional `forTodo` brief.
115
+ - Supply `appBundle` (files, contract, manifest) to `recess_apps_validate`; preview and confirm,
116
+ then keep its project/version/build IDs and poll `recess_apps_status`.
117
+ - Before editing an existing project, call `recess_apps_pull` and preserve its `baseVersion`.
118
+ - `recess_apps_preview` previews creation of a temporary capability URL. Preview and confirm
119
+ `recess_apps_publish` for the exact project/version; it reviews that validated version.
120
+ - Use `assign` and optional `due` on publication for recoverable publish-and-assign, or use
121
+ `recess_apps_assign` separately. A pending build retains the same operation key for retry.
123
122
 
124
123
  Two ways in. If the guide names a kid or a todo, pull the analysis first and build against it;
125
124
  otherwise build from their description:
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "2.6.0",
3
- "minCliVersion": "2.6.1"
2
+ "version": "3.0.0",
3
+ "minCliVersion": "3.0.0"
4
4
  }