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.
- package/README.md +23 -2
- package/dist/api.js +17 -0
- package/dist/auth.js +7 -2
- package/dist/cli.js +253 -9
- package/dist/command-schema.js +222 -8
- package/dist/commands/onboarding.js +186 -0
- package/dist/commands/school.js +262 -0
- package/dist/help.js +54 -3
- package/dist/http.js +45 -2
- package/package.json +1 -1
- package/skill/recess-cli/SKILL.md +7 -4
- package/skill/recess-cli/agents/version.json +2 -2
package/dist/commands/school.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
1
4
|
import { unwrap } from "../api.js";
|
|
2
5
|
import { flagList, flagString, hasFlag } from "../args.js";
|
|
3
6
|
import { CliError } from "../errors.js";
|
|
@@ -9,6 +12,7 @@ const SCHOOL_TIERS = [
|
|
|
9
12
|
"complete",
|
|
10
13
|
"platform",
|
|
11
14
|
];
|
|
15
|
+
const PROGRAM_TYPES = ["PARTNER", "SCHOOL"];
|
|
12
16
|
const RESOLVE_ACTIONS = ["waive", "cancel"];
|
|
13
17
|
const RECONCILIATION_OUTCOMES = ["refunded", "kept", "written_off"];
|
|
14
18
|
/** post.start-memberships.ts caps the body at ten kids per call. */
|
|
@@ -16,6 +20,38 @@ const MEMBERSHIP_KID_LIMIT = 10;
|
|
|
16
20
|
function dollars(cents) {
|
|
17
21
|
return `$${(cents / 100).toFixed(2)}`;
|
|
18
22
|
}
|
|
23
|
+
/** The exact type set post.partner-logo-upload.ts accepts, keyed by extension. */
|
|
24
|
+
const LOGO_MIME_BY_EXT = {
|
|
25
|
+
".jpg": "image/jpeg",
|
|
26
|
+
".jpeg": "image/jpeg",
|
|
27
|
+
".png": "image/png",
|
|
28
|
+
".gif": "image/gif",
|
|
29
|
+
".webp": "image/webp",
|
|
30
|
+
".svg": "image/svg+xml",
|
|
31
|
+
};
|
|
32
|
+
async function readLogoFile(filePath) {
|
|
33
|
+
const absolutePath = path.resolve(filePath);
|
|
34
|
+
const mimeType = LOGO_MIME_BY_EXT[path.extname(absolutePath).toLowerCase()];
|
|
35
|
+
if (!mimeType) {
|
|
36
|
+
throw new CliError("invalid_arguments", "--file must be a .png, .jpg, .jpeg, .gif, .webp, or .svg image.");
|
|
37
|
+
}
|
|
38
|
+
let bytes;
|
|
39
|
+
try {
|
|
40
|
+
bytes = await fs.readFile(absolutePath);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (error.code === "ENOENT") {
|
|
44
|
+
throw new CliError("invalid_arguments", `Logo file does not exist: ${absolutePath}`);
|
|
45
|
+
}
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
bytes,
|
|
50
|
+
fileName: path.basename(absolutePath),
|
|
51
|
+
mimeType,
|
|
52
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
19
55
|
function kidName(firstName, lastName, fallback) {
|
|
20
56
|
return [firstName, lastName].filter(Boolean).join(" ") || fallback;
|
|
21
57
|
}
|
|
@@ -460,6 +496,232 @@ export async function runSchoolCommand({ parsed, api, writeCommand, }) {
|
|
|
460
496
|
}
|
|
461
497
|
throw new CliError("invalid_arguments", "Use school codes list|get|create|set-kids|replace|resend|revoke.");
|
|
462
498
|
}
|
|
499
|
+
if (verb === "create") {
|
|
500
|
+
const slug = flagString(parsed, "slug", { required: true });
|
|
501
|
+
const name = flagString(parsed, "name", { required: true });
|
|
502
|
+
const logoUrl = flagString(parsed, "logo-url");
|
|
503
|
+
const creditGrant = flagInteger(parsed, "credit-grant", {
|
|
504
|
+
required: true,
|
|
505
|
+
min: 0,
|
|
506
|
+
});
|
|
507
|
+
const programTypeRaw = flagString(parsed, "program-type");
|
|
508
|
+
const programType = programTypeRaw
|
|
509
|
+
? assertChoice(programTypeRaw, PROGRAM_TYPES, "--program-type")
|
|
510
|
+
: undefined;
|
|
511
|
+
const tokenTopUpCents = flagInteger(parsed, "token-topup-cents", {
|
|
512
|
+
min: 0,
|
|
513
|
+
max: 10_000_000,
|
|
514
|
+
});
|
|
515
|
+
if (programType === "SCHOOL" && tokenTopUpCents !== creditGrant) {
|
|
516
|
+
throw new CliError("invalid_arguments", "SCHOOL programs require --token-topup-cents equal to --credit-grant (the server enforces the same rule).");
|
|
517
|
+
}
|
|
518
|
+
const body = {
|
|
519
|
+
slug,
|
|
520
|
+
name,
|
|
521
|
+
defaultInitialCreditGrant: creditGrant,
|
|
522
|
+
...(logoUrl ? { logoUrl } : {}),
|
|
523
|
+
...(programType ? { programType } : {}),
|
|
524
|
+
...(tokenTopUpCents !== undefined
|
|
525
|
+
? { monthlyTokenTopUpCents: tokenTopUpCents }
|
|
526
|
+
: {}),
|
|
527
|
+
};
|
|
528
|
+
return writeCommand(parsed, {
|
|
529
|
+
action: `create the ${programType ?? "PARTNER"}-program institution "${name}" (${slug})`,
|
|
530
|
+
target: { slug },
|
|
531
|
+
request: body,
|
|
532
|
+
details: {
|
|
533
|
+
moneyLevers: {
|
|
534
|
+
defaultInitialCreditGrant: creditGrant,
|
|
535
|
+
monthlyTokenTopUpCents: tokenTopUpCents ?? null,
|
|
536
|
+
warning: "For SCHOOL programs the monthly target tops EVERY kid in the institution up to it on the next cron run — these numbers spend real money at scale.",
|
|
537
|
+
},
|
|
538
|
+
duplicateGuard: "A taken slug 409s; pick another rather than retrying.",
|
|
539
|
+
},
|
|
540
|
+
}, async () => unwrap(await api.client.POST("/admin/partner/", { body })));
|
|
541
|
+
}
|
|
542
|
+
if (verb === "update") {
|
|
543
|
+
const institutionId = positional(parsed, 2, "institution ID");
|
|
544
|
+
const slug = flagString(parsed, "slug");
|
|
545
|
+
const name = flagString(parsed, "name");
|
|
546
|
+
const logoUrl = flagString(parsed, "logo-url");
|
|
547
|
+
const creditGrant = flagInteger(parsed, "credit-grant", { min: 0 });
|
|
548
|
+
const programTypeRaw = flagString(parsed, "program-type");
|
|
549
|
+
const programType = programTypeRaw
|
|
550
|
+
? assertChoice(programTypeRaw, PROGRAM_TYPES, "--program-type")
|
|
551
|
+
: undefined;
|
|
552
|
+
const tokenTopUpRaw = flagString(parsed, "token-topup-cents");
|
|
553
|
+
const tokenTopUpCents = tokenTopUpRaw === undefined
|
|
554
|
+
? undefined
|
|
555
|
+
: tokenTopUpRaw === "none"
|
|
556
|
+
? null
|
|
557
|
+
: flagInteger(parsed, "token-topup-cents", {
|
|
558
|
+
min: 0,
|
|
559
|
+
max: 10_000_000,
|
|
560
|
+
});
|
|
561
|
+
const body = {
|
|
562
|
+
...(slug ? { slug } : {}),
|
|
563
|
+
...(name ? { name } : {}),
|
|
564
|
+
...(logoUrl ? { logoUrl } : {}),
|
|
565
|
+
...(creditGrant !== undefined
|
|
566
|
+
? { defaultInitialCreditGrant: creditGrant }
|
|
567
|
+
: {}),
|
|
568
|
+
...(programType ? { programType } : {}),
|
|
569
|
+
...(tokenTopUpCents !== undefined
|
|
570
|
+
? { monthlyTokenTopUpCents: tokenTopUpCents }
|
|
571
|
+
: {}),
|
|
572
|
+
};
|
|
573
|
+
if (Object.keys(body).length === 0) {
|
|
574
|
+
throw new CliError("invalid_arguments", "Pass at least one field to change (--slug, --name, --logo-url, --credit-grant, --program-type, --token-topup-cents).");
|
|
575
|
+
}
|
|
576
|
+
const touchesMoney = creditGrant !== undefined ||
|
|
577
|
+
tokenTopUpCents !== undefined ||
|
|
578
|
+
programType !== undefined;
|
|
579
|
+
return writeCommand(parsed, {
|
|
580
|
+
action: touchesMoney
|
|
581
|
+
? "edit this institution INCLUDING a money lever or the program type (exact-ADMIN only)"
|
|
582
|
+
: "edit this institution's descriptive metadata (name/slug/logo)",
|
|
583
|
+
target: { institutionId },
|
|
584
|
+
request: body,
|
|
585
|
+
details: {
|
|
586
|
+
...(touchesMoney
|
|
587
|
+
? {
|
|
588
|
+
moneyWarning: "defaultInitialCreditGrant / monthlyTokenTopUpCents / programType are money levers: the monthly target tops EVERY kid up to it on the next cron. A GUIDE or PROGRAM session gets 403 for these fields.",
|
|
589
|
+
}
|
|
590
|
+
: {}),
|
|
591
|
+
...(programType
|
|
592
|
+
? {
|
|
593
|
+
programTypeGuard: "A type flip is refused while families are attached or unused invite codes exist — resolve those first.",
|
|
594
|
+
}
|
|
595
|
+
: {}),
|
|
596
|
+
},
|
|
597
|
+
}, async () => unwrap(await api.client.PATCH("/admin/partner/{id}", {
|
|
598
|
+
params: { path: { id: institutionId } },
|
|
599
|
+
body,
|
|
600
|
+
})));
|
|
601
|
+
}
|
|
602
|
+
if (verb === "representatives") {
|
|
603
|
+
const subverb = parsed.positionals[2] ?? "";
|
|
604
|
+
if (subverb === "list") {
|
|
605
|
+
return unwrap(await api.client.GET("/admin/partner/{slug}/representatives", {
|
|
606
|
+
params: { path: { slug: requireSlug() } },
|
|
607
|
+
}));
|
|
608
|
+
}
|
|
609
|
+
if (subverb === "search") {
|
|
610
|
+
const q = flagString(parsed, "query", { required: true });
|
|
611
|
+
return unwrap(await api.client.GET("/admin/partner/representatives/search", {
|
|
612
|
+
params: { query: { q } },
|
|
613
|
+
}));
|
|
614
|
+
}
|
|
615
|
+
if (subverb === "add") {
|
|
616
|
+
const slug = requireSlug();
|
|
617
|
+
const userId = flagString(parsed, "user", { required: true });
|
|
618
|
+
// Resolve the exact person first so the approval names who gains the
|
|
619
|
+
// representative surface, not a bare id.
|
|
620
|
+
const { user } = unwrap(await api.client.GET("/admin/users/{userId}", {
|
|
621
|
+
params: { path: { userId } },
|
|
622
|
+
}));
|
|
623
|
+
return writeCommand(parsed, {
|
|
624
|
+
action: "add this GUIDE/ADMIN as a representative of the institution",
|
|
625
|
+
target: {
|
|
626
|
+
slug,
|
|
627
|
+
userId,
|
|
628
|
+
name: kidName(user.firstName, user.lastName, userId),
|
|
629
|
+
role: user.role,
|
|
630
|
+
},
|
|
631
|
+
request: { userId },
|
|
632
|
+
details: {
|
|
633
|
+
refusal: "Non-GUIDE/ADMIN users are refused by the server.",
|
|
634
|
+
},
|
|
635
|
+
}, async () => unwrap(await api.client.POST("/admin/partner/{slug}/representatives", {
|
|
636
|
+
params: { path: { slug } },
|
|
637
|
+
body: { userId },
|
|
638
|
+
})));
|
|
639
|
+
}
|
|
640
|
+
if (subverb === "remove") {
|
|
641
|
+
const slug = requireSlug();
|
|
642
|
+
const userId = flagString(parsed, "user", { required: true });
|
|
643
|
+
return writeCommand(parsed, {
|
|
644
|
+
action: "remove this representative from the institution",
|
|
645
|
+
target: { slug, userId },
|
|
646
|
+
request: {},
|
|
647
|
+
}, async () => unwrap(await api.client.DELETE("/admin/partner/{slug}/representatives/{userId}", { params: { path: { slug, userId } } })));
|
|
648
|
+
}
|
|
649
|
+
throw new CliError("invalid_arguments", "Use school representatives list|search|add|remove.");
|
|
650
|
+
}
|
|
651
|
+
if (verb === "credit-transactions") {
|
|
652
|
+
const slug = requireSlug();
|
|
653
|
+
const page = flagInteger(parsed, "page", { min: 0 });
|
|
654
|
+
const limit = flagInteger(parsed, "limit", { min: 1, max: 100 });
|
|
655
|
+
return unwrap(await api.client.GET("/admin/partner/{slug}/credit-transactions", {
|
|
656
|
+
params: {
|
|
657
|
+
path: { slug },
|
|
658
|
+
query: {
|
|
659
|
+
...(page !== undefined ? { page } : {}),
|
|
660
|
+
...(limit !== undefined ? { limit } : {}),
|
|
661
|
+
},
|
|
662
|
+
},
|
|
663
|
+
}));
|
|
664
|
+
}
|
|
665
|
+
if (verb === "kid-slots") {
|
|
666
|
+
const kidId = positional(parsed, 2, "kid ID");
|
|
667
|
+
const slug = requireSlug();
|
|
668
|
+
const slotsRaw = flagString(parsed, "slots", { required: true });
|
|
669
|
+
const concurrentClassSlots = slotsRaw === "unlimited"
|
|
670
|
+
? null
|
|
671
|
+
: flagInteger(parsed, "slots", { min: 0 });
|
|
672
|
+
const premiumClassSlots = flagInteger(parsed, "premium-slots", {
|
|
673
|
+
min: 0,
|
|
674
|
+
max: 20,
|
|
675
|
+
});
|
|
676
|
+
const body = {
|
|
677
|
+
concurrentClassSlots,
|
|
678
|
+
...(premiumClassSlots !== undefined ? { premiumClassSlots } : {}),
|
|
679
|
+
};
|
|
680
|
+
return writeCommand(parsed, {
|
|
681
|
+
action: "set this school kid's concurrent class slots directly (the raw slot override — `users tier set` is the tier-driven path)",
|
|
682
|
+
target: { kidId, slug },
|
|
683
|
+
request: body,
|
|
684
|
+
details: {
|
|
685
|
+
slots: concurrentClassSlots ?? "unlimited",
|
|
686
|
+
...(premiumClassSlots !== undefined
|
|
687
|
+
? { premiumSlots: premiumClassSlots }
|
|
688
|
+
: {}),
|
|
689
|
+
note: "SCHOOL-program kids only; the write re-checks membership under the family lock so it cannot race a concurrent revert.",
|
|
690
|
+
},
|
|
691
|
+
}, async () => unwrap(await api.client.PATCH("/admin/partner/{slug}/kids/{kidId}/slots", {
|
|
692
|
+
params: { path: { slug, kidId } },
|
|
693
|
+
body,
|
|
694
|
+
})));
|
|
695
|
+
}
|
|
696
|
+
if (verb === "partner-family") {
|
|
697
|
+
const familyId = positional(parsed, 2, "family ID");
|
|
698
|
+
const slug = requireSlug();
|
|
699
|
+
const enabledRaw = flagString(parsed, "enabled", { required: true });
|
|
700
|
+
const enabled = assertChoice(enabledRaw, ["true", "false"], "--enabled") ===
|
|
701
|
+
"true";
|
|
702
|
+
return writeCommand(parsed, {
|
|
703
|
+
action: enabled
|
|
704
|
+
? "attach this family to the PARTNER institution"
|
|
705
|
+
: "detach this family from the PARTNER institution",
|
|
706
|
+
target: { familyId, slug },
|
|
707
|
+
request: { enabled },
|
|
708
|
+
details: {
|
|
709
|
+
scope: "PARTNER programs only — the server refuses SCHOOL institutions here, because school families carry entitlements this generic toggle cannot safely provision or remove (use `school convert`/`school revert`).",
|
|
710
|
+
},
|
|
711
|
+
}, async () => unwrap(await api.client.PATCH("/admin/partner/{slug}/families/{familyId}", {
|
|
712
|
+
params: { path: { slug, familyId } },
|
|
713
|
+
body: { enabled },
|
|
714
|
+
})));
|
|
715
|
+
}
|
|
716
|
+
if (verb === "logo-upload") {
|
|
717
|
+
const filePath = flagString(parsed, "file", { required: true });
|
|
718
|
+
const { bytes, fileName, mimeType, sha256 } = await readLogoFile(filePath);
|
|
719
|
+
return writeCommand(parsed, {
|
|
720
|
+
action: "upload this image to the assets CDN as a partner-institution logo (returns a URL for `school create/update --logo-url`)",
|
|
721
|
+
target: { fileName },
|
|
722
|
+
request: { fileName, mimeType, bytes: bytes.byteLength, sha256 },
|
|
723
|
+
}, async () => api.uploadPartnerLogo(bytes, fileName, mimeType));
|
|
724
|
+
}
|
|
463
725
|
throw new CliError("invalid_arguments", "Unknown school command. Run `recess school --help` for the current command list.");
|
|
464
726
|
}
|
|
465
727
|
//# sourceMappingURL=school.js.map
|
package/dist/help.js
CHANGED
|
@@ -133,6 +133,11 @@ Usage:
|
|
|
133
133
|
[--limit N] [--stage-filter all|scheduled|oriented|course|converted|lost]
|
|
134
134
|
recess [--json] onboarding timeline <family-id>
|
|
135
135
|
recess [--json] onboarding readiness <family-id>
|
|
136
|
+
recess [--json] onboarding family <family-id>
|
|
137
|
+
recess [--json] onboarding next <family-id>
|
|
138
|
+
recess [--json] onboarding doctor [--family <family-id>]
|
|
139
|
+
recess [--json] onboarding starter-coverage
|
|
140
|
+
recess [--json] onboarding backfill-trackers [--apply] [--confirm]
|
|
136
141
|
recess [--json] onboarding active-tutors <family-id>
|
|
137
142
|
recess [--json] onboarding meetings <family-id>
|
|
138
143
|
recess [--json] onboarding cohort-options <family-id> [--kid <kid-id>]
|
|
@@ -223,6 +228,24 @@ Usage:
|
|
|
223
228
|
--data-file <invite.json> [--confirm]
|
|
224
229
|
recess [--json] school codes resend <code-id> --school <institution-slug> [--confirm]
|
|
225
230
|
recess [--json] school codes revoke <code-id> --school <institution-slug> [--confirm]
|
|
231
|
+
recess [--json] school create --slug <slug> --name TEXT --credit-grant N
|
|
232
|
+
[--logo-url URL] [--program-type PARTNER|SCHOOL] [--token-topup-cents N] [--confirm]
|
|
233
|
+
recess [--json] school update <institution-id> [--slug <slug>] [--name TEXT]
|
|
234
|
+
[--logo-url URL] [--credit-grant N] [--program-type PARTNER|SCHOOL]
|
|
235
|
+
[--token-topup-cents N] [--confirm]
|
|
236
|
+
recess [--json] school representatives list --school <institution-slug>
|
|
237
|
+
recess [--json] school representatives search --query TEXT
|
|
238
|
+
recess [--json] school representatives add --school <institution-slug>
|
|
239
|
+
--user <user-id> [--confirm]
|
|
240
|
+
recess [--json] school representatives remove --school <institution-slug>
|
|
241
|
+
--user <user-id> [--confirm]
|
|
242
|
+
recess [--json] school credit-transactions --school <institution-slug>
|
|
243
|
+
[--page 0] [--limit 50]
|
|
244
|
+
recess [--json] school kid-slots <kid-id> --school <institution-slug>
|
|
245
|
+
--slots N [--premium-slots N] [--confirm]
|
|
246
|
+
recess [--json] school partner-family <family-id> --school <institution-slug>
|
|
247
|
+
--enabled true|false [--confirm]
|
|
248
|
+
recess [--json] school logo-upload --file </path/logo.png> [--confirm]
|
|
226
249
|
recess [--json] village models list [--world village-1] [--query TEXT] [--archived]
|
|
227
250
|
recess [--json] village models upload --file </path/model.glb>
|
|
228
251
|
[--world village-1] [--name TEXT] [--id ID] [--description TEXT] [--tags A,B]
|
|
@@ -309,6 +332,10 @@ Usage:
|
|
|
309
332
|
[--confirm --approval-token TOKEN]
|
|
310
333
|
recess [--json] goals complete <goal-id> [--confirm]
|
|
311
334
|
recess [--json] goals undo-completion <goal-id> [--confirm]
|
|
335
|
+
recess [--json] goals archive <goal-id> --student <kid-id>
|
|
336
|
+
[--confirm --approval-token TOKEN]
|
|
337
|
+
recess [--json] goals unarchive <goal-id> --student <kid-id>
|
|
338
|
+
[--confirm --approval-token TOKEN]
|
|
312
339
|
recess [--json] goals queue get <goal-id> --student <kid-id>
|
|
313
340
|
recess [--json] goals queue set <goal-id> --student <kid-id>
|
|
314
341
|
--entries-file <path.json> --delta TEXT [--replace-description-pointer]
|
|
@@ -317,6 +344,8 @@ Usage:
|
|
|
317
344
|
[--due-date YYYY-MM-DD] [--estimated-minutes N] [--url URL] [--confirm]
|
|
318
345
|
recess [--json] todos edit <todo-id> --patch-file <path/patch.json>
|
|
319
346
|
[--confirm --approval-token TOKEN]
|
|
347
|
+
recess [--json] todos complete <todo-id> --xp N
|
|
348
|
+
[--confirm --approval-token TOKEN]
|
|
320
349
|
recess [--json] todos delete <todo-id>
|
|
321
350
|
[--confirm --approval-token TOKEN]
|
|
322
351
|
recess [--json] todos generate-applet <todo-id> --student <kid-id>
|
|
@@ -366,6 +395,26 @@ the backend's own dryRun before the gate and previews the per-student outcome.
|
|
|
366
395
|
409s STALE_WRITE and writes nothing. The spec is unreachable from "set-metadata"
|
|
367
396
|
by design — an existing spec is edited only through the guarded /ai patch path.
|
|
368
397
|
|
|
398
|
+
School-onboarding workflow notes: "onboarding family" is the one-screen view
|
|
399
|
+
(status + readiness + next action + intake session + active tutors, in
|
|
400
|
+
parallel; flag-gated pieces degrade to a labeled "unavailable"). "onboarding
|
|
401
|
+
next" returns the mission-control queue's current action for the family PLUS
|
|
402
|
+
suggestedCommands — the exact commands that perform it, ids substituted.
|
|
403
|
+
"onboarding doctor" answers "who actually sees the school-onboarding surface
|
|
404
|
+
and why": the env kill-switches (SCHOOL_ONBOARDING_V1_FORCE,
|
|
405
|
+
SCHOOL_ONBOARDING_CUTOVER), comms master switch + mode, the acting staffer's
|
|
406
|
+
flag evaluation, and per-guardian/per-kid flag, capability-lock, and
|
|
407
|
+
cohort-gate state; env values are the answering service's only — the Worker
|
|
408
|
+
can differ. "onboarding starter-coverage" is the read-only pre-flip gate
|
|
409
|
+
(ok:false = do not flip the flag). "onboarding backfill-trackers" is the WS-H
|
|
410
|
+
census (read-only); --apply re-takes the census as the preview and stamps only
|
|
411
|
+
the arm-1 guardians, exact-ADMIN, re-derived under a per-guardian lock
|
|
412
|
+
server-side. "school create/update" carry money levers
|
|
413
|
+
(--credit-grant/--token-topup-cents/--program-type spend real money via the
|
|
414
|
+
monthly top-up cron and are exact-ADMIN on update); descriptive edits
|
|
415
|
+
(name/slug/logo) are ordinary staff writes. "school kid-slots" is the raw
|
|
416
|
+
slot override; "users tier set" is the tier-driven path.
|
|
417
|
+
|
|
369
418
|
Onboarding notes: "status" and "intake-session" are reads — "intake-session"
|
|
370
419
|
looks up the current IN_PROGRESS session without creating one (prints a "none
|
|
371
420
|
yet" result when absent). "intake-session-create" is the explicit write that
|
|
@@ -392,9 +441,11 @@ endDate minus one day) and shows the computed date in the preview.
|
|
|
392
441
|
Auth notes: "auth login" runs the browser loopback flow for ADMIN, GUIDE, a GUARDIAN with
|
|
393
442
|
access:ai, or a KID using only Village home building. Guardian sessions are family-scoped and
|
|
394
443
|
cannot call /admin; KID sessions cannot call any non-Village API command. For a headless
|
|
395
|
-
cloud agent
|
|
396
|
-
|
|
397
|
-
|
|
444
|
+
cloud agent that has no local browser to open, "auth request" prints an approval URL; whoever
|
|
445
|
+
opens it and approves in a signed-in web session grants THEIR OWN scope, so a guardian approving
|
|
446
|
+
mints a family-scoped session and only an admin can mint a full-admin one. "auth poll" then
|
|
447
|
+
collects the 12h session. Kids cannot approve. When it lapses, run "auth request" again for a
|
|
448
|
+
fresh link. Both paths yield the same session.
|
|
398
449
|
|
|
399
450
|
Skill notes: this CLI's own agent skill ships inside the npm package AND is served
|
|
400
451
|
by the server, so wording/Gotcha updates arrive without an npm release. "setup"
|
package/dist/http.js
CHANGED
|
@@ -3,6 +3,44 @@ export const RECESS_CLIENT_HEADER = "x-recess-client";
|
|
|
3
3
|
export const RECESS_CLIENT_CLI = "cli";
|
|
4
4
|
export const RECESS_CLIENT_CLI_UI = "cli-ui";
|
|
5
5
|
export const RECESS_REASON_HEADER = "x-recess-reason";
|
|
6
|
+
/**
|
|
7
|
+
* Set alongside the reason when it had to be percent-encoded to survive the
|
|
8
|
+
* header. The server decodes only when this is present, so a literal `%` in an
|
|
9
|
+
* all-ASCII reason is never mangled.
|
|
10
|
+
*/
|
|
11
|
+
export const RECESS_REASON_ENCODING_HEADER = "x-recess-reason-encoding";
|
|
12
|
+
export const RECESS_REASON_ENCODING_UTF8 = "utf-8-percent";
|
|
13
|
+
const ASCII_PRINTABLE = /^[\x20-\x7E]*$/;
|
|
14
|
+
/**
|
|
15
|
+
* Make a human-written reason safe to put in an HTTP header.
|
|
16
|
+
*
|
|
17
|
+
* Headers are ByteStrings: any code point above U+00FF throws
|
|
18
|
+
* "Cannot convert argument to a ByteString because the character at index N
|
|
19
|
+
* has a value of 8212" — 8212 being an em dash. The error names no field, so
|
|
20
|
+
* the failure looks like a bug in whatever command you happened to run, and
|
|
21
|
+
* typing an em dash or a curly quote in `--reason` is completely ordinary.
|
|
22
|
+
*
|
|
23
|
+
* Common typography is folded to its ASCII equivalent so the audit log stays
|
|
24
|
+
* readable; anything still non-ASCII (accented names, CJK, emoji) is
|
|
25
|
+
* UTF-8 percent-encoded and flagged for the server to decode, which is lossless.
|
|
26
|
+
*/
|
|
27
|
+
export function encodeReasonHeader(reason) {
|
|
28
|
+
const folded = reason
|
|
29
|
+
// Typography first, so the audit log keeps a readable reason instead of a
|
|
30
|
+
// percent-escaped one. These are what people actually type.
|
|
31
|
+
.replace(/[\u2010-\u2015\u2212]/g, "-") // hyphens, en/em dashes, minus
|
|
32
|
+
.replace(/[\u2018\u2019\u201A\u201B]/g, "'")
|
|
33
|
+
.replace(/[\u201C\u201D\u201E\u201F]/g, '"')
|
|
34
|
+
.replace(/\u2026/g, "...")
|
|
35
|
+
.replace(/[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g, " ")
|
|
36
|
+
.replace(/[\u200B-\u200D\uFEFF]/g, "");
|
|
37
|
+
if (ASCII_PRINTABLE.test(folded))
|
|
38
|
+
return { value: folded };
|
|
39
|
+
return {
|
|
40
|
+
value: encodeURIComponent(folded),
|
|
41
|
+
encoding: RECESS_REASON_ENCODING_UTF8,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
6
44
|
export function requireCliRequestReason(value) {
|
|
7
45
|
const reason = value?.trim();
|
|
8
46
|
if (!reason) {
|
|
@@ -15,8 +53,13 @@ export function requireCliRequestReason(value) {
|
|
|
15
53
|
}
|
|
16
54
|
export function markCliRequest(headers, reason, client = RECESS_CLIENT_CLI) {
|
|
17
55
|
headers.set(RECESS_CLIENT_HEADER, client);
|
|
18
|
-
if (reason)
|
|
19
|
-
|
|
56
|
+
if (reason) {
|
|
57
|
+
const encoded = encodeReasonHeader(reason);
|
|
58
|
+
headers.set(RECESS_REASON_HEADER, encoded.value);
|
|
59
|
+
if (encoded.encoding) {
|
|
60
|
+
headers.set(RECESS_REASON_ENCODING_HEADER, encoded.encoding);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
20
63
|
return headers;
|
|
21
64
|
}
|
|
22
65
|
export function cliRequestHeaders(init, reason, client = RECESS_CLIENT_CLI) {
|
package/package.json
CHANGED
|
@@ -33,14 +33,17 @@ 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
|
|
|
40
40
|
- Workstation: `recess --json auth login` opens Recess SSO.
|
|
41
|
-
- Headless
|
|
42
|
-
|
|
43
|
-
-
|
|
41
|
+
- Headless (no browser): `auth request`, then a human approves the printed URL while signed in to
|
|
42
|
+
Recess, then `auth poll`. The session takes on the **approver's** scope — a guardian approving
|
|
43
|
+
grants family-only access, not staff access. Kids cannot approve.
|
|
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.
|
|
44
47
|
- The default API is production. If `RECESS_CLI_API_ORIGIN` is set, state the non-default origin before acting.
|
|
45
48
|
|
|
46
49
|
Never print or paste session cookies, device codes, or config-file contents.
|