recess-cli 1.0.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 ADDED
@@ -0,0 +1,95 @@
1
+ # Recess CLI
2
+
3
+ `recess-cli` is the typed, agent-friendly command layer for Recess staff operations. It uses the web-server OpenAPI document, authenticates admins through Recess SSO, emits stable JSON, and refuses live writes until the exact command is rerun with `--confirm` after human approval.
4
+
5
+ ## Install (staff — no checkout needed)
6
+
7
+ Published to npm as [`recess-cli`](https://www.npmjs.com/package/recess-cli). On any machine with Node 20+:
8
+
9
+ ```bash
10
+ npm install -g recess-cli
11
+ recess setup
12
+ ```
13
+
14
+ `setup` installs the bundled skill for both Codex and Claude and then opens Recess SSO in your browser (skip the browser step with `--skill-only`; it is also skipped when a live session already exists). Restart your agent afterwards so it discovers the skill. `npx -y recess-cli setup` works too, but leaves no `recess` on your PATH — which is the command the installed skill tells the agent to run — so `setup` warns when it detects it is running from an npx cache.
15
+
16
+ Publishing is a manual GitHub Action (`.github/workflows/admin-cli-publish.yml`): bump `version` in `apps/admin-cli/package.json` in a normal PR, then run the workflow. It publishes with `pnpm`, not `npm`, because the manifest uses the workspace `catalog:` protocol for `openapi-fetch` and only pnpm rewrites it to a real range in the tarball.
17
+
18
+ ## Install (from a checkout — CLI development)
19
+
20
+ From the monolith root:
21
+
22
+ ```bash
23
+ pnpm install
24
+ pnpm --dir apps/admin-cli run client:generate
25
+ pnpm --dir apps/admin-cli run install-persistent
26
+ ```
27
+
28
+ `install-persistent` copies a self-contained build (non-test `dist/` JS + the `openapi-fetch` runtime dep) to `~/.recess-cli/cli/` and points `~/.local/bin/recess` at it — the install keeps working after the checkout or worktree it was built from is deleted. Use it on any machine that operates on production. `install-local` instead symlinks `~/.local/bin/recess` straight to this checkout's `dist/index.js` so rebuilds are picked up live — use it only while actively developing the CLI, and expect the link to die with the worktree. Both targets install the bundled skill for Codex at `${CODEX_HOME:-~/.codex}/skills/recess-cli` and Claude at `${CLAUDE_CONFIG_DIR:-~/.claude}/skills/recess-cli`. The skill is a multi-file bundle: `skill/recess-cli/SKILL.md` carries the safety model, auth troubleshooting, JSON contract, and command quick reference, and routes to the deep workflow playbooks in `skill/recess-cli/reference/` (billing, MAP scores, payouts, class ops).
29
+
30
+ ## One-time SSO setup
31
+
32
+ Create an approved `OAuthClient` row in each Recess environment. This remains a manual security operation. Configure:
33
+
34
+ - `name`: `Recess Admin CLI`
35
+ - `approved`: `true`
36
+ - `adminCliEnabled`: `true`
37
+ - `jwtSecret`: a new secret
38
+ - `redirectUris`: exactly `http://127.0.0.1:8765/callback`
39
+ - `defaultlaunchUrl`: leave `null`; the CLI constructs and opens the Recess OAuth URL itself
40
+
41
+ The production row ID (`c7e34138-18f9-45b1-a2fb-26a4e3a6d739`) is the CLI's built-in default, so production login needs no client-ID setup. `--client-id`, `RECESS_CLI_OAUTH_CLIENT_ID`, and a stored client ID remain overrides for local/staging clients. The web-server decodes the assertion audience, loads that exact `OAuthClient`, and requires both `approved` and `adminCliEnabled`; there is no separate server environment allowlist. Production redirect validation is exact, so a different callback port must also be explicitly registered.
42
+
43
+ The browser SSO assertion is exchanged once and discarded. The CLI stores a separate 12-hour signed Recess admin session at `~/.recess-cli/config.json` with mode `0600`.
44
+
45
+ ```bash
46
+ recess --json auth login
47
+ recess --json doctor
48
+ ```
49
+
50
+ ## JSON contract
51
+
52
+ With `--json`, stdout contains only one JSON object.
53
+
54
+ Success:
55
+
56
+ ```json
57
+ {"ok":true,"data":{"results":[]}}
58
+ ```
59
+
60
+ Error or write preview:
61
+
62
+ ```json
63
+ {"ok":false,"error":{"code":"confirmation_required","message":"...","details":{"preview":{},"requiredFlag":"--confirm"}}}
64
+ ```
65
+
66
+ Exit code `0` means success, `1` means an input/auth/API failure, and `2` means a write is awaiting explicit human confirmation.
67
+
68
+ ## Common flow
69
+
70
+ ```bash
71
+ recess --json users search "Morgan Rivera"
72
+ recess --json users tier get <kid-id>
73
+ recess --json users tier preview <kid-id> --tier lite --slots 1
74
+ recess --json students upload-map-scores --student <kid-id> --file /path/to/map-report.pdf
75
+ recess --json enrollments list --user <kid-id>
76
+ recess --json subscriptions list --family <family-id> --kid <kid-id>
77
+ recess --json invoices list --subscription <subscription-id>
78
+ ```
79
+
80
+ Preview a write by omitting `--confirm`:
81
+
82
+ ```bash
83
+ recess --json billing pause --subscription <subscription-id> --until 2026-09-01
84
+ ```
85
+
86
+ After a human approves that exact preview, rerun the unchanged command with `--confirm`.
87
+
88
+ School tier writes require the `updatedAt` token from `users tier get`. Their read-only preflight
89
+ shows `capabilitiesLockedNow`/`capabilitiesLockedAfter`, the resolved class allowance, current
90
+ `slotsUsed`, and whether the change would strand registrations. Stranding is refused unless the
91
+ human explicitly approves the exceptional `--allow-strand` override.
92
+
93
+ MAP uploads accept one PDF up to 15 MB. The preview includes the resolved path, byte count, and SHA-256 without contacting the API; the confirmed command sends the report to the existing tutor-dashboard extraction route.
94
+
95
+ See `recess --help` for the complete P0 command surface. The raw escape hatch is intentionally read-only: `recess --json request get /path`.
package/dist/api.js ADDED
@@ -0,0 +1,120 @@
1
+ import createClient from "openapi-fetch";
2
+ import { apiError, CliError } from "./errors.js";
3
+ export class RecessAdminApi {
4
+ config;
5
+ client;
6
+ constructor(config) {
7
+ this.config = config;
8
+ this.client = createClient({
9
+ baseUrl: config.apiOrigin,
10
+ headers: config.sessionCookie
11
+ ? { cookie: config.sessionCookie }
12
+ : undefined,
13
+ });
14
+ }
15
+ requireAuth() {
16
+ if (!this.config.sessionCookie) {
17
+ throw new CliError("auth_required", "No admin session found. Run `recess auth login`.");
18
+ }
19
+ }
20
+ async rawGet(path) {
21
+ this.requireAuth();
22
+ if (!path.startsWith("/") || path.startsWith("//")) {
23
+ throw new CliError("invalid_arguments", "Raw request paths must start with one '/'.");
24
+ }
25
+ const response = await fetch(new URL(path, this.config.apiOrigin), {
26
+ headers: { cookie: this.config.sessionCookie },
27
+ });
28
+ const text = await response.text();
29
+ let body = text;
30
+ try {
31
+ body = text ? JSON.parse(text) : null;
32
+ }
33
+ catch {
34
+ // Preserve a non-JSON API response as text.
35
+ }
36
+ if (!response.ok)
37
+ throw apiError(response.status, body);
38
+ return body;
39
+ }
40
+ async villageRequest(path, options = {}) {
41
+ this.requireAuth();
42
+ const response = await fetch(new URL(path, this.config.apiOrigin), {
43
+ method: options.method ?? "GET",
44
+ headers: {
45
+ cookie: this.config.sessionCookie,
46
+ ...(options.body === undefined
47
+ ? {}
48
+ : { "content-type": "application/json" }),
49
+ },
50
+ ...(options.body === undefined
51
+ ? {}
52
+ : { body: JSON.stringify(options.body) }),
53
+ });
54
+ const text = await response.text();
55
+ let body = text;
56
+ try {
57
+ body = text ? JSON.parse(text) : null;
58
+ }
59
+ catch {
60
+ // Preserve a non-JSON bridge response as text.
61
+ }
62
+ if (!response.ok)
63
+ throw apiError(response.status, body);
64
+ return body;
65
+ }
66
+ async uploadVillageModel(worldId, file, fileName, metadata) {
67
+ this.requireAuth();
68
+ const form = new FormData();
69
+ for (const [key, value] of Object.entries(metadata)) {
70
+ if (value !== undefined)
71
+ form.set(key, String(value));
72
+ }
73
+ const arrayBuffer = file.buffer.slice(file.byteOffset, file.byteOffset + file.byteLength);
74
+ form.set("file", new Blob([arrayBuffer], { type: "model/gltf-binary" }), fileName);
75
+ const response = await fetch(new URL(`/admin/village/models/upload?worldId=${encodeURIComponent(worldId)}`, this.config.apiOrigin), {
76
+ method: "POST",
77
+ headers: { cookie: this.config.sessionCookie },
78
+ body: form,
79
+ });
80
+ const text = await response.text();
81
+ let body = text;
82
+ try {
83
+ body = text ? JSON.parse(text) : null;
84
+ }
85
+ catch {
86
+ // Preserve a non-JSON bridge response as text.
87
+ }
88
+ if (!response.ok)
89
+ throw apiError(response.status, body);
90
+ return body;
91
+ }
92
+ async uploadMapTestScores(studentId, pdf, fileName) {
93
+ this.requireAuth();
94
+ const formData = new FormData();
95
+ formData.append("file", new Blob([pdf], { type: "application/pdf" }), fileName);
96
+ const response = await fetch(new URL(`/tutor/students/${encodeURIComponent(studentId)}/map-test-scores/upload`, this.config.apiOrigin), {
97
+ method: "POST",
98
+ headers: { cookie: this.config.sessionCookie },
99
+ body: formData,
100
+ });
101
+ const text = await response.text();
102
+ let body = text;
103
+ try {
104
+ body = text ? JSON.parse(text) : null;
105
+ }
106
+ catch {
107
+ // Preserve a non-JSON API response as text.
108
+ }
109
+ if (!response.ok)
110
+ throw apiError(response.status, body);
111
+ return body;
112
+ }
113
+ }
114
+ export function unwrap(result) {
115
+ if (!result.response.ok || result.data === undefined) {
116
+ throw apiError(result.response.status, result.error);
117
+ }
118
+ return result.data;
119
+ }
120
+ //# sourceMappingURL=api.js.map
package/dist/args.js ADDED
@@ -0,0 +1,71 @@
1
+ import { CliError } from "./errors.js";
2
+ const BOOLEAN_FLAGS = new Set([
3
+ "allow-strand",
4
+ "cancel-subscriptions",
5
+ "archived",
6
+ "confirm",
7
+ "full",
8
+ "help",
9
+ "immediate",
10
+ "mirrored",
11
+ "no-collision",
12
+ "json",
13
+ "restore",
14
+ "revoke",
15
+ "send-email",
16
+ "visual-only",
17
+ ]);
18
+ export function parseArgs(args) {
19
+ const positionals = [];
20
+ const flags = new Map();
21
+ for (let index = 0; index < args.length; index += 1) {
22
+ const value = args[index];
23
+ if (!value.startsWith("--")) {
24
+ positionals.push(value);
25
+ continue;
26
+ }
27
+ const equalsAt = value.indexOf("=");
28
+ if (equalsAt > 2) {
29
+ flags.set(value.slice(2, equalsAt), value.slice(equalsAt + 1));
30
+ continue;
31
+ }
32
+ const name = value.slice(2);
33
+ if (BOOLEAN_FLAGS.has(name)) {
34
+ flags.set(name, true);
35
+ continue;
36
+ }
37
+ const next = args[index + 1];
38
+ if (next && !next.startsWith("--")) {
39
+ flags.set(name, next);
40
+ index += 1;
41
+ }
42
+ else {
43
+ flags.set(name, true);
44
+ }
45
+ }
46
+ return { positionals, flags };
47
+ }
48
+ export function flagString(parsed, name, options = {}) {
49
+ const value = parsed.flags.get(name);
50
+ if (value === true) {
51
+ throw new CliError("invalid_arguments", `--${name} requires a value.`);
52
+ }
53
+ if (value === undefined && options.required) {
54
+ throw new CliError("invalid_arguments", `Missing required --${name}.`);
55
+ }
56
+ return value;
57
+ }
58
+ export function flagNumber(parsed, name) {
59
+ const value = flagString(parsed, name);
60
+ if (value === undefined)
61
+ return undefined;
62
+ const number = Number(value);
63
+ if (!Number.isFinite(number)) {
64
+ throw new CliError("invalid_arguments", `--${name} must be a number.`);
65
+ }
66
+ return number;
67
+ }
68
+ export function hasFlag(parsed, name) {
69
+ return parsed.flags.has(name);
70
+ }
71
+ //# sourceMappingURL=args.js.map
package/dist/auth.js ADDED
@@ -0,0 +1,189 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createServer } from "node:http";
3
+ import { randomBytes } from "node:crypto";
4
+ import { clearPendingDeviceAuth, updateStoredConfig } from "./config.js";
5
+ import { apiError, CliError } from "./errors.js";
6
+ function openBrowser(url) {
7
+ const command = process.platform === "darwin"
8
+ ? "open"
9
+ : process.platform === "win32"
10
+ ? "cmd"
11
+ : "xdg-open";
12
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
13
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
14
+ child.unref();
15
+ }
16
+ export function waitForSsoCallback(port, expectedState) {
17
+ return new Promise((resolve, reject) => {
18
+ const timeout = setTimeout(() => {
19
+ server.close();
20
+ reject(new CliError("auth_timeout", "Timed out waiting for Recess SSO."));
21
+ }, 5 * 60 * 1000);
22
+ const server = createServer((request, response) => {
23
+ const url = new URL(request.url ?? "/", `http://127.0.0.1:${port}`);
24
+ if (url.pathname !== "/callback") {
25
+ response.writeHead(404).end("Not found");
26
+ return;
27
+ }
28
+ const state = url.searchParams.get("state");
29
+ if (state !== expectedState) {
30
+ response.writeHead(400, { "content-type": "text/plain" });
31
+ response.end("Invalid Recess admin CLI sign-in callback.");
32
+ return;
33
+ }
34
+ const oauthError = url.searchParams.get("error");
35
+ if (oauthError) {
36
+ response.writeHead(400, { "content-type": "text/plain" });
37
+ response.end("Recess admin CLI sign-in was denied.");
38
+ clearTimeout(timeout);
39
+ server.close();
40
+ reject(new CliError("auth_failed", `SSO login was denied: ${oauthError}.`));
41
+ return;
42
+ }
43
+ const token = url.searchParams.get("token");
44
+ if (!token) {
45
+ response.writeHead(400, { "content-type": "text/plain" });
46
+ response.end("Invalid Recess admin CLI sign-in callback.");
47
+ return;
48
+ }
49
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
50
+ response.end("<!doctype html><title>Recess admin CLI</title><p>Signed in. You can close this tab and return to the terminal.</p>");
51
+ clearTimeout(timeout);
52
+ server.close();
53
+ resolve(token);
54
+ });
55
+ server.once("error", (error) => {
56
+ clearTimeout(timeout);
57
+ reject(new CliError("callback_unavailable", `Could not listen on 127.0.0.1:${port}: ${error.message}`));
58
+ });
59
+ server.listen(port, "127.0.0.1");
60
+ });
61
+ }
62
+ export async function login(config, options) {
63
+ const state = randomBytes(24).toString("base64url");
64
+ const redirectUri = `http://127.0.0.1:${options.callbackPort}/callback`;
65
+ const authorizeUrl = new URL("/oauth", config.webOrigin);
66
+ authorizeUrl.searchParams.set("client_id", options.oauthClientId);
67
+ authorizeUrl.searchParams.set("redirect_uri", redirectUri);
68
+ authorizeUrl.searchParams.set("state", state);
69
+ authorizeUrl.searchParams.set("type", "sso");
70
+ const callback = waitForSsoCallback(options.callbackPort, state);
71
+ openBrowser(authorizeUrl.toString());
72
+ const assertion = await callback;
73
+ const response = await fetch(new URL("/auth/admin-cli/exchange/", config.apiOrigin), {
74
+ method: "POST",
75
+ headers: { authorization: `Bearer ${assertion}` },
76
+ });
77
+ const body = (await response.json());
78
+ if (!response.ok)
79
+ throw apiError(response.status, body);
80
+ const pair = response.headers.get("set-cookie")?.split(";", 1)[0];
81
+ if (!pair?.includes("=")) {
82
+ throw new CliError("auth_failed", "The Recess API did not return an admin session cookie.");
83
+ }
84
+ const exchange = body;
85
+ await updateStoredConfig({
86
+ apiOrigin: config.apiOrigin,
87
+ webOrigin: config.webOrigin,
88
+ oauthClientId: options.oauthClientId,
89
+ sessionCookie: pair,
90
+ sessionExpiresAt: exchange.expiresAt,
91
+ user: exchange.user,
92
+ });
93
+ return exchange;
94
+ }
95
+ // Start the headless (device-authorization) sign-in. Non-blocking: it stores the secret
96
+ // device code locally and returns the approval URL for the agent to surface to a human, who
97
+ // approves it in a browser. The agent then calls `pollDeviceAuth` to collect the session.
98
+ export async function requestDeviceAuth(config, options) {
99
+ const response = await fetch(new URL("/auth/admin-cli/device/authorize/", config.apiOrigin), {
100
+ method: "POST",
101
+ headers: { "content-type": "application/json" },
102
+ body: JSON.stringify(options.label ? { label: options.label } : {}),
103
+ });
104
+ const body = (await response.json());
105
+ if (!response.ok)
106
+ throw apiError(response.status, body);
107
+ const authorize = body;
108
+ await updateStoredConfig({
109
+ apiOrigin: config.apiOrigin,
110
+ webOrigin: config.webOrigin,
111
+ pendingDeviceCode: authorize.deviceCode,
112
+ pendingUserCode: authorize.userCode,
113
+ pendingApprovalUrl: authorize.approvalUrl,
114
+ pendingExpiresAt: authorize.expiresAt,
115
+ });
116
+ return {
117
+ approvalUrl: authorize.approvalUrl,
118
+ userCode: authorize.userCode,
119
+ expiresAt: authorize.expiresAt,
120
+ interval: authorize.interval,
121
+ instructions: `Open ${authorize.approvalUrl} in a browser, approve as a Recess admin, then run \`recess auth poll\`.`,
122
+ };
123
+ }
124
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
125
+ // Poll the device-authorization request until an admin approves it (then store the session),
126
+ // or it is denied / expires / the timeout elapses. On a plain timeout the pending request is
127
+ // preserved so the agent can call `auth poll` again after the human approves.
128
+ export async function pollDeviceAuth(config, options) {
129
+ const deviceCode = config.pendingDeviceCode;
130
+ if (!deviceCode) {
131
+ throw new CliError("auth_required", "No pending sign-in. Run `recess auth request` first.");
132
+ }
133
+ const intervalMs = options.intervalMs ?? 5000;
134
+ const deadline = Date.now() + options.timeoutMs;
135
+ const pendingExpiry = config.pendingExpiresAt
136
+ ? Date.parse(config.pendingExpiresAt)
137
+ : undefined;
138
+ for (;;) {
139
+ const response = await fetch(new URL("/auth/admin-cli/device/token/", config.apiOrigin), {
140
+ method: "POST",
141
+ headers: { "content-type": "application/json" },
142
+ body: JSON.stringify({ deviceCode }),
143
+ });
144
+ const body = (await response.json());
145
+ if (!response.ok) {
146
+ await clearPendingDeviceAuth();
147
+ throw apiError(response.status, body);
148
+ }
149
+ const token = body;
150
+ if (token.status === "approved") {
151
+ const pair = response.headers.get("set-cookie")?.split(";", 1)[0];
152
+ if (!pair?.includes("=")) {
153
+ throw new CliError("auth_failed", "The Recess API approved the request but did not return a session cookie.");
154
+ }
155
+ const exchange = {
156
+ success: true,
157
+ expiresAt: token.expiresAt ?? "",
158
+ user: token.user,
159
+ };
160
+ await updateStoredConfig({
161
+ apiOrigin: config.apiOrigin,
162
+ webOrigin: config.webOrigin,
163
+ sessionCookie: pair,
164
+ sessionExpiresAt: exchange.expiresAt,
165
+ user: exchange.user,
166
+ });
167
+ await clearPendingDeviceAuth();
168
+ return exchange;
169
+ }
170
+ if (token.status === "denied") {
171
+ await clearPendingDeviceAuth();
172
+ throw new CliError("auth_failed", "The sign-in request was denied.");
173
+ }
174
+ if (token.status === "expired" || token.status === "consumed") {
175
+ await clearPendingDeviceAuth();
176
+ throw new CliError("auth_failed", token.status === "expired"
177
+ ? "The sign-in request expired. Run `recess auth request` again."
178
+ : "The sign-in request was already used. Run `recess auth request` again.");
179
+ }
180
+ // status === "pending": wait and retry, unless we would run past the deadline
181
+ // or the request's own expiry.
182
+ const nextTick = Date.now() + intervalMs;
183
+ if (nextTick >= deadline || (pendingExpiry && nextTick >= pendingExpiry)) {
184
+ throw new CliError("auth_timeout", "Timed out waiting for approval. Approve the link, then run `recess auth poll`.");
185
+ }
186
+ await sleep(intervalMs);
187
+ }
188
+ }
189
+ //# sourceMappingURL=auth.js.map