recess-cli 1.7.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.
package/dist/cli.js CHANGED
@@ -148,7 +148,9 @@ 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]
@@ -2659,6 +2661,11 @@ export async function runCommand(argv) {
2659
2661
  const tags = flagString(parsed, "tags");
2660
2662
  const kind = flagString(parsed, "kind");
2661
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";
2662
2669
  const body = {
2663
2670
  expectedVersion,
2664
2671
  ...(flagString(parsed, "title")
@@ -2682,6 +2689,12 @@ export async function runCommand(argv) {
2682
2689
  }
2683
2690
  : {}),
2684
2691
  ...(sortOrder === undefined ? {} : { sortOrder }),
2692
+ ...(isStarter === undefined ? {} : { isStarter }),
2693
+ ...(setupAudienceRaw
2694
+ ? {
2695
+ setupAudience: assertChoice(setupAudienceRaw, GOAL_TEMPLATE_SETUP_AUDIENCES, "--setup-audience"),
2696
+ }
2697
+ : {}),
2685
2698
  ...(kind
2686
2699
  ? { kind: assertChoice(kind, GOAL_TEMPLATE_KINDS, "--kind") }
2687
2700
  : {}),
@@ -2701,7 +2714,7 @@ export async function runCommand(argv) {
2701
2714
  // shape that caused the template incident; editing an existing spec goes
2702
2715
  // through the guarded /ai patch path with its destructive-change token.
2703
2716
  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).");
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).");
2705
2718
  }
2706
2719
  return writeCommand(parsed, {
2707
2720
  action: "update goal template metadata (never its setupWorkflowSpec)",
@@ -3024,6 +3037,19 @@ export async function runCommand(argv) {
3024
3037
  }
3025
3038
  if (noun === "students") {
3026
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
+ }
3027
3053
  return unwrap(await api.client.GET("/family/kids", {
3028
3054
  params: { query: { includeSelf: "false" } },
3029
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.7.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",