recess-cli 2.0.0 → 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.
@@ -2,9 +2,11 @@ import { CliError } from "./errors.js";
2
2
  export const AGENT_CONTEXT_SCHEMA_VERSION = "3";
3
3
  const BOOLEAN_FLAGS = new Set([
4
4
  "all-references",
5
+ "apply",
5
6
  "allow-strand",
6
7
  "archived",
7
8
  "cancel-subscriptions",
9
+ "clear",
8
10
  "confirm",
9
11
  "confirm-destructive-changes",
10
12
  "disable-applet-follow-ups",
@@ -19,12 +21,14 @@ const BOOLEAN_FLAGS = new Set([
19
21
  "no-collision",
20
22
  "no-invite",
21
23
  "refresh",
24
+ "resend",
22
25
  "restore",
23
26
  "revoke",
24
27
  "send-email",
25
28
  "skill-only",
26
29
  "spec-only",
27
30
  "starter-only",
31
+ "supersede",
28
32
  "visual-only",
29
33
  "wait",
30
34
  ]);
@@ -0,0 +1,336 @@
1
+ import { unwrap } from "../api.js";
2
+ import { flagList, flagString, hasFlag } from "../args.js";
3
+ import { CliError } from "../errors.js";
4
+ import { assertChoice, flagInteger, positional, readJsonFile, } from "./shared.js";
5
+ const APPLICATION_STATUSES = [
6
+ "SUBMITTED",
7
+ "CLAIMED",
8
+ "ENROLLED",
9
+ "CLOSED",
10
+ ];
11
+ const APPLICATION_DISPOSITIONS = [
12
+ "READY_TO_ENROLL",
13
+ "TRIAL",
14
+ "PENDING_FUNDING",
15
+ "NO",
16
+ ];
17
+ const APPLICATION_QUALITIES = [
18
+ "QUALIFIED",
19
+ "NEEDS_NURTURE",
20
+ "UNKNOWN",
21
+ ];
22
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
23
+ function parseEnrollKidSpec(raw) {
24
+ const parts = raw.split(":").map((part) => part.trim());
25
+ const [quoteLineId, firstName, ageRaw, ...extra] = parts;
26
+ if (!quoteLineId || !firstName || extra.length > 0) {
27
+ throw new CliError("invalid_arguments", `--kid must be "<quoteLineId>:<firstName>[:<age>]"; got "${raw}".`);
28
+ }
29
+ if (ageRaw === undefined || ageRaw === "") {
30
+ return { quoteLineId, firstName };
31
+ }
32
+ const age = Number(ageRaw);
33
+ if (!Number.isInteger(age) || age < 1 || age > 25) {
34
+ throw new CliError("invalid_arguments", `--kid age must be a whole number from 1 to 25; got "${ageRaw}".`);
35
+ }
36
+ return { quoteLineId, firstName, age };
37
+ }
38
+ export async function runApplicationsCommand({ parsed, api, writeCommand, }) {
39
+ const noun = parsed.positionals[0] ?? "";
40
+ const verb = parsed.positionals[1] ?? "";
41
+ if (noun === "applications" && verb === "list") {
42
+ const statusRaw = flagString(parsed, "status");
43
+ const dispositionRaw = flagString(parsed, "disposition");
44
+ const qualityRaw = flagString(parsed, "quality");
45
+ const sourceRaw = flagString(parsed, "source");
46
+ const statusScopeRaw = flagString(parsed, "status-scope");
47
+ const dispositionedRaw = flagString(parsed, "dispositioned");
48
+ const status = statusRaw
49
+ ? assertChoice(statusRaw, APPLICATION_STATUSES, "--status")
50
+ : undefined;
51
+ const dispositionBucket = dispositionRaw
52
+ ? assertChoice(dispositionRaw, APPLICATION_DISPOSITIONS, "--disposition")
53
+ : undefined;
54
+ const leadQuality = qualityRaw
55
+ ? assertChoice(qualityRaw, APPLICATION_QUALITIES, "--quality")
56
+ : undefined;
57
+ const source = sourceRaw
58
+ ? assertChoice(sourceRaw, ["PUBLIC", "STAFF"], "--source")
59
+ : undefined;
60
+ const statusScope = statusScopeRaw
61
+ ? assertChoice(statusScopeRaw, ["active", "closed"], "--status-scope")
62
+ : undefined;
63
+ const dispositioned = dispositionedRaw
64
+ ? assertChoice(dispositionedRaw, ["true", "false"], "--dispositioned")
65
+ : undefined;
66
+ const limit = flagInteger(parsed, "limit", { min: 1, max: 500 });
67
+ const cursor = flagString(parsed, "cursor");
68
+ return unwrap(await api.client.GET("/admin/applications/", {
69
+ params: {
70
+ query: {
71
+ ...(status ? { status } : {}),
72
+ ...(dispositionBucket ? { dispositionBucket } : {}),
73
+ ...(leadQuality ? { leadQuality } : {}),
74
+ ...(source ? { source } : {}),
75
+ ...(statusScope ? { statusScope } : {}),
76
+ ...(dispositioned ? { dispositioned } : {}),
77
+ ...(limit !== undefined ? { limit } : {}),
78
+ ...(cursor ? { cursor } : {}),
79
+ },
80
+ },
81
+ }));
82
+ }
83
+ if (noun === "applications" &&
84
+ ["get", "quotes", "meetings", "call-notes"].includes(verb)) {
85
+ const applicationId = positional(parsed, 2, "application ID");
86
+ if (verb === "get") {
87
+ return unwrap(await api.client.GET("/admin/applications/{applicationId}", {
88
+ params: { path: { applicationId } },
89
+ }));
90
+ }
91
+ if (verb === "quotes") {
92
+ return unwrap(await api.client.GET("/admin/applications/{applicationId}/quotes", {
93
+ params: { path: { applicationId } },
94
+ }));
95
+ }
96
+ if (verb === "meetings") {
97
+ return unwrap(await api.client.GET("/admin/applications/{applicationId}/meetings", {
98
+ params: { path: { applicationId } },
99
+ }));
100
+ }
101
+ return unwrap(await api.client.GET("/admin/applications/{applicationId}/call-notes", {
102
+ params: { path: { applicationId } },
103
+ }));
104
+ }
105
+ if (noun === "applications" && verb === "create") {
106
+ const body = (await readJsonFile(flagString(parsed, "data-file", { required: true }), "Staff application file"));
107
+ return writeCommand(parsed, {
108
+ action: "create a staff-entered application and guardian-only family shell (does not enroll, charge, create kid accounts, or send email)",
109
+ target: {
110
+ partnerSlug: body.partnerSlug,
111
+ parentEmail: body.parent?.email,
112
+ },
113
+ request: body,
114
+ }, async () => unwrap(await api.client.POST("/admin/onboarding/applications", { body })));
115
+ }
116
+ if (noun === "applications" && verb === "disposition") {
117
+ const applicationId = positional(parsed, 2, "application ID");
118
+ const body = (await readJsonFile(flagString(parsed, "data-file", { required: true }), "Application disposition file"));
119
+ return writeCommand(parsed, {
120
+ action: "record the application's sales disposition",
121
+ target: { applicationId },
122
+ request: body,
123
+ }, async () => unwrap(await api.client.POST("/admin/applications/{applicationId}/disposition", { params: { path: { applicationId } }, body })));
124
+ }
125
+ if (noun === "applications" && verb === "qualify") {
126
+ const applicationId = positional(parsed, 2, "application ID");
127
+ const qualityOverride = assertChoice(flagString(parsed, "quality", { required: true }), APPLICATION_QUALITIES, "--quality");
128
+ const reason = flagString(parsed, "reason", { required: true }).trim();
129
+ if (!reason) {
130
+ throw new CliError("invalid_arguments", "--reason must not be empty.");
131
+ }
132
+ const body = { qualityOverride, reason };
133
+ return writeCommand(parsed, {
134
+ action: "override the application's qualification verdict",
135
+ target: { applicationId },
136
+ request: body,
137
+ }, async () => unwrap(await api.client.POST("/admin/applications/{applicationId}/qualification", { params: { path: { applicationId } }, body })));
138
+ }
139
+ if (noun === "applications" && verb === "update-contact") {
140
+ const applicationId = positional(parsed, 2, "application ID");
141
+ const body = (await readJsonFile(flagString(parsed, "data-file", { required: true }), "Application contact file"));
142
+ return writeCommand(parsed, {
143
+ action: "correct application contact details (changing parent email revokes every live claim link)",
144
+ target: { applicationId },
145
+ request: body,
146
+ }, async () => unwrap(await api.client.PATCH("/admin/applications/{applicationId}/contact", { params: { path: { applicationId } }, body })));
147
+ }
148
+ if (noun === "applications" &&
149
+ (verb === "claim-email" || verb === "claim-link")) {
150
+ const applicationId = positional(parsed, 2, "application ID");
151
+ const intentKey = flagString(parsed, "intent-key", { required: true });
152
+ if (!UUID_RE.test(intentKey)) {
153
+ throw new CliError("invalid_arguments", "--intent-key must be a UUID.");
154
+ }
155
+ const supersede = hasFlag(parsed, "supersede");
156
+ const body = { intentKey, supersede };
157
+ return writeCommand(parsed, {
158
+ action: verb === "claim-email"
159
+ ? `${supersede ? "replace the current claim link and " : ""}email the application claim link to its frozen recipient`
160
+ : `${supersede ? "replace the current claim link and " : ""}REVEAL a one-time application claim URL in CLI output`,
161
+ target: {
162
+ applicationId,
163
+ channel: verb === "claim-email" ? "email" : "reveal",
164
+ },
165
+ request: body,
166
+ }, async () => {
167
+ if (verb === "claim-email") {
168
+ return unwrap(await api.client.POST("/admin/applications/{applicationId}/claim-token/email", { params: { path: { applicationId } }, body }));
169
+ }
170
+ return unwrap(await api.client.POST("/admin/applications/{applicationId}/claim-token", { params: { path: { applicationId } }, body }));
171
+ });
172
+ }
173
+ if (noun === "applications" && verb === "revoke-claim") {
174
+ const applicationId = positional(parsed, 2, "application ID");
175
+ return writeCommand(parsed, {
176
+ action: "revoke every live claim link for the application",
177
+ target: { applicationId },
178
+ request: {},
179
+ }, async () => unwrap(await api.client.POST("/admin/applications/{applicationId}/claim-token/revoke", { params: { path: { applicationId } } })));
180
+ }
181
+ if (noun === "applications" && verb === "link-family") {
182
+ const applicationId = positional(parsed, 2, "application ID");
183
+ const familyId = flagString(parsed, "family", { required: true });
184
+ const body = { familyId };
185
+ return writeCommand(parsed, {
186
+ action: "permanently associate the application with this existing family (there is no unlink route)",
187
+ target: { applicationId, familyId },
188
+ request: body,
189
+ }, async () => unwrap(await api.client.POST("/admin/applications/{applicationId}/link-family", { params: { path: { applicationId } }, body })));
190
+ }
191
+ if (noun === "applications" && verb === "close") {
192
+ const applicationId = positional(parsed, 2, "application ID");
193
+ const reason = flagString(parsed, "reason", { required: true }).trim();
194
+ if (!reason) {
195
+ throw new CliError("invalid_arguments", "--reason must not be empty.");
196
+ }
197
+ const body = { reason };
198
+ return writeCommand(parsed, {
199
+ action: "close the submitted application permanently (no reopen route)",
200
+ target: { applicationId },
201
+ request: body,
202
+ }, async () => unwrap(await api.client.POST("/admin/applications/{applicationId}/close", {
203
+ params: { path: { applicationId } },
204
+ body,
205
+ })));
206
+ }
207
+ if (noun === "applications" && verb === "add-call-note") {
208
+ const applicationId = positional(parsed, 2, "application ID");
209
+ const body = (await readJsonFile(flagString(parsed, "data-file", { required: true }), "Application call-note file"));
210
+ return writeCommand(parsed, {
211
+ action: "append a call note to the application (Granola references are resolved and persisted now)",
212
+ target: { applicationId },
213
+ request: body,
214
+ }, async () => unwrap(await api.client.POST("/admin/applications/{applicationId}/call-notes", { params: { path: { applicationId } }, body })));
215
+ }
216
+ if (noun === "applications" && verb === "edit-call-note") {
217
+ const applicationId = positional(parsed, 2, "application ID");
218
+ const noteId = flagString(parsed, "note-id", { required: true });
219
+ const body = (await readJsonFile(flagString(parsed, "data-file", { required: true }), "Application call-note comment file"));
220
+ return writeCommand(parsed, {
221
+ action: "edit only the staff comment on the application call note (the persisted call summary is unchanged)",
222
+ target: { applicationId, noteId },
223
+ request: body,
224
+ }, async () => unwrap(await api.client.PATCH("/admin/applications/{applicationId}/call-notes/{noteId}", { params: { path: { applicationId, noteId } }, body })));
225
+ }
226
+ if (noun === "applications" && verb === "enroll") {
227
+ const applicationId = positional(parsed, 2, "application ID");
228
+ const quoteId = flagString(parsed, "quote", { required: true });
229
+ const institutionSlug = flagString(parsed, "school", { required: true });
230
+ const familyId = flagString(parsed, "family");
231
+ const note = flagString(parsed, "note");
232
+ // Repeatable --kid, one per PRICED LINE on the quote. The server refuses a
233
+ // partial roster (every priced student must be enrolled), so this is
234
+ // deliberately not a convenience list — it is the whole quote, echoed back.
235
+ const kidSpecs = flagList(parsed, "kid");
236
+ if (kidSpecs.length === 0) {
237
+ throw new CliError("invalid_arguments", 'Missing --kid. Pass one per quote line: --kid "<quoteLineId>:<firstName>[:<age>]".');
238
+ }
239
+ const kids = kidSpecs.map(parseEnrollKidSpec);
240
+ // Every OTHER live child in the family must be named explicitly. The server
241
+ // refuses the whole enrollment otherwise, listing who was unlisted — so the
242
+ // failure is legible either way, but naming them here is how a staffer says
243
+ // "yes, I know, they are not enrolling".
244
+ const dispositions = flagList(parsed, "unassign").map((kidUserId) => ({
245
+ kidUserId,
246
+ action: "unassigned",
247
+ }));
248
+ return writeCommand(parsed, {
249
+ // One short clause, like every other preview in this file. The
250
+ // consequences are enumerated in `request` below, which is what the
251
+ // confirmation prompt prints in full — restating them here would make
252
+ // this the only preview a staffer has to read twice.
253
+ action: "enroll an application from its accepted quote (creates children, charges the first month, cancels marketplace subscriptions)",
254
+ target: { applicationId, quoteId, institutionSlug, familyId },
255
+ request: { kids, dispositions, note },
256
+ }, async () => unwrap(await api.client.POST("/admin/applications/{applicationId}/enroll", {
257
+ params: { path: { applicationId } },
258
+ body: {
259
+ quoteId,
260
+ institutionSlug,
261
+ kids,
262
+ ...(familyId ? { familyId } : {}),
263
+ ...(dispositions.length > 0 ? { dispositions } : {}),
264
+ ...(note ? { note } : {}),
265
+ },
266
+ })));
267
+ }
268
+ if (noun === "quotes" && verb === "get") {
269
+ const id = positional(parsed, 2, "quote ID");
270
+ return unwrap(await api.client.GET("/admin/quotes/{id}", {
271
+ params: { path: { id } },
272
+ }));
273
+ }
274
+ if (noun === "quotes" && verb === "preview") {
275
+ const body = (await readJsonFile(flagString(parsed, "data-file", { required: true }), "Quote preview file"));
276
+ return unwrap(await api.client.POST("/admin/quotes/preview", { body }));
277
+ }
278
+ if (noun === "quotes" && verb === "create") {
279
+ const body = (await readJsonFile(flagString(parsed, "data-file", { required: true }), "Quote file"));
280
+ return writeCommand(parsed, {
281
+ action: "create and store a DRAFT manual quote for the application (no email and no charge)",
282
+ target: { applicationId: body.applicationId },
283
+ request: body,
284
+ }, async () => unwrap(await api.client.POST("/admin/quotes/", { body })));
285
+ }
286
+ if (noun === "quotes" && verb === "update") {
287
+ const id = positional(parsed, 2, "quote ID");
288
+ const body = (await readJsonFile(flagString(parsed, "data-file", { required: true }), "Quote update file"));
289
+ return writeCommand(parsed, {
290
+ action: "replace the DRAFT quote's manual lines and note (sent quotes are immutable)",
291
+ target: { quoteId: id },
292
+ request: body,
293
+ }, async () => unwrap(await api.client.PATCH("/admin/quotes/{id}", {
294
+ params: { path: { id } },
295
+ body,
296
+ })));
297
+ }
298
+ if (noun === "quotes" && verb === "send") {
299
+ const id = positional(parsed, 2, "quote ID");
300
+ const expectedRevision = flagString(parsed, "expected-revision");
301
+ const body = expectedRevision ? { expectedRevision } : {};
302
+ return writeCommand(parsed, {
303
+ action: "send the DRAFT quote to the family and transition it to SENT",
304
+ target: { quoteId: id },
305
+ request: body,
306
+ }, async () => unwrap(await api.client.POST("/admin/quotes/{id}/send", {
307
+ params: { path: { id } },
308
+ body,
309
+ })));
310
+ }
311
+ if (noun === "quotes" && verb === "accept") {
312
+ const id = positional(parsed, 2, "quote ID");
313
+ return writeCommand(parsed, {
314
+ action: "record the family's acceptance of this SENT quote (does not enroll or charge yet)",
315
+ target: { quoteId: id },
316
+ request: {},
317
+ }, async () => unwrap(await api.client.POST("/admin/quotes/{id}/accept", {
318
+ params: { path: { id } },
319
+ })));
320
+ }
321
+ if (noun === "quotes" && verb === "decline") {
322
+ const id = positional(parsed, 2, "quote ID");
323
+ const note = flagString(parsed, "note");
324
+ const body = note ? { note } : {};
325
+ return writeCommand(parsed, {
326
+ action: "decline the SENT quote",
327
+ target: { quoteId: id },
328
+ request: body,
329
+ }, async () => unwrap(await api.client.POST("/admin/quotes/{id}/decline", {
330
+ params: { path: { id } },
331
+ body,
332
+ })));
333
+ }
334
+ throw new CliError("invalid_arguments", `Unknown ${noun} command. Run \`recess ${noun} --help\` for the current command list.`);
335
+ }
336
+ //# sourceMappingURL=applications.js.map