recess-cli 1.9.2 → 2.1.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
@@ -1,6 +1,13 @@
1
1
  # Recess CLI
2
2
 
3
- `recess-cli` is the typed, agent-friendly command layer for Recess operations. ADMIN accounts receive the full staff surface; GUARDIAN accounts with `access:ai` receive family-scoped class schedules, progress, goals, todos, memories, Rocky configuration, learning research, GoalTemplate, and goal-content commands; GUIDE accounts receive that same student surface for the students they hold an ACTIVE tutor assignment to — not their wider class roster. It uses the web-server OpenAPI document, authenticates through Recess SSO, emits stable JSON, and refuses live writes until the exact command is rerun with `--confirm` after human approval.
3
+ `recess-cli` is the typed, agent-friendly command layer for Recess operations. ADMIN accounts
4
+ receive the full staff surface; GUARDIAN accounts with `access:ai` receive family-scoped class
5
+ schedules, progress, goals, todos, memories, Rocky configuration, learning research, GoalTemplate,
6
+ and goal-content commands; GUIDE accounts receive that same student surface for the students they
7
+ hold an ACTIVE tutor assignment to—not their wider class roster. KID accounts receive only
8
+ authenticated Village home building. It uses the web-server OpenAPI document, authenticates
9
+ through Recess SSO, emits stable JSON, and refuses live writes until the exact command is rerun with
10
+ `--confirm` plus the preview's operation key after human approval.
4
11
 
5
12
  ## Install (no checkout needed)
6
13
 
@@ -8,7 +15,7 @@ Published to npm as [`recess-cli`](https://www.npmjs.com/package/recess-cli). On
8
15
 
9
16
  ```bash
10
17
  npm install -g recess-cli
11
- recess setup
18
+ recess setup --reason "Install and update the Recess agent skill"
12
19
  ```
13
20
 
14
21
  `setup` installs the bundled skill in the Codex and Claude Code user directories, which Cursor also discovers for compatibility, and then opens Recess SSO in your browser (skip the browser step with `--skill-only`; it is also skipped when a live session already exists). Restart your agent afterwards so it discovers the skill. `npx -y recess-cli setup` works too, but leaves no `recess` on your PATH — which is the command the installed skill tells the agent to run — so `setup` warns when it detects it is running from an npx cache.
@@ -40,11 +47,11 @@ Create an approved `OAuthClient` row in each Recess environment. This remains a
40
47
 
41
48
  The production row ID (`c7e34138-18f9-45b1-a2fb-26a4e3a6d739`) is the CLI's built-in default, so production login needs no client-ID setup. `--client-id`, `RECESS_CLI_OAUTH_CLIENT_ID`, and a stored client ID remain overrides for local/staging clients. The web-server decodes the assertion audience, loads that exact `OAuthClient`, and requires both `approved` and `adminCliEnabled`; there is no separate server environment allowlist. Production redirect validation is exact, so a different callback port must also be explicitly registered.
42
49
 
43
- The browser SSO assertion is exchanged once and discarded. The CLI stores a separate 12-hour signed Recess session at `~/.recess-cli/config.json` with mode `0600`. A guardian must hold the live `access:ai` permission; removing it immediately invalidates session checks. Guardian sessions cannot call `/admin` routes and every target is independently restricted to their family.
50
+ The browser SSO assertion is exchanged once and discarded. The CLI stores a separate 12-hour signed Recess session at `~/.recess-cli/config.json` with mode `0600`. A guardian must hold the live `access:ai` permission; removing it immediately invalidates session checks. Guardian sessions cannot call `/admin` routes and every target is independently restricted to their family. A KID session has `village_home` scope: application-level and shared-auth fences permit only session inspection and Village assertion minting, denying every other backend route.
44
51
 
45
52
  ```bash
46
53
  recess --json auth login
47
- recess --json doctor
54
+ recess --json doctor --reason "Verify CLI connectivity, identity, and scope"
48
55
  ```
49
56
 
50
57
  ## Testing against a local server
@@ -54,7 +61,7 @@ recess --json doctor
54
61
  ```bash
55
62
  export RECESS_CLI_API_ORIGIN=http://localhost:5068
56
63
  export RECESS_CLI_COOKIE='recess.auth-token=<signed-value>'
57
- recess --json doctor
64
+ recess --json doctor --reason "Verify the local Recess API connection"
58
65
  ```
59
66
 
60
67
  To mint `<signed-value>`: sign `{sub, role, cliScope}` with the server's `JWT_SECRET` (audience = `CLIENT_ORIGIN`), then sign THAT string with `cookie.signerFactory(COOKIE_SECRET)` from `@fastify/cookie`. Two traps, both silent:
@@ -97,32 +104,42 @@ Error or write preview:
97
104
  "error": {
98
105
  "code": "confirmation_required",
99
106
  "message": "...",
100
- "details": { "preview": {}, "requiredFlag": "--confirm" }
107
+ "details": { "preview": { "details": { "operationKey": "..." } } }
101
108
  }
102
109
  }
103
110
  ```
104
111
 
105
112
  Exit code `0` means success, `1` means an input/auth/API failure, and `2` means a write is awaiting explicit human confirmation.
106
113
 
114
+ `recess --json agent-context` returns the canonical command/flag/positional schema. `recess --json help payout recipients` returns scoped help. Unknown flags, duplicate non-repeatable flags, missing values, and extra positionals are errors instead of being silently ignored.
115
+
116
+ Every command-driven request to the Recess API except `auth` requires `--reason "..."`: a
117
+ non-empty, human-readable purpose of at most 1024 characters. The CLI sends it as
118
+ `x-recess-reason`; the server rejects missing reasons before route execution and stores one audit
119
+ row per request. The human-only `recess ui` console is explicitly exempt and identifies itself as
120
+ `x-recess-client: cli-ui` instead.
121
+
107
122
  ## Common flow
108
123
 
109
124
  ```bash
110
- recess --json users search "Morgan Rivera"
111
- recess --json users tier get <kid-id>
112
- recess --json users tier preview <kid-id> --tier lite --slots 1
113
- recess --json students upload-map-scores --student <kid-id> --file /path/to/map-report.pdf
114
- recess --json enrollments list --user <kid-id>
115
- recess --json subscriptions list --family <family-id> --kid <kid-id>
116
- recess --json invoices list --subscription <subscription-id>
125
+ recess --json users search "Morgan Rivera" --reason "Find the exact student record"
126
+ recess --json users tier get <kid-id> --reason "Inspect the student's current tier"
127
+ recess --json users tier preview <kid-id> --tier lite --slots 1 --reason "Preview a tier change"
128
+ recess --json students upload-map-scores --student <kid-id> --file /path/to/map-report.pdf --reason "Import this student's MAP scores"
129
+ recess --json enrollments list --user <kid-id> --reason "Inspect the student's enrollments"
130
+ recess --json subscriptions list --family <family-id> --kid <kid-id> --reason "Inspect family subscriptions"
131
+ recess --json invoices list --subscription <subscription-id> --reason "Inspect subscription invoices"
117
132
  ```
118
133
 
119
134
  Preview a write by omitting `--confirm`:
120
135
 
121
136
  ```bash
122
- recess --json billing pause --subscription <subscription-id> --until 2026-09-01
137
+ recess --json billing pause --subscription <subscription-id> --until 2026-09-01 --reason "Pause billing through September 1"
123
138
  ```
124
139
 
125
- After a human approves that exact preview, rerun the unchanged command with `--confirm`. File-backed family edits, deterministic template apply, and goal-content writes also return `details.approvalToken`; echo it with `--approval-token TOKEN`. The token covers the local bytes plus the server preflight/CAS state, so a changed file or goal produces a new preview instead of consuming stale approval.
140
+ After a human approves that exact preview, rerun the unchanged command with `--confirm --operation-key <operationKey>`. File-backed family edits, deterministic template apply, and goal-content writes also return `details.approvalToken`; echo it with `--approval-token TOKEN`. The token covers the local bytes plus the server preflight/CAS state, so a changed file or goal produces a new preview instead of consuming stale approval. Confirmed requests are fenced server-side per actor, operation, route/body, and preview fingerprint; retry an interrupted command with the same operation key to replay a completed response rather than duplicate the write. `jobs list|get` reads the protected local recovery ledger.
141
+
142
+ Named `profile save|use|list` configurations keep environment selection explicit, with global `--profile` as a one-command override. `--deliver file:<path>` atomically writes the JSON envelope with mode 0600; webhook delivery is intentionally unavailable for authenticated Recess data. `feedback submit` stores deduplicated CLI friction locally and can forward it to `RECESS_CLI_FEEDBACK_ENDPOINT`.
126
143
 
127
144
  School tier writes require the `updatedAt` token from `users tier get`. Their read-only preflight
128
145
  shows `capabilitiesLockedNow`/`capabilitiesLockedAfter`, the resolved class allowance, current
@@ -137,34 +154,49 @@ Village store goods are main-Recess `StoreItem` rows, not Village-island databas
137
154
  and preview an availability change with:
138
155
 
139
156
  ```bash
140
- recess --json store-items list --item-type VILLAGE_ITEM
141
- recess --json store-items set-status <store-item-id> --status INACTIVE
157
+ recess --json store-items list --item-type VILLAGE_ITEM --reason "Inspect the Village item catalog"
158
+ recess --json store-items set-status <store-item-id> --status INACTIVE --reason "Deactivate this Village item"
142
159
  ```
143
160
 
144
161
  The status command resolves the exact `VILLAGE_ITEM` first and includes its name, catalog metadata,
145
162
  current status, purchase count, and lack of feed-publication side effects in the approval preview;
146
- it refuses non-Village item IDs. After approval, rerun the unchanged command with `--confirm`. The
163
+ it refuses non-Village item IDs. After approval, rerun the unchanged command with `--confirm` and
164
+ the preview's `--operation-key`. The
147
165
  separate `village models` commands edit the Village island's reusable models and placements.
166
+ `village build cmd` sends one confirmed command through Village's ordinary authenticated command
167
+ dispatcher. `village library search|get`, `village objects list|get`, and `village render` use the
168
+ same scoped Village identity and are own-home-only outside ADMIN. `village worlds
169
+ export|import|promote` moves or promotes data-built worlds through the fixed admin bridge.
148
170
 
149
171
  ## Authoring learning content
150
172
 
151
173
  ```bash
152
- recess --json skills guardian get recess-goal-authoring --all-references
153
- recess --json content-library search "fractions through visual puzzles" --limit 8
154
- recess --json content-library status <gem-id-or-url>
155
- recess --json content-library set-stage <gem-id-or-url...> --stage archived
156
- recess --json goal-templates validate-spec --file ./template.json # iterate; writes nothing
157
- recess --json goal-templates create --file ./template.json # preview, exit 2
158
- recess --json goal-templates create --file ./template.json --confirm
159
- recess --json goal-templates patch-spec <id-or-slug> --expected-version 7 --patches-file ./patches.json
160
- recess --json goals files write --student <kid-id> --draft <draft-slug> --source-dir ./goal-content
161
- recess --json goals pdf upload --student <kid-id> --draft <draft-slug> --source-file ./textbook.pdf
162
- recess --json goal-templates capture-snapshot <id-or-slug> --source-draft <draft-slug> --student <kid-id> --dry-run
163
- recess --json goal-templates apply <id-or-slug> --answers-file ./answers.json --dry-run
164
- recess --json goals create --student <kid-id> --title "..." --description-file ./goal.md
165
- recess --json goals create --student <kid-id> --draft <draft-slug> --title "..." \
166
- --description-file ./goal.md --enable-applet-follow-ups
167
- recess --json goals files list --student <kid-id> --goal <goal-id>
174
+ recess --json skills guardian get recess-goal-authoring --all-references --reason "Load goal authoring guidance"
175
+ recess --json content-library search "fractions through visual puzzles" --limit 8 --reason "Find visual fraction resources"
176
+ recess --json content-library status <gem-id-or-url> --reason "Inspect this gem's pipeline status"
177
+ recess --json content-library set-stage <gem-id-or-url...> --stage archived --reason "Archive these gems"
178
+ recess --json goal-templates validate-spec --file ./template.json --reason "Validate this template draft"
179
+ recess --json goal-templates create --file ./template.json --reason "Create this reusable goal template"
180
+ recess --json goal-templates create --file ./template.json --confirm \
181
+ --operation-key <preview-operation-key> --reason "Create this reusable goal template"
182
+ recess --json goal-templates patch-spec <id-or-slug> --expected-version 7 --patches-file ./patches.json --reason "Update this template specification"
183
+ recess --json goals files init --student <kid-id> --draft <draft-slug> \
184
+ --output-dir ./goal-content --reason "Start this goal draft"
185
+ git -C ./goal-content add -A
186
+ git -C ./goal-content commit -m "Author the learning path"
187
+ recess --json goals files push --source-dir ./goal-content --reason "Publish this goal draft"
188
+ recess --json goals pdf upload --student <kid-id> --draft <draft-slug> --source-file ./textbook.pdf --reason "Attach this textbook to the goal draft"
189
+ recess --json goal-templates capture-snapshot <id-or-slug> --source-dir ./goal-content --dry-run --reason "Preview a template snapshot"
190
+ recess --json goal-templates apply <id-or-slug> --answers-file ./answers.json --dry-run --reason "Preview applying this template"
191
+ recess --json goals create --student <kid-id> --title "..." --description-file ./goal.md --reason "Create this student goal"
192
+ recess --json goals create --source-dir ./goal-content --title "..." \
193
+ --description-file ./goal.md --enable-applet-follow-ups --reason "Create this student goal draft"
194
+ recess --json goals files list --student <kid-id> --goal <goal-id> --reason "Inspect the goal workspace files"
195
+ recess --json goals files checkout --student <kid-id> --goal <goal-id> \
196
+ --output-dir ./goal-content --reason "Check out this goal for editing"
197
+ git -C ./goal-content add -A
198
+ git -C ./goal-content commit -m "Revise module 3"
199
+ recess --json goals files push --source-dir ./goal-content --reason "Publish the module revision"
168
200
  ```
169
201
 
170
202
  ADMIN discovery batches use the same Content Library admission door as the dashboard. Omit
@@ -173,9 +205,10 @@ Review preselected; JSON/non-interactive runs safely default to Review. Use `--s
173
205
  start automatic decoration immediately:
174
206
 
175
207
  ```bash
176
- recess --json content-library submit https://example.org/activity --stage review
177
- recess --json content-library submit --file ./gems.json
178
- recess --json content-library submit --file ./urls.txt --stage polish --confirm
208
+ recess --json content-library submit https://example.org/activity --stage review --reason "Submit this activity for review"
209
+ recess --json content-library submit --file ./gems.json --reason "Submit this resource batch for review"
210
+ recess --json content-library submit --file ./urls.txt --stage polish --confirm \
211
+ --operation-key <preview-operation-key> --reason "Submit this batch for polishing"
179
212
  ```
180
213
 
181
214
  JSON files are arrays of URL strings or `{ "url", "title"?, "summary"?, "lane"? }` objects;
@@ -186,21 +219,24 @@ reports its Review/Polishing/Live/Archived stage plus metadata, cover, and searc
186
219
  `content-library set-stage` accepts one or many IDs/URLs (or a newline/JSON-string-array `--file`),
187
220
  previews every resolved current stage, and requires `--confirm`. It uses the same lifecycle as
188
221
  Manage: direct-to-Live routes unfinished gems through Polishing, and moving out of Polishing
189
- cancels that exact run first.
222
+ cancels that exact run first. Add `--wait --timeout 900` when promoting to Live (or submitting to
223
+ polish) to poll the durable island status with bounded concurrency and exponential backoff.
190
224
 
191
225
  ## Family AI operations
192
226
 
193
227
  ```bash
194
- recess --json students list
195
- recess --json students today --student <kid-id>
196
- recess --json students schedule --student <kid-id> --days 30
197
- recess --json students xp-history --student <kid-id> --range month
198
- recess --json goals list --student <kid-id>
199
- recess --json todos create --student <kid-id> --title "Read chapter 4"
200
- recess --json todos generate-applet <todo-id> --student <kid-id> [--due-date YYYY-MM-DD]
201
- recess --json memories context --student <kid-id>
202
- recess --json memories log --student <kid-id> --date 2026-08-12
203
- recess --json rocky get --student <kid-id>
228
+ recess --json students list --reason "List the students I can support"
229
+ recess --json students today --student <kid-id> --reason "Review today's learning plan"
230
+ recess --json students schedule --student <kid-id> --days 30 --reason "Review the student's upcoming schedule"
231
+ recess --json students xp-history --student <kid-id> --range month --reason "Review recent XP history"
232
+ recess --json goals list --student <kid-id> --reason "Review the student's goals"
233
+ recess --json todos create --student <kid-id> --title "Read chapter 4" --reason "Add the assigned reading"
234
+ recess --json goals delete <goal-id> --student <kid-id> --reason "Remove this obsolete goal"
235
+ recess --json todos delete <todo-id> --reason "Remove this disposable todo"
236
+ recess --json todos generate-applet <todo-id> --student <kid-id> --reason "Generate this todo's applet"
237
+ recess --json memories context --student <kid-id> --reason "Review durable tutor context"
238
+ recess --json memories log --student <kid-id> --date 2026-08-12 --reason "Review the learning log for this date"
239
+ recess --json rocky get --student <kid-id> --reason "Inspect the student's Rocky configuration"
204
240
  ```
205
241
 
206
242
  All family writes still preview first. Goal/todo/Rocky edits also carry the current server version
@@ -216,8 +252,8 @@ not expose Postgres `UserMemory`.
216
252
 
217
253
  ```bash
218
254
  recess --json --version # {cliVersion, skillVersion}
219
- recess --json doctor # .skill reports whether a newer bundle exists
220
- recess --json setup --skill-only # bundled copy, then the served upgrade
255
+ recess --json doctor --reason "Check CLI health" # .skill reports whether a newer bundle exists
256
+ recess --json setup --skill-only --reason "Update the installed Recess skill"
221
257
  ```
222
258
 
223
259
  The CLI's small shared router skill is **both** bundled in this package and served from
@@ -243,11 +279,13 @@ previews the per-student outcome. `set-metadata` and `delete` require `--expecte
243
279
  it always runs the backend's guarded preview first and requires the preview's exact loss token in
244
280
  addition to `--confirm` when protected template data would be removed.
245
281
 
246
- Module-backed content is authored as a local workspace tree and batch-upserted to a named goal
247
- draft. For a personal one-off goal, `goals create --draft` validates and materializes that draft
248
- directly. For reusable content, attach it to a BLUEPRINT with `capture-snapshot`. Every mutation
249
- runs a server preview before the confirmation gate and is compare-and-set against the previewed
250
- revision. Direct live-goal `modules/` and `state/` writes remain blocked because those files have
251
- database projections.
282
+ Module-backed content starts with `goals files init --draft`, which creates a normal local Git
283
+ repository pinned to the student's Mesa revision. Edit, rename, delete, inspect, and commit with
284
+ ordinary Git, then use `goals files push`. The same checkout can become a personal goal through
285
+ `goals create --source-dir` or a reusable BLUEPRINT snapshot through
286
+ `goal-templates capture-snapshot --source-dir`; successful goal creation retargets its metadata to
287
+ the new live goal for continued pushes. Existing live goals use `checkout --goal`. Every push
288
+ previews the committed range as one Mesa change and refuses a stale remote tip. The direct
289
+ `goals files write` upsert remains available for small or automated writes.
252
290
 
253
291
  See `recess --help` for the complete command surface. The raw escape hatch is intentionally read-only: `recess --json request get /path`.
package/dist/api.js CHANGED
@@ -1,16 +1,43 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  import createClient from "openapi-fetch";
2
3
  import { apiError, CliError } from "./errors.js";
4
+ import { cliRequestHeaders, markCliRequest, RECESS_CLIENT_CLI, } from "./http.js";
5
+ const idempotencyContext = new AsyncLocalStorage();
6
+ export function withIdempotencyContext(context, execute) {
7
+ return idempotencyContext.run(context, execute);
8
+ }
9
+ function applyIdempotencyHeaders(headers) {
10
+ const context = idempotencyContext.getStore();
11
+ if (!context)
12
+ return;
13
+ headers.set("x-recess-operation-key", context.operationKey);
14
+ headers.set("x-recess-operation-fingerprint", context.fingerprint);
15
+ }
3
16
  export class RecessAdminApi {
4
17
  config;
18
+ reason;
19
+ clientTag;
5
20
  client;
6
- constructor(config) {
21
+ villageSessionPromise;
22
+ constructor(config, reason, clientTag = RECESS_CLIENT_CLI) {
7
23
  this.config = config;
24
+ this.reason = reason;
25
+ this.clientTag = clientTag;
8
26
  this.client = createClient({
9
27
  baseUrl: config.apiOrigin,
10
28
  headers: config.sessionCookie
11
29
  ? { cookie: config.sessionCookie }
12
30
  : undefined,
13
31
  });
32
+ const requestReason = this.reason;
33
+ const requestClientTag = this.clientTag;
34
+ this.client.use({
35
+ onRequest({ request }) {
36
+ markCliRequest(request.headers, requestReason, requestClientTag);
37
+ applyIdempotencyHeaders(request.headers);
38
+ return request;
39
+ },
40
+ });
14
41
  }
15
42
  requireAuth() {
16
43
  if (!this.config.sessionCookie) {
@@ -23,7 +50,7 @@ export class RecessAdminApi {
23
50
  throw new CliError("invalid_arguments", "Raw request paths must start with one '/'.");
24
51
  }
25
52
  const response = await fetch(new URL(path, this.config.apiOrigin), {
26
- headers: { cookie: this.config.sessionCookie },
53
+ headers: cliRequestHeaders({ cookie: this.config.sessionCookie }, this.reason, this.clientTag),
27
54
  });
28
55
  const text = await response.text();
29
56
  let body = text;
@@ -39,14 +66,16 @@ export class RecessAdminApi {
39
66
  }
40
67
  async villageRequest(path, options = {}) {
41
68
  this.requireAuth();
69
+ const headers = cliRequestHeaders({
70
+ cookie: this.config.sessionCookie,
71
+ ...(options.body === undefined
72
+ ? {}
73
+ : { "content-type": "application/json" }),
74
+ }, this.reason, this.clientTag);
75
+ applyIdempotencyHeaders(headers);
42
76
  const response = await fetch(new URL(path, this.config.apiOrigin), {
43
77
  method: options.method ?? "GET",
44
- headers: {
45
- cookie: this.config.sessionCookie,
46
- ...(options.body === undefined
47
- ? {}
48
- : { "content-type": "application/json" }),
49
- },
78
+ headers,
50
79
  ...(options.body === undefined
51
80
  ? {}
52
81
  : { body: JSON.stringify(options.body) }),
@@ -63,6 +92,28 @@ export class RecessAdminApi {
63
92
  throw apiError(response.status, body);
64
93
  return body;
65
94
  }
95
+ async villageCommand(worldId, command) {
96
+ const result = await this.villageScopedRequest("/api/cli/command", {
97
+ method: "POST",
98
+ body: { worldId, command },
99
+ });
100
+ if (result &&
101
+ typeof result === "object" &&
102
+ "ok" in result &&
103
+ result.ok === false) {
104
+ const reason = "reason" in result && typeof result.reason === "string"
105
+ ? result.reason
106
+ : "Village rejected the command.";
107
+ throw new CliError("village_command_rejected", reason, 1, result);
108
+ }
109
+ return result;
110
+ }
111
+ async villageRead(path) {
112
+ if (!path.startsWith("/api/cli/") || path.startsWith("//")) {
113
+ throw new CliError("invalid_arguments", "Village read paths must stay under /api/cli/.");
114
+ }
115
+ return this.villageScopedRequest(path);
116
+ }
66
117
  async uploadVillageModel(worldId, file, fileName, metadata) {
67
118
  this.requireAuth();
68
119
  const form = new FormData();
@@ -72,9 +123,11 @@ export class RecessAdminApi {
72
123
  }
73
124
  const arrayBuffer = file.buffer.slice(file.byteOffset, file.byteOffset + file.byteLength);
74
125
  form.set("file", new Blob([arrayBuffer], { type: "model/gltf-binary" }), fileName);
126
+ const headers = cliRequestHeaders({ cookie: this.config.sessionCookie }, this.reason, this.clientTag);
127
+ applyIdempotencyHeaders(headers);
75
128
  const response = await fetch(new URL(`/admin/village/models/upload?worldId=${encodeURIComponent(worldId)}`, this.config.apiOrigin), {
76
129
  method: "POST",
77
- headers: { cookie: this.config.sessionCookie },
130
+ headers,
78
131
  body: form,
79
132
  });
80
133
  const text = await response.text();
@@ -93,9 +146,11 @@ export class RecessAdminApi {
93
146
  this.requireAuth();
94
147
  const formData = new FormData();
95
148
  formData.append("file", new Blob([pdf], { type: "application/pdf" }), fileName);
149
+ const headers = cliRequestHeaders({ cookie: this.config.sessionCookie }, this.reason, this.clientTag);
150
+ applyIdempotencyHeaders(headers);
96
151
  const response = await fetch(new URL(`/tutor/students/${encodeURIComponent(studentId)}/map-test-scores/upload`, this.config.apiOrigin), {
97
152
  method: "POST",
98
- headers: { cookie: this.config.sessionCookie },
153
+ headers,
99
154
  body: formData,
100
155
  });
101
156
  const text = await response.text();
@@ -110,6 +165,63 @@ export class RecessAdminApi {
110
165
  throw apiError(response.status, body);
111
166
  return body;
112
167
  }
168
+ villageSession() {
169
+ this.requireAuth();
170
+ this.villageSessionPromise ??= (async () => {
171
+ const assertionResult = await this.client.POST("/auth/admin-cli/village-assertion/");
172
+ const signed = unwrap(assertionResult);
173
+ const exchangeResponse = await fetch(new URL("/api/auth/cli/exchange", signed.villageOrigin), {
174
+ method: "POST",
175
+ headers: { "content-type": "application/json" },
176
+ body: JSON.stringify({ assertion: signed.assertion }),
177
+ });
178
+ const exchangeBody = await readResponseBody(exchangeResponse);
179
+ if (!exchangeResponse.ok) {
180
+ throw apiError(exchangeResponse.status, exchangeBody);
181
+ }
182
+ const token = exchangeBody &&
183
+ typeof exchangeBody === "object" &&
184
+ "token" in exchangeBody &&
185
+ typeof exchangeBody.token === "string"
186
+ ? exchangeBody.token
187
+ : null;
188
+ if (!token) {
189
+ throw new CliError("invalid_response", "Village assertion exchange returned no scoped token.");
190
+ }
191
+ return { villageOrigin: signed.villageOrigin, token };
192
+ })();
193
+ return this.villageSessionPromise;
194
+ }
195
+ async villageScopedRequest(path, options = {}) {
196
+ const { villageOrigin, token } = await this.villageSession();
197
+ const response = await fetch(new URL(path, villageOrigin), {
198
+ method: options.method ?? "GET",
199
+ headers: {
200
+ authorization: `Bearer ${token}`,
201
+ ...(options.body === undefined
202
+ ? {}
203
+ : { "content-type": "application/json" }),
204
+ },
205
+ ...(options.body === undefined
206
+ ? {}
207
+ : { body: JSON.stringify(options.body) }),
208
+ });
209
+ const body = await readResponseBody(response);
210
+ if (!response.ok)
211
+ throw apiError(response.status, body);
212
+ return body;
213
+ }
214
+ }
215
+ async function readResponseBody(response) {
216
+ const text = await response.text();
217
+ if (!text)
218
+ return null;
219
+ try {
220
+ return JSON.parse(text);
221
+ }
222
+ catch {
223
+ return text;
224
+ }
113
225
  }
114
226
  export function unwrap(result) {
115
227
  if (!result.response.ok || result.data === undefined) {
package/dist/args.js CHANGED
@@ -10,6 +10,7 @@ const BOOLEAN_FLAGS = new Set([
10
10
  "allow-strand",
11
11
  "cancel-subscriptions",
12
12
  "archived",
13
+ "confirm-destructive-changes",
13
14
  "confirm",
14
15
  "dry-run",
15
16
  "disable-applet-follow-ups",
@@ -19,20 +20,28 @@ const BOOLEAN_FLAGS = new Set([
19
20
  "immediate",
20
21
  "include-deleted",
21
22
  "mirrored",
23
+ "no-invite",
22
24
  "no-collision",
23
25
  "json",
24
26
  "refresh",
25
27
  "restore",
26
28
  "revoke",
27
29
  "send-email",
30
+ "skill-only",
28
31
  "spec-only",
29
32
  "starter-only",
30
33
  "visual-only",
34
+ "version",
35
+ "wait",
31
36
  ]);
32
37
  export function parseArgs(args) {
33
38
  const positionals = [];
34
39
  const flags = new Map();
35
40
  const repeated = new Map();
41
+ const occurrences = new Map();
42
+ const markSeen = (name) => {
43
+ occurrences.set(name, (occurrences.get(name) ?? 0) + 1);
44
+ };
36
45
  const record = (name, value) => {
37
46
  if (!REPEATABLE_FLAGS.has(name))
38
47
  return;
@@ -52,11 +61,13 @@ export function parseArgs(args) {
52
61
  if (equalsAt > 2) {
53
62
  const name = value.slice(2, equalsAt);
54
63
  const flagValue = value.slice(equalsAt + 1);
64
+ markSeen(name);
55
65
  flags.set(name, flagValue);
56
66
  record(name, flagValue);
57
67
  continue;
58
68
  }
59
69
  const name = value.slice(2);
70
+ markSeen(name);
60
71
  if (BOOLEAN_FLAGS.has(name)) {
61
72
  flags.set(name, true);
62
73
  continue;
@@ -71,7 +82,7 @@ export function parseArgs(args) {
71
82
  flags.set(name, true);
72
83
  }
73
84
  }
74
- return { positionals, flags, repeated };
85
+ return { positionals, flags, repeated, occurrences };
75
86
  }
76
87
  /**
77
88
  * Every value given for a repeatable flag, in order.
package/dist/auth.js CHANGED
@@ -3,6 +3,7 @@ import { createServer } from "node:http";
3
3
  import { randomBytes } from "node:crypto";
4
4
  import { clearPendingDeviceAuth, updateStoredConfig } from "./config.js";
5
5
  import { apiError, CliError } from "./errors.js";
6
+ import { cliRequestHeaders } from "./http.js";
6
7
  function openBrowser(url) {
7
8
  // Windows goes through rundll32, not `cmd /c start`: cmd treats `&` (and the
8
9
  // `%`-encoded redirect_uri) as metacharacters and chops the URL at the first
@@ -76,7 +77,7 @@ export async function login(config, options) {
76
77
  const assertion = await callback;
77
78
  const response = await fetch(new URL("/auth/admin-cli/exchange/", config.apiOrigin), {
78
79
  method: "POST",
79
- headers: { authorization: `Bearer ${assertion}` },
80
+ headers: cliRequestHeaders({ authorization: `Bearer ${assertion}` }),
80
81
  });
81
82
  const body = (await response.json());
82
83
  if (!response.ok)
@@ -93,7 +94,7 @@ export async function login(config, options) {
93
94
  sessionCookie: pair,
94
95
  sessionExpiresAt: exchange.expiresAt,
95
96
  user: exchange.user,
96
- });
97
+ }, config.profileName);
97
98
  return exchange;
98
99
  }
99
100
  // Start the headless (device-authorization) sign-in. Non-blocking: it stores the secret
@@ -102,7 +103,7 @@ export async function login(config, options) {
102
103
  export async function requestDeviceAuth(config, options) {
103
104
  const response = await fetch(new URL("/auth/admin-cli/device/authorize/", config.apiOrigin), {
104
105
  method: "POST",
105
- headers: { "content-type": "application/json" },
106
+ headers: cliRequestHeaders({ "content-type": "application/json" }),
106
107
  body: JSON.stringify(options.label ? { label: options.label } : {}),
107
108
  });
108
109
  const body = (await response.json());
@@ -116,7 +117,7 @@ export async function requestDeviceAuth(config, options) {
116
117
  pendingUserCode: authorize.userCode,
117
118
  pendingApprovalUrl: authorize.approvalUrl,
118
119
  pendingExpiresAt: authorize.expiresAt,
119
- });
120
+ }, config.profileName);
120
121
  return {
121
122
  approvalUrl: authorize.approvalUrl,
122
123
  userCode: authorize.userCode,
@@ -142,12 +143,12 @@ export async function pollDeviceAuth(config, options) {
142
143
  for (;;) {
143
144
  const response = await fetch(new URL("/auth/admin-cli/device/token/", config.apiOrigin), {
144
145
  method: "POST",
145
- headers: { "content-type": "application/json" },
146
+ headers: cliRequestHeaders({ "content-type": "application/json" }),
146
147
  body: JSON.stringify({ deviceCode }),
147
148
  });
148
149
  const body = (await response.json());
149
150
  if (!response.ok) {
150
- await clearPendingDeviceAuth();
151
+ await clearPendingDeviceAuth(config.profileName);
151
152
  throw apiError(response.status, body);
152
153
  }
153
154
  const token = body;
@@ -167,16 +168,16 @@ export async function pollDeviceAuth(config, options) {
167
168
  sessionCookie: pair,
168
169
  sessionExpiresAt: exchange.expiresAt,
169
170
  user: exchange.user,
170
- });
171
- await clearPendingDeviceAuth();
171
+ }, config.profileName);
172
+ await clearPendingDeviceAuth(config.profileName);
172
173
  return exchange;
173
174
  }
174
175
  if (token.status === "denied") {
175
- await clearPendingDeviceAuth();
176
+ await clearPendingDeviceAuth(config.profileName);
176
177
  throw new CliError("auth_failed", "The sign-in request was denied.");
177
178
  }
178
179
  if (token.status === "expired" || token.status === "consumed") {
179
- await clearPendingDeviceAuth();
180
+ await clearPendingDeviceAuth(config.profileName);
180
181
  throw new CliError("auth_failed", token.status === "expired"
181
182
  ? "The sign-in request expired. Run `recess auth request` again."
182
183
  : "The sign-in request was already used. Run `recess auth request` again.");