recess-cli 1.7.0 → 1.9.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,6 @@
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. 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 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.
4
4
 
5
5
  ## Install (no checkout needed)
6
6
 
@@ -11,7 +11,7 @@ npm install -g recess-cli
11
11
  recess setup
12
12
  ```
13
13
 
14
- `setup` installs the bundled skill for both Codex and Claude 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.
14
+ `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.
15
15
 
16
16
  Publishing rides the production deploy (`.github/workflows/admin-cli-publish.yml`): bump `version` in `apps/admin-cli/package.json` in a normal PR to `staging`, and it publishes when `staging` promotes to `production`. A production deploy that did not bump the version is a no-op — a `gate` job checks the version against npm first. The same workflow is still dispatchable by hand for out-of-band releases. pnpm packs the CLI so the workspace `catalog:` dependency becomes a real range; npm then publishes that tarball through the workflow's OIDC trusted-publishing path. The registry-side publisher must be configured as described in `docs/codebase/admin-cli.md`.
17
17
 
@@ -47,6 +47,38 @@ recess --json auth login
47
47
  recess --json doctor
48
48
  ```
49
49
 
50
+ ## Testing against a local server
51
+
52
+ `auth login` opens production SSO, so local iteration uses the env-cookie hatch instead:
53
+
54
+ ```bash
55
+ export RECESS_CLI_API_ORIGIN=http://localhost:5068
56
+ export RECESS_CLI_COOKIE='recess.auth-token=<signed-value>'
57
+ recess --json doctor
58
+ ```
59
+
60
+ 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:
61
+
62
+ - `@fastify/jwt` reads the auth cookie with `signed: true`, so a **bare JWT is rejected as "missing token"** before it is ever verified — the `@fastify/cookie` signature is not optional.
63
+ - A **stored identity from a previous `auth login` does not describe an env-cookie session** (it may even be a different person on a different environment). Commands that branch on role ask the server whenever `authSource !== "config"`.
64
+
65
+ Real SSO against a local server instead needs an `OAuthClient` row in the local database with `approved && adminCliEnabled` and redirect `http://127.0.0.1:8765/callback`, passed via `RECESS_CLI_OAUTH_CLIENT_ID`.
66
+
67
+ ## Interactive console (`recess ui`)
68
+
69
+ ```bash
70
+ recess ui
71
+ ```
72
+
73
+ A terminal console for the human half of the job: roster on the left (live-session dot, today's
74
+ todo bar, XP, active goals), detail on the right (today's numbers, what they're working on right
75
+ now, recent daily summaries). Refreshes every 30s. Keys: `↑↓`/`jk` move, `/` filter, `r` refresh,
76
+ `q` quit. It is scope-aware — an ADMIN sees every student, a GUIDE sees the students they are
77
+ actively assigned to.
78
+
79
+ `ui` deliberately never touches the `--json` path: agents parse stdout, so the TUI runs before the
80
+ JSON envelope and writes only to the terminal.
81
+
50
82
  ## JSON contract
51
83
 
52
84
  With `--json`, stdout contains only one JSON object.
@@ -119,6 +151,8 @@ separate `village models` commands edit the Village island's reusable models and
119
151
  ```bash
120
152
  recess --json skills guardian get recess-goal-authoring --all-references
121
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
122
156
  recess --json goal-templates validate-spec --file ./template.json # iterate; writes nothing
123
157
  recess --json goal-templates create --file ./template.json # preview, exit 2
124
158
  recess --json goal-templates create --file ./template.json --confirm
@@ -133,6 +167,27 @@ recess --json goals create --student <kid-id> --draft <draft-slug> --title "..."
133
167
  recess --json goals files list --student <kid-id> --goal <goal-id>
134
168
  ```
135
169
 
170
+ ADMIN discovery batches use the same Content Library admission door as the dashboard. Omit
171
+ `--confirm` to preview the exact payload first. An interactive run asks for Review or polish with
172
+ Review preselected; JSON/non-interactive runs safely default to Review. Use `--stage polish` to
173
+ start automatic decoration immediately:
174
+
175
+ ```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
179
+ ```
180
+
181
+ JSON files are arrays of URL strings or `{ "url", "title"?, "summary"?, "lane"? }` objects;
182
+ plain-text files contain one URL per line. The server checks that the deployed island understands
183
+ the review/polish lifecycle before sending any item, and then writes with concurrency three.
184
+ `content-library status` accepts an exact gem ID (including one returned by search) or URL and
185
+ reports its Review/Polishing/Live/Archived stage plus metadata, cover, and search-index progress.
186
+ `content-library set-stage` accepts one or many IDs/URLs (or a newline/JSON-string-array `--file`),
187
+ previews every resolved current stage, and requires `--confirm`. It uses the same lifecycle as
188
+ Manage: direct-to-Live routes unfinished gems through Polishing, and moving out of Polishing
189
+ cancels that exact run first.
190
+
136
191
  ## Family AI operations
137
192
 
138
193
  ```bash
package/dist/cli.js CHANGED
@@ -130,6 +130,13 @@ Usage:
130
130
  recess [--json] store-items set-status <village-store-item-id>
131
131
  --status ACTIVE|INACTIVE|COMING_SOON [--confirm]
132
132
  recess [--json] content-library search <query> [--limit 8]
133
+ recess [--json] content-library status <gem-id-or-url>
134
+ recess [--json] content-library set-stage <gem-id-or-url...> [--file <path>]
135
+ --stage review|polishing|live|archived [--confirm]
136
+ recess [--json] content-library submit <url...> [--file <path>]
137
+ [--stage review|polish] [--title TEXT] [--summary TEXT]
138
+ [--lane web-toys|mechanics|explorables|data-stories|sims|maps-scale|sound-art|puzzles|wonder|idea-games]
139
+ [--confirm]
133
140
  recess [--json] skills guardian list [--query TEXT] [--category TEXT]
134
141
  recess [--json] skills guardian get <skill-name>
135
142
  [--reference NAME | --all-references] [--refresh]
@@ -148,7 +155,9 @@ Usage:
148
155
  [--confirm-destructive-changes --destructive-change-token TOKEN]
149
156
  recess [--json] goal-templates set-metadata <template-id> --expected-version N
150
157
  [--title TEXT] [--description TEXT] [--emoji X] [--category TEXT] [--tags A,B]
151
- [--sort-order N] [--kind SIMPLE|BLUEPRINT] [--agent-instructions-file <path>]
158
+ [--sort-order N] [--is-starter true|false]
159
+ [--setup-audience KID_FRIENDLY|PARENT_SETUP] [--kind SIMPLE|BLUEPRINT]
160
+ [--agent-instructions-file <path>]
152
161
  [--output-template-file <path>] [--confirm]
153
162
  recess [--json] goal-templates delete <template-id> --expected-version N [--confirm]
154
163
  recess [--json] goal-templates snapshot-files <template-id> [--path P]
@@ -187,8 +196,8 @@ Usage:
187
196
  [--path uploads/name.pdf] [--message TEXT]
188
197
  [--confirm --approval-token TOKEN]
189
198
 
190
- Authoring notes: "skills" serves the in-product tutor skills (the PRIVATE
191
- packages/skills submodule) read-only over your admin session — they are never
199
+ Authoring notes: "skills" serves the in-product tutor skills (the private
200
+ packages/skills workspace package) read-only over your admin session — they are never
192
201
  bundled into this npm package. Load os-v2-goal-template-builder and its
193
202
  references/deterministic-workflow-setup.md BEFORE authoring a template; that is
194
203
  the same guidance the recess.gg/ai agent follows, so there is exactly one
@@ -627,6 +636,25 @@ const STORE_ITEM_SORT_FIELDS = [
627
636
  "updatedAt",
628
637
  "order",
629
638
  ];
639
+ const CONTENT_LIBRARY_STAGES = ["review", "polish"];
640
+ const CONTENT_LIBRARY_RESOURCE_STAGES = [
641
+ "review",
642
+ "polishing",
643
+ "live",
644
+ "archived",
645
+ ];
646
+ const CONTENT_LIBRARY_DISCOVERY_LANES = [
647
+ "web-toys",
648
+ "mechanics",
649
+ "explorables",
650
+ "data-stories",
651
+ "sims",
652
+ "maps-scale",
653
+ "sound-art",
654
+ "puzzles",
655
+ "wonder",
656
+ "idea-games",
657
+ ];
630
658
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
631
659
  /**
632
660
  * Read and parse a JSON file supplied by an authoring agent. Large payloads —
@@ -663,6 +691,215 @@ async function readJsonFile(filePath, label) {
663
691
  }
664
692
  return parsed;
665
693
  }
694
+ function contentLibrarySubmitItem(value, label) {
695
+ const record = typeof value === "string"
696
+ ? { url: value }
697
+ : value && typeof value === "object" && !Array.isArray(value)
698
+ ? value
699
+ : null;
700
+ if (!record) {
701
+ throw new CliError("invalid_arguments", `${label} must be a URL string or an object with url and optional title, summary, and lane.`);
702
+ }
703
+ const allowed = new Set(["url", "title", "summary", "lane"]);
704
+ const unknown = Object.keys(record).filter((key) => !allowed.has(key));
705
+ if (unknown.length) {
706
+ throw new CliError("invalid_arguments", `${label} has unsupported fields: ${unknown.join(", ")}.`);
707
+ }
708
+ const text = (key) => {
709
+ const raw = record[key];
710
+ if (raw === undefined)
711
+ return undefined;
712
+ if (typeof raw !== "string" || !raw.trim()) {
713
+ throw new CliError("invalid_arguments", `${label}.${key} must be a non-empty string.`);
714
+ }
715
+ return raw.trim();
716
+ };
717
+ const rawUrl = text("url");
718
+ if (!rawUrl) {
719
+ throw new CliError("invalid_arguments", `${label}.url is required.`);
720
+ }
721
+ let url;
722
+ try {
723
+ url = new URL(rawUrl);
724
+ }
725
+ catch {
726
+ throw new CliError("invalid_arguments", `${label}.url must be a valid http(s) URL.`);
727
+ }
728
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
729
+ throw new CliError("invalid_arguments", `${label}.url must be a valid http(s) URL.`);
730
+ }
731
+ if (url.toString().length > 2_000) {
732
+ throw new CliError("invalid_arguments", `${label}.url must be at most 2000 characters.`);
733
+ }
734
+ const title = text("title");
735
+ const summary = text("summary");
736
+ const laneRaw = text("lane");
737
+ const lane = laneRaw
738
+ ? assertChoice(laneRaw, CONTENT_LIBRARY_DISCOVERY_LANES, `${label}.lane`)
739
+ : undefined;
740
+ if (title && title.length > 500) {
741
+ throw new CliError("invalid_arguments", `${label}.title must be at most 500 characters.`);
742
+ }
743
+ if (summary && summary.length > 4_000) {
744
+ throw new CliError("invalid_arguments", `${label}.summary must be at most 4000 characters.`);
745
+ }
746
+ return {
747
+ url: url.toString(),
748
+ ...(title ? { title } : {}),
749
+ ...(summary ? { summary } : {}),
750
+ ...(lane ? { lane } : {}),
751
+ };
752
+ }
753
+ async function readContentLibrarySubmitItems(parsed) {
754
+ const items = parsed.positionals
755
+ .slice(2)
756
+ .map((url, index) => contentLibrarySubmitItem(url, `URL ${index + 1}`));
757
+ const filePath = flagString(parsed, "file");
758
+ let file;
759
+ if (filePath) {
760
+ const absolutePath = path.resolve(filePath);
761
+ let raw;
762
+ try {
763
+ raw = await fs.readFile(absolutePath, "utf8");
764
+ }
765
+ catch (error) {
766
+ if (error.code === "ENOENT") {
767
+ throw new CliError("invalid_arguments", `Content Library input file does not exist: ${absolutePath}`);
768
+ }
769
+ throw error;
770
+ }
771
+ const trimmed = raw.trim();
772
+ if (!trimmed) {
773
+ throw new CliError("invalid_arguments", `Content Library input file is empty: ${absolutePath}`);
774
+ }
775
+ let entries;
776
+ if (trimmed.startsWith("[")) {
777
+ let parsedFile;
778
+ try {
779
+ parsedFile = JSON.parse(trimmed);
780
+ }
781
+ catch (error) {
782
+ throw new CliError("invalid_arguments", `Content Library input file is not valid JSON (${absolutePath}): ${error instanceof Error ? error.message : String(error)}`);
783
+ }
784
+ if (!Array.isArray(parsedFile)) {
785
+ throw new CliError("invalid_arguments", `Content Library JSON input must be an array (${absolutePath}).`);
786
+ }
787
+ entries = parsedFile;
788
+ }
789
+ else {
790
+ entries = raw
791
+ .split(/\r?\n/)
792
+ .map((line) => line.trim())
793
+ .filter((line) => line && !line.startsWith("#"));
794
+ }
795
+ items.push(...entries.map((entry, index) => contentLibrarySubmitItem(entry, `File item ${index + 1}`)));
796
+ file = {
797
+ absolutePath,
798
+ sizeBytes: Buffer.byteLength(raw),
799
+ sha256: createHash("sha256").update(raw).digest("hex"),
800
+ };
801
+ }
802
+ if (items.length === 0) {
803
+ throw new CliError("invalid_arguments", "Pass at least one URL as an argument or through --file.");
804
+ }
805
+ if (items.length > 500) {
806
+ throw new CliError("invalid_arguments", "Content Library submit accepts at most 500 items per confirmed batch.");
807
+ }
808
+ const title = flagString(parsed, "title");
809
+ const summary = flagString(parsed, "summary");
810
+ const laneRaw = flagString(parsed, "lane");
811
+ if ((title || summary || laneRaw) && items.length !== 1) {
812
+ throw new CliError("invalid_arguments", "--title, --summary, and --lane apply to one URL only; use JSON objects in --file for per-item metadata.");
813
+ }
814
+ if (items.length === 1 && (title || summary || laneRaw)) {
815
+ items[0] = contentLibrarySubmitItem({
816
+ ...items[0],
817
+ ...(title ? { title } : {}),
818
+ ...(summary ? { summary } : {}),
819
+ ...(laneRaw ? { lane: laneRaw } : {}),
820
+ }, "Submitted item");
821
+ }
822
+ return { items, ...(file ? { file } : {}) };
823
+ }
824
+ function contentLibraryGemTarget(value, label) {
825
+ if (typeof value !== "string" || !value.trim()) {
826
+ throw new CliError("invalid_arguments", `${label} must be an exact resource ID or http(s) URL.`);
827
+ }
828
+ const target = value.trim();
829
+ if (UUID_RE.test(target))
830
+ return target;
831
+ let url;
832
+ try {
833
+ url = new URL(target);
834
+ }
835
+ catch {
836
+ throw new CliError("invalid_arguments", `${label} must be an exact resource ID or http(s) URL. Use content-library search to find a live gem's ID.`);
837
+ }
838
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
839
+ throw new CliError("invalid_arguments", `${label} must be an exact resource ID or http(s) URL.`);
840
+ }
841
+ if (url.toString().length > 2_000) {
842
+ throw new CliError("invalid_arguments", `${label} must be at most 2000 characters.`);
843
+ }
844
+ return url.toString();
845
+ }
846
+ async function readContentLibraryGemTargets(parsed) {
847
+ const gems = parsed.positionals
848
+ .slice(2)
849
+ .map((value, index) => contentLibraryGemTarget(value, `Gem ${index + 1}`));
850
+ const filePath = flagString(parsed, "file");
851
+ let file;
852
+ if (filePath) {
853
+ const absolutePath = path.resolve(filePath);
854
+ let raw;
855
+ try {
856
+ raw = await fs.readFile(absolutePath, "utf8");
857
+ }
858
+ catch (error) {
859
+ if (error.code === "ENOENT") {
860
+ throw new CliError("invalid_arguments", `Content Library gem file does not exist: ${absolutePath}`);
861
+ }
862
+ throw error;
863
+ }
864
+ const trimmed = raw.trim();
865
+ if (!trimmed) {
866
+ throw new CliError("invalid_arguments", `Content Library gem file is empty: ${absolutePath}`);
867
+ }
868
+ let entries;
869
+ if (trimmed.startsWith("[")) {
870
+ let parsedFile;
871
+ try {
872
+ parsedFile = JSON.parse(trimmed);
873
+ }
874
+ catch (error) {
875
+ throw new CliError("invalid_arguments", `Content Library gem file is not valid JSON (${absolutePath}): ${error instanceof Error ? error.message : String(error)}`);
876
+ }
877
+ if (!Array.isArray(parsedFile)) {
878
+ throw new CliError("invalid_arguments", `Content Library gem JSON input must be an array (${absolutePath}).`);
879
+ }
880
+ entries = parsedFile;
881
+ }
882
+ else {
883
+ entries = raw
884
+ .split(/\r?\n/)
885
+ .map((line) => line.trim())
886
+ .filter((line) => line && !line.startsWith("#"));
887
+ }
888
+ gems.push(...entries.map((entry, index) => contentLibraryGemTarget(entry, `File gem ${index + 1}`)));
889
+ file = {
890
+ absolutePath,
891
+ sizeBytes: Buffer.byteLength(raw),
892
+ sha256: createHash("sha256").update(raw).digest("hex"),
893
+ };
894
+ }
895
+ if (gems.length === 0) {
896
+ throw new CliError("invalid_arguments", "Pass at least one gem ID or URL as an argument or through --file.");
897
+ }
898
+ if (gems.length > 500) {
899
+ throw new CliError("invalid_arguments", "Content Library set-stage accepts at most 500 gems per confirmed batch.");
900
+ }
901
+ return { gems, ...(file ? { file } : {}) };
902
+ }
666
903
  async function readGoalTemplateSpecPatches(filePath) {
667
904
  const { absolutePath, raw, parsed } = await readJsonValue(filePath, "Goal-template patch file");
668
905
  if (!Array.isArray(parsed) || parsed.length === 0) {
@@ -2444,7 +2681,60 @@ export async function runCommand(argv) {
2444
2681
  params: { query: { q: query, limit } },
2445
2682
  }));
2446
2683
  }
2447
- throw new CliError("invalid_arguments", "Use content-library search.");
2684
+ if (verb === "status") {
2685
+ const gem = contentLibraryGemTarget(positional(parsed, 2, "gem id or URL"), "Gem");
2686
+ return unwrap(await api.client.GET("/admin/content-library/status", {
2687
+ params: { query: { gem } },
2688
+ }));
2689
+ }
2690
+ if (verb === "set-stage") {
2691
+ const stage = assertChoice(flagString(parsed, "stage", { required: true }), CONTENT_LIBRARY_RESOURCE_STAGES, "--stage");
2692
+ const input = await readContentLibraryGemTargets(parsed);
2693
+ const preflightBody = {
2694
+ gems: input.gems,
2695
+ stage,
2696
+ dryRun: true,
2697
+ };
2698
+ const preflight = unwrap(await api.client.POST("/admin/content-library/transition", {
2699
+ body: preflightBody,
2700
+ }));
2701
+ const preview = {
2702
+ action: "content-library.set-stage",
2703
+ target: { stage, resourceCount: preflight.results.length },
2704
+ request: { gems: input.gems, stage },
2705
+ details: {
2706
+ resources: preflight.results,
2707
+ lifecycle: "Uses the same lifecycle as Manage. Direct-to-Live never bypasses unfinished polishing; leaving Polishing cancels its bound run before moving.",
2708
+ concurrency: 3,
2709
+ ...(input.file ? { inputFile: input.file } : {}),
2710
+ },
2711
+ };
2712
+ return writeCommand(parsed, preview, async () => unwrap(await api.client.POST("/admin/content-library/transition", {
2713
+ body: { gems: input.gems, stage, dryRun: false },
2714
+ })));
2715
+ }
2716
+ if (verb === "submit") {
2717
+ const stage = assertChoice(flagString(parsed, "stage") ?? "review", CONTENT_LIBRARY_STAGES, "--stage");
2718
+ const input = await readContentLibrarySubmitItems(parsed);
2719
+ const body = { stage, items: input.items };
2720
+ const preview = {
2721
+ action: "content-library.submit",
2722
+ target: { stage, resourceCount: input.items.length },
2723
+ request: body,
2724
+ details: {
2725
+ admission: stage === "polish"
2726
+ ? "Starts automatic decoration; the island promotes each successful resource to LIVE after the polish and deterministic tail finish."
2727
+ : "Holds each new resource in REVIEW until an admin approves it.",
2728
+ duplicateBehavior: "Existing URLs are returned as duplicates and are not overwritten.",
2729
+ deployOrderFence: "The server verifies the island's review/polish lifecycle capability before its first write.",
2730
+ ...(input.file ? { inputFile: input.file } : {}),
2731
+ },
2732
+ };
2733
+ return writeCommand(parsed, preview, async () => unwrap(await api.client.POST("/admin/content-library/submit", {
2734
+ body,
2735
+ })));
2736
+ }
2737
+ throw new CliError("invalid_arguments", "Use content-library search, status, set-stage, or submit.");
2448
2738
  }
2449
2739
  if (noun === "goal-templates") {
2450
2740
  if (verb === "list") {
@@ -2659,6 +2949,11 @@ export async function runCommand(argv) {
2659
2949
  const tags = flagString(parsed, "tags");
2660
2950
  const kind = flagString(parsed, "kind");
2661
2951
  const sortOrder = flagNumber(parsed, "sort-order");
2952
+ const isStarterRaw = flagString(parsed, "is-starter");
2953
+ const setupAudienceRaw = flagString(parsed, "setup-audience");
2954
+ const isStarter = isStarterRaw === undefined
2955
+ ? undefined
2956
+ : assertChoice(isStarterRaw, ["true", "false"], "--is-starter") === "true";
2662
2957
  const body = {
2663
2958
  expectedVersion,
2664
2959
  ...(flagString(parsed, "title")
@@ -2682,6 +2977,12 @@ export async function runCommand(argv) {
2682
2977
  }
2683
2978
  : {}),
2684
2979
  ...(sortOrder === undefined ? {} : { sortOrder }),
2980
+ ...(isStarter === undefined ? {} : { isStarter }),
2981
+ ...(setupAudienceRaw
2982
+ ? {
2983
+ setupAudience: assertChoice(setupAudienceRaw, GOAL_TEMPLATE_SETUP_AUDIENCES, "--setup-audience"),
2984
+ }
2985
+ : {}),
2685
2986
  ...(kind
2686
2987
  ? { kind: assertChoice(kind, GOAL_TEMPLATE_KINDS, "--kind") }
2687
2988
  : {}),
@@ -2701,7 +3002,7 @@ export async function runCommand(argv) {
2701
3002
  // shape that caused the template incident; editing an existing spec goes
2702
3003
  // through the guarded /ai patch path with its destructive-change token.
2703
3004
  if (Object.keys(body).length === 1) {
2704
- throw new CliError("invalid_arguments", "Pass at least one field to change (--title, --description, --emoji, --category, --tags, --sort-order, --kind, --agent-instructions-file, --output-template-file).");
3005
+ throw new CliError("invalid_arguments", "Pass at least one field to change (--title, --description, --emoji, --category, --tags, --sort-order, --is-starter, --setup-audience, --kind, --agent-instructions-file, --output-template-file).");
2705
3006
  }
2706
3007
  return writeCommand(parsed, {
2707
3008
  action: "update goal template metadata (never its setupWorkflowSpec)",
@@ -3024,6 +3325,19 @@ export async function runCommand(argv) {
3024
3325
  }
3025
3326
  if (noun === "students") {
3026
3327
  if (verb === "list") {
3328
+ // A guide has no family — their roster is the students they are
3329
+ // actively assigned to, which the tutor surface already scopes. The
3330
+ // stored identity is absent on an env-cookie session, so fall back to
3331
+ // asking the server who this session belongs to.
3332
+ // The stored identity only describes a `auth login` session; an
3333
+ // env-cookie session may belong to someone else entirely, so ask the
3334
+ // server rather than trusting a stale login.
3335
+ const role = api.config.authSource === "config"
3336
+ ? api.config.user?.role
3337
+ : (await api.client.GET("/auth/admin-cli/session/")).data?.user.role;
3338
+ if (role === "GUIDE") {
3339
+ return unwrap(await api.client.GET("/tutor/students", {}));
3340
+ }
3027
3341
  return unwrap(await api.client.GET("/family/kids", {
3028
3342
  params: { query: { includeSelf: "false" } },
3029
3343
  }));
package/dist/index.js CHANGED
@@ -1,9 +1,62 @@
1
1
  #!/usr/bin/env node
2
+ import { createInterface } from "node:readline/promises";
3
+ import { parseArgs } from "./args.js";
2
4
  import { runCommand } from "./cli.js";
3
5
  import { CliError } from "./errors.js";
4
- const json = process.argv.slice(2).includes("--json");
6
+ async function withInteractiveAdmissionStage(args) {
7
+ const parsed = parseArgs(args);
8
+ if (parsed.flags.has("json") ||
9
+ parsed.flags.has("help") ||
10
+ parsed.flags.has("stage") ||
11
+ parsed.positionals[0] !== "content-library" ||
12
+ parsed.positionals[1] !== "submit" ||
13
+ !process.stdin.isTTY ||
14
+ !process.stderr.isTTY) {
15
+ return args;
16
+ }
17
+ const terminal = createInterface({
18
+ input: process.stdin,
19
+ output: process.stderr,
20
+ terminal: true,
21
+ });
22
+ try {
23
+ while (true) {
24
+ const answer = (await terminal.question("Admission stage [Review/polish] (Review): "))
25
+ .trim()
26
+ .toLowerCase();
27
+ if (!answer || answer === "r" || answer === "review") {
28
+ return [...args, "--stage", "review"];
29
+ }
30
+ if (answer === "p" || answer === "polish") {
31
+ return [...args, "--stage", "polish"];
32
+ }
33
+ process.stderr.write("Choose Review or polish.\n");
34
+ }
35
+ }
36
+ finally {
37
+ terminal.close();
38
+ }
39
+ }
40
+ const argv = await withInteractiveAdmissionStage(process.argv.slice(2));
41
+ const json = argv.includes("--json");
42
+ // The interactive console owns the terminal, so it runs before the JSON
43
+ // envelope machinery rather than through it.
44
+ if (argv[0] === "ui") {
45
+ const { runUi } = await import("./ui/index.js");
46
+ try {
47
+ await runUi();
48
+ process.exit(0);
49
+ }
50
+ catch (error) {
51
+ const cliError = error instanceof CliError
52
+ ? error
53
+ : new CliError("unexpected_error", error instanceof Error ? error.message : String(error));
54
+ process.stderr.write(`Error: ${cliError.message}\n`);
55
+ process.exit(cliError.exitCode);
56
+ }
57
+ }
5
58
  try {
6
- const data = await runCommand(process.argv.slice(2));
59
+ const data = await runCommand(argv);
7
60
  if (typeof data === "object" && data && "help" in data && !json) {
8
61
  process.stdout.write(`${String(data.help)}\n`);
9
62
  }
Binary file
package/dist/ui/app.js ADDED
@@ -0,0 +1,127 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Box, Text, useApp, useInput } from "ink";
3
+ import { useCallback, useEffect, useState } from "react";
4
+ const REFRESH_MS = 30_000;
5
+ function relative(iso) {
6
+ if (!iso)
7
+ return "never";
8
+ const deltaMin = Math.round((Date.now() - new Date(iso).getTime()) / 60_000);
9
+ if (deltaMin < 1)
10
+ return "just now";
11
+ if (deltaMin < 60)
12
+ return `${deltaMin}m ago`;
13
+ const hours = Math.round(deltaMin / 60);
14
+ if (hours < 24)
15
+ return `${hours}h ago`;
16
+ return `${Math.round(hours / 24)}d ago`;
17
+ }
18
+ /** done/total as a compact bar — the fastest read on "is today on track". */
19
+ function todoBar(done, total) {
20
+ if (total === 0)
21
+ return "·".repeat(5);
22
+ const filled = Math.round((done / total) * 5);
23
+ return "█".repeat(filled) + "░".repeat(5 - filled);
24
+ }
25
+ export function App({ api }) {
26
+ const { exit } = useApp();
27
+ const [session, setSession] = useState(null);
28
+ const [rows, setRows] = useState([]);
29
+ const [summaries, setSummaries] = useState(null);
30
+ const [cursor, setCursor] = useState(0);
31
+ const [filter, setFilter] = useState("");
32
+ const [filtering, setFiltering] = useState(false);
33
+ const [error, setError] = useState(null);
34
+ const [loadedAt, setLoadedAt] = useState(null);
35
+ const visible = rows.filter((r) => filter ? r.name.toLowerCase().includes(filter.toLowerCase()) : true);
36
+ const selected = visible[Math.min(cursor, visible.length - 1)];
37
+ const loadRoster = useCallback(async () => {
38
+ try {
39
+ const [sessionRes, rosterRes] = await Promise.all([
40
+ api.client.GET("/auth/admin-cli/session/"),
41
+ api.client.GET("/tutor/students", {}),
42
+ ]);
43
+ const s = sessionRes.data;
44
+ if (s) {
45
+ setSession({
46
+ role: s.user.role,
47
+ cliScope: s.cliScope,
48
+ name: [s.user.firstName, s.user.lastName].filter(Boolean).join(" "),
49
+ });
50
+ }
51
+ const items = (rosterRes.data?.items ?? []);
52
+ setRows(items);
53
+ setLoadedAt(new Date());
54
+ setError(null);
55
+ }
56
+ catch (err) {
57
+ setError(err instanceof Error ? err.message : String(err));
58
+ }
59
+ }, [api]);
60
+ useEffect(() => {
61
+ void loadRoster();
62
+ const timer = setInterval(() => void loadRoster(), REFRESH_MS);
63
+ return () => clearInterval(timer);
64
+ }, [loadRoster]);
65
+ // Detail is fetched per selection rather than up front: a roster of 40
66
+ // students would otherwise fire 40 requests nobody asked for.
67
+ useEffect(() => {
68
+ let cancelled = false;
69
+ setSummaries(null);
70
+ if (!selected)
71
+ return;
72
+ void (async () => {
73
+ try {
74
+ const res = await api.client.GET("/tutor/students/{studentId}/daily-summaries", { params: { path: { studentId: selected.userId } } });
75
+ if (cancelled)
76
+ return;
77
+ const data = res.data;
78
+ setSummaries(data?.items?.slice(0, 3) ?? []);
79
+ }
80
+ catch {
81
+ if (!cancelled)
82
+ setSummaries([]);
83
+ }
84
+ })();
85
+ return () => {
86
+ cancelled = true;
87
+ };
88
+ }, [api, selected?.userId]);
89
+ useInput((input, key) => {
90
+ if (filtering) {
91
+ if (key.return || key.escape) {
92
+ setFiltering(false);
93
+ return;
94
+ }
95
+ if (key.backspace || key.delete) {
96
+ setFilter((f) => f.slice(0, -1));
97
+ return;
98
+ }
99
+ if (input)
100
+ setFilter((f) => f + input);
101
+ return;
102
+ }
103
+ if (input === "q" || key.escape)
104
+ exit();
105
+ if (input === "r")
106
+ void loadRoster();
107
+ if (input === "/") {
108
+ setFilter("");
109
+ setFiltering(true);
110
+ }
111
+ if (key.downArrow || input === "j") {
112
+ setCursor((c) => Math.min(c + 1, Math.max(visible.length - 1, 0)));
113
+ }
114
+ if (key.upArrow || input === "k")
115
+ setCursor((c) => Math.max(c - 1, 0));
116
+ });
117
+ const liveCount = rows.filter((r) => r.liveSession).length;
118
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, color: "cyan", children: "recess" }), _jsxs(Text, { dimColor: true, children: [" ", session
119
+ ? `${session.name} · ${session.role} · ${session.cliScope}`
120
+ : "connecting…"] })] }), _jsxs(Text, { dimColor: true, children: [rows.length, " students", liveCount > 0 ? ` · ${liveCount} live` : "", loadedAt ? ` · ${loadedAt.toLocaleTimeString()}` : ""] })] }), error ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "red", children: error }) })) : null, _jsxs(Box, { marginTop: 1, children: [_jsxs(Box, { flexDirection: "column", width: "55%", children: [_jsxs(Text, { dimColor: true, children: ["STUDENT".padEnd(22), "TODAY".padEnd(8), "XP".padEnd(6), "GOALS"] }), visible.length === 0 ? (_jsx(Text, { dimColor: true, children: rows.length === 0 ? "no students in scope" : "no match" })) : null, visible.map((row, index) => {
121
+ const active = row.userId === selected?.userId;
122
+ return (_jsxs(Text, { inverse: active, color: row.liveSession ? "green" : undefined, children: [`${row.liveSession ? "●" : " "} ${row.name}`
123
+ .slice(0, 21)
124
+ .padEnd(22), `${todoBar(row.todosToday.done, row.todosToday.total)}`.padEnd(8), `${row.todayXp}`.padEnd(6), `${row.activeGoalCount}`] }, row.userId));
125
+ })] }), _jsx(Box, { flexDirection: "column", width: "45%", paddingLeft: 2, children: selected ? (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: selected.name }), _jsxs(Text, { dimColor: true, children: ["last active ", relative(selected.lastActiveAt)] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { children: ["todos today", " ", _jsxs(Text, { bold: true, children: [selected.todosToday.done, "/", selected.todosToday.total] }), " ", "xp ", _jsx(Text, { bold: true, children: selected.todayXp })] }), _jsxs(Text, { children: ["active goals ", _jsx(Text, { bold: true, children: selected.activeGoalCount }), " ", "modules done", " ", _jsx(Text, { bold: true, children: selected.modulesCompleted })] })] }), selected.liveSession ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "green", children: ["\u25CF working on ", selected.liveSession.todoTitle, " (", relative(selected.liveSession.startedAt), ")"] }) })) : null, _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "RECENT SUMMARIES" }), summaries === null ? _jsx(Text, { dimColor: true, children: "loading\u2026" }) : null, summaries?.length === 0 ? _jsx(Text, { dimColor: true, children: "none" }) : null, summaries?.map((s) => (_jsxs(Text, { children: [(s.summaryDate ?? "").slice(0, 10), " ", _jsx(Text, { dimColor: true, children: s.status ?? "" }), " ", (s.smsNotification ?? "").slice(0, 40)] }, s.id)))] })] })) : (_jsx(Text, { dimColor: true, children: "select a student" })) })] }), _jsx(Box, { marginTop: 1, children: filtering ? (_jsxs(Text, { children: ["filter: ", _jsx(Text, { bold: true, children: filter }), _jsx(Text, { dimColor: true, children: " (enter to apply, esc to stop)" })] })) : (_jsxs(Text, { dimColor: true, children: ["\u2191\u2193/jk move \u00B7 / filter", filter ? ` (${filter})` : "", " \u00B7 r refresh \u00B7 q quit"] })) })] }));
126
+ }
127
+ //# sourceMappingURL=app.js.map
@@ -0,0 +1,21 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { render } from "ink";
3
+ import { RecessAdminApi } from "../api.js";
4
+ import { resolveConfig } from "../config.js";
5
+ import { CliError } from "../errors.js";
6
+ import { App } from "./app.js";
7
+ /**
8
+ * The interactive console. Kept off the JSON path on purpose: agents parse
9
+ * stdout, so the TUI is only reachable through its own command and never
10
+ * writes an envelope.
11
+ */
12
+ export async function runUi() {
13
+ const config = await resolveConfig();
14
+ if (!config.sessionCookie) {
15
+ throw new CliError("auth_required", "No Recess CLI session found. Run `recess auth login`.");
16
+ }
17
+ const api = new RecessAdminApi(config);
18
+ const instance = render(_jsx(App, { api: api }));
19
+ await instance.waitUntilExit();
20
+ }
21
+ //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "recess-cli",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "description": "Safe Recess administration and family AI tools from the command line.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -26,11 +26,15 @@
26
26
  "access": "public"
27
27
  },
28
28
  "dependencies": {
29
- "openapi-fetch": "^0.14.0"
29
+ "ink": "^7.1.1",
30
+ "openapi-fetch": "^0.14.0",
31
+ "react": "^19.2.8"
30
32
  },
31
33
  "devDependencies": {
32
34
  "@types/node": "^24.10.0",
35
+ "@types/react": "^19.2.18",
33
36
  "@typescript/native-preview": "7.0.0-dev.20251015.1",
37
+ "esbuild": "^0.28.2",
34
38
  "openapi-typescript": "^7.8.0",
35
39
  "oxlint": "^1.28.0",
36
40
  "oxlint-tsgolint": "^0.6.0",