recess-cli 1.6.0 → 1.8.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 +41 -6
- package/dist/args.js +2 -0
- package/dist/cli.js +103 -9
- package/dist/index.js +19 -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
|
|
|
@@ -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.
|
|
@@ -128,6 +160,8 @@ recess --json goals pdf upload --student <kid-id> --draft <draft-slug> --source-
|
|
|
128
160
|
recess --json goal-templates capture-snapshot <id-or-slug> --source-draft <draft-slug> --student <kid-id> --dry-run
|
|
129
161
|
recess --json goal-templates apply <id-or-slug> --answers-file ./answers.json --dry-run
|
|
130
162
|
recess --json goals create --student <kid-id> --title "..." --description-file ./goal.md
|
|
163
|
+
recess --json goals create --student <kid-id> --draft <draft-slug> --title "..." \
|
|
164
|
+
--description-file ./goal.md --enable-applet-follow-ups
|
|
131
165
|
recess --json goals files list --student <kid-id> --goal <goal-id>
|
|
132
166
|
```
|
|
133
167
|
|
|
@@ -183,10 +217,11 @@ previews the per-student outcome. `set-metadata` and `delete` require `--expecte
|
|
|
183
217
|
it always runs the backend's guarded preview first and requires the preview's exact loss token in
|
|
184
218
|
addition to `--confirm` when protected template data would be removed.
|
|
185
219
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
`state/` writes
|
|
220
|
+
Module-backed content is authored as a local workspace tree and batch-upserted to a named goal
|
|
221
|
+
draft. For a personal one-off goal, `goals create --draft` validates and materializes that draft
|
|
222
|
+
directly. For reusable content, attach it to a BLUEPRINT with `capture-snapshot`. Every mutation
|
|
223
|
+
runs a server preview before the confirmation gate and is compare-and-set against the previewed
|
|
224
|
+
revision. Direct live-goal `modules/` and `state/` writes remain blocked because those files have
|
|
225
|
+
database projections.
|
|
191
226
|
|
|
192
227
|
See `recess --help` for the complete command surface. The raw escape hatch is intentionally read-only: `recess --json request get /path`.
|
package/dist/args.js
CHANGED
package/dist/cli.js
CHANGED
|
@@ -148,12 +148,14 @@ Usage:
|
|
|
148
148
|
[--confirm-destructive-changes --destructive-change-token TOKEN]
|
|
149
149
|
recess [--json] goal-templates set-metadata <template-id> --expected-version N
|
|
150
150
|
[--title TEXT] [--description TEXT] [--emoji X] [--category TEXT] [--tags A,B]
|
|
151
|
-
[--sort-order N] [--
|
|
151
|
+
[--sort-order N] [--is-starter true|false]
|
|
152
|
+
[--setup-audience KID_FRIENDLY|PARENT_SETUP] [--kind SIMPLE|BLUEPRINT]
|
|
153
|
+
[--agent-instructions-file <path>]
|
|
152
154
|
[--output-template-file <path>] [--confirm]
|
|
153
155
|
recess [--json] goal-templates delete <template-id> --expected-version N [--confirm]
|
|
154
156
|
recess [--json] goal-templates snapshot-files <template-id> [--path P]
|
|
155
157
|
recess [--json] goal-templates capture-snapshot <template-id|slug>
|
|
156
|
-
(--source-goal <goal-id> | --source-draft <draft-slug> --student <
|
|
158
|
+
(--source-goal <goal-id> | --source-draft <draft-slug> --student <goal-owner-id>)
|
|
157
159
|
[--dry-run] [--confirm]
|
|
158
160
|
recess [--json] goal-templates apply <template-id> --answers-file <path>
|
|
159
161
|
[--dry-run] [--confirm --approval-token TOKEN]
|
|
@@ -162,7 +164,9 @@ Usage:
|
|
|
162
164
|
recess [--json] goals list --student <kid-id>
|
|
163
165
|
recess [--json] goals create --student <kid-id> --title TEXT
|
|
164
166
|
(--description TEXT | --description-file <path>) [--target-date <iso>]
|
|
165
|
-
[--schedule TEXT] [--
|
|
167
|
+
[--schedule TEXT] [--draft <draft-slug>
|
|
168
|
+
(--enable-applet-follow-ups | --disable-applet-follow-ups)]
|
|
169
|
+
[--confirm --approval-token TOKEN]
|
|
166
170
|
recess [--json] goals edit <goal-id> --student <kid-id> --patch-file <path/patch.json>
|
|
167
171
|
--delta TEXT [--confirm --approval-token TOKEN]
|
|
168
172
|
recess [--json] todos create --student <kid-id> --title TEXT
|
|
@@ -174,13 +178,13 @@ Usage:
|
|
|
174
178
|
recess [--json] rocky get --student <kid-id>
|
|
175
179
|
recess [--json] rocky set --student <kid-id> --patch-file <path/patch.json>
|
|
176
180
|
--expected-updated-at ISO|none [--confirm]
|
|
177
|
-
recess [--json] goals files list --student <
|
|
178
|
-
recess [--json] goals files read --student <
|
|
179
|
-
recess [--json] goals files write --student <
|
|
181
|
+
recess [--json] goals files list --student <goal-owner-id> --goal <goal-id>
|
|
182
|
+
recess [--json] goals files read --student <goal-owner-id> --goal <goal-id> --path P
|
|
183
|
+
recess [--json] goals files write --student <goal-owner-id>
|
|
180
184
|
(--goal <goal-id> | --draft <draft-slug>)
|
|
181
185
|
(--source-dir <local-dir> | --source-file <local-file> --path P)
|
|
182
186
|
[--message TEXT] [--confirm --approval-token TOKEN]
|
|
183
|
-
recess [--json] goals pdf upload --student <
|
|
187
|
+
recess [--json] goals pdf upload --student <goal-owner-id>
|
|
184
188
|
(--goal <goal-id> | --draft <draft-slug>) --source-file <local.pdf>
|
|
185
189
|
[--path uploads/name.pdf] [--message TEXT]
|
|
186
190
|
[--confirm --approval-token TOKEN]
|
|
@@ -191,6 +195,13 @@ bundled into this npm package. Load os-v2-goal-template-builder and its
|
|
|
191
195
|
references/deterministic-workflow-setup.md BEFORE authoring a template; that is
|
|
192
196
|
the same guidance the recess.gg/ai agent follows, so there is exactly one
|
|
193
197
|
standard. Responses cache under ~/.recess-cli/skills-cache/ (--refresh re-fetches).
|
|
198
|
+
For goal workspace and PDF commands, --student names the goal owner. A
|
|
199
|
+
full_admin session may use a KID or ADMIN user id, including its own ADMIN id;
|
|
200
|
+
family_ai sessions remain limited to managed KID profiles.
|
|
201
|
+
"goals create --draft" materializes that validated draft directly as a personal
|
|
202
|
+
module-backed goal; it does not create or apply a reusable template. Its preview
|
|
203
|
+
is bound to the exact workspace revision, hash, module inventory, and applet
|
|
204
|
+
follow-up choice.
|
|
194
205
|
Every template created here is setupMode DETERMINISTIC_WORKFLOW and CANNOT be
|
|
195
206
|
converted back, so "validate-spec" against the same file until it passes, then
|
|
196
207
|
"create". "create" runs a real server-side validation before its gate, so the
|
|
@@ -2650,6 +2661,11 @@ export async function runCommand(argv) {
|
|
|
2650
2661
|
const tags = flagString(parsed, "tags");
|
|
2651
2662
|
const kind = flagString(parsed, "kind");
|
|
2652
2663
|
const sortOrder = flagNumber(parsed, "sort-order");
|
|
2664
|
+
const isStarterRaw = flagString(parsed, "is-starter");
|
|
2665
|
+
const setupAudienceRaw = flagString(parsed, "setup-audience");
|
|
2666
|
+
const isStarter = isStarterRaw === undefined
|
|
2667
|
+
? undefined
|
|
2668
|
+
: assertChoice(isStarterRaw, ["true", "false"], "--is-starter") === "true";
|
|
2653
2669
|
const body = {
|
|
2654
2670
|
expectedVersion,
|
|
2655
2671
|
...(flagString(parsed, "title")
|
|
@@ -2673,6 +2689,12 @@ export async function runCommand(argv) {
|
|
|
2673
2689
|
}
|
|
2674
2690
|
: {}),
|
|
2675
2691
|
...(sortOrder === undefined ? {} : { sortOrder }),
|
|
2692
|
+
...(isStarter === undefined ? {} : { isStarter }),
|
|
2693
|
+
...(setupAudienceRaw
|
|
2694
|
+
? {
|
|
2695
|
+
setupAudience: assertChoice(setupAudienceRaw, GOAL_TEMPLATE_SETUP_AUDIENCES, "--setup-audience"),
|
|
2696
|
+
}
|
|
2697
|
+
: {}),
|
|
2676
2698
|
...(kind
|
|
2677
2699
|
? { kind: assertChoice(kind, GOAL_TEMPLATE_KINDS, "--kind") }
|
|
2678
2700
|
: {}),
|
|
@@ -2692,7 +2714,7 @@ export async function runCommand(argv) {
|
|
|
2692
2714
|
// shape that caused the template incident; editing an existing spec goes
|
|
2693
2715
|
// through the guarded /ai patch path with its destructive-change token.
|
|
2694
2716
|
if (Object.keys(body).length === 1) {
|
|
2695
|
-
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).");
|
|
2717
|
+
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).");
|
|
2696
2718
|
}
|
|
2697
2719
|
return writeCommand(parsed, {
|
|
2698
2720
|
action: "update goal template metadata (never its setupWorkflowSpec)",
|
|
@@ -2901,6 +2923,65 @@ export async function runCommand(argv) {
|
|
|
2901
2923
|
throw new CliError("invalid_arguments", "--target-date must be an ISO 8601 datetime.");
|
|
2902
2924
|
}
|
|
2903
2925
|
const schedule = flagString(parsed, "schedule");
|
|
2926
|
+
const draftSlug = flagString(parsed, "draft");
|
|
2927
|
+
const enablesAppletFollowUps = hasFlag(parsed, "enable-applet-follow-ups");
|
|
2928
|
+
const disablesAppletFollowUps = hasFlag(parsed, "disable-applet-follow-ups");
|
|
2929
|
+
if (draftSlug && enablesAppletFollowUps === disablesAppletFollowUps) {
|
|
2930
|
+
throw new CliError("invalid_arguments", "A module-backed goal requires exactly one of --enable-applet-follow-ups or --disable-applet-follow-ups.");
|
|
2931
|
+
}
|
|
2932
|
+
if (!draftSlug && (enablesAppletFollowUps || disablesAppletFollowUps)) {
|
|
2933
|
+
throw new CliError("invalid_arguments", "Applet follow-up flags are used with --draft for direct module-backed goals.");
|
|
2934
|
+
}
|
|
2935
|
+
if (draftSlug) {
|
|
2936
|
+
const body = {
|
|
2937
|
+
studentUserId: userId,
|
|
2938
|
+
draftSlug,
|
|
2939
|
+
title,
|
|
2940
|
+
description,
|
|
2941
|
+
enableAppletFollowUps: enablesAppletFollowUps,
|
|
2942
|
+
...(targetDate ? { targetDate } : {}),
|
|
2943
|
+
...(schedule ? { schedule } : {}),
|
|
2944
|
+
};
|
|
2945
|
+
const preflight = unwrap(await api.client.POST("/tutor/browser/mesa/goals", {
|
|
2946
|
+
body: { ...body, dryRun: true },
|
|
2947
|
+
}));
|
|
2948
|
+
if (preflight.action !== "preview_module_goal_create") {
|
|
2949
|
+
throw new CliError("unexpected_response", "Direct module goal creation did not return a preview; no goal was created.");
|
|
2950
|
+
}
|
|
2951
|
+
const preview = {
|
|
2952
|
+
action: "create a module-backed goal directly from a validated draft workspace",
|
|
2953
|
+
target: preflight.target,
|
|
2954
|
+
request: {
|
|
2955
|
+
title,
|
|
2956
|
+
descriptionChars: description.length,
|
|
2957
|
+
descriptionSha256: createHash("sha256")
|
|
2958
|
+
.update(description)
|
|
2959
|
+
.digest("hex"),
|
|
2960
|
+
targetDate: targetDate ?? null,
|
|
2961
|
+
schedule: schedule ?? null,
|
|
2962
|
+
enableAppletFollowUps: enablesAppletFollowUps,
|
|
2963
|
+
},
|
|
2964
|
+
details: {
|
|
2965
|
+
currentChangeId: preflight.currentChangeId,
|
|
2966
|
+
workspaceSha256: preflight.workspaceSha256,
|
|
2967
|
+
fileCount: preflight.fileCount,
|
|
2968
|
+
sizeBytes: preflight.sizeBytes,
|
|
2969
|
+
modules: preflight.modules,
|
|
2970
|
+
warnings: preflight.warnings,
|
|
2971
|
+
currentGoalCount: preflight.currentGoalCount,
|
|
2972
|
+
goalLimit: preflight.goalLimit,
|
|
2973
|
+
note: "Creates a personal module-backed goal directly. It does not create, capture, or apply a reusable template. After success, Recess removes the draft only when no newer Mesa edit has landed; otherwise it preserves the draft.",
|
|
2974
|
+
},
|
|
2975
|
+
};
|
|
2976
|
+
requirePreviewBoundConfirmation(parsed, preview);
|
|
2977
|
+
return unwrap(await api.client.POST("/tutor/browser/mesa/goals", {
|
|
2978
|
+
body: {
|
|
2979
|
+
...body,
|
|
2980
|
+
dryRun: false,
|
|
2981
|
+
expectedWorkspaceChangeId: preflight.currentChangeId,
|
|
2982
|
+
},
|
|
2983
|
+
}));
|
|
2984
|
+
}
|
|
2904
2985
|
const body = {
|
|
2905
2986
|
title,
|
|
2906
2987
|
description,
|
|
@@ -2917,7 +2998,7 @@ export async function runCommand(argv) {
|
|
|
2917
2998
|
schedule: schedule ?? null,
|
|
2918
2999
|
},
|
|
2919
3000
|
details: {
|
|
2920
|
-
note: "Creates a description-only goal with no modules or course workspace.
|
|
3001
|
+
note: "Creates a description-only goal with no modules or course workspace. Pass --draft to create a personal module-backed goal directly. A 409 GOAL_LIMIT_REACHED means the kid is at capacity and nothing was created.",
|
|
2921
3002
|
},
|
|
2922
3003
|
}, async () => unwrap(await api.client.POST("/tutor/browser/students/{userId}/goals/", {
|
|
2923
3004
|
params: { path: { userId } },
|
|
@@ -2956,6 +3037,19 @@ export async function runCommand(argv) {
|
|
|
2956
3037
|
}
|
|
2957
3038
|
if (noun === "students") {
|
|
2958
3039
|
if (verb === "list") {
|
|
3040
|
+
// A guide has no family — their roster is the students they are
|
|
3041
|
+
// actively assigned to, which the tutor surface already scopes. The
|
|
3042
|
+
// stored identity is absent on an env-cookie session, so fall back to
|
|
3043
|
+
// asking the server who this session belongs to.
|
|
3044
|
+
// The stored identity only describes a `auth login` session; an
|
|
3045
|
+
// env-cookie session may belong to someone else entirely, so ask the
|
|
3046
|
+
// server rather than trusting a stale login.
|
|
3047
|
+
const role = api.config.authSource === "config"
|
|
3048
|
+
? api.config.user?.role
|
|
3049
|
+
: (await api.client.GET("/auth/admin-cli/session/")).data?.user.role;
|
|
3050
|
+
if (role === "GUIDE") {
|
|
3051
|
+
return unwrap(await api.client.GET("/tutor/students", {}));
|
|
3052
|
+
}
|
|
2959
3053
|
return unwrap(await api.client.GET("/family/kids", {
|
|
2960
3054
|
params: { query: { includeSelf: "false" } },
|
|
2961
3055
|
}));
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,26 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { runCommand } from "./cli.js";
|
|
3
3
|
import { CliError } from "./errors.js";
|
|
4
|
-
const
|
|
4
|
+
const argv = process.argv.slice(2);
|
|
5
|
+
const json = argv.includes("--json");
|
|
6
|
+
// The interactive console owns the terminal, so it runs before the JSON
|
|
7
|
+
// envelope machinery rather than through it.
|
|
8
|
+
if (argv[0] === "ui") {
|
|
9
|
+
const { runUi } = await import("./ui/index.js");
|
|
10
|
+
try {
|
|
11
|
+
await runUi();
|
|
12
|
+
process.exit(0);
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
const cliError = error instanceof CliError
|
|
16
|
+
? error
|
|
17
|
+
: new CliError("unexpected_error", error instanceof Error ? error.message : String(error));
|
|
18
|
+
process.stderr.write(`Error: ${cliError.message}\n`);
|
|
19
|
+
process.exit(cliError.exitCode);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
5
22
|
try {
|
|
6
|
-
const data = await runCommand(
|
|
23
|
+
const data = await runCommand(argv);
|
|
7
24
|
if (typeof data === "object" && data && "help" in data && !json) {
|
|
8
25
|
process.stdout.write(`${String(data.help)}\n`);
|
|
9
26
|
}
|
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.8.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",
|