recess-cli 1.8.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
@@ -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
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]
@@ -189,8 +196,8 @@ Usage:
189
196
  [--path uploads/name.pdf] [--message TEXT]
190
197
  [--confirm --approval-token TOKEN]
191
198
 
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
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
194
201
  bundled into this npm package. Load os-v2-goal-template-builder and its
195
202
  references/deterministic-workflow-setup.md BEFORE authoring a template; that is
196
203
  the same guidance the recess.gg/ai agent follows, so there is exactly one
@@ -629,6 +636,25 @@ const STORE_ITEM_SORT_FIELDS = [
629
636
  "updatedAt",
630
637
  "order",
631
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
+ ];
632
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;
633
659
  /**
634
660
  * Read and parse a JSON file supplied by an authoring agent. Large payloads —
@@ -665,6 +691,215 @@ async function readJsonFile(filePath, label) {
665
691
  }
666
692
  return parsed;
667
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
+ }
668
903
  async function readGoalTemplateSpecPatches(filePath) {
669
904
  const { absolutePath, raw, parsed } = await readJsonValue(filePath, "Goal-template patch file");
670
905
  if (!Array.isArray(parsed) || parsed.length === 0) {
@@ -2446,7 +2681,60 @@ export async function runCommand(argv) {
2446
2681
  params: { query: { q: query, limit } },
2447
2682
  }));
2448
2683
  }
2449
- 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.");
2450
2738
  }
2451
2739
  if (noun === "goal-templates") {
2452
2740
  if (verb === "list") {
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.0",
4
4
  "description": "Safe Recess administration and family AI tools from the command line.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {