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 +57 -2
- package/dist/cli.js +319 -5
- package/dist/index.js +55 -2
- package/dist/skills-cache.js +0 -0
- package/dist/ui/app.js +127 -0
- package/dist/ui/index.js +21 -0
- package/package.json +6 -2
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
|
|
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] [--
|
|
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
|
|
191
|
-
packages/skills
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
}
|
package/dist/skills-cache.js
CHANGED
|
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
|
package/dist/ui/index.js
ADDED
|
@@ -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.
|
|
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
|
-
"
|
|
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",
|