recess-cli 1.8.0 → 1.9.1

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
@@ -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
 
@@ -151,6 +151,8 @@ separate `village models` commands edit the Village island's reusable models and
151
151
  ```bash
152
152
  recess --json skills guardian get recess-goal-authoring --all-references
153
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
154
156
  recess --json goal-templates validate-spec --file ./template.json # iterate; writes nothing
155
157
  recess --json goal-templates create --file ./template.json # preview, exit 2
156
158
  recess --json goal-templates create --file ./template.json --confirm
@@ -165,6 +167,27 @@ recess --json goals create --student <kid-id> --draft <draft-slug> --title "..."
165
167
  recess --json goals files list --student <kid-id> --goal <goal-id>
166
168
  ```
167
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
+
168
191
  ## Family AI operations
169
192
 
170
193
  ```bash
@@ -174,13 +197,16 @@ recess --json students schedule --student <kid-id> --days 30
174
197
  recess --json students xp-history --student <kid-id> --range month
175
198
  recess --json goals list --student <kid-id>
176
199
  recess --json todos create --student <kid-id> --title "Read chapter 4"
200
+ recess --json todos generate-applet <todo-id> --student <kid-id> [--due-date YYYY-MM-DD]
177
201
  recess --json memories context --student <kid-id>
178
202
  recess --json memories log --student <kid-id> --date 2026-08-12
179
203
  recess --json rocky get --student <kid-id>
180
204
  ```
181
205
 
182
206
  All family writes still preview first. Goal/todo/Rocky edits also carry the current server version
183
- into the confirmed request. A guardian cannot target another family, inspect frozen/deleted
207
+ into the confirmed request. Applet generation is staff-only: it resolves the todo's latest learning
208
+ analysis, defaults the generated todo to tomorrow in the student's timezone, and lets the server
209
+ select v1 or v2 for that student. A guardian cannot target another family, inspect frozen/deleted
184
210
  template history, choose todo rewards/completion/internal fields, or use the ADMIN-only
185
211
  device-authorization flow. `memories context` and `memories log` read only the tutor repository's
186
212
  spine, rules, reminders, and session logs; private guide remarks are never returned. The CLI does
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]
@@ -173,6 +180,8 @@ Usage:
173
180
  [--due-date YYYY-MM-DD] [--estimated-minutes N] [--url URL] [--confirm]
174
181
  recess [--json] todos edit <todo-id> --patch-file <path/patch.json>
175
182
  [--confirm --approval-token TOKEN]
183
+ recess [--json] todos generate-applet <todo-id> --student <kid-id>
184
+ [--due-date YYYY-MM-DD] [--confirm --approval-token TOKEN]
176
185
  recess [--json] memories context --student <kid-id>
177
186
  recess [--json] memories log --student <kid-id> --date YYYY-MM-DD
178
187
  recess [--json] rocky get --student <kid-id>
@@ -189,8 +198,8 @@ Usage:
189
198
  [--path uploads/name.pdf] [--message TEXT]
190
199
  [--confirm --approval-token TOKEN]
191
200
 
192
- Authoring notes: "skills" serves the in-product tutor skills (the PRIVATE
193
- packages/skills submodule) read-only over your admin session — they are never
201
+ Authoring notes: "skills" serves the in-product tutor skills (the private
202
+ packages/skills workspace package) read-only over your admin session — they are never
194
203
  bundled into this npm package. Load os-v2-goal-template-builder and its
195
204
  references/deterministic-workflow-setup.md BEFORE authoring a template; that is
196
205
  the same guidance the recess.gg/ai agent follows, so there is exactly one
@@ -629,6 +638,25 @@ const STORE_ITEM_SORT_FIELDS = [
629
638
  "updatedAt",
630
639
  "order",
631
640
  ];
641
+ const CONTENT_LIBRARY_STAGES = ["review", "polish"];
642
+ const CONTENT_LIBRARY_RESOURCE_STAGES = [
643
+ "review",
644
+ "polishing",
645
+ "live",
646
+ "archived",
647
+ ];
648
+ const CONTENT_LIBRARY_DISCOVERY_LANES = [
649
+ "web-toys",
650
+ "mechanics",
651
+ "explorables",
652
+ "data-stories",
653
+ "sims",
654
+ "maps-scale",
655
+ "sound-art",
656
+ "puzzles",
657
+ "wonder",
658
+ "idea-games",
659
+ ];
632
660
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
633
661
  /**
634
662
  * Read and parse a JSON file supplied by an authoring agent. Large payloads —
@@ -665,6 +693,215 @@ async function readJsonFile(filePath, label) {
665
693
  }
666
694
  return parsed;
667
695
  }
696
+ function contentLibrarySubmitItem(value, label) {
697
+ const record = typeof value === "string"
698
+ ? { url: value }
699
+ : value && typeof value === "object" && !Array.isArray(value)
700
+ ? value
701
+ : null;
702
+ if (!record) {
703
+ throw new CliError("invalid_arguments", `${label} must be a URL string or an object with url and optional title, summary, and lane.`);
704
+ }
705
+ const allowed = new Set(["url", "title", "summary", "lane"]);
706
+ const unknown = Object.keys(record).filter((key) => !allowed.has(key));
707
+ if (unknown.length) {
708
+ throw new CliError("invalid_arguments", `${label} has unsupported fields: ${unknown.join(", ")}.`);
709
+ }
710
+ const text = (key) => {
711
+ const raw = record[key];
712
+ if (raw === undefined)
713
+ return undefined;
714
+ if (typeof raw !== "string" || !raw.trim()) {
715
+ throw new CliError("invalid_arguments", `${label}.${key} must be a non-empty string.`);
716
+ }
717
+ return raw.trim();
718
+ };
719
+ const rawUrl = text("url");
720
+ if (!rawUrl) {
721
+ throw new CliError("invalid_arguments", `${label}.url is required.`);
722
+ }
723
+ let url;
724
+ try {
725
+ url = new URL(rawUrl);
726
+ }
727
+ catch {
728
+ throw new CliError("invalid_arguments", `${label}.url must be a valid http(s) URL.`);
729
+ }
730
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
731
+ throw new CliError("invalid_arguments", `${label}.url must be a valid http(s) URL.`);
732
+ }
733
+ if (url.toString().length > 2_000) {
734
+ throw new CliError("invalid_arguments", `${label}.url must be at most 2000 characters.`);
735
+ }
736
+ const title = text("title");
737
+ const summary = text("summary");
738
+ const laneRaw = text("lane");
739
+ const lane = laneRaw
740
+ ? assertChoice(laneRaw, CONTENT_LIBRARY_DISCOVERY_LANES, `${label}.lane`)
741
+ : undefined;
742
+ if (title && title.length > 500) {
743
+ throw new CliError("invalid_arguments", `${label}.title must be at most 500 characters.`);
744
+ }
745
+ if (summary && summary.length > 4_000) {
746
+ throw new CliError("invalid_arguments", `${label}.summary must be at most 4000 characters.`);
747
+ }
748
+ return {
749
+ url: url.toString(),
750
+ ...(title ? { title } : {}),
751
+ ...(summary ? { summary } : {}),
752
+ ...(lane ? { lane } : {}),
753
+ };
754
+ }
755
+ async function readContentLibrarySubmitItems(parsed) {
756
+ const items = parsed.positionals
757
+ .slice(2)
758
+ .map((url, index) => contentLibrarySubmitItem(url, `URL ${index + 1}`));
759
+ const filePath = flagString(parsed, "file");
760
+ let file;
761
+ if (filePath) {
762
+ const absolutePath = path.resolve(filePath);
763
+ let raw;
764
+ try {
765
+ raw = await fs.readFile(absolutePath, "utf8");
766
+ }
767
+ catch (error) {
768
+ if (error.code === "ENOENT") {
769
+ throw new CliError("invalid_arguments", `Content Library input file does not exist: ${absolutePath}`);
770
+ }
771
+ throw error;
772
+ }
773
+ const trimmed = raw.trim();
774
+ if (!trimmed) {
775
+ throw new CliError("invalid_arguments", `Content Library input file is empty: ${absolutePath}`);
776
+ }
777
+ let entries;
778
+ if (trimmed.startsWith("[")) {
779
+ let parsedFile;
780
+ try {
781
+ parsedFile = JSON.parse(trimmed);
782
+ }
783
+ catch (error) {
784
+ throw new CliError("invalid_arguments", `Content Library input file is not valid JSON (${absolutePath}): ${error instanceof Error ? error.message : String(error)}`);
785
+ }
786
+ if (!Array.isArray(parsedFile)) {
787
+ throw new CliError("invalid_arguments", `Content Library JSON input must be an array (${absolutePath}).`);
788
+ }
789
+ entries = parsedFile;
790
+ }
791
+ else {
792
+ entries = raw
793
+ .split(/\r?\n/)
794
+ .map((line) => line.trim())
795
+ .filter((line) => line && !line.startsWith("#"));
796
+ }
797
+ items.push(...entries.map((entry, index) => contentLibrarySubmitItem(entry, `File item ${index + 1}`)));
798
+ file = {
799
+ absolutePath,
800
+ sizeBytes: Buffer.byteLength(raw),
801
+ sha256: createHash("sha256").update(raw).digest("hex"),
802
+ };
803
+ }
804
+ if (items.length === 0) {
805
+ throw new CliError("invalid_arguments", "Pass at least one URL as an argument or through --file.");
806
+ }
807
+ if (items.length > 500) {
808
+ throw new CliError("invalid_arguments", "Content Library submit accepts at most 500 items per confirmed batch.");
809
+ }
810
+ const title = flagString(parsed, "title");
811
+ const summary = flagString(parsed, "summary");
812
+ const laneRaw = flagString(parsed, "lane");
813
+ if ((title || summary || laneRaw) && items.length !== 1) {
814
+ throw new CliError("invalid_arguments", "--title, --summary, and --lane apply to one URL only; use JSON objects in --file for per-item metadata.");
815
+ }
816
+ if (items.length === 1 && (title || summary || laneRaw)) {
817
+ items[0] = contentLibrarySubmitItem({
818
+ ...items[0],
819
+ ...(title ? { title } : {}),
820
+ ...(summary ? { summary } : {}),
821
+ ...(laneRaw ? { lane: laneRaw } : {}),
822
+ }, "Submitted item");
823
+ }
824
+ return { items, ...(file ? { file } : {}) };
825
+ }
826
+ function contentLibraryGemTarget(value, label) {
827
+ if (typeof value !== "string" || !value.trim()) {
828
+ throw new CliError("invalid_arguments", `${label} must be an exact resource ID or http(s) URL.`);
829
+ }
830
+ const target = value.trim();
831
+ if (UUID_RE.test(target))
832
+ return target;
833
+ let url;
834
+ try {
835
+ url = new URL(target);
836
+ }
837
+ catch {
838
+ 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.`);
839
+ }
840
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
841
+ throw new CliError("invalid_arguments", `${label} must be an exact resource ID or http(s) URL.`);
842
+ }
843
+ if (url.toString().length > 2_000) {
844
+ throw new CliError("invalid_arguments", `${label} must be at most 2000 characters.`);
845
+ }
846
+ return url.toString();
847
+ }
848
+ async function readContentLibraryGemTargets(parsed) {
849
+ const gems = parsed.positionals
850
+ .slice(2)
851
+ .map((value, index) => contentLibraryGemTarget(value, `Gem ${index + 1}`));
852
+ const filePath = flagString(parsed, "file");
853
+ let file;
854
+ if (filePath) {
855
+ const absolutePath = path.resolve(filePath);
856
+ let raw;
857
+ try {
858
+ raw = await fs.readFile(absolutePath, "utf8");
859
+ }
860
+ catch (error) {
861
+ if (error.code === "ENOENT") {
862
+ throw new CliError("invalid_arguments", `Content Library gem file does not exist: ${absolutePath}`);
863
+ }
864
+ throw error;
865
+ }
866
+ const trimmed = raw.trim();
867
+ if (!trimmed) {
868
+ throw new CliError("invalid_arguments", `Content Library gem file is empty: ${absolutePath}`);
869
+ }
870
+ let entries;
871
+ if (trimmed.startsWith("[")) {
872
+ let parsedFile;
873
+ try {
874
+ parsedFile = JSON.parse(trimmed);
875
+ }
876
+ catch (error) {
877
+ throw new CliError("invalid_arguments", `Content Library gem file is not valid JSON (${absolutePath}): ${error instanceof Error ? error.message : String(error)}`);
878
+ }
879
+ if (!Array.isArray(parsedFile)) {
880
+ throw new CliError("invalid_arguments", `Content Library gem JSON input must be an array (${absolutePath}).`);
881
+ }
882
+ entries = parsedFile;
883
+ }
884
+ else {
885
+ entries = raw
886
+ .split(/\r?\n/)
887
+ .map((line) => line.trim())
888
+ .filter((line) => line && !line.startsWith("#"));
889
+ }
890
+ gems.push(...entries.map((entry, index) => contentLibraryGemTarget(entry, `File gem ${index + 1}`)));
891
+ file = {
892
+ absolutePath,
893
+ sizeBytes: Buffer.byteLength(raw),
894
+ sha256: createHash("sha256").update(raw).digest("hex"),
895
+ };
896
+ }
897
+ if (gems.length === 0) {
898
+ throw new CliError("invalid_arguments", "Pass at least one gem ID or URL as an argument or through --file.");
899
+ }
900
+ if (gems.length > 500) {
901
+ throw new CliError("invalid_arguments", "Content Library set-stage accepts at most 500 gems per confirmed batch.");
902
+ }
903
+ return { gems, ...(file ? { file } : {}) };
904
+ }
668
905
  async function readGoalTemplateSpecPatches(filePath) {
669
906
  const { absolutePath, raw, parsed } = await readJsonValue(filePath, "Goal-template patch file");
670
907
  if (!Array.isArray(parsed) || parsed.length === 0) {
@@ -940,6 +1177,20 @@ function flagItemDateMs(parsed) {
940
1177
  }
941
1178
  return ms;
942
1179
  }
1180
+ function flagDateOnly(parsed, name) {
1181
+ const raw = flagString(parsed, name);
1182
+ if (raw === undefined)
1183
+ return undefined;
1184
+ const parsedDate = /^\d{4}-\d{2}-\d{2}$/.test(raw)
1185
+ ? new Date(`${raw}T00:00:00.000Z`)
1186
+ : null;
1187
+ if (!parsedDate ||
1188
+ Number.isNaN(parsedDate.getTime()) ||
1189
+ parsedDate.toISOString().slice(0, 10) !== raw) {
1190
+ throw new CliError("invalid_arguments", `--${name} must be a valid YYYY-MM-DD date.`);
1191
+ }
1192
+ return raw;
1193
+ }
943
1194
  const DAY_MS = 24 * 60 * 60 * 1000;
944
1195
  // The cohort event routes take zoneless local wall-clock datetimes
945
1196
  // (interpreted in the cohort's timezone server-side).
@@ -2446,7 +2697,60 @@ export async function runCommand(argv) {
2446
2697
  params: { query: { q: query, limit } },
2447
2698
  }));
2448
2699
  }
2449
- throw new CliError("invalid_arguments", "Use content-library search.");
2700
+ if (verb === "status") {
2701
+ const gem = contentLibraryGemTarget(positional(parsed, 2, "gem id or URL"), "Gem");
2702
+ return unwrap(await api.client.GET("/admin/content-library/status", {
2703
+ params: { query: { gem } },
2704
+ }));
2705
+ }
2706
+ if (verb === "set-stage") {
2707
+ const stage = assertChoice(flagString(parsed, "stage", { required: true }), CONTENT_LIBRARY_RESOURCE_STAGES, "--stage");
2708
+ const input = await readContentLibraryGemTargets(parsed);
2709
+ const preflightBody = {
2710
+ gems: input.gems,
2711
+ stage,
2712
+ dryRun: true,
2713
+ };
2714
+ const preflight = unwrap(await api.client.POST("/admin/content-library/transition", {
2715
+ body: preflightBody,
2716
+ }));
2717
+ const preview = {
2718
+ action: "content-library.set-stage",
2719
+ target: { stage, resourceCount: preflight.results.length },
2720
+ request: { gems: input.gems, stage },
2721
+ details: {
2722
+ resources: preflight.results,
2723
+ lifecycle: "Uses the same lifecycle as Manage. Direct-to-Live never bypasses unfinished polishing; leaving Polishing cancels its bound run before moving.",
2724
+ concurrency: 3,
2725
+ ...(input.file ? { inputFile: input.file } : {}),
2726
+ },
2727
+ };
2728
+ return writeCommand(parsed, preview, async () => unwrap(await api.client.POST("/admin/content-library/transition", {
2729
+ body: { gems: input.gems, stage, dryRun: false },
2730
+ })));
2731
+ }
2732
+ if (verb === "submit") {
2733
+ const stage = assertChoice(flagString(parsed, "stage") ?? "review", CONTENT_LIBRARY_STAGES, "--stage");
2734
+ const input = await readContentLibrarySubmitItems(parsed);
2735
+ const body = { stage, items: input.items };
2736
+ const preview = {
2737
+ action: "content-library.submit",
2738
+ target: { stage, resourceCount: input.items.length },
2739
+ request: body,
2740
+ details: {
2741
+ admission: stage === "polish"
2742
+ ? "Starts automatic decoration; the island promotes each successful resource to LIVE after the polish and deterministic tail finish."
2743
+ : "Holds each new resource in REVIEW until an admin approves it.",
2744
+ duplicateBehavior: "Existing URLs are returned as duplicates and are not overwritten.",
2745
+ deployOrderFence: "The server verifies the island's review/polish lifecycle capability before its first write.",
2746
+ ...(input.file ? { inputFile: input.file } : {}),
2747
+ },
2748
+ };
2749
+ return writeCommand(parsed, preview, async () => unwrap(await api.client.POST("/admin/content-library/submit", {
2750
+ body,
2751
+ })));
2752
+ }
2753
+ throw new CliError("invalid_arguments", "Use content-library search, status, set-stage, or submit.");
2450
2754
  }
2451
2755
  if (noun === "goal-templates") {
2452
2756
  if (verb === "list") {
@@ -3135,7 +3439,49 @@ export async function runCommand(argv) {
3135
3439
  body,
3136
3440
  }));
3137
3441
  }
3138
- throw new CliError("invalid_arguments", "Use todos create|edit.");
3442
+ if (verb === "generate-applet") {
3443
+ const todoId = positional(parsed, 2, "todo ID");
3444
+ const studentId = flagString(parsed, "student", { required: true });
3445
+ const targetDueDateISO = flagDateOnly(parsed, "due-date");
3446
+ const [todo, learningAnalysis] = await Promise.all([
3447
+ api.client.GET("/tutor/browser/todos/{id}/", {
3448
+ params: { path: { id: todoId } },
3449
+ }),
3450
+ api.client.GET("/tutor/students/{studentId}/todos/{todoId}/learning-analysis", {
3451
+ params: { path: { studentId, todoId } },
3452
+ }),
3453
+ ]);
3454
+ const currentTodo = unwrap(todo);
3455
+ if (currentTodo.userId !== studentId) {
3456
+ throw new CliError("not_found", "The todo does not belong to the requested student.");
3457
+ }
3458
+ const analysis = unwrap(learningAnalysis).analysis;
3459
+ if (!analysis) {
3460
+ throw new CliError("not_found", "This todo does not have a learning analysis to generate an applet from.");
3461
+ }
3462
+ const preview = {
3463
+ action: "generate an applet from a student's todo analysis",
3464
+ target: {
3465
+ studentUserId: studentId,
3466
+ todoId,
3467
+ todoTitle: currentTodo.title,
3468
+ analysisId: analysis.id,
3469
+ },
3470
+ request: {
3471
+ targetDueDateISO: targetDueDateISO ?? null,
3472
+ },
3473
+ details: {
3474
+ dueDate: targetDueDateISO ?? "tomorrow in the student's timezone",
3475
+ versionRouting: "The server evaluates applet-gen-v2 for this student and uses v1 when the flag is off or its evaluation fails.",
3476
+ },
3477
+ };
3478
+ requirePreviewBoundConfirmation(parsed, preview);
3479
+ return unwrap(await api.client.POST("/admin/learning-pipeline/{analysisId}/generate-applet/", {
3480
+ params: { path: { analysisId: analysis.id } },
3481
+ body: targetDueDateISO ? { targetDueDateISO } : {},
3482
+ }));
3483
+ }
3484
+ throw new CliError("invalid_arguments", "Use todos create|edit|generate-applet.");
3139
3485
  }
3140
3486
  if (noun === "memories") {
3141
3487
  const studentId = flagString(parsed, "student", { required: true });
package/dist/index.js CHANGED
@@ -1,7 +1,43 @@
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 argv = process.argv.slice(2);
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));
5
41
  const json = argv.includes("--json");
6
42
  // The interactive console owns the terminal, so it runs before the JSON
7
43
  // envelope machinery rather than through it.
@@ -6,12 +6,12 @@ import { defaultConfigPath } from "./config.js";
6
6
  * On-disk cache for tutor skills fetched from `GET /admin/skills/*`.
7
7
  *
8
8
  * The skills are proprietary and live in the private `packages/skills`
9
- * submodule; `recess-cli` is published PUBLIC on npm, so they are fetched at
9
+ * workspace package; `recess-cli` is published PUBLIC on npm, so they are fetched at
10
10
  * runtime and never bundled. The cache exists so an authoring loop that reads
11
11
  * `os-v2-goal-template-builder` on every step does not re-fetch a ~40 KB
12
12
  * document each time — it is a latency cache, not a distribution channel:
13
13
  * entries live under the operator's 0700 config directory, and `--refresh`
14
- * bypasses them so a skills-repo update is always one flag away.
14
+ * bypasses them so a newly deployed skills update is always one flag away.
15
15
  *
16
16
  * Deliberately short-lived. A stale authoring standard is the failure this whole
17
17
  * design exists to prevent, so an entry older than the TTL is simply re-fetched.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "recess-cli",
3
- "version": "1.8.0",
3
+ "version": "1.9.1",
4
4
  "description": "Safe Recess administration and family AI tools from the command line.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {