recess-cli 2.2.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.
@@ -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: {
@@ -48,6 +48,75 @@ function summaryPreview(text, max = 600) {
48
48
  return null;
49
49
  return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
50
50
  }
51
+ /**
52
+ * The mission-control queue rule → the CLI command(s) that perform it, with
53
+ * real ids substituted where the action carries them. Placeholders stay in
54
+ * angle brackets where a human choice remains (a template, a cohort, a
55
+ * transcript). Suggestions only: every write below still previews and demands
56
+ * its own --confirm when actually run.
57
+ */
58
+ function suggestedCommandsForQueueAction(action, familyId) {
59
+ const slug = action.partnerSlug ?? "<institution-slug>";
60
+ const invite = action.inviteId ?? "<code-id>";
61
+ const kid = (action.kids.find((row) => row.activeGoalCount === 0) ?? action.kids[0])
62
+ ?.id ?? "<kid-id>";
63
+ switch (action.key) {
64
+ case "invite_waiting":
65
+ return [
66
+ `recess school codes get ${invite} --school ${slug}`,
67
+ `recess school codes resend ${invite} --school ${slug}`,
68
+ ];
69
+ case "invite_expired":
70
+ return [
71
+ `recess school codes replace ${invite} --school ${slug} --data-file <invite.json>`,
72
+ ];
73
+ case "parked":
74
+ return [`recess onboarding set-account-state ${familyId} --state ACTIVE`];
75
+ case "start_intake":
76
+ return [`recess onboarding intake-session-create ${familyId}`];
77
+ case "fill_intake":
78
+ return [
79
+ `recess onboarding extract ${familyId} --session <session-id> --granola <ref>`,
80
+ `recess onboarding set-intake ${familyId} --session <session-id> --data <json>`,
81
+ ];
82
+ case "send_welcome":
83
+ return [`recess onboarding send-welcome ${familyId}`];
84
+ case "nudge_confirm":
85
+ return [
86
+ `recess onboarding send-comms --subject family:${familyId} --kind confirm_nudge_1`,
87
+ ];
88
+ case "load_goals":
89
+ return [
90
+ `recess goal-templates list --starter-only`,
91
+ `recess goal-templates apply-starter <template-id> --student ${kid}`,
92
+ ];
93
+ case "assign_tutor":
94
+ return [
95
+ `recess onboarding active-tutors ${familyId}`,
96
+ `recess onboarding set-primary-tutor ${familyId} --tutor <user-id>`,
97
+ ];
98
+ case "attest":
99
+ return [
100
+ `recess onboarding attest ${familyId} --condition <app_downloaded|tutor_met|goals_loaded|ma_diagnostic>`,
101
+ ];
102
+ case "clear_for_cohort":
103
+ return [`recess onboarding clear-for-cohort ${familyId}`];
104
+ case "register_cohort":
105
+ return [
106
+ `recess onboarding cohort-options ${familyId} --kid ${kid}`,
107
+ `recess onboarding register-cohort ${familyId} --kid ${kid} --cohort <event-series-id>`,
108
+ ];
109
+ case "at_risk":
110
+ return [
111
+ `recess onboarding readiness ${familyId}`,
112
+ `recess onboarding timeline ${familyId}`,
113
+ ];
114
+ case "mark_complete":
115
+ return [`recess onboarding set-stage ${familyId} --stage COMPLETE`];
116
+ default:
117
+ return [];
118
+ }
119
+ }
51
120
  export async function runOnboardingCommand({ parsed, api, writeCommand, }) {
52
121
  const noun = parsed.positionals[0] ?? "";
53
122
  const verb = parsed.positionals[1] ?? "";
@@ -97,6 +166,123 @@ export async function runOnboardingCommand({ parsed, api, writeCommand, }) {
97
166
  const familyId = positional(parsed, 2, "family ID");
98
167
  return unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/active-tutors", { params: { path: { familyId } } }));
99
168
  }
169
+ if (verb === "family") {
170
+ const familyId = positional(parsed, 2, "family ID");
171
+ // The whole family on one screen: status + readiness + the queue's next
172
+ // action + the current intake session + active tutors, in parallel.
173
+ // `status` is the required backbone (a missing family fails the command);
174
+ // the flag-gated reads degrade to a labeled `unavailable` instead of
175
+ // failing the view — the doctor command is the tool for "why".
176
+ const soft = async (read) => {
177
+ try {
178
+ return await read();
179
+ }
180
+ catch (error) {
181
+ return {
182
+ unavailable: error instanceof CliError ? error.message : String(error),
183
+ };
184
+ }
185
+ };
186
+ const [status, readiness, nextAction, intakeSession, activeTutors] = await Promise.all([
187
+ api.client
188
+ .GET("/admin/onboarding/families/{familyId}/status", {
189
+ params: { path: { familyId } },
190
+ })
191
+ .then(unwrap),
192
+ soft(() => api.client
193
+ .GET("/admin/onboarding/families/{familyId}/readiness", {
194
+ params: { path: { familyId } },
195
+ })
196
+ .then(unwrap)),
197
+ soft(() => api.client
198
+ .GET("/admin/onboarding/families/{familyId}/next-action", {
199
+ params: { path: { familyId } },
200
+ })
201
+ .then(unwrap)),
202
+ soft(async () => {
203
+ try {
204
+ return await api.rawGet(`/admin/onboarding/families/${encodeURIComponent(familyId)}/intake-session`);
205
+ }
206
+ catch (error) {
207
+ if (error instanceof CliError &&
208
+ typeof error.details === "object" &&
209
+ error.details !== null &&
210
+ error.details.status === 404) {
211
+ return { intakeSession: null };
212
+ }
213
+ throw error;
214
+ }
215
+ }),
216
+ soft(() => api.client
217
+ .GET("/admin/onboarding/families/{familyId}/active-tutors", {
218
+ params: { path: { familyId } },
219
+ })
220
+ .then(unwrap)),
221
+ ]);
222
+ return {
223
+ familyId,
224
+ status,
225
+ readiness,
226
+ nextAction,
227
+ intakeSession,
228
+ activeTutors,
229
+ };
230
+ }
231
+ if (verb === "next") {
232
+ const familyId = positional(parsed, 2, "family ID");
233
+ const result = unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/next-action", { params: { path: { familyId } } }));
234
+ const action = result.action;
235
+ return {
236
+ ...result,
237
+ // The queue rule mapped to the command that performs it, with real ids
238
+ // substituted where the action carries them — so "what's next" arrives
239
+ // holding "and here is how", instead of making the agent re-derive the
240
+ // funnel from documentation. Every suggested write still previews and
241
+ // requires its own --confirm.
242
+ suggestedCommands: action === null
243
+ ? []
244
+ : suggestedCommandsForQueueAction(action, familyId),
245
+ };
246
+ }
247
+ if (verb === "doctor") {
248
+ // --family is a flag rather than an optional positional: the schema
249
+ // derives positional bounds from the usage line, which cannot express
250
+ // optionality.
251
+ const familyId = flagString(parsed, "family");
252
+ return unwrap(await api.client.GET("/admin/onboarding/doctor", {
253
+ params: { query: familyId ? { familyId } : {} },
254
+ }));
255
+ }
256
+ if (verb === "starter-coverage") {
257
+ // The read-only pre-flip gate (same evaluation as the runbook's
258
+ // check-starter-coverage script). `ok: false` = do not flip the flag.
259
+ return unwrap(await api.client.GET("/admin/onboarding/starter-coverage"));
260
+ }
261
+ if (verb === "backfill-trackers") {
262
+ // WS-H over the wire. Without --apply this is the read-only census
263
+ // (dry-run POST, same precedent as `goal-templates apply --dry-run`).
264
+ // With --apply, the census is re-taken as the preview — the runbook's
265
+ // "fresh dry run on the identical predicate immediately before
266
+ // applying" — and the confirmed write re-derives every conjunct under a
267
+ // per-guardian lock server-side anyway.
268
+ if (!hasFlag(parsed, "apply")) {
269
+ return unwrap(await api.client.POST("/admin/onboarding/backfill-guardian-trackers", {
270
+ body: { apply: false },
271
+ }));
272
+ }
273
+ const census = unwrap(await api.client.POST("/admin/onboarding/backfill-guardian-trackers", {
274
+ body: { apply: false },
275
+ }));
276
+ return writeCommand(parsed, {
277
+ action: "apply the WS-H guardian-tracker backfill: stamp parentOnboardingCompletedAt for the arm-1 (crash-between-commits) school guardians below",
278
+ target: { repairableCount: census.repairable.length },
279
+ request: { apply: true },
280
+ details: {
281
+ freshCensus: census,
282
+ authority: "The census is a photograph; the server re-derives every conjunct under a per-guardian lock at write time and skips anyone who no longer qualifies. Exact-ADMIN only.",
283
+ },
284
+ }, async () => unwrap(await api.client.POST("/admin/onboarding/backfill-guardian-trackers", { body: { apply: true } })));
285
+ }
100
286
  if (verb === "meetings") {
101
287
  const familyId = positional(parsed, 2, "family ID");
102
288
  return unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/meetings", {