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 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
- MODULE_BACKED BLUEPRINT content is authored as a local workspace tree, batch-upserted to a named
187
- goal draft, then attached with `capture-snapshot`. Both mutations run server previews before the
188
- confirmation gate. Goal-content upserts are compare-and-set against the previewed revision; capture is
189
- fenced to both that source change and the template version. Direct live-goal `modules/` and
190
- `state/` writes are blocked because those files have database projections.
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
@@ -12,6 +12,8 @@ const BOOLEAN_FLAGS = new Set([
12
12
  "archived",
13
13
  "confirm",
14
14
  "dry-run",
15
+ "disable-applet-follow-ups",
16
+ "enable-applet-follow-ups",
15
17
  "full",
16
18
  "help",
17
19
  "immediate",
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] [--kind SIMPLE|BLUEPRINT] [--agent-instructions-file <path>]
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 <kid-id>)
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] [--confirm]
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 <kid-id> --goal <goal-id>
178
- recess [--json] goals files read --student <kid-id> --goal <goal-id> --path P
179
- recess [--json] goals files write --student <kid-id>
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 <kid-id>
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. For a module-backed course, apply a BLUEPRINT template instead. A 409 GOAL_LIMIT_REACHED means the kid is at capacity and nothing was created.",
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 json = process.argv.slice(2).includes("--json");
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(process.argv.slice(2));
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
  }
Binary file
package/dist/ui/app.js ADDED
@@ -0,0 +1,127 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Box, Text, useApp, useInput } from "ink";
3
+ import { useCallback, useEffect, useState } from "react";
4
+ const REFRESH_MS = 30_000;
5
+ function relative(iso) {
6
+ if (!iso)
7
+ return "never";
8
+ const deltaMin = Math.round((Date.now() - new Date(iso).getTime()) / 60_000);
9
+ if (deltaMin < 1)
10
+ return "just now";
11
+ if (deltaMin < 60)
12
+ return `${deltaMin}m ago`;
13
+ const hours = Math.round(deltaMin / 60);
14
+ if (hours < 24)
15
+ return `${hours}h ago`;
16
+ return `${Math.round(hours / 24)}d ago`;
17
+ }
18
+ /** done/total as a compact bar — the fastest read on "is today on track". */
19
+ function todoBar(done, total) {
20
+ if (total === 0)
21
+ return "·".repeat(5);
22
+ const filled = Math.round((done / total) * 5);
23
+ return "█".repeat(filled) + "░".repeat(5 - filled);
24
+ }
25
+ export function App({ api }) {
26
+ const { exit } = useApp();
27
+ const [session, setSession] = useState(null);
28
+ const [rows, setRows] = useState([]);
29
+ const [summaries, setSummaries] = useState(null);
30
+ const [cursor, setCursor] = useState(0);
31
+ const [filter, setFilter] = useState("");
32
+ const [filtering, setFiltering] = useState(false);
33
+ const [error, setError] = useState(null);
34
+ const [loadedAt, setLoadedAt] = useState(null);
35
+ const visible = rows.filter((r) => filter ? r.name.toLowerCase().includes(filter.toLowerCase()) : true);
36
+ const selected = visible[Math.min(cursor, visible.length - 1)];
37
+ const loadRoster = useCallback(async () => {
38
+ try {
39
+ const [sessionRes, rosterRes] = await Promise.all([
40
+ api.client.GET("/auth/admin-cli/session/"),
41
+ api.client.GET("/tutor/students", {}),
42
+ ]);
43
+ const s = sessionRes.data;
44
+ if (s) {
45
+ setSession({
46
+ role: s.user.role,
47
+ cliScope: s.cliScope,
48
+ name: [s.user.firstName, s.user.lastName].filter(Boolean).join(" "),
49
+ });
50
+ }
51
+ const items = (rosterRes.data?.items ?? []);
52
+ setRows(items);
53
+ setLoadedAt(new Date());
54
+ setError(null);
55
+ }
56
+ catch (err) {
57
+ setError(err instanceof Error ? err.message : String(err));
58
+ }
59
+ }, [api]);
60
+ useEffect(() => {
61
+ void loadRoster();
62
+ const timer = setInterval(() => void loadRoster(), REFRESH_MS);
63
+ return () => clearInterval(timer);
64
+ }, [loadRoster]);
65
+ // Detail is fetched per selection rather than up front: a roster of 40
66
+ // students would otherwise fire 40 requests nobody asked for.
67
+ useEffect(() => {
68
+ let cancelled = false;
69
+ setSummaries(null);
70
+ if (!selected)
71
+ return;
72
+ void (async () => {
73
+ try {
74
+ const res = await api.client.GET("/tutor/students/{studentId}/daily-summaries", { params: { path: { studentId: selected.userId } } });
75
+ if (cancelled)
76
+ return;
77
+ const data = res.data;
78
+ setSummaries(data?.items?.slice(0, 3) ?? []);
79
+ }
80
+ catch {
81
+ if (!cancelled)
82
+ setSummaries([]);
83
+ }
84
+ })();
85
+ return () => {
86
+ cancelled = true;
87
+ };
88
+ }, [api, selected?.userId]);
89
+ useInput((input, key) => {
90
+ if (filtering) {
91
+ if (key.return || key.escape) {
92
+ setFiltering(false);
93
+ return;
94
+ }
95
+ if (key.backspace || key.delete) {
96
+ setFilter((f) => f.slice(0, -1));
97
+ return;
98
+ }
99
+ if (input)
100
+ setFilter((f) => f + input);
101
+ return;
102
+ }
103
+ if (input === "q" || key.escape)
104
+ exit();
105
+ if (input === "r")
106
+ void loadRoster();
107
+ if (input === "/") {
108
+ setFilter("");
109
+ setFiltering(true);
110
+ }
111
+ if (key.downArrow || input === "j") {
112
+ setCursor((c) => Math.min(c + 1, Math.max(visible.length - 1, 0)));
113
+ }
114
+ if (key.upArrow || input === "k")
115
+ setCursor((c) => Math.max(c - 1, 0));
116
+ });
117
+ const liveCount = rows.filter((r) => r.liveSession).length;
118
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, color: "cyan", children: "recess" }), _jsxs(Text, { dimColor: true, children: [" ", session
119
+ ? `${session.name} · ${session.role} · ${session.cliScope}`
120
+ : "connecting…"] })] }), _jsxs(Text, { dimColor: true, children: [rows.length, " students", liveCount > 0 ? ` · ${liveCount} live` : "", loadedAt ? ` · ${loadedAt.toLocaleTimeString()}` : ""] })] }), error ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "red", children: error }) })) : null, _jsxs(Box, { marginTop: 1, children: [_jsxs(Box, { flexDirection: "column", width: "55%", children: [_jsxs(Text, { dimColor: true, children: ["STUDENT".padEnd(22), "TODAY".padEnd(8), "XP".padEnd(6), "GOALS"] }), visible.length === 0 ? (_jsx(Text, { dimColor: true, children: rows.length === 0 ? "no students in scope" : "no match" })) : null, visible.map((row, index) => {
121
+ const active = row.userId === selected?.userId;
122
+ return (_jsxs(Text, { inverse: active, color: row.liveSession ? "green" : undefined, children: [`${row.liveSession ? "●" : " "} ${row.name}`
123
+ .slice(0, 21)
124
+ .padEnd(22), `${todoBar(row.todosToday.done, row.todosToday.total)}`.padEnd(8), `${row.todayXp}`.padEnd(6), `${row.activeGoalCount}`] }, row.userId));
125
+ })] }), _jsx(Box, { flexDirection: "column", width: "45%", paddingLeft: 2, children: selected ? (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: selected.name }), _jsxs(Text, { dimColor: true, children: ["last active ", relative(selected.lastActiveAt)] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { children: ["todos today", " ", _jsxs(Text, { bold: true, children: [selected.todosToday.done, "/", selected.todosToday.total] }), " ", "xp ", _jsx(Text, { bold: true, children: selected.todayXp })] }), _jsxs(Text, { children: ["active goals ", _jsx(Text, { bold: true, children: selected.activeGoalCount }), " ", "modules done", " ", _jsx(Text, { bold: true, children: selected.modulesCompleted })] })] }), selected.liveSession ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "green", children: ["\u25CF working on ", selected.liveSession.todoTitle, " (", relative(selected.liveSession.startedAt), ")"] }) })) : null, _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "RECENT SUMMARIES" }), summaries === null ? _jsx(Text, { dimColor: true, children: "loading\u2026" }) : null, summaries?.length === 0 ? _jsx(Text, { dimColor: true, children: "none" }) : null, summaries?.map((s) => (_jsxs(Text, { children: [(s.summaryDate ?? "").slice(0, 10), " ", _jsx(Text, { dimColor: true, children: s.status ?? "" }), " ", (s.smsNotification ?? "").slice(0, 40)] }, s.id)))] })] })) : (_jsx(Text, { dimColor: true, children: "select a student" })) })] }), _jsx(Box, { marginTop: 1, children: filtering ? (_jsxs(Text, { children: ["filter: ", _jsx(Text, { bold: true, children: filter }), _jsx(Text, { dimColor: true, children: " (enter to apply, esc to stop)" })] })) : (_jsxs(Text, { dimColor: true, children: ["\u2191\u2193/jk move \u00B7 / filter", filter ? ` (${filter})` : "", " \u00B7 r refresh \u00B7 q quit"] })) })] }));
126
+ }
127
+ //# sourceMappingURL=app.js.map
@@ -0,0 +1,21 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { render } from "ink";
3
+ import { RecessAdminApi } from "../api.js";
4
+ import { resolveConfig } from "../config.js";
5
+ import { CliError } from "../errors.js";
6
+ import { App } from "./app.js";
7
+ /**
8
+ * The interactive console. Kept off the JSON path on purpose: agents parse
9
+ * stdout, so the TUI is only reachable through its own command and never
10
+ * writes an envelope.
11
+ */
12
+ export async function runUi() {
13
+ const config = await resolveConfig();
14
+ if (!config.sessionCookie) {
15
+ throw new CliError("auth_required", "No Recess CLI session found. Run `recess auth login`.");
16
+ }
17
+ const api = new RecessAdminApi(config);
18
+ const instance = render(_jsx(App, { api: api }));
19
+ await instance.waitUntilExit();
20
+ }
21
+ //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "recess-cli",
3
- "version": "1.6.0",
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
- "openapi-fetch": "^0.14.0"
29
+ "ink": "^7.1.1",
30
+ "openapi-fetch": "^0.14.0",
31
+ "react": "^19.2.8"
30
32
  },
31
33
  "devDependencies": {
32
34
  "@types/node": "^24.10.0",
35
+ "@types/react": "^19.2.18",
33
36
  "@typescript/native-preview": "7.0.0-dev.20251015.1",
37
+ "esbuild": "^0.28.2",
34
38
  "openapi-typescript": "^7.8.0",
35
39
  "oxlint": "^1.28.0",
36
40
  "oxlint-tsgolint": "^0.6.0",