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 +33 -1
- package/dist/cli.js +28 -2
- 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.
|
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] [--
|
|
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
|
|
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",
|