recess-cli 2.3.0 → 2.4.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
@@ -111,7 +111,7 @@ Error or write preview:
111
111
 
112
112
  Exit code `0` means success, `1` means an input/auth/API failure, and `2` means a write is awaiting explicit human confirmation.
113
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.
114
+ `recess --json agent-context` returns the canonical command/flag/positional schema filtered to the scope claim already stored in the current CLI session. Bare help, scoped help, and `agent-context` make no API request; real commands still go through server authorization, while `auth status` and `doctor` perform live session checks. `recess --json help payout recipients` returns scoped help, and human help marks exact-admin commands with `◆`. Unknown flags, duplicate non-repeatable flags, missing values, and extra positionals are errors instead of being silently ignored.
115
115
 
116
116
  Every command-driven request to the Recess API except `auth` requires `--reason "..."`: a
117
117
  non-empty, human-readable purpose of at most 1024 characters. The CLI sends it as
@@ -249,6 +249,7 @@ recess --json students schedule --student <kid-id> --days 30 --reason "Review th
249
249
  recess --json students xp-history --student <kid-id> --range month --reason "Review recent XP history"
250
250
  recess --json goals list --student <kid-id> --reason "Review the student's goals"
251
251
  recess --json todos create --student <kid-id> --title "Read chapter 4" --reason "Add the assigned reading"
252
+ recess --json todos complete <todo-id> --xp 35 --reason "Complete the todo with a 35 XP total reward"
252
253
  recess --json goals delete <goal-id> --student <kid-id> --reason "Remove this obsolete goal"
253
254
  recess --json todos delete <todo-id> --reason "Remove this disposable todo"
254
255
  recess --json todos generate-applet <todo-id> --student <kid-id> --reason "Generate this todo's applet"
@@ -258,7 +259,9 @@ recess --json rocky get --student <kid-id> --reason "Inspect the student's Rocky
258
259
  ```
259
260
 
260
261
  All family writes still preview first. Goal/todo/Rocky edits also carry the current server version
261
- into the confirmed request. Applet generation is staff-only: it resolves the todo's latest learning
262
+ into the confirmed request. `todos complete` is staff-only; `--xp` is the target total XP for the
263
+ todo, so its preview reports prior credit and the new delta before the normal completion and reward
264
+ side effects run. Applet generation is also staff-only: it resolves the todo's latest learning
262
265
  analysis, defaults the generated todo to tomorrow in the student's timezone, and lets the server
263
266
  select v1 or v2 for that student. A guardian cannot target another family, inspect frozen/deleted
264
267
  template history, choose todo rewards/completion/internal fields, or use the ADMIN-only
package/dist/cli.js CHANGED
@@ -91,6 +91,69 @@ async function sessionStatus(config) {
91
91
  }
92
92
  return { ...result.data, authSource: config.authSource };
93
93
  }
94
+ const DISCOVERY_ROLE_BY_SCOPE = {
95
+ full_admin: "ADMIN",
96
+ family_ai: "GUARDIAN",
97
+ guide_students: "GUIDE",
98
+ village_home: "KID",
99
+ };
100
+ /**
101
+ * Command discovery is intentionally local: the signed session cookie already
102
+ * carries the role, CLI scope, and expiry minted at login. Decoding those
103
+ * unverified claims is safe here because they only hide or reveal help text;
104
+ * every real command still sends the cookie to the server for authorization.
105
+ */
106
+ function resolveCommandDiscovery(config) {
107
+ const signedOut = {
108
+ scope: null,
109
+ role: null,
110
+ source: "signed_out",
111
+ };
112
+ const unavailable = {
113
+ scope: null,
114
+ role: null,
115
+ source: "unavailable",
116
+ };
117
+ if (!config.sessionCookie)
118
+ return signedOut;
119
+ try {
120
+ const separator = config.sessionCookie.indexOf("=");
121
+ if (separator < 1)
122
+ return unavailable;
123
+ const cookieValue = decodeURIComponent(config.sessionCookie.slice(separator + 1).split(";", 1)[0]);
124
+ const payloadSegment = cookieValue.split(".")[1];
125
+ if (!payloadSegment)
126
+ return unavailable;
127
+ const value = JSON.parse(Buffer.from(payloadSegment, "base64url").toString("utf8"));
128
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
129
+ return unavailable;
130
+ }
131
+ const claim = value;
132
+ if (typeof claim.exp !== "number" || !Number.isFinite(claim.exp)) {
133
+ return unavailable;
134
+ }
135
+ if (claim.exp * 1000 <= Date.now()) {
136
+ return signedOut;
137
+ }
138
+ const scope = typeof claim.cliScope === "string" &&
139
+ Object.hasOwn(DISCOVERY_ROLE_BY_SCOPE, claim.cliScope)
140
+ ? claim.cliScope
141
+ : claim.role === "ADMIN" && claim.adminCli === true
142
+ ? "full_admin"
143
+ : null;
144
+ if (!scope || claim.role !== DISCOVERY_ROLE_BY_SCOPE[scope]) {
145
+ return unavailable;
146
+ }
147
+ return {
148
+ scope,
149
+ role: DISCOVERY_ROLE_BY_SCOPE[scope],
150
+ source: "session_claim",
151
+ };
152
+ }
153
+ catch {
154
+ return unavailable;
155
+ }
156
+ }
94
157
  async function doctor(config, reason) {
95
158
  const checks = {
96
159
  config: {
@@ -1510,19 +1573,28 @@ export async function runCommand(argv) {
1510
1573
  .map((name) => `--${name}`)
1511
1574
  .join(", ")}.`, 1, { validFlags: ["--deliver", "--help", "--json", "--profile"] });
1512
1575
  }
1513
- return { help: scopedHelp(HELP, commands, []) };
1576
+ const config = await resolveConfig(flagString(parsed, "profile"));
1577
+ const discovery = resolveCommandDiscovery(config);
1578
+ return { help: scopedHelp(HELP, commands, [], discovery) };
1514
1579
  }
1515
1580
  if (noun === "help" || hasFlag(parsed, "help")) {
1516
1581
  const scope = noun === "help" ? parsed.positionals.slice(1) : parsed.positionals;
1517
- return { help: scopedHelp(HELP, commands, scope) };
1582
+ const config = await resolveConfig(flagString(parsed, "profile"));
1583
+ const discovery = resolveCommandDiscovery(config);
1584
+ return { help: scopedHelp(HELP, commands, scope, discovery) };
1518
1585
  }
1519
1586
  validateInvocation(parsed, commands);
1520
1587
  if (noun === "agent-context") {
1521
- const profiles = await listProfiles();
1588
+ const [profiles, config] = await Promise.all([
1589
+ listProfiles(),
1590
+ resolveConfig(flagString(parsed, "profile")),
1591
+ ]);
1592
+ const discovery = resolveCommandDiscovery(config);
1522
1593
  return agentContext(commands, {
1523
1594
  cliVersion: await readCliVersion(),
1524
1595
  availableProfiles: profiles.profiles.map((profile) => profile.name),
1525
1596
  feedbackUpstreamConfigured: Boolean(process.env.RECESS_CLI_FEEDBACK_ENDPOINT),
1597
+ discovery,
1526
1598
  });
1527
1599
  }
1528
1600
  if (noun === "profile") {
@@ -4088,6 +4160,60 @@ export async function runCommand(argv) {
4088
4160
  body,
4089
4161
  })));
4090
4162
  }
4163
+ if (verb === "complete") {
4164
+ const todoId = positional(parsed, 2, "todo ID");
4165
+ const targetXp = flagNumber(parsed, "xp");
4166
+ if (targetXp === undefined ||
4167
+ !Number.isInteger(targetXp) ||
4168
+ targetXp <= 0) {
4169
+ throw new CliError("invalid_arguments", "--xp must be a positive integer. It is the target total XP credited for this todo; prior awards count toward that total.");
4170
+ }
4171
+ const [currentResponse, detailsResponse] = await Promise.all([
4172
+ api.client.GET("/tutor/browser/todos/{id}/", {
4173
+ params: { path: { id: todoId } },
4174
+ }),
4175
+ api.client.GET("/admin/todos/{id}/details", {
4176
+ params: { path: { id: todoId } },
4177
+ }),
4178
+ ]);
4179
+ const current = unwrap(currentResponse);
4180
+ const completionXpAwarded = unwrap(detailsResponse).totalXpAwarded ?? 0;
4181
+ if (current.status === "COMPLETED") {
4182
+ throw new CliError("already_completed", `Todo ${todoId} ("${current.title}") is already completed with ${completionXpAwarded} completion XP credited.`);
4183
+ }
4184
+ const body = {
4185
+ status: "COMPLETED",
4186
+ xpReward: targetXp,
4187
+ expectedUpdatedAt: current.updatedAt,
4188
+ };
4189
+ const newXp = Math.max(0, targetXp - completionXpAwarded);
4190
+ const preview = {
4191
+ action: "complete a todo through the staff reward path and set its total credited XP target",
4192
+ target: {
4193
+ todoId,
4194
+ studentUserId: current.userId,
4195
+ studentName: [current.user.firstName, current.user.lastName]
4196
+ .filter(Boolean)
4197
+ .join(" ") || null,
4198
+ title: current.title,
4199
+ status: current.status,
4200
+ goal: current.goal,
4201
+ },
4202
+ request: body,
4203
+ details: {
4204
+ currentXpReward: current.xpReward,
4205
+ alreadyAwardedXp: completionXpAwarded,
4206
+ targetTotalXp: targetXp,
4207
+ newlyAwardedXp: newXp,
4208
+ configuredCoinReward: current.reward,
4209
+ note: `The XP ledger is delta-guarded: this writes at most ${newXp} new XP so the todo reaches ${targetXp} total. Completion also claims any unawarded portion of the todo's configured ${current.reward}-coin reward, replaces its active completion analysis with a manual-completion record, records completion activity, may complete a linked goal module and its configured rewards, and may notify the parent through the normal completion pipeline.`,
4210
+ },
4211
+ };
4212
+ return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.PATCH("/admin/todos/{id}", {
4213
+ params: { path: { id: todoId } },
4214
+ body,
4215
+ })));
4216
+ }
4091
4217
  if (verb === "delete") {
4092
4218
  const todoId = positional(parsed, 2, "todo ID");
4093
4219
  const current = unwrap(await api.client.GET("/tutor/browser/todos/{id}/", {
@@ -4156,7 +4282,7 @@ export async function runCommand(argv) {
4156
4282
  body: targetDueDateISO ? { targetDueDateISO } : {},
4157
4283
  })));
4158
4284
  }
4159
- throw new CliError("invalid_arguments", "Use todos create|edit|delete|generate-applet.");
4285
+ throw new CliError("invalid_arguments", "Use todos create|edit|complete|delete|generate-applet.");
4160
4286
  }
4161
4287
  if (noun === "memories") {
4162
4288
  const studentId = flagString(parsed, "student", { required: true });
@@ -1,5 +1,5 @@
1
1
  import { CliError } from "./errors.js";
2
- export const AGENT_CONTEXT_SCHEMA_VERSION = "3";
2
+ export const AGENT_CONTEXT_SCHEMA_VERSION = "4";
3
3
  const BOOLEAN_FLAGS = new Set([
4
4
  "all-references",
5
5
  "apply",
@@ -50,6 +50,185 @@ const GLOBAL_FLAG_TYPES = {
50
50
  profile: "string",
51
51
  reason: "string",
52
52
  };
53
+ const LOCAL_COMMAND_NOUNS = new Set([
54
+ "--version",
55
+ "agent-context",
56
+ "auth",
57
+ "doctor",
58
+ "feedback",
59
+ "jobs",
60
+ "profile",
61
+ "setup",
62
+ ]);
63
+ const AUTHENTICATED_COMMAND_PREFIXES = [
64
+ "village build",
65
+ "village library",
66
+ "village objects",
67
+ "village render",
68
+ ];
69
+ const FAMILY_AI_COMMANDS = new Set([
70
+ "content-library search",
71
+ "goal-templates apply",
72
+ "goal-templates apply-starter",
73
+ "goal-templates capture-snapshot",
74
+ "goal-templates create",
75
+ "goal-templates get",
76
+ "goal-templates list",
77
+ "goal-templates snapshot-files",
78
+ "goal-templates validate-spec",
79
+ "goals archive",
80
+ "goals create",
81
+ "goals edit",
82
+ "goals files checkout",
83
+ "goals files init",
84
+ "goals files list",
85
+ "goals files push",
86
+ "goals files read",
87
+ "goals files write",
88
+ "goals list",
89
+ "goals pdf upload",
90
+ "goals queue get",
91
+ "goals queue set",
92
+ "goals unarchive",
93
+ "memories context",
94
+ "memories log",
95
+ "onboarding pairing-code",
96
+ "request get",
97
+ "rocky get",
98
+ "rocky set",
99
+ "skills guardian get",
100
+ "skills guardian list",
101
+ "students list",
102
+ "students schedule",
103
+ "students today",
104
+ "students xp-history",
105
+ "todos create",
106
+ "todos edit",
107
+ ]);
108
+ const STAFF_COMMANDS = new Set([
109
+ "cohorts email",
110
+ "cohorts end",
111
+ "cohorts get",
112
+ "cohorts parent-emails",
113
+ "cohorts pause-billing",
114
+ "cohorts resume-billing",
115
+ "cohorts search",
116
+ "enrollments create",
117
+ "enrollments register-cohort",
118
+ "enrollments unregister-cohort",
119
+ "events add",
120
+ "events cancel",
121
+ "events get",
122
+ "events reschedule",
123
+ "events set-status",
124
+ "events take-attendance",
125
+ "goal-templates delete",
126
+ "goal-templates set-metadata",
127
+ "goal-templates versions",
128
+ "goals complete",
129
+ "goals undo-completion",
130
+ "onboarding active-tutors",
131
+ "onboarding attest",
132
+ "onboarding clear-for-cohort",
133
+ "onboarding cohort-options",
134
+ "onboarding comms",
135
+ "onboarding contracts",
136
+ "onboarding doctor",
137
+ "onboarding extract",
138
+ "onboarding family",
139
+ "onboarding generate-summaries",
140
+ "onboarding intake-session",
141
+ "onboarding intake-session-create",
142
+ "onboarding ixl-preview",
143
+ "onboarding kids",
144
+ "onboarding lifecycle-prompts",
145
+ "onboarding mark-reviewed",
146
+ "onboarding next",
147
+ "onboarding orientation-attendance",
148
+ "onboarding orientation-sessions",
149
+ "onboarding provision-ixl",
150
+ "onboarding provision-math-academy",
151
+ "onboarding queue",
152
+ "onboarding readiness",
153
+ "onboarding register-cohort",
154
+ "onboarding remove-ixl",
155
+ "onboarding review",
156
+ "onboarding reviews",
157
+ "onboarding seed-feed",
158
+ "onboarding send-comms",
159
+ "onboarding send-contract",
160
+ "onboarding set-intake",
161
+ "onboarding set-kid-grade",
162
+ "onboarding set-primary-tutor",
163
+ "onboarding starter-coverage",
164
+ "onboarding status",
165
+ "onboarding timeline",
166
+ "payout invoices get",
167
+ "payout invoices list",
168
+ "payout invoices set-status",
169
+ "payout items add",
170
+ "payout items delete",
171
+ "payout items edit",
172
+ "payout payruns list",
173
+ "payout recipients list",
174
+ "registrations approve",
175
+ "registrations deny",
176
+ "school codes get",
177
+ "school codes list",
178
+ "school create",
179
+ "school credit-transactions",
180
+ "school families",
181
+ "school family-search",
182
+ "school kid-slots",
183
+ "school list",
184
+ "school logo-upload",
185
+ "school partner-family",
186
+ "school representatives add",
187
+ "school representatives list",
188
+ "school representatives remove",
189
+ "school representatives search",
190
+ "school update",
191
+ "store-items list",
192
+ "students upload-map-scores",
193
+ "todos complete",
194
+ "todos delete",
195
+ "todos generate-applet",
196
+ "users get",
197
+ "users tier list-tiers",
198
+ ]);
199
+ /**
200
+ * Classify the command itself, not the target passed to it. Target-level
201
+ * family, assignment, ownership, and feature-flag checks remain server-owned.
202
+ * A newly added command fails closed to admin-only discovery until its CLI
203
+ * audience is classified here.
204
+ */
205
+ function commandAccess(path) {
206
+ const key = path.join(" ");
207
+ if (LOCAL_COMMAND_NOUNS.has(path[0]))
208
+ return "local";
209
+ if (AUTHENTICATED_COMMAND_PREFIXES.some((prefix) => key === prefix || key.startsWith(`${prefix} `))) {
210
+ return "authenticated";
211
+ }
212
+ if (FAMILY_AI_COMMANDS.has(key))
213
+ return "family_ai";
214
+ if (STAFF_COMMANDS.has(key))
215
+ return "staff";
216
+ return "admin";
217
+ }
218
+ export function commandIsAvailable(command, scope) {
219
+ if (command.access === "local")
220
+ return true;
221
+ if (!scope)
222
+ return false;
223
+ if (command.access === "authenticated")
224
+ return true;
225
+ if (command.access === "family_ai")
226
+ return scope !== "village_home";
227
+ if (command.access === "staff") {
228
+ return scope === "guide_students" || scope === "full_admin";
229
+ }
230
+ return scope === "full_admin";
231
+ }
53
232
  function usageBlocks(help) {
54
233
  const lines = help.split("\n");
55
234
  const blocks = [];
@@ -161,6 +340,7 @@ export function buildCommandSchema(help) {
161
340
  usage,
162
341
  flags,
163
342
  positionals,
343
+ access: commandAccess(path),
164
344
  }));
165
345
  });
166
346
  }
@@ -172,20 +352,50 @@ export function findCommandSchema(commands, positionals) {
172
352
  .filter((command) => pathStartsWith(positionals, command.path))
173
353
  .sort((left, right) => right.path.length - left.path.length)[0];
174
354
  }
175
- export function scopedHelp(help, commands, scope) {
176
- if (scope.length === 0)
177
- return help;
178
- const matches = commands.filter((command) => pathStartsWith(command.path, scope));
355
+ export function scopedHelp(_help, commands, scope, context) {
356
+ const matches = commands.filter((command) => pathStartsWith(command.path, scope) &&
357
+ commandIsAvailable(command, context.scope));
179
358
  if (matches.length === 0) {
180
359
  throw new CliError("unknown_command", `Unknown command scope: ${scope.join(" ")}. Run \`recess --help\` for available commands.`);
181
360
  }
361
+ const uniqueUsages = Array.from(new Map(matches.map((command) => [command.usage, command])).values());
362
+ const accessLabel = context.scope
363
+ ? context.role
364
+ : context.source === "unavailable"
365
+ ? "session unavailable (local and authentication commands only)"
366
+ : "signed out (local and authentication commands only)";
182
367
  return [
183
- `recess ${scope.join(" ")} — command help`,
368
+ scope.length > 0
369
+ ? `recess ${scope.join(" ")} — command help`
370
+ : "recess — safe Recess administration and family AI tools",
371
+ "",
372
+ `Access: ${accessLabel}`,
373
+ "Key: ◇ shared command ◆ admin-only command",
184
374
  "",
185
375
  "Usage:",
186
- ...Array.from(new Set(matches.map((command) => ` ${command.usage}`))),
376
+ ...uniqueUsages.flatMap((command) => wrapUsage(command.usage, command.access === "admin" ? "◆" : "◇")),
377
+ "",
378
+ "Run `recess help <noun> [verb]` for a focused list.",
187
379
  ].join("\n");
188
380
  }
381
+ function wrapUsage(usage, icon, width = 100) {
382
+ const firstPrefix = ` ${icon} `;
383
+ const continuationPrefix = " ";
384
+ const output = [];
385
+ let current = firstPrefix;
386
+ for (const word of usage.split(/\s+/)) {
387
+ const separator = current.trim().length === 1 ? "" : " ";
388
+ if (current.length > firstPrefix.length &&
389
+ current.length + separator.length + word.length > width) {
390
+ output.push(current);
391
+ current = `${continuationPrefix}${word}`;
392
+ continue;
393
+ }
394
+ current += `${separator}${word}`;
395
+ }
396
+ output.push(current);
397
+ return output;
398
+ }
189
399
  export function validateInvocation(parsed, commands) {
190
400
  const command = findCommandSchema(commands, parsed.positionals);
191
401
  if (!command) {
@@ -232,14 +442,18 @@ export function agentContext(commands, options) {
232
442
  return {
233
443
  schema_version: AGENT_CONTEXT_SCHEMA_VERSION,
234
444
  cli_version: options.cliVersion,
445
+ session: options.discovery,
235
446
  commands: Object.fromEntries(commands
236
- .filter((command) => command.path[0] !== "--version")
447
+ .filter((command) => command.path[0] !== "--version" &&
448
+ commandIsAvailable(command, options.discovery.scope))
237
449
  .map((command) => [
238
450
  command.path.join(" "),
239
451
  {
240
452
  usage: command.usage,
241
453
  flags: command.flags,
242
454
  positionals: command.positionals,
455
+ access: command.access,
456
+ admin_only: command.access === "admin",
243
457
  },
244
458
  ])),
245
459
  global_flags: {
package/dist/help.js CHANGED
@@ -344,6 +344,8 @@ Usage:
344
344
  [--due-date YYYY-MM-DD] [--estimated-minutes N] [--url URL] [--confirm]
345
345
  recess [--json] todos edit <todo-id> --patch-file <path/patch.json>
346
346
  [--confirm --approval-token TOKEN]
347
+ recess [--json] todos complete <todo-id> --xp N
348
+ [--confirm --approval-token TOKEN]
347
349
  recess [--json] todos delete <todo-id>
348
350
  [--confirm --approval-token TOKEN]
349
351
  recess [--json] todos generate-applet <todo-id> --student <kid-id>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "recess-cli",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "Safe Recess administration and family AI tools from the command line.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -33,7 +33,7 @@ recess --json skills admin get recess-admin --all-references --reason "Load staf
33
33
 
34
34
  Use `skills guardian list` or `skills admin list` when the needed skill is not obvious. Never substitute one audience for the other. A guardian session cannot fetch the admin catalog; an admin may fetch either catalog.
35
35
 
36
- For machine-readable discovery, use `recess --json agent-context`; for a smaller syntax surface, use `recess --json help <noun> [verb]`. Unknown commands, flags, duplicate non-repeatable flags, missing flag values, and extra positional arguments fail explicitly.
36
+ For machine-readable discovery filtered to the current session's locally decoded CLI scope, use `recess --json agent-context`; for a smaller syntax surface, use `recess --json help <noun> [verb]`. These discovery commands make no API request; `auth status` and `doctor` are the live session checks. Human help uses `◇` for shared commands and `◆` for exact-admin commands. Unknown commands, flags, duplicate non-repeatable flags, missing flag values, and extra positional arguments fail explicitly.
37
37
 
38
38
  ## Authentication
39
39
 
@@ -41,8 +41,9 @@ For machine-readable discovery, use `recess --json agent-context`; for a smaller
41
41
  - Headless (no browser): `auth request`, then a human approves the printed URL while signed in to
42
42
  Recess, then `auth poll`. The session takes on the **approver's** scope — a guardian approving
43
43
  grants family-only access, not staff access. Kids cannot approve.
44
- - Sessions last 12 hours and are rechecked against the live user role and permissions.
45
- - `auth status` inspects the current session; `auth logout` clears the stored session.
44
+ - Sessions last 12 hours. `auth status` and `doctor` recheck the live user role and permissions;
45
+ discovery stays local and may reflect the minted scope until the next login.
46
+ - `auth logout` clears the stored session.
46
47
  - The default API is production. If `RECESS_CLI_API_ORIGIN` is set, state the non-default origin before acting.
47
48
 
48
49
  Never print or paste session cookies, device codes, or config-file contents.
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "2.4.0",
3
- "minCliVersion": "2.1.0"
2
+ "version": "2.5.0",
3
+ "minCliVersion": "2.4.0"
4
4
  }