recess-cli 3.3.0 → 3.4.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/dist/api.js +6 -1
- package/dist/args.js +2 -0
- package/dist/cli.js +250 -13
- package/dist/command-schema.js +2 -0
- package/dist/help.js +16 -3
- package/dist/http.js +21 -0
- package/dist/index.js +8 -0
- package/dist/update-notice.js +85 -0
- package/package.json +1 -1
- package/skill/recess-cli/SKILL.md +3 -0
package/dist/api.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
import createClient from "openapi-fetch";
|
|
3
3
|
import { apiError, CliError } from "./errors.js";
|
|
4
|
-
import { cliRequestHeaders, markCliRequest, RECESS_CLIENT_CLI, } from "./http.js";
|
|
4
|
+
import { cliRequestHeaders, markCliRequest, noteServerCliVersions, RECESS_CLIENT_CLI, } from "./http.js";
|
|
5
5
|
const idempotencyContext = new AsyncLocalStorage();
|
|
6
6
|
export function withIdempotencyContext(context, execute) {
|
|
7
7
|
return idempotencyContext.run(context, execute);
|
|
@@ -40,6 +40,10 @@ export class RecessAdminApi {
|
|
|
40
40
|
applyIdempotencyHeaders(request.headers);
|
|
41
41
|
return request;
|
|
42
42
|
},
|
|
43
|
+
onResponse({ response }) {
|
|
44
|
+
noteServerCliVersions(response.headers);
|
|
45
|
+
return response;
|
|
46
|
+
},
|
|
43
47
|
});
|
|
44
48
|
}
|
|
45
49
|
requireAuth() {
|
|
@@ -55,6 +59,7 @@ export class RecessAdminApi {
|
|
|
55
59
|
const response = await fetch(new URL(path, this.config.apiOrigin), {
|
|
56
60
|
headers: cliRequestHeaders({ cookie: this.config.sessionCookie }, this.reason, this.clientTag),
|
|
57
61
|
});
|
|
62
|
+
noteServerCliVersions(response.headers);
|
|
58
63
|
const text = await response.text();
|
|
59
64
|
let body = text;
|
|
60
65
|
try {
|
package/dist/args.js
CHANGED
package/dist/cli.js
CHANGED
|
@@ -21,7 +21,8 @@ import { CliError } from "./errors.js";
|
|
|
21
21
|
import { listFeedback, submitFeedback } from "./feedback.js";
|
|
22
22
|
import { HELP } from "./help.js";
|
|
23
23
|
export { HELP };
|
|
24
|
-
import { cliRequestHeaders, requireCliRequestReason } from "./http.js";
|
|
24
|
+
import { cliRequestHeaders, noteServerCliVersions, requireCliRequestReason, } from "./http.js";
|
|
25
|
+
import { runSelfUpdate, UPGRADE_COMMAND, updateNoticeText, updateStatus, } from "./update-notice.js";
|
|
25
26
|
import { requireConfirmation } from "./safety.js";
|
|
26
27
|
import { installSkill, isEphemeralInstall, readBundledSkillVersion, readCliVersion, } from "./setup.js";
|
|
27
28
|
import { compareVersions, updateSkillFromServer } from "./skill-update.js";
|
|
@@ -174,6 +175,7 @@ async function doctor(config, reason) {
|
|
|
174
175
|
headers: cliRequestHeaders(undefined, reason),
|
|
175
176
|
signal: AbortSignal.timeout(5000),
|
|
176
177
|
});
|
|
178
|
+
noteServerCliVersions(response.headers);
|
|
177
179
|
checks.api = { reachable: response.ok, status: response.status };
|
|
178
180
|
}
|
|
179
181
|
catch (error) {
|
|
@@ -183,6 +185,9 @@ async function doctor(config, reason) {
|
|
|
183
185
|
};
|
|
184
186
|
}
|
|
185
187
|
checks.skill = await skillStatus(config, reason);
|
|
188
|
+
const update = updateStatus(await readCliVersion());
|
|
189
|
+
const updateAction = updateNoticeText(update);
|
|
190
|
+
checks.update = updateAction ? { ...update, action: updateAction } : update;
|
|
186
191
|
return checks;
|
|
187
192
|
}
|
|
188
193
|
/**
|
|
@@ -255,7 +260,8 @@ async function localWriteCommand(parsed, preview, execute) {
|
|
|
255
260
|
fingerprint,
|
|
256
261
|
target: preview.target,
|
|
257
262
|
...(preview.action.startsWith("mastery.") ||
|
|
258
|
-
preview.request.attendanceTakenAt
|
|
263
|
+
preview.request.attendanceTakenAt ||
|
|
264
|
+
preview.request.zeroBalance === "true"
|
|
259
265
|
? { preview }
|
|
260
266
|
: {}),
|
|
261
267
|
});
|
|
@@ -1437,6 +1443,19 @@ function parseGoalTemplateDocument(doc) {
|
|
|
1437
1443
|
* Resolve a template reference that may be a UUID or a slug. Slugs are what an
|
|
1438
1444
|
* author types and what the skill's prose uses; the routes take a UUID only.
|
|
1439
1445
|
*/
|
|
1446
|
+
/**
|
|
1447
|
+
* `/challenges-v2/{id}/preview` on the web app: the template as the card a
|
|
1448
|
+
* kid would see, with no chat around it. Returned by `goal-templates get`
|
|
1449
|
+
* and `create` so an agent can open it, or hand it to the human, right after
|
|
1450
|
+
* authoring.
|
|
1451
|
+
*/
|
|
1452
|
+
function goalTemplatePreviewUrl(webOrigin, templateId) {
|
|
1453
|
+
return new URL(`/challenges-v2/${encodeURIComponent(templateId)}/preview`, webOrigin).toString();
|
|
1454
|
+
}
|
|
1455
|
+
/** Runs the template with Rocky as a kid would (staff only; ephemeral, never touches real todos). */
|
|
1456
|
+
function goalTemplatePlayTestUrl(webOrigin, templateId) {
|
|
1457
|
+
return new URL(`/challenges-v2/${encodeURIComponent(templateId)}?play=1`, webOrigin).toString();
|
|
1458
|
+
}
|
|
1440
1459
|
async function resolveGoalTemplateId(api, reference) {
|
|
1441
1460
|
if (UUID_RE.test(reference))
|
|
1442
1461
|
return reference;
|
|
@@ -1909,7 +1928,11 @@ export async function executeRecessCommand(argv, options = {}) {
|
|
|
1909
1928
|
}
|
|
1910
1929
|
}
|
|
1911
1930
|
const config = options.config ?? (await resolveConfig(flagString(parsed, "profile")));
|
|
1912
|
-
const requestReason = noun === "auth"
|
|
1931
|
+
const requestReason = noun === "auth"
|
|
1932
|
+
? undefined
|
|
1933
|
+
: noun === "update"
|
|
1934
|
+
? requireCliRequestReason(flagString(parsed, "reason") ?? "check for a recess CLI update")
|
|
1935
|
+
: requiredRequestReason(parsed);
|
|
1913
1936
|
if (noun === "doctor" && isRemote) {
|
|
1914
1937
|
return {
|
|
1915
1938
|
transport: "streamable-http-mcp",
|
|
@@ -1921,6 +1944,31 @@ export async function executeRecessCommand(argv, options = {}) {
|
|
|
1921
1944
|
}
|
|
1922
1945
|
if (noun === "doctor")
|
|
1923
1946
|
return doctor(config, requestReason);
|
|
1947
|
+
if (noun === "update") {
|
|
1948
|
+
if (isRemote) {
|
|
1949
|
+
throw new CliError("remote_command_unavailable", "The update command is not exposed by the hosted Recess CLI.");
|
|
1950
|
+
}
|
|
1951
|
+
const cliVersion = await readCliVersion();
|
|
1952
|
+
const probe = await fetch(new URL("/health", config.apiOrigin), {
|
|
1953
|
+
headers: cliRequestHeaders(undefined, requestReason),
|
|
1954
|
+
signal: AbortSignal.timeout(5000),
|
|
1955
|
+
}).catch(() => null);
|
|
1956
|
+
if (probe)
|
|
1957
|
+
noteServerCliVersions(probe.headers);
|
|
1958
|
+
const status = updateStatus(cliVersion);
|
|
1959
|
+
if (status.state === "current")
|
|
1960
|
+
return { ...status, updated: false };
|
|
1961
|
+
if (isEphemeralInstall()) {
|
|
1962
|
+
throw new CliError("invalid_arguments", "This recess is running from a temporary npx cache. Run `npx recess-cli@latest` or install it with `npm install -g recess-cli@latest`.");
|
|
1963
|
+
}
|
|
1964
|
+
requireConfirmation(hasFlag(parsed, "confirm"), {
|
|
1965
|
+
action: "install the latest recess CLI globally with npm",
|
|
1966
|
+
target: { ...status },
|
|
1967
|
+
request: { command: UPGRADE_COMMAND },
|
|
1968
|
+
});
|
|
1969
|
+
const result = await runSelfUpdate();
|
|
1970
|
+
return { ...status, updated: true, ...result };
|
|
1971
|
+
}
|
|
1924
1972
|
if (noun === "setup") {
|
|
1925
1973
|
// Always lay down the bundled copy first: it is the floor, and it is the
|
|
1926
1974
|
// only copy guaranteed to match this binary. The served upgrade below is
|
|
@@ -2483,6 +2531,145 @@ export async function executeRecessCommand(argv, options = {}) {
|
|
|
2483
2531
|
body,
|
|
2484
2532
|
})));
|
|
2485
2533
|
}
|
|
2534
|
+
if (noun === "users" && verb === "school") {
|
|
2535
|
+
const action = parsed.positionals[2];
|
|
2536
|
+
const userId = positional(parsed, 3, "user ID");
|
|
2537
|
+
if (action === "status")
|
|
2538
|
+
return unwrap(await api.client.GET("/admin/users/{userId}/school-status", {
|
|
2539
|
+
params: { path: { userId } },
|
|
2540
|
+
}));
|
|
2541
|
+
if (action === "history")
|
|
2542
|
+
return unwrap(await api.client.GET("/admin/users/{userId}/school-history", {
|
|
2543
|
+
params: {
|
|
2544
|
+
path: { userId },
|
|
2545
|
+
query: {
|
|
2546
|
+
limit: flagNumber(parsed, "limit"),
|
|
2547
|
+
cursor: flagNumber(parsed, "cursor"),
|
|
2548
|
+
},
|
|
2549
|
+
},
|
|
2550
|
+
}));
|
|
2551
|
+
if (action === "complete-action") {
|
|
2552
|
+
const actionId = positional(parsed, 4, "action ID");
|
|
2553
|
+
const body = { reason: flagString(parsed, "note", { required: true }) };
|
|
2554
|
+
return writeCommand(parsed, {
|
|
2555
|
+
action: "record verified provider cancellation or deactivation; does not contact the provider",
|
|
2556
|
+
target: { userId, actionId },
|
|
2557
|
+
request: body,
|
|
2558
|
+
}, async () => unwrap(await api.client.POST("/admin/users/{userId}/school-actions/{actionId}/complete", { params: { path: { userId, actionId } }, body })));
|
|
2559
|
+
}
|
|
2560
|
+
const status = action === "withdraw" ? "WITHDRAWN" : "ACTIVE";
|
|
2561
|
+
const tierId = action === "restore"
|
|
2562
|
+
? assertChoice(flagString(parsed, "tier", { required: true }), SCHOOL_TIER_IDS, "--tier")
|
|
2563
|
+
: undefined;
|
|
2564
|
+
const body = {
|
|
2565
|
+
status,
|
|
2566
|
+
reason: flagString(parsed, "note", { required: true }),
|
|
2567
|
+
expectedUpdatedAt: flagString(parsed, "expected-updated-at", {
|
|
2568
|
+
required: true,
|
|
2569
|
+
}),
|
|
2570
|
+
...(tierId ? { tierId } : {}),
|
|
2571
|
+
};
|
|
2572
|
+
return writeCommand(parsed, {
|
|
2573
|
+
action: status === "WITHDRAWN"
|
|
2574
|
+
? "withdraw SCHOOL student, cancel subscriptions immediately without refund, and revoke external learning access"
|
|
2575
|
+
: "restore Recess access; billing, classes, and external accounts are not restarted",
|
|
2576
|
+
target: { userId },
|
|
2577
|
+
request: body,
|
|
2578
|
+
details: status === "WITHDRAWN"
|
|
2579
|
+
? {
|
|
2580
|
+
cancelSubscriptionsImmediately: true,
|
|
2581
|
+
refunds: false,
|
|
2582
|
+
revokeExternalAccess: true,
|
|
2583
|
+
ixlRemoval: "nightly roster import",
|
|
2584
|
+
manualProviderFollowUpMayBeRequired: true,
|
|
2585
|
+
}
|
|
2586
|
+
: { restartsBilling: false, restoresExternalAccounts: false },
|
|
2587
|
+
}, async () => unwrap(await api.client.PATCH("/admin/users/{userId}/school-status", {
|
|
2588
|
+
params: { path: { userId } },
|
|
2589
|
+
body,
|
|
2590
|
+
})));
|
|
2591
|
+
}
|
|
2592
|
+
if (noun === "users" && verb === "enrollment") {
|
|
2593
|
+
if (parsed.positionals[2] === "set-partner-school-cc") {
|
|
2594
|
+
const school = positional(parsed, 3, "partner school ID or slug");
|
|
2595
|
+
const email = flagString(parsed, "email");
|
|
2596
|
+
const clear = hasFlag(parsed, "clear");
|
|
2597
|
+
if (!!email === clear) {
|
|
2598
|
+
throw new CliError("invalid_arguments", "Specify either --email <address> or --clear.");
|
|
2599
|
+
}
|
|
2600
|
+
const { schools } = unwrap(await api.client.GET("/admin/users/enrollment-partner-schools"));
|
|
2601
|
+
const partnerSchool = schools.find((entry) => entry.id === school || entry.slug === school);
|
|
2602
|
+
if (!partnerSchool)
|
|
2603
|
+
throw new CliError("invalid_arguments", "Unknown partner school. Use users enrollment list-partner-schools.");
|
|
2604
|
+
const body = {
|
|
2605
|
+
communicationsCcEmail: email?.trim().toLowerCase() ?? null,
|
|
2606
|
+
};
|
|
2607
|
+
return writeCommand(parsed, {
|
|
2608
|
+
action: "set partner school communications CC",
|
|
2609
|
+
target: {
|
|
2610
|
+
id: partnerSchool.id,
|
|
2611
|
+
slug: partnerSchool.slug,
|
|
2612
|
+
name: partnerSchool.name,
|
|
2613
|
+
},
|
|
2614
|
+
request: body,
|
|
2615
|
+
}, async () => unwrap(await api.client.PATCH("/admin/users/enrollment-partner-schools/{schoolId}/communications", {
|
|
2616
|
+
params: { path: { schoolId: partnerSchool.id } },
|
|
2617
|
+
body,
|
|
2618
|
+
})));
|
|
2619
|
+
}
|
|
2620
|
+
if (parsed.positionals[2] === "history") {
|
|
2621
|
+
const userId = positional(parsed, 3, "user ID");
|
|
2622
|
+
return unwrap(await api.client.GET("/admin/users/{userId}/enrollment-history", {
|
|
2623
|
+
params: {
|
|
2624
|
+
path: { userId },
|
|
2625
|
+
query: {
|
|
2626
|
+
limit: flagNumber(parsed, "limit"),
|
|
2627
|
+
cursor: flagNumber(parsed, "cursor"),
|
|
2628
|
+
},
|
|
2629
|
+
},
|
|
2630
|
+
}));
|
|
2631
|
+
}
|
|
2632
|
+
if (parsed.positionals[2] === "list-partner-schools") {
|
|
2633
|
+
return unwrap(await api.client.GET("/admin/users/enrollment-partner-schools"));
|
|
2634
|
+
}
|
|
2635
|
+
const userId = positional(parsed, 3, "user ID");
|
|
2636
|
+
const entities = {
|
|
2637
|
+
"online-learning-program": "ONLINE_LEARNING_PROGRAM",
|
|
2638
|
+
"recess-academy-school": "RECESS_ACADEMY_SCHOOL",
|
|
2639
|
+
"partner-school": "PARTNER_SCHOOL",
|
|
2640
|
+
unset: null,
|
|
2641
|
+
};
|
|
2642
|
+
const entity = assertChoice(flagString(parsed, "entity", { required: true }), [
|
|
2643
|
+
"online-learning-program",
|
|
2644
|
+
"recess-academy-school",
|
|
2645
|
+
"partner-school",
|
|
2646
|
+
"unset",
|
|
2647
|
+
], "--entity");
|
|
2648
|
+
const partner = flagString(parsed, "partner-school");
|
|
2649
|
+
if ((entity === "partner-school") !== !!partner) {
|
|
2650
|
+
throw new CliError("invalid_arguments", "--partner-school is required only with --entity partner-school.");
|
|
2651
|
+
}
|
|
2652
|
+
const schools = partner
|
|
2653
|
+
? unwrap(await api.client.GET("/admin/users/enrollment-partner-schools"))
|
|
2654
|
+
.schools
|
|
2655
|
+
: [];
|
|
2656
|
+
const partnerSchool = schools.find((school) => school.id === partner || school.slug === partner);
|
|
2657
|
+
if (partner && !partnerSchool) {
|
|
2658
|
+
throw new CliError("invalid_arguments", "Unknown partner school. Use users enrollment list-partner-schools to find its ID or slug.");
|
|
2659
|
+
}
|
|
2660
|
+
const body = {
|
|
2661
|
+
enrollmentEntity: entities[entity],
|
|
2662
|
+
enrollmentPartnerSchoolId: partnerSchool?.id ?? null,
|
|
2663
|
+
};
|
|
2664
|
+
return writeCommand(parsed, {
|
|
2665
|
+
action: "set user enrollment entity",
|
|
2666
|
+
target: { userId, partnerSchool: partnerSchool ?? null },
|
|
2667
|
+
request: body,
|
|
2668
|
+
}, async () => unwrap(await api.client.PATCH("/admin/users/{userId}/enrollment-entity", {
|
|
2669
|
+
params: { path: { userId } },
|
|
2670
|
+
body,
|
|
2671
|
+
})));
|
|
2672
|
+
}
|
|
2486
2673
|
if (noun === "users" && verb === "get") {
|
|
2487
2674
|
const userId = positional(parsed, 2, "user ID");
|
|
2488
2675
|
return unwrap(await api.client.GET("/admin/users/{userId}", {
|
|
@@ -3045,6 +3232,24 @@ export async function executeRecessCommand(argv, options = {}) {
|
|
|
3045
3232
|
const account = unwrap(await api.client.GET("/admin/payout/recipient/", {
|
|
3046
3233
|
params: { query: { id: accountId, includeArchived: "true" } },
|
|
3047
3234
|
})).recipients[0];
|
|
3235
|
+
const zeroBalance = hasFlag(parsed, "zero-balance");
|
|
3236
|
+
// Keep the approved before-balance on retries, so the server can replay
|
|
3237
|
+
// a completed operation after the account has already been zeroed.
|
|
3238
|
+
const operationKey = flagString(parsed, "operation-key");
|
|
3239
|
+
const stored = zeroBalance && hasFlag(parsed, "confirm") && operationKey
|
|
3240
|
+
? await getJob(operationKey)
|
|
3241
|
+
: undefined;
|
|
3242
|
+
const approvedBalance = stored?.history.find((event) => event.preview?.request.zeroBalance === "true")?.preview?.request.expectedBalanceCents;
|
|
3243
|
+
const expectedBalanceCents = typeof approvedBalance === "number"
|
|
3244
|
+
? approvedBalance
|
|
3245
|
+
: account.accountBalance;
|
|
3246
|
+
const removalQuery = zeroBalance
|
|
3247
|
+
? {
|
|
3248
|
+
zeroBalance: "true",
|
|
3249
|
+
reason: flagString(parsed, "reason", { required: true }),
|
|
3250
|
+
expectedBalanceCents,
|
|
3251
|
+
}
|
|
3252
|
+
: {};
|
|
3048
3253
|
const effects = action === "archive"
|
|
3049
3254
|
? "Archive payout account, end all schedules and cancel all draft/review invoices. Stops new Recess accruals; preserves payment history and the guide login."
|
|
3050
3255
|
: action === "unarchive"
|
|
@@ -3055,18 +3260,27 @@ export async function executeRecessCommand(argv, options = {}) {
|
|
|
3055
3260
|
? "Add active payout account to recurring schedule. Future payroll cycles will accrue compensation in Recess."
|
|
3056
3261
|
: "Remove account from this payroll cycle and cancel its draft/review invoices. Future cycles are unchanged; regeneration will not re-add this account.";
|
|
3057
3262
|
return writeCommand(parsed, {
|
|
3058
|
-
action: effects
|
|
3263
|
+
action: effects +
|
|
3264
|
+
(zeroBalance
|
|
3265
|
+
? " OVERRIDE: cancel unpaid OPEN invoices in this scope and set the entire account balance to zero. Invoice amounts and payment history are preserved; no money is sent. Pending payments and obligations outside this scope must be resolved first."
|
|
3266
|
+
: ""),
|
|
3059
3267
|
target: {
|
|
3060
3268
|
accountId,
|
|
3061
3269
|
...(payRunId ? { payRunId } : {}),
|
|
3062
3270
|
...(scheduleId ? { scheduleId } : {}),
|
|
3063
3271
|
},
|
|
3064
|
-
request: { action },
|
|
3065
|
-
details: {
|
|
3272
|
+
request: { action, ...removalQuery },
|
|
3273
|
+
details: {
|
|
3274
|
+
accountName: account.name,
|
|
3275
|
+
user: account.user,
|
|
3276
|
+
...(zeroBalance
|
|
3277
|
+
? { previousBalanceCents: expectedBalanceCents, balanceCents: 0 }
|
|
3278
|
+
: {}),
|
|
3279
|
+
},
|
|
3066
3280
|
}, async () => {
|
|
3067
3281
|
if (action === "archive")
|
|
3068
3282
|
return unwrap(await api.client.DELETE("/admin/payout/recipient/{accountId}", {
|
|
3069
|
-
params: { path: { accountId } },
|
|
3283
|
+
params: { path: { accountId }, query: removalQuery },
|
|
3070
3284
|
}));
|
|
3071
3285
|
if (action === "unarchive")
|
|
3072
3286
|
return unwrap(await api.client.POST("/admin/payout/recipient/{accountId}/unarchive", { params: { path: { accountId } } }));
|
|
@@ -3075,8 +3289,18 @@ export async function executeRecessCommand(argv, options = {}) {
|
|
|
3075
3289
|
body: { accountId, scheduleId: scheduleId },
|
|
3076
3290
|
}));
|
|
3077
3291
|
if (action === "remove-schedule")
|
|
3078
|
-
return unwrap(await api.client.DELETE("/admin/payout/schedule/{targetId}/recipient/{accountId}", {
|
|
3079
|
-
|
|
3292
|
+
return unwrap(await api.client.DELETE("/admin/payout/schedule/{targetId}/recipient/{accountId}", {
|
|
3293
|
+
params: {
|
|
3294
|
+
path: { accountId, targetId: scheduleId },
|
|
3295
|
+
query: removalQuery,
|
|
3296
|
+
},
|
|
3297
|
+
}));
|
|
3298
|
+
return unwrap(await api.client.DELETE("/admin/payout/cycle/{targetId}/recipient/{accountId}", {
|
|
3299
|
+
params: {
|
|
3300
|
+
path: { accountId, targetId: payRunId },
|
|
3301
|
+
query: removalQuery,
|
|
3302
|
+
},
|
|
3303
|
+
}));
|
|
3080
3304
|
});
|
|
3081
3305
|
}
|
|
3082
3306
|
if (area === "invoices" && action === "list") {
|
|
@@ -3670,7 +3894,13 @@ export async function executeRecessCommand(argv, options = {}) {
|
|
|
3670
3894
|
setupWorkflowSpec: template.setupWorkflowSpec,
|
|
3671
3895
|
};
|
|
3672
3896
|
}
|
|
3673
|
-
|
|
3897
|
+
// The card a kid would see, with no chat around it: the page to open
|
|
3898
|
+
// (or hand to the human) after authoring.
|
|
3899
|
+
return {
|
|
3900
|
+
...template,
|
|
3901
|
+
previewUrl: goalTemplatePreviewUrl(config.webOrigin, template.id),
|
|
3902
|
+
playTestUrl: goalTemplatePlayTestUrl(config.webOrigin, template.id),
|
|
3903
|
+
};
|
|
3674
3904
|
}
|
|
3675
3905
|
if (verb === "versions") {
|
|
3676
3906
|
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
@@ -3762,9 +3992,16 @@ export async function executeRecessCommand(argv, options = {}) {
|
|
|
3762
3992
|
specInventory: validation.totals,
|
|
3763
3993
|
note: "Setup mode is DETERMINISTIC_WORKFLOW and is one-way: this template can never be converted back to a chat-driven setup.",
|
|
3764
3994
|
},
|
|
3765
|
-
}, async () =>
|
|
3766
|
-
|
|
3767
|
-
|
|
3995
|
+
}, async () => {
|
|
3996
|
+
const created = unwrap(await api.client.POST("/ai/goal-templates/", {
|
|
3997
|
+
body: document,
|
|
3998
|
+
}));
|
|
3999
|
+
return {
|
|
4000
|
+
...created,
|
|
4001
|
+
previewUrl: goalTemplatePreviewUrl(config.webOrigin, created.id),
|
|
4002
|
+
playTestUrl: goalTemplatePlayTestUrl(config.webOrigin, created.id),
|
|
4003
|
+
};
|
|
4004
|
+
});
|
|
3768
4005
|
}
|
|
3769
4006
|
if (verb === "policy") {
|
|
3770
4007
|
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
package/dist/command-schema.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { CliError } from "./errors.js";
|
|
2
2
|
export const AGENT_CONTEXT_SCHEMA_VERSION = "4";
|
|
3
3
|
const BOOLEAN_FLAGS = new Set([
|
|
4
|
+
"zero-balance",
|
|
4
5
|
"allow-possible-duplicate",
|
|
5
6
|
"allow-state-modification",
|
|
6
7
|
"allow-assigned-edits",
|
|
@@ -134,6 +135,7 @@ const LOCAL_COMMAND_NOUNS = new Set([
|
|
|
134
135
|
"jobs",
|
|
135
136
|
"profile",
|
|
136
137
|
"setup",
|
|
138
|
+
"update",
|
|
137
139
|
]);
|
|
138
140
|
const AUTHENTICATED_COMMAND_PREFIXES = [
|
|
139
141
|
"village build",
|
package/dist/help.js
CHANGED
|
@@ -17,6 +17,7 @@ Usage:
|
|
|
17
17
|
recess [--json] agent-context
|
|
18
18
|
recess [--json] setup [--skill-only]
|
|
19
19
|
recess [--json] doctor
|
|
20
|
+
recess [--json] update [--confirm]
|
|
20
21
|
recess [--json] profile list
|
|
21
22
|
recess [--json] profile show <name>
|
|
22
23
|
recess [--json] profile save <name> [--api-origin URL] [--web-origin URL]
|
|
@@ -34,6 +35,18 @@ Usage:
|
|
|
34
35
|
recess [--json] auth status|logout
|
|
35
36
|
recess [--json] users search <name-or-id> [--limit 10]
|
|
36
37
|
recess [--json] users get <user-id>
|
|
38
|
+
recess [--json] users school complete-action <user-id> <action-id> --note <text> [--confirm]
|
|
39
|
+
recess [--json] users school status <user-id>
|
|
40
|
+
recess [--json] users school history <user-id> [--limit 25] [--cursor <id>]
|
|
41
|
+
recess [--json] users school withdraw <user-id> --note <text> --expected-updated-at <iso> [--confirm]
|
|
42
|
+
recess [--json] users school restore <user-id> --tier <tier-id> --note <text> --expected-updated-at <iso> [--confirm]
|
|
43
|
+
recess [--json] users enrollment history <user-id> [--limit 25] [--cursor <id>]
|
|
44
|
+
recess [--json] users enrollment set-partner-school-cc <school-id-or-slug>
|
|
45
|
+
[--email <address> | --clear] [--confirm]
|
|
46
|
+
recess [--json] users enrollment list-partner-schools
|
|
47
|
+
recess [--json] users enrollment set <user-id>
|
|
48
|
+
--entity online-learning-program|recess-academy-school|partner-school|unset
|
|
49
|
+
[--partner-school <school-id-or-slug>] [--confirm]
|
|
37
50
|
recess [--json] users lock <user-id> [--confirm]
|
|
38
51
|
recess [--json] users unlock <user-id> [--confirm]
|
|
39
52
|
recess [--json] users disable <user-id> [--whole-family] [--allow-billing] [--confirm]
|
|
@@ -134,11 +147,11 @@ Usage:
|
|
|
134
147
|
[--limit 20] [--cursor <id>]
|
|
135
148
|
recess [--json] payout invoices list [--payrun <id>] [--recipient <account-id>]
|
|
136
149
|
[--user <id>] [--status A,B] (at least one filter)
|
|
137
|
-
recess [--json] payout recipients archive <account-id> [--confirm]
|
|
150
|
+
recess [--json] payout recipients archive <account-id> [--zero-balance] [--confirm]
|
|
138
151
|
recess [--json] payout recipients unarchive <account-id> [--confirm]
|
|
139
|
-
recess [--json] payout recipients remove-schedule <account-id> --schedule <id> [--confirm]
|
|
152
|
+
recess [--json] payout recipients remove-schedule <account-id> --schedule <id> [--zero-balance] [--confirm]
|
|
140
153
|
recess [--json] payout recipients add-schedule <account-id> --schedule <id> [--confirm]
|
|
141
|
-
recess [--json] payout payruns remove-account <payrun-id> --recipient <account-id> [--confirm]
|
|
154
|
+
recess [--json] payout payruns remove-account <payrun-id> --recipient <account-id> [--zero-balance] [--confirm]
|
|
142
155
|
recess [--json] payout invoices get <invoice-id>
|
|
143
156
|
recess [--json] payout invoices set-status <invoice-id>
|
|
144
157
|
--status IN_REVIEW|OPEN|PAID|CANCELED [--send-email] [--confirm]
|
package/dist/http.js
CHANGED
|
@@ -10,6 +10,25 @@ export const RECESS_REASON_HEADER = "x-recess-reason";
|
|
|
10
10
|
*/
|
|
11
11
|
export const RECESS_REASON_ENCODING_HEADER = "x-recess-reason-encoding";
|
|
12
12
|
export const RECESS_REASON_ENCODING_UTF8 = "utf-8-percent";
|
|
13
|
+
export const RECESS_CLI_VERSION_HEADER = "x-recess-cli-version";
|
|
14
|
+
export const RECESS_CLI_LATEST_HEADER = "x-recess-cli-latest";
|
|
15
|
+
export const RECESS_CLI_MINIMUM_HEADER = "x-recess-cli-minimum";
|
|
16
|
+
let requestCliVersion;
|
|
17
|
+
const observedCliVersions = {};
|
|
18
|
+
export function setRequestCliVersion(version) {
|
|
19
|
+
requestCliVersion = version;
|
|
20
|
+
}
|
|
21
|
+
export function noteServerCliVersions(headers) {
|
|
22
|
+
const latest = headers.get(RECESS_CLI_LATEST_HEADER)?.trim();
|
|
23
|
+
const minimum = headers.get(RECESS_CLI_MINIMUM_HEADER)?.trim();
|
|
24
|
+
if (latest)
|
|
25
|
+
observedCliVersions.latest = latest;
|
|
26
|
+
if (minimum)
|
|
27
|
+
observedCliVersions.minimum = minimum;
|
|
28
|
+
}
|
|
29
|
+
export function serverCliVersions() {
|
|
30
|
+
return { ...observedCliVersions };
|
|
31
|
+
}
|
|
13
32
|
const ASCII_PRINTABLE = /^[\x20-\x7E]*$/;
|
|
14
33
|
/**
|
|
15
34
|
* Make a human-written reason safe to put in an HTTP header.
|
|
@@ -53,6 +72,8 @@ export function requireCliRequestReason(value) {
|
|
|
53
72
|
}
|
|
54
73
|
export function markCliRequest(headers, reason, client = RECESS_CLIENT_CLI) {
|
|
55
74
|
headers.set(RECESS_CLIENT_HEADER, client);
|
|
75
|
+
if (requestCliVersion)
|
|
76
|
+
headers.set(RECESS_CLI_VERSION_HEADER, requestCliVersion);
|
|
56
77
|
if (reason) {
|
|
57
78
|
const encoded = encodeReasonHeader(reason);
|
|
58
79
|
headers.set(RECESS_REASON_HEADER, encoded.value);
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,9 @@ import { parseArgs } from "./args.js";
|
|
|
4
4
|
import { runCommand } from "./cli.js";
|
|
5
5
|
import { deliverJson } from "./delivery.js";
|
|
6
6
|
import { CliError } from "./errors.js";
|
|
7
|
+
import { setRequestCliVersion } from "./http.js";
|
|
8
|
+
import { readCliVersion } from "./setup.js";
|
|
9
|
+
import { printUpdateNotice } from "./update-notice.js";
|
|
7
10
|
async function withInteractiveAdmissionStage(args) {
|
|
8
11
|
const parsed = parseArgs(args);
|
|
9
12
|
if (parsed.flags.has("json") ||
|
|
@@ -41,6 +44,8 @@ async function withInteractiveAdmissionStage(args) {
|
|
|
41
44
|
const argv = await withInteractiveAdmissionStage(process.argv.slice(2));
|
|
42
45
|
const parsedArgv = parseArgs(argv);
|
|
43
46
|
const json = parsedArgv.flags.has("json");
|
|
47
|
+
const cliVersion = await readCliVersion();
|
|
48
|
+
setRequestCliVersion(cliVersion);
|
|
44
49
|
// The interactive console owns the terminal, so it runs before the JSON
|
|
45
50
|
// envelope machinery rather than through it.
|
|
46
51
|
if (argv[0] === "ui") {
|
|
@@ -103,4 +108,7 @@ catch (error) {
|
|
|
103
108
|
}
|
|
104
109
|
process.exitCode = cliError.exitCode;
|
|
105
110
|
}
|
|
111
|
+
if (parsedArgv.positionals[0] !== "update") {
|
|
112
|
+
await printUpdateNotice(cliVersion);
|
|
113
|
+
}
|
|
106
114
|
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { defaultConfigPath } from "./config.js";
|
|
5
|
+
import { CliError } from "./errors.js";
|
|
6
|
+
import { serverCliVersions } from "./http.js";
|
|
7
|
+
import { compareVersions } from "./skill-update.js";
|
|
8
|
+
export const UPGRADE_COMMAND = "npm install -g recess-cli@latest";
|
|
9
|
+
const NOTICE_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
10
|
+
export function updateStatus(cliVersion, versions = serverCliVersions()) {
|
|
11
|
+
const { latest, minimum } = versions;
|
|
12
|
+
if (minimum && compareVersions(cliVersion, minimum) < 0) {
|
|
13
|
+
return { state: "required", cliVersion, latest, minimum };
|
|
14
|
+
}
|
|
15
|
+
if (!latest)
|
|
16
|
+
return { state: "unknown", cliVersion };
|
|
17
|
+
if (compareVersions(cliVersion, latest) < 0) {
|
|
18
|
+
return { state: "available", cliVersion, latest, minimum };
|
|
19
|
+
}
|
|
20
|
+
return { state: "current", cliVersion, latest };
|
|
21
|
+
}
|
|
22
|
+
export function updateNoticeText(status) {
|
|
23
|
+
if (status.state === "required") {
|
|
24
|
+
return `Warning: recess ${status.cliVersion} is older than the minimum supported version ${status.minimum}, so some commands may fail. Update with \`recess update --confirm\` or \`${UPGRADE_COMMAND}\`.`;
|
|
25
|
+
}
|
|
26
|
+
if (status.state === "available") {
|
|
27
|
+
return `recess ${status.latest} is available (you have ${status.cliVersion}). Update with \`recess update --confirm\` or \`${UPGRADE_COMMAND}\`.`;
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
function noticeStampPath() {
|
|
32
|
+
return path.join(path.dirname(defaultConfigPath()), "update-notice.json");
|
|
33
|
+
}
|
|
34
|
+
async function recentlyNotified(latest) {
|
|
35
|
+
try {
|
|
36
|
+
const stamp = JSON.parse(await fs.readFile(noticeStampPath(), "utf8"));
|
|
37
|
+
if (stamp.latest !== latest || !stamp.notifiedAt)
|
|
38
|
+
return false;
|
|
39
|
+
return Date.now() - Date.parse(stamp.notifiedAt) < NOTICE_INTERVAL_MS;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function recordNotice(latest) {
|
|
46
|
+
try {
|
|
47
|
+
await fs.mkdir(path.dirname(noticeStampPath()), { recursive: true });
|
|
48
|
+
await fs.writeFile(noticeStampPath(), JSON.stringify({ latest, notifiedAt: new Date().toISOString() }));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export async function printUpdateNotice(cliVersion, write = (text) => process.stderr.write(text)) {
|
|
55
|
+
if (process.env.RECESS_CLI_NO_UPDATE_NOTICE === "1")
|
|
56
|
+
return;
|
|
57
|
+
const status = updateStatus(cliVersion);
|
|
58
|
+
const text = updateNoticeText(status);
|
|
59
|
+
if (!text)
|
|
60
|
+
return;
|
|
61
|
+
if (status.state === "available") {
|
|
62
|
+
if (await recentlyNotified(status.latest))
|
|
63
|
+
return;
|
|
64
|
+
await recordNotice(status.latest);
|
|
65
|
+
}
|
|
66
|
+
write(`\n${text}\n`);
|
|
67
|
+
}
|
|
68
|
+
export async function runSelfUpdate() {
|
|
69
|
+
const [bin, ...args] = UPGRADE_COMMAND.split(" ");
|
|
70
|
+
const exitCode = await new Promise((resolve, reject) => {
|
|
71
|
+
const child = spawn(bin, args, {
|
|
72
|
+
stdio: ["ignore", process.stderr, process.stderr],
|
|
73
|
+
shell: process.platform === "win32",
|
|
74
|
+
});
|
|
75
|
+
child.on("error", reject);
|
|
76
|
+
child.on("close", (code) => resolve(code ?? 1));
|
|
77
|
+
}).catch((error) => {
|
|
78
|
+
throw new CliError("update_failed", `Could not run \`${UPGRADE_COMMAND}\`: ${error instanceof Error ? error.message : String(error)}`);
|
|
79
|
+
});
|
|
80
|
+
if (exitCode !== 0) {
|
|
81
|
+
throw new CliError("update_failed", `\`${UPGRADE_COMMAND}\` exited with code ${exitCode}. Run it yourself to see the full npm output; a permissions error usually means the global npm directory needs sudo or a Node version manager.`);
|
|
82
|
+
}
|
|
83
|
+
return { command: UPGRADE_COMMAND, exitCode };
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=update-notice.js.map
|
package/package.json
CHANGED
|
@@ -107,6 +107,9 @@ prompts a model for them.
|
|
|
107
107
|
findings — fix them and publish again. Both commands wait for the result (`--no-wait` to return the
|
|
108
108
|
build id and poll with `apps status`).
|
|
109
109
|
- `recess --json apps pull <project-id> [dir]` downloads a project you own to keep editing it.
|
|
110
|
+
- A published `appUrl` is also a valid goal queue entry (`goals queue set`, or a template's
|
|
111
|
+
external practice queue): each app becomes one module. Build interactive content this way rather
|
|
112
|
+
than linking a chat artifact the kid cannot open inside Recess.
|
|
110
113
|
- `recess --json apps standards "<words or notation>"` finds the CCSS standard for `manifest.json`
|
|
111
114
|
(`"grade 4 adding fractions"` → `4.NF.B.3a`…); guides rarely know the codes, look them up.
|
|
112
115
|
|