recess-cli 2.3.0 → 2.5.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 +28 -2
- package/dist/args.js +1 -0
- package/dist/cli.js +310 -7
- package/dist/command-schema.js +237 -9
- package/dist/commands/apps.js +413 -0
- package/dist/commands/onboarding.js +15 -0
- package/dist/commands/village-events.js +150 -0
- package/dist/help.js +48 -0
- package/package.json +1 -1
- package/skill/recess-cli/SKILL.md +40 -3
- package/skill/recess-cli/agents/version.json +2 -2
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { unwrap } from "../api.js";
|
|
4
|
+
import { flagString, hasFlag } from "../args.js";
|
|
5
|
+
import { CliError } from "../errors.js";
|
|
6
|
+
import { positional } from "./shared.js";
|
|
7
|
+
const PROJECT_FILE = ".recess/app.json";
|
|
8
|
+
const CONTRACT_FILE = "contract.json";
|
|
9
|
+
const MANIFEST_FILE = "manifest.json";
|
|
10
|
+
const BRIEF_FILE = "brief.md";
|
|
11
|
+
const SKIP_DIRS = new Set([".recess", ".git", "node_modules"]);
|
|
12
|
+
const MAX_FILE_BYTES = 512 * 1024;
|
|
13
|
+
const TERMINAL = new Set(["COMPLETE", "FAILED", "REJECTED"]);
|
|
14
|
+
async function readAppLink(dir) {
|
|
15
|
+
try {
|
|
16
|
+
const raw = await fs.readFile(path.join(dir, PROJECT_FILE), "utf8");
|
|
17
|
+
const parsed = JSON.parse(raw);
|
|
18
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
async function writeAppLink(dir, link) {
|
|
25
|
+
await fs.mkdir(path.join(dir, ".recess"), { recursive: true });
|
|
26
|
+
await fs.writeFile(path.join(dir, PROJECT_FILE), JSON.stringify(link, null, 2) + "\n");
|
|
27
|
+
}
|
|
28
|
+
/** Every text file under dir (relative, forward-slash paths); the CLI's own state stays out. */
|
|
29
|
+
async function collectFiles(dir) {
|
|
30
|
+
const files = {};
|
|
31
|
+
async function walk(current, prefix) {
|
|
32
|
+
const entries = await fs.readdir(current, { withFileTypes: true });
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
if (SKIP_DIRS.has(entry.name))
|
|
35
|
+
continue;
|
|
36
|
+
const full = path.join(current, entry.name);
|
|
37
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
38
|
+
if (entry.isDirectory()) {
|
|
39
|
+
await walk(full, rel);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (!entry.isFile())
|
|
43
|
+
continue;
|
|
44
|
+
const stat = await fs.stat(full);
|
|
45
|
+
if (stat.size > MAX_FILE_BYTES) {
|
|
46
|
+
throw new CliError("invalid_arguments", `${rel} is larger than ${MAX_FILE_BYTES / 1024} KB; Studio apps are small text files.`);
|
|
47
|
+
}
|
|
48
|
+
files[rel] = await fs.readFile(full, "utf8");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
await walk(dir, "");
|
|
52
|
+
if (!files["index.html"]) {
|
|
53
|
+
throw new CliError("invalid_arguments", `${dir} has no index.html. Run \`recess apps init\` first, or point --dir at the app.`);
|
|
54
|
+
}
|
|
55
|
+
return files;
|
|
56
|
+
}
|
|
57
|
+
async function writeFiles(dir, files) {
|
|
58
|
+
let count = 0;
|
|
59
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
60
|
+
if (rel.includes("..") || path.isAbsolute(rel))
|
|
61
|
+
continue;
|
|
62
|
+
const full = path.join(dir, rel);
|
|
63
|
+
await fs.mkdir(path.dirname(full), { recursive: true });
|
|
64
|
+
await fs.writeFile(full, content);
|
|
65
|
+
count += 1;
|
|
66
|
+
}
|
|
67
|
+
return count;
|
|
68
|
+
}
|
|
69
|
+
function resolveDir(ctx, positionalIndex) {
|
|
70
|
+
const flag = flagString(ctx.parsed, "dir");
|
|
71
|
+
const fromPositional = ctx.parsed.positionals[positionalIndex];
|
|
72
|
+
return path.resolve(flag ?? fromPositional ?? ".");
|
|
73
|
+
}
|
|
74
|
+
async function readContract(dir) {
|
|
75
|
+
const raw = await fs
|
|
76
|
+
.readFile(path.join(dir, CONTRACT_FILE), "utf8")
|
|
77
|
+
.catch(() => null);
|
|
78
|
+
if (!raw) {
|
|
79
|
+
throw new CliError("invalid_arguments", `${CONTRACT_FILE} is missing. Your agent writes the BuildContract there (see AGENTS.md in the app folder).`);
|
|
80
|
+
}
|
|
81
|
+
let parsed;
|
|
82
|
+
try {
|
|
83
|
+
parsed = JSON.parse(raw);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
throw new CliError("invalid_arguments", `${CONTRACT_FILE} is not valid JSON.`);
|
|
87
|
+
}
|
|
88
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
89
|
+
throw new CliError("invalid_arguments", `${CONTRACT_FILE} must be a JSON object (the BuildContract).`);
|
|
90
|
+
}
|
|
91
|
+
return parsed;
|
|
92
|
+
}
|
|
93
|
+
async function readManifest(dir) {
|
|
94
|
+
const raw = await fs
|
|
95
|
+
.readFile(path.join(dir, MANIFEST_FILE), "utf8")
|
|
96
|
+
.catch(() => null);
|
|
97
|
+
if (!raw)
|
|
98
|
+
return {};
|
|
99
|
+
try {
|
|
100
|
+
return JSON.parse(raw);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
throw new CliError("invalid_arguments", `${MANIFEST_FILE} is not valid JSON.`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function slug(value) {
|
|
107
|
+
return value
|
|
108
|
+
.toLowerCase()
|
|
109
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
110
|
+
.replace(/^-+|-+$/g, "")
|
|
111
|
+
.slice(0, 48);
|
|
112
|
+
}
|
|
113
|
+
function briefFor(data) {
|
|
114
|
+
const { todo, analysis } = data;
|
|
115
|
+
const misconceptions = analysis.patterns.map((p) => p.instanceKey ?? slug(p.name));
|
|
116
|
+
const lines = [
|
|
117
|
+
"# Build brief — remediation for a kid's todo",
|
|
118
|
+
"",
|
|
119
|
+
`Source todo: "${todo.title}" (${todo.id})${todo.completedAt ? `, completed ${todo.completedAt.slice(0, 10)}` : ""}`,
|
|
120
|
+
...(todo.description ? [`Todo description: ${todo.description}`] : []),
|
|
121
|
+
...(data.standards.length
|
|
122
|
+
? [
|
|
123
|
+
"",
|
|
124
|
+
"## Standard(s)",
|
|
125
|
+
...data.standards.map((s) => `- ${s.notation} — ${s.description}`),
|
|
126
|
+
]
|
|
127
|
+
: [
|
|
128
|
+
"",
|
|
129
|
+
"## Standard(s)",
|
|
130
|
+
"- none mapped; pick one with `recess apps standards <words>`",
|
|
131
|
+
]),
|
|
132
|
+
...(data.gradeBand ? ["", `Grade band: ${data.gradeBand}`] : []),
|
|
133
|
+
...(data.interests.length
|
|
134
|
+
? [`Interests: ${data.interests.join(", ")}`]
|
|
135
|
+
: []),
|
|
136
|
+
"",
|
|
137
|
+
"## What went wrong (forensic analysis of the session)",
|
|
138
|
+
"",
|
|
139
|
+
...(analysis.overallPerformance
|
|
140
|
+
? [`Overall: ${analysis.overallPerformance}`, ""]
|
|
141
|
+
: []),
|
|
142
|
+
...(analysis.rootCause ? [`Root cause: ${analysis.rootCause}`, ""] : []),
|
|
143
|
+
...(analysis.summary ? [`Summary: ${analysis.summary}`, ""] : []),
|
|
144
|
+
"### Patterns",
|
|
145
|
+
...(analysis.patterns.length
|
|
146
|
+
? analysis.patterns.flatMap((p) => [
|
|
147
|
+
`- **${p.name}**${p.instanceKey ? ` (\`${p.instanceKey}\`)` : ""}: ${p.issue}`,
|
|
148
|
+
` ${p.analysis}`,
|
|
149
|
+
...(p.behavioralGap
|
|
150
|
+
? [
|
|
151
|
+
` Observed: ${p.behavioralGap.observed}`,
|
|
152
|
+
` Expected: ${p.behavioralGap.expected}`,
|
|
153
|
+
]
|
|
154
|
+
: []),
|
|
155
|
+
])
|
|
156
|
+
: ["- none recorded"]),
|
|
157
|
+
"",
|
|
158
|
+
"### Prescriptions",
|
|
159
|
+
...(analysis.prescriptions.length
|
|
160
|
+
? analysis.prescriptions.map((p) => `- ${p.type}: ${p.recommendation}`)
|
|
161
|
+
: ["- none recorded"]),
|
|
162
|
+
"",
|
|
163
|
+
"## Build against this",
|
|
164
|
+
"",
|
|
165
|
+
"- Mode: remediate. The app exists to close the gap above — every practice item must be one the kid",
|
|
166
|
+
" would get wrong by the observed behaviour, and the fork must confront observed vs expected.",
|
|
167
|
+
`- In manifest.json declare template.misconceptions as ${JSON.stringify(misconceptions)} (match the`,
|
|
168
|
+
" keys above; add more only if the model teaches them) and template.ccss as the standard.",
|
|
169
|
+
"- Theme items with the interests listed; never use the kid's name or anything from the session.",
|
|
170
|
+
"- The kid must never learn this app exists because of a mistake: no 'last time', 'you got',",
|
|
171
|
+
" 'let's fix your'. The confront intro shows a stranger's attempt ('Someone tried this'); the",
|
|
172
|
+
" title and every line read like any other practice.",
|
|
173
|
+
"- Follow AGENTS.md for everything else (contract, no reveals, one reading per step).",
|
|
174
|
+
"",
|
|
175
|
+
];
|
|
176
|
+
return lines.join("\n");
|
|
177
|
+
}
|
|
178
|
+
function agentsGuide(docs) {
|
|
179
|
+
return [
|
|
180
|
+
"# Building this Recess app",
|
|
181
|
+
"",
|
|
182
|
+
"If a brief.md sits next to this file, it is the assignment: build for that kid's gap first —",
|
|
183
|
+
"but the kid never hears about the gap: no copy that refers to their past work or a mistake.",
|
|
184
|
+
"",
|
|
185
|
+
"This folder is a Recess Studio app on the locked scaffold. Edit ONLY: skill.js, model.js,",
|
|
186
|
+
"model.css, params.schema.json, params.json. Never touch index.html, styles.css, shell.js,",
|
|
187
|
+
"widgets.js, engine.js, key.js — a modified locked file fails validation.",
|
|
188
|
+
"",
|
|
189
|
+
"Before publishing, write two files at the root:",
|
|
190
|
+
"- contract.json — the BuildContract described below (copy the shape in the template contract).",
|
|
191
|
+
'- manifest.json — { "title": "...", "description": "...", "template": { "ccss": "5.NF.A.1", "modes": ["prepare","remediate"], "misconceptions": ["..."] } }',
|
|
192
|
+
"",
|
|
193
|
+
'Then: `recess apps validate --reason "..."` (reports without publishing) and',
|
|
194
|
+
'`recess apps publish --reason "..."` (validation + independent review; on pass the app is live).',
|
|
195
|
+
"",
|
|
196
|
+
"---",
|
|
197
|
+
"",
|
|
198
|
+
docs.runtime ?? "",
|
|
199
|
+
"",
|
|
200
|
+
"---",
|
|
201
|
+
"",
|
|
202
|
+
docs.template ?? "",
|
|
203
|
+
"",
|
|
204
|
+
"---",
|
|
205
|
+
"",
|
|
206
|
+
docs.pedagogy ? `# Pedagogy\n\n${docs.pedagogy}` : "",
|
|
207
|
+
"",
|
|
208
|
+
"---",
|
|
209
|
+
"",
|
|
210
|
+
docs.design ? `# Design\n\n${docs.design}` : "",
|
|
211
|
+
"",
|
|
212
|
+
].join("\n");
|
|
213
|
+
}
|
|
214
|
+
async function pollBuild(ctx, buildId, timeoutMs) {
|
|
215
|
+
const started = Date.now();
|
|
216
|
+
let delay = 3_000;
|
|
217
|
+
for (;;) {
|
|
218
|
+
const status = unwrap(await ctx.api.client.GET("/studio/builds/{buildId}", {
|
|
219
|
+
params: { path: { buildId } },
|
|
220
|
+
}));
|
|
221
|
+
if (TERMINAL.has(status.status))
|
|
222
|
+
return status;
|
|
223
|
+
if (Date.now() - started > timeoutMs) {
|
|
224
|
+
throw new CliError("timeout", `Build ${buildId} is still ${status.status} after ${Math.round(timeoutMs / 1000)}s; check later with \`recess apps status ${buildId}\`.`);
|
|
225
|
+
}
|
|
226
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
227
|
+
delay = Math.min(delay * 1.5, 15_000);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
async function submit(ctx, dir, dryRun) {
|
|
231
|
+
const files = await collectFiles(dir);
|
|
232
|
+
const contract = await readContract(dir);
|
|
233
|
+
const manifest = await readManifest(dir);
|
|
234
|
+
const link = await readAppLink(dir);
|
|
235
|
+
const name = flagString(ctx.parsed, "name") ?? manifest.title ?? path.basename(dir);
|
|
236
|
+
const body = {
|
|
237
|
+
...(link?.projectId ? { projectId: link.projectId } : {}),
|
|
238
|
+
name,
|
|
239
|
+
...(manifest.description ? { description: manifest.description } : {}),
|
|
240
|
+
files,
|
|
241
|
+
contract,
|
|
242
|
+
...(manifest.template ? { template: manifest.template } : {}),
|
|
243
|
+
dryRun,
|
|
244
|
+
};
|
|
245
|
+
const assignTo = flagString(ctx.parsed, "assign");
|
|
246
|
+
const due = flagString(ctx.parsed, "due");
|
|
247
|
+
if (assignTo && dryRun) {
|
|
248
|
+
throw new CliError("invalid_arguments", "--assign publishes; use `recess apps publish --assign <student-id>`.");
|
|
249
|
+
}
|
|
250
|
+
if (due && !/^\d{4}-\d{2}-\d{2}$/.test(due)) {
|
|
251
|
+
throw new CliError("invalid_arguments", "--due must be YYYY-MM-DD.");
|
|
252
|
+
}
|
|
253
|
+
const created = unwrap(await ctx.api.client.POST("/studio/imports", { body }));
|
|
254
|
+
await writeAppLink(dir, {
|
|
255
|
+
...link,
|
|
256
|
+
projectId: created.projectId,
|
|
257
|
+
appUrl: created.appUrl,
|
|
258
|
+
});
|
|
259
|
+
const wait = !hasFlag(ctx.parsed, "no-wait");
|
|
260
|
+
if (!wait) {
|
|
261
|
+
if (assignTo) {
|
|
262
|
+
throw new CliError("invalid_arguments", "--assign needs the build result; drop --no-wait.");
|
|
263
|
+
}
|
|
264
|
+
return { ...created, dryRun };
|
|
265
|
+
}
|
|
266
|
+
const status = await pollBuild(ctx, created.buildId, 30 * 60 * 1000);
|
|
267
|
+
const result = {
|
|
268
|
+
projectId: created.projectId,
|
|
269
|
+
buildId: created.buildId,
|
|
270
|
+
appUrl: created.appUrl,
|
|
271
|
+
dryRun,
|
|
272
|
+
status: status.status,
|
|
273
|
+
report: status.report ?? null,
|
|
274
|
+
error: status.error ?? null,
|
|
275
|
+
};
|
|
276
|
+
if (!assignTo)
|
|
277
|
+
return result;
|
|
278
|
+
if (status.status !== "COMPLETE") {
|
|
279
|
+
return {
|
|
280
|
+
...result,
|
|
281
|
+
assigned: null,
|
|
282
|
+
assignError: `Not assigned: the build ended ${status.status}. Fix the report and publish again.`,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
const assigned = await assign(ctx, created.projectId, {
|
|
286
|
+
studentId: assignTo,
|
|
287
|
+
...(due ? { dueDate: due } : {}),
|
|
288
|
+
...(link?.forTodoId ? { sourceTodoId: link.forTodoId } : {}),
|
|
289
|
+
});
|
|
290
|
+
return { ...result, assigned };
|
|
291
|
+
}
|
|
292
|
+
async function assign(ctx, projectId, body) {
|
|
293
|
+
return unwrap(await ctx.api.client.POST("/studio/projects/{projectId}/assign", {
|
|
294
|
+
params: { path: { projectId } },
|
|
295
|
+
body,
|
|
296
|
+
}));
|
|
297
|
+
}
|
|
298
|
+
export async function runAppsCommand(ctx) {
|
|
299
|
+
const { parsed, api } = ctx;
|
|
300
|
+
const verb = parsed.positionals[1];
|
|
301
|
+
if (verb === "init") {
|
|
302
|
+
const dir = path.resolve(positional(parsed, 2, "app folder"));
|
|
303
|
+
await fs.mkdir(dir, { recursive: true });
|
|
304
|
+
const existing = await fs.readdir(dir);
|
|
305
|
+
if (existing.length > 0 && !hasFlag(parsed, "force")) {
|
|
306
|
+
throw new CliError("invalid_arguments", `${dir} is not empty. Use --force to write the scaffold over it.`);
|
|
307
|
+
}
|
|
308
|
+
const forTodo = flagString(parsed, "for-todo");
|
|
309
|
+
const analysis = forTodo
|
|
310
|
+
? unwrap(await api.client.GET("/studio/todos/{todoId}/analysis", {
|
|
311
|
+
params: { path: { todoId: forTodo } },
|
|
312
|
+
}))
|
|
313
|
+
: null;
|
|
314
|
+
const scaffold = unwrap(await api.client.GET("/studio/scaffold"));
|
|
315
|
+
const written = await writeFiles(dir, scaffold.files);
|
|
316
|
+
await fs.writeFile(path.join(dir, "AGENTS.md"), agentsGuide(scaffold.docs));
|
|
317
|
+
if (analysis) {
|
|
318
|
+
await fs.writeFile(path.join(dir, BRIEF_FILE), briefFor(analysis));
|
|
319
|
+
await writeAppLink(dir, {
|
|
320
|
+
forTodoId: analysis.todo.id,
|
|
321
|
+
studentId: analysis.todo.studentId,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
return {
|
|
325
|
+
dir,
|
|
326
|
+
filesWritten: written + (analysis ? 2 : 1),
|
|
327
|
+
...(analysis
|
|
328
|
+
? {
|
|
329
|
+
brief: BRIEF_FILE,
|
|
330
|
+
studentId: analysis.todo.studentId,
|
|
331
|
+
standards: analysis.standards.map((s) => s.notation),
|
|
332
|
+
misconceptions: analysis.analysis.patterns.map((p) => p.instanceKey ?? slug(p.name)),
|
|
333
|
+
}
|
|
334
|
+
: {}),
|
|
335
|
+
next: [
|
|
336
|
+
`cd ${dir}`,
|
|
337
|
+
analysis
|
|
338
|
+
? "Open the folder with your agent: brief.md is the assignment, AGENTS.md the contract."
|
|
339
|
+
: "Open the folder with your agent and describe what the app should teach (AGENTS.md has the contract).",
|
|
340
|
+
'recess apps validate --reason "Check my app"',
|
|
341
|
+
analysis
|
|
342
|
+
? `recess apps publish --assign ${analysis.todo.studentId} --reason "Ship it to the kid"`
|
|
343
|
+
: 'recess apps publish --reason "Ship my app"',
|
|
344
|
+
],
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
if (verb === "standards") {
|
|
348
|
+
const q = parsed.positionals.slice(2).join(" ").trim();
|
|
349
|
+
if (!q) {
|
|
350
|
+
throw new CliError("invalid_arguments", 'Give words or a notation: recess apps standards "grade 4 adding fractions".');
|
|
351
|
+
}
|
|
352
|
+
return unwrap(await api.client.GET("/studio/standards", {
|
|
353
|
+
params: { query: { q } },
|
|
354
|
+
}));
|
|
355
|
+
}
|
|
356
|
+
if (verb === "assign") {
|
|
357
|
+
const dir = resolveDir(ctx, 2);
|
|
358
|
+
const link = await readAppLink(dir);
|
|
359
|
+
const studentId = flagString(parsed, "student") ?? link?.studentId;
|
|
360
|
+
if (!link?.projectId) {
|
|
361
|
+
throw new CliError("invalid_arguments", `${dir} has no published app yet; run \`recess apps publish\` first.`);
|
|
362
|
+
}
|
|
363
|
+
if (!studentId) {
|
|
364
|
+
throw new CliError("invalid_arguments", "--student <student-id> is required (or init the app with --for-todo).");
|
|
365
|
+
}
|
|
366
|
+
const due = flagString(parsed, "due");
|
|
367
|
+
if (due && !/^\d{4}-\d{2}-\d{2}$/.test(due)) {
|
|
368
|
+
throw new CliError("invalid_arguments", "--due must be YYYY-MM-DD.");
|
|
369
|
+
}
|
|
370
|
+
return assign(ctx, link.projectId, {
|
|
371
|
+
studentId,
|
|
372
|
+
...(due ? { dueDate: due } : {}),
|
|
373
|
+
...(link.forTodoId ? { sourceTodoId: link.forTodoId } : {}),
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
if (verb === "list") {
|
|
377
|
+
return unwrap(await api.client.GET("/studio/projects"));
|
|
378
|
+
}
|
|
379
|
+
if (verb === "pull") {
|
|
380
|
+
const projectId = positional(parsed, 2, "project id");
|
|
381
|
+
const dir = resolveDir(ctx, 3);
|
|
382
|
+
const project = unwrap(await api.client.GET("/studio/projects/{projectId}/files", {
|
|
383
|
+
params: { path: { projectId } },
|
|
384
|
+
}));
|
|
385
|
+
await fs.mkdir(dir, { recursive: true });
|
|
386
|
+
const written = await writeFiles(dir, project.files);
|
|
387
|
+
if (project.contract)
|
|
388
|
+
await fs.writeFile(path.join(dir, CONTRACT_FILE), JSON.stringify(project.contract, null, 2) + "\n");
|
|
389
|
+
await fs.writeFile(path.join(dir, MANIFEST_FILE), JSON.stringify({ title: project.name }, null, 2) + "\n");
|
|
390
|
+
await writeAppLink(dir, { projectId });
|
|
391
|
+
return {
|
|
392
|
+
dir,
|
|
393
|
+
projectId,
|
|
394
|
+
name: project.name,
|
|
395
|
+
versionNumber: project.versionNumber,
|
|
396
|
+
filesWritten: written,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
if (verb === "validate") {
|
|
400
|
+
return submit(ctx, resolveDir(ctx, 2), true);
|
|
401
|
+
}
|
|
402
|
+
if (verb === "publish") {
|
|
403
|
+
return submit(ctx, resolveDir(ctx, 2), false);
|
|
404
|
+
}
|
|
405
|
+
if (verb === "status") {
|
|
406
|
+
const buildId = positional(parsed, 2, "build id");
|
|
407
|
+
return unwrap(await api.client.GET("/studio/builds/{buildId}", {
|
|
408
|
+
params: { path: { buildId } },
|
|
409
|
+
}));
|
|
410
|
+
}
|
|
411
|
+
throw new CliError("invalid_arguments", "Unknown apps command. Use: apps init <dir> [--for-todo <todo-id>] | apps standards <words> | apps list | apps pull <project-id> [dir] | apps validate [dir] | apps publish [dir] [--assign <student-id>] | apps assign [dir] --student <id> | apps status <build-id>.");
|
|
412
|
+
}
|
|
413
|
+
//# sourceMappingURL=apps.js.map
|
|
@@ -503,6 +503,21 @@ export async function runOnboardingCommand({ parsed, api, writeCommand, }) {
|
|
|
503
503
|
request: {},
|
|
504
504
|
}, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/intake/generate-summaries", { params: { path: { familyId } } })));
|
|
505
505
|
}
|
|
506
|
+
if (verb === "link-math-academy") {
|
|
507
|
+
const familyId = positional(parsed, 2, "family ID");
|
|
508
|
+
const kidUserId = flagString(parsed, "kid", { required: true });
|
|
509
|
+
const mathAcademyStudentId = flagInteger(parsed, "student-id", {
|
|
510
|
+
min: 1,
|
|
511
|
+
});
|
|
512
|
+
const body = mathAcademyStudentId === undefined ? {} : { mathAcademyStudentId };
|
|
513
|
+
return writeCommand(parsed, {
|
|
514
|
+
action: mathAcademyStudentId === undefined
|
|
515
|
+
? "link the kid to an existing Math Academy account using the saved login or a unique name match"
|
|
516
|
+
: `link the kid to existing Math Academy student ${mathAcademyStudentId} after verifying that student exists`,
|
|
517
|
+
target: { familyId, kidUserId },
|
|
518
|
+
request: body,
|
|
519
|
+
}, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/kids/{kidUserId}/link/math-academy", { params: { path: { familyId, kidUserId } }, body })));
|
|
520
|
+
}
|
|
506
521
|
if (verb === "provision-math-academy") {
|
|
507
522
|
const familyId = positional(parsed, 2, "family ID");
|
|
508
523
|
const kidUserId = flagString(parsed, "kid", { required: true });
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { unwrap } from "../api.js";
|
|
2
|
+
import { flagString, hasFlag } from "../args.js";
|
|
3
|
+
import { apiError, CliError } from "../errors.js";
|
|
4
|
+
const NONE = "—";
|
|
5
|
+
async function fetchTemplatesAndRooms(ctx) {
|
|
6
|
+
const [templatesResponse, rooms] = await Promise.all([
|
|
7
|
+
ctx.api.client.GET("/recess/event-templates-editable/"),
|
|
8
|
+
ctx.api.client.GET("/recess/village-rooms/"),
|
|
9
|
+
]);
|
|
10
|
+
return {
|
|
11
|
+
templates: unwrap(templatesResponse).eventTemplates,
|
|
12
|
+
rooms: unwrap(rooms),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function schedule(template) {
|
|
16
|
+
if (template.singleEventDate)
|
|
17
|
+
return template.singleEventDate.slice(0, 10);
|
|
18
|
+
return template.rrule || NONE;
|
|
19
|
+
}
|
|
20
|
+
function renderColumns(rows) {
|
|
21
|
+
const widths = rows[0].map((_, column) => Math.max(...rows.map((row) => row[column].length)));
|
|
22
|
+
return rows
|
|
23
|
+
.map((row) => row
|
|
24
|
+
.map((cell, column) => column === row.length - 1 ? cell : cell.padEnd(widths[column]))
|
|
25
|
+
.join(" ")
|
|
26
|
+
.trimEnd())
|
|
27
|
+
.join("\n");
|
|
28
|
+
}
|
|
29
|
+
/** `village events` — every editable Recess Hour template and its Town Center zone. */
|
|
30
|
+
async function listEvents(ctx) {
|
|
31
|
+
const { templates, rooms } = await fetchTemplatesAndRooms(ctx);
|
|
32
|
+
const roomsById = new Map(rooms.map((room) => [room.id, room]));
|
|
33
|
+
const templateRows = templates.map((template) => {
|
|
34
|
+
const room = template.villageRoomId
|
|
35
|
+
? roomsById.get(template.villageRoomId)
|
|
36
|
+
: undefined;
|
|
37
|
+
return {
|
|
38
|
+
id: template.id,
|
|
39
|
+
name: template.name,
|
|
40
|
+
status: template.status,
|
|
41
|
+
schedule: schedule(template),
|
|
42
|
+
villageRoomId: template.villageRoomId,
|
|
43
|
+
villageRoomName: room?.name ?? null,
|
|
44
|
+
joinUrl: template.joinUrl ?? null,
|
|
45
|
+
};
|
|
46
|
+
});
|
|
47
|
+
const unlinkedRooms = rooms
|
|
48
|
+
.filter((room) => !room.linkedTemplateId)
|
|
49
|
+
.map((room) => ({ id: room.id, name: room.name }));
|
|
50
|
+
if (hasFlag(ctx.parsed, "json")) {
|
|
51
|
+
return { templates: templateRows, unlinkedRooms };
|
|
52
|
+
}
|
|
53
|
+
const table = renderColumns([
|
|
54
|
+
["TEMPLATE", "NAME", "STATUS", "SCHEDULE", "ZONE", "ZONE NAME", "URL"],
|
|
55
|
+
...templateRows.map((row) => [
|
|
56
|
+
row.id,
|
|
57
|
+
row.name,
|
|
58
|
+
row.status,
|
|
59
|
+
row.schedule,
|
|
60
|
+
row.villageRoomId ?? NONE,
|
|
61
|
+
row.villageRoomName ?? (row.villageRoomId ? "(unknown zone)" : NONE),
|
|
62
|
+
row.joinUrl ?? NONE,
|
|
63
|
+
]),
|
|
64
|
+
]);
|
|
65
|
+
const unlinked = unlinkedRooms.length === 0
|
|
66
|
+
? " (none)"
|
|
67
|
+
: renderColumns(unlinkedRooms.map((room) => [` ${room.id}`, room.name]));
|
|
68
|
+
return {
|
|
69
|
+
help: `${templates.length === 0 ? "No editable event templates." : table}\n\nUnlinked Town Center zones:\n${unlinked}`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function isHttpUrl(value) {
|
|
73
|
+
try {
|
|
74
|
+
const url = new URL(value);
|
|
75
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** `village link-event --template <id> (--room <zoneId> | --unlink) [--url <https://…>]` */
|
|
82
|
+
async function linkEvent(ctx) {
|
|
83
|
+
const { parsed, api, writeCommand } = ctx;
|
|
84
|
+
const templateId = flagString(parsed, "template", { required: true });
|
|
85
|
+
const roomId = flagString(parsed, "room");
|
|
86
|
+
const unlink = hasFlag(parsed, "unlink");
|
|
87
|
+
const url = flagString(parsed, "url");
|
|
88
|
+
if (url !== undefined && !isHttpUrl(url)) {
|
|
89
|
+
throw new CliError("invalid_arguments", "--url must be an http(s) URL (e.g. https://example.com/event).");
|
|
90
|
+
}
|
|
91
|
+
if (unlink === Boolean(roomId)) {
|
|
92
|
+
throw new CliError("invalid_arguments", "Pass exactly one of --room <zoneId> or --unlink.");
|
|
93
|
+
}
|
|
94
|
+
const { templates, rooms } = await fetchTemplatesAndRooms(ctx);
|
|
95
|
+
const template = templates.find((row) => row.id === templateId);
|
|
96
|
+
if (!template) {
|
|
97
|
+
throw new CliError("not_found", `No editable event template found for ${templateId}. Run \`recess village events\` to list them.`);
|
|
98
|
+
}
|
|
99
|
+
let room;
|
|
100
|
+
if (roomId) {
|
|
101
|
+
room = rooms.find((row) => row.id === roomId);
|
|
102
|
+
if (!room) {
|
|
103
|
+
throw new CliError("not_found", `No Village Town Center zone found for ${roomId}. Run \`recess village events\` to list linkable zones.`);
|
|
104
|
+
}
|
|
105
|
+
if (room.linkedTemplateId && room.linkedTemplateId !== templateId) {
|
|
106
|
+
throw new CliError("invalid_arguments", `Zone ${room.id} (${room.name}) is already linked to template ${room.linkedTemplateId} (${room.linkedTemplateName ?? "unnamed"}). Unlink that template first.`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const previousRoom = template.villageRoomId
|
|
110
|
+
? rooms.find((row) => row.id === template.villageRoomId)
|
|
111
|
+
: undefined;
|
|
112
|
+
const body = {
|
|
113
|
+
villageRoomId: room ? room.id : null,
|
|
114
|
+
...(url !== undefined ? { joinUrl: url } : {}),
|
|
115
|
+
};
|
|
116
|
+
return writeCommand(parsed, {
|
|
117
|
+
action: room
|
|
118
|
+
? `Link template ${template.id} (${template.name}) → zone ${room.id} (${room.name}); zone will be renamed to the event title${url ? ` and its link set to ${url}` : ""}`
|
|
119
|
+
: `Unlink template ${template.id} (${template.name}) from zone ${template.villageRoomId ?? NONE}${previousRoom ? ` (${previousRoom.name})` : ""}`,
|
|
120
|
+
target: {
|
|
121
|
+
templateId: template.id,
|
|
122
|
+
templateName: template.name,
|
|
123
|
+
currentVillageRoomId: template.villageRoomId,
|
|
124
|
+
},
|
|
125
|
+
request: body,
|
|
126
|
+
}, async () => {
|
|
127
|
+
const result = await api.client.PATCH("/recess/event-templates/{id}/", {
|
|
128
|
+
params: { path: { id: template.id } },
|
|
129
|
+
body,
|
|
130
|
+
});
|
|
131
|
+
if (!result.response.ok) {
|
|
132
|
+
throw apiError(result.response.status, result.error);
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
templateId: template.id,
|
|
136
|
+
templateName: template.name,
|
|
137
|
+
villageRoomId: body.villageRoomId,
|
|
138
|
+
villageRoomName: room?.name ?? null,
|
|
139
|
+
...(url !== undefined ? { joinUrl: url } : {}),
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
export async function runVillageEventsCommand(ctx, verb) {
|
|
144
|
+
if (verb === "events")
|
|
145
|
+
return listEvents(ctx);
|
|
146
|
+
if (verb === "link-event")
|
|
147
|
+
return linkEvent(ctx);
|
|
148
|
+
throw new CliError("invalid_arguments", "Use village events or link-event.");
|
|
149
|
+
}
|
|
150
|
+
//# sourceMappingURL=village-events.js.map
|
package/dist/help.js
CHANGED
|
@@ -22,6 +22,10 @@ Usage:
|
|
|
22
22
|
recess [--json] auth status|logout
|
|
23
23
|
recess [--json] users search <name-or-id> [--limit 10]
|
|
24
24
|
recess [--json] users get <user-id>
|
|
25
|
+
recess [--json] users lock <user-id> [--confirm]
|
|
26
|
+
recess [--json] users unlock <user-id> [--confirm]
|
|
27
|
+
recess [--json] users disable <user-id> [--whole-family] [--allow-billing] [--confirm]
|
|
28
|
+
recess [--json] users restore <user-id> --role kid|guardian|guide|program [--confirm]
|
|
25
29
|
recess [--json] users tier list-tiers
|
|
26
30
|
recess [--json] users tier get <kid-id>
|
|
27
31
|
recess [--json] users tier preview <kid-id> --tier social|academics|lite|complete|platform
|
|
@@ -38,6 +42,8 @@ Usage:
|
|
|
38
42
|
recess [--json] students list
|
|
39
43
|
recess [--json] students today --student <kid-id> [--date YYYY-MM-DD]
|
|
40
44
|
recess [--json] students schedule --student <kid-id> [--days 14]
|
|
45
|
+
recess [--json] students todos --student <kid-id> [--limit 30] [--analyzed]
|
|
46
|
+
recess [--json] students analysis --todo <todo-id>
|
|
41
47
|
recess [--json] students xp-history --student <kid-id>
|
|
42
48
|
[--range week|month|quarter|year]
|
|
43
49
|
recess [--json] enrollments list --user <user-id>
|
|
@@ -48,6 +54,14 @@ Usage:
|
|
|
48
54
|
recess [--json] invoices refund --invoice <id> --line-item <id>
|
|
49
55
|
--method refund|credit|tokens [--full | --amount-cents N]
|
|
50
56
|
[--who-pays guide|recess] [--reason TEXT] [--confirm]
|
|
57
|
+
recess [--json] apps init <dir> [--for-todo <todo-id>] [--force]
|
|
58
|
+
recess [--json] apps standards <query>
|
|
59
|
+
recess [--json] apps list
|
|
60
|
+
recess [--json] apps pull <project-id> [dir]
|
|
61
|
+
recess [--json] apps validate [dir] [--name TEXT] [--no-wait]
|
|
62
|
+
recess [--json] apps publish [dir] [--name TEXT] [--assign <kid-id>] [--due YYYY-MM-DD] [--no-wait]
|
|
63
|
+
recess [--json] apps assign [dir] --student <kid-id> [--due YYYY-MM-DD]
|
|
64
|
+
recess [--json] apps status <build-id>
|
|
51
65
|
recess [--json] applications list [--status SUBMITTED|CLAIMED|ENROLLED|CLOSED]
|
|
52
66
|
[--status-scope active|closed] [--disposition READY_TO_ENROLL|TRIAL|PENDING_FUNDING|NO]
|
|
53
67
|
[--dispositioned true|false] [--quality QUALIFIED|NEEDS_NURTURE|UNKNOWN]
|
|
@@ -168,6 +182,8 @@ Usage:
|
|
|
168
182
|
recess [--json] onboarding generate-summaries <family-id> [--confirm]
|
|
169
183
|
recess [--json] onboarding provision-math-academy <family-id>
|
|
170
184
|
--kid <kid-id> [--grade 1..12] [--confirm]
|
|
185
|
+
recess [--json] onboarding link-math-academy <family-id>
|
|
186
|
+
--kid <kid-id> [--student-id <math-academy-student-id>] [--confirm]
|
|
171
187
|
recess [--json] onboarding provision-ixl <family-id>
|
|
172
188
|
--kid <kid-id> [--credentials-file <credentials.json>] [--confirm]
|
|
173
189
|
recess [--json] onboarding remove-ixl <family-id> --kid <kid-id> [--confirm]
|
|
@@ -270,6 +286,9 @@ Usage:
|
|
|
270
286
|
recess [--json] village worlds export <world-id> [--out <bundle.json>] [--confirm]
|
|
271
287
|
recess [--json] village worlds import <world-id> --file <bundle.json> [--confirm]
|
|
272
288
|
recess [--json] village worlds promote <mirror-or-archive-id> [--confirm]
|
|
289
|
+
recess [--json] village events
|
|
290
|
+
recess [--json] village link-event --template <template-id>
|
|
291
|
+
[--room <zone-id>] [--unlink] [--url <https://…>] [--confirm]
|
|
273
292
|
recess [--json] store-items list [--search TEXT]
|
|
274
293
|
[--status ACTIVE|INACTIVE|COMING_SOON] [--item-type TYPE]
|
|
275
294
|
[--page 0] [--limit 20] [--sort-by name|price|createdAt|updatedAt|order]
|
|
@@ -344,6 +363,8 @@ Usage:
|
|
|
344
363
|
[--due-date YYYY-MM-DD] [--estimated-minutes N] [--url URL] [--confirm]
|
|
345
364
|
recess [--json] todos edit <todo-id> --patch-file <path/patch.json>
|
|
346
365
|
[--confirm --approval-token TOKEN]
|
|
366
|
+
recess [--json] todos complete <todo-id> --xp N
|
|
367
|
+
[--confirm --approval-token TOKEN]
|
|
347
368
|
recess [--json] todos delete <todo-id>
|
|
348
369
|
[--confirm --approval-token TOKEN]
|
|
349
370
|
recess [--json] todos generate-applet <todo-id> --student <kid-id>
|
|
@@ -413,6 +434,33 @@ monthly top-up cron and are exact-ADMIN on update); descriptive edits
|
|
|
413
434
|
(name/slug/logo) are ordinary staff writes. "school kid-slots" is the raw
|
|
414
435
|
slot override; "users tier set" is the tier-driven path.
|
|
415
436
|
|
|
437
|
+
"users lock"/"users unlock" are the CLI twin of the Lock/Unlock buttons on
|
|
438
|
+
recess.gg/ai/students/<kid-id>/admin. They act on the whole account, not one
|
|
439
|
+
person: a KID target is locked or unlocked together with every GUARDIAN in
|
|
440
|
+
their family, because an unlocked kid under a locked guardian is not actually
|
|
441
|
+
unlocked. Unlock grants the kid and guardian access roles, restores the kid's
|
|
442
|
+
Recess channel memberships, sets FLAG_ACCOUNT_UNLOCKED, and notifies the kid's
|
|
443
|
+
live clients; lock is the exact inverse. Rerunning "unlock" on an already
|
|
444
|
+
unlocked account is the "Refresh Unlock" button and is safe. The preview shows
|
|
445
|
+
each affected person's current flag; "users get" is the read.
|
|
446
|
+
|
|
447
|
+
"users disable" is the harder stop, and a different mechanism: it sets
|
|
448
|
+
User.role to DISABLED, so the account stops resolving in every dashboard,
|
|
449
|
+
search, chat, and todo query at the database layer, and its coherent-feed
|
|
450
|
+
personal data is purged. "users lock" only strips access roles. Disabling is
|
|
451
|
+
SOFT and reversible — "users restore <id> --role ..." puts the account back —
|
|
452
|
+
and it is NOT a GDPR erasure; nothing is deleted. A read-only preflight resolves
|
|
453
|
+
the whole family (including members already DISABLED, which ordinary reads hide)
|
|
454
|
+
and each person's billable enrollments. "--whole-family" extends the disable to
|
|
455
|
+
every member of the target's family; ADMIN/MODERATOR members, your own account,
|
|
456
|
+
and anyone already in the destination role are listed under preview.skipped
|
|
457
|
+
rather than silently dropped. A target holding an ACTIVE or PAST_DUE enrollment
|
|
458
|
+
is refused until billing is cancelled or "--allow-billing" deliberately orphans
|
|
459
|
+
the seat. Restore names the role explicitly because DISABLED does not record
|
|
460
|
+
what the account was; ADMIN and MODERATOR are not restorable from the CLI.
|
|
461
|
+
Note that after a disable "users get" and "users search" no longer resolve the
|
|
462
|
+
account — the confirmed write's own response is the record of what changed.
|
|
463
|
+
|
|
416
464
|
Onboarding notes: "status" and "intake-session" are reads — "intake-session"
|
|
417
465
|
looks up the current IN_PROGRESS session without creating one (prints a "none
|
|
418
466
|
yet" result when absent). "intake-session-create" is the explicit write that
|