recess-cli 2.6.1 → 2.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 +9 -1
- package/dist/api.js +13 -8
- package/dist/args.js +0 -1
- package/dist/cli.js +363 -46
- package/dist/command-schema.js +197 -34
- package/dist/commands/apps.js +282 -23
- package/dist/commands/mastery.js +214 -0
- package/dist/commands/onboarding.js +1 -7
- package/dist/commands/school.js +1 -1
- package/dist/commands/village-events.js +26 -3
- package/dist/engine.js +6 -0
- package/dist/errors.js +13 -4
- package/dist/help.js +23 -6
- package/dist/upload-names.js +19 -0
- package/package.json +8 -1
- package/skill/recess-cli/SKILL.md +31 -1
- package/skill/recess-cli/agents/version.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -6,9 +6,10 @@ import { RecessAdminApi, unwrap, withIdempotencyContext } from "./api.js";
|
|
|
6
6
|
import { flagNumber, flagString, hasFlag, parseArgs, } from "./args.js";
|
|
7
7
|
import { login, pollDeviceAuth, requestDeviceAuth } from "./auth.js";
|
|
8
8
|
import { clearStoredSession, deleteProfile, listProfiles, resolveConfig, saveProfile, useProfile, } from "./config.js";
|
|
9
|
-
import { agentContext, buildCommandSchema, scopedHelp, validateInvocation, } from "./command-schema.js";
|
|
9
|
+
import { agentContext, buildCommandSchema, findCommandSchema, remoteCommands, scopedHelp, validateInvocation, } from "./command-schema.js";
|
|
10
10
|
import { runApplicationsCommand } from "./commands/applications.js";
|
|
11
11
|
import { runAppsCommand } from "./commands/apps.js";
|
|
12
|
+
import { runMasteryCommand } from "./commands/mastery.js";
|
|
12
13
|
import { runOnboardingCommand } from "./commands/onboarding.js";
|
|
13
14
|
import { runSchoolCommand } from "./commands/school.js";
|
|
14
15
|
import { runVillageEventsCommand } from "./commands/village-events.js";
|
|
@@ -22,6 +23,7 @@ import { requireConfirmation } from "./safety.js";
|
|
|
22
23
|
import { installSkill, isEphemeralInstall, readBundledSkillVersion, readCliVersion, } from "./setup.js";
|
|
23
24
|
import { compareVersions, updateSkillFromServer } from "./skill-update.js";
|
|
24
25
|
import { readSkillCache, writeSkillCache } from "./skills-cache.js";
|
|
26
|
+
import { sanitizeWorkspaceUploadName } from "./upload-names.js";
|
|
25
27
|
import { appendJobEvent, getJob, listJobs, pruneJobs } from "./jobs.js";
|
|
26
28
|
function requiredRequestReason(parsed) {
|
|
27
29
|
return requireCliRequestReason(flagString(parsed, "reason", { required: true }));
|
|
@@ -36,9 +38,6 @@ function requiredRequestReason(parsed) {
|
|
|
36
38
|
* worse than no preview at all.
|
|
37
39
|
*/
|
|
38
40
|
async function resolveSessionRole(api) {
|
|
39
|
-
if (api.config.authSource === "config" && api.config.user?.role) {
|
|
40
|
-
return api.config.user.role;
|
|
41
|
-
}
|
|
42
41
|
const session = await api.client.GET("/auth/admin-cli/session/");
|
|
43
42
|
return session.data?.user.role;
|
|
44
43
|
}
|
|
@@ -232,7 +231,7 @@ async function skillStatus(config, reason) {
|
|
|
232
231
|
};
|
|
233
232
|
}
|
|
234
233
|
}
|
|
235
|
-
async function
|
|
234
|
+
async function localWriteCommand(parsed, preview, execute) {
|
|
236
235
|
const fingerprint = approvalTokenFor(preview);
|
|
237
236
|
const suppliedOperationKey = flagString(parsed, "operation-key");
|
|
238
237
|
if (!hasFlag(parsed, "confirm")) {
|
|
@@ -252,6 +251,7 @@ async function writeCommand(parsed, preview, execute) {
|
|
|
252
251
|
action: preview.action,
|
|
253
252
|
fingerprint,
|
|
254
253
|
target: preview.target,
|
|
254
|
+
...(preview.action.startsWith("mastery.") ? { preview } : {}),
|
|
255
255
|
});
|
|
256
256
|
requireConfirmation(false, boundPreview);
|
|
257
257
|
}
|
|
@@ -699,9 +699,6 @@ async function cleanupPlannerUpload(api, conversationId, signed, includeDelete)
|
|
|
699
699
|
.catch(() => undefined);
|
|
700
700
|
}
|
|
701
701
|
}
|
|
702
|
-
function safeGoalUploadName(fileName) {
|
|
703
|
-
return fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
704
|
-
}
|
|
705
702
|
async function sha256File(absolutePath) {
|
|
706
703
|
const handle = await fs.open(absolutePath, "r");
|
|
707
704
|
const hash = createHash("sha256");
|
|
@@ -917,6 +914,8 @@ const CONTENT_LIBRARY_DISCOVERY_LANES = [
|
|
|
917
914
|
"puzzles",
|
|
918
915
|
"wonder",
|
|
919
916
|
"idea-games",
|
|
917
|
+
"makers",
|
|
918
|
+
"drills",
|
|
920
919
|
];
|
|
921
920
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
922
921
|
function parseGoalQueueEntries(value, label) {
|
|
@@ -1554,13 +1553,86 @@ function tierSlots(parsed) {
|
|
|
1554
1553
|
}
|
|
1555
1554
|
return slots;
|
|
1556
1555
|
}
|
|
1557
|
-
|
|
1556
|
+
function cliErrorShape(error) {
|
|
1557
|
+
const cliError = error instanceof CliError
|
|
1558
|
+
? error
|
|
1559
|
+
: new CliError("unexpected_error", error instanceof Error ? error.message : String(error));
|
|
1560
|
+
return {
|
|
1561
|
+
code: cliError.code,
|
|
1562
|
+
message: cliError.message,
|
|
1563
|
+
...(cliError.details === undefined ? {} : { details: cliError.details }),
|
|
1564
|
+
};
|
|
1565
|
+
}
|
|
1566
|
+
function remoteWriteCommand(argv, structuredInput, store) {
|
|
1567
|
+
return async (parsed, preview, execute) => {
|
|
1568
|
+
const fingerprint = approvalTokenFor({
|
|
1569
|
+
...preview,
|
|
1570
|
+
details: {
|
|
1571
|
+
...preview.details,
|
|
1572
|
+
command: parsed.positionals.join(" "),
|
|
1573
|
+
},
|
|
1574
|
+
});
|
|
1575
|
+
const suppliedOperationKey = flagString(parsed, "operation-key");
|
|
1576
|
+
if (!hasFlag(parsed, "confirm")) {
|
|
1577
|
+
const operationKey = await store.stage({
|
|
1578
|
+
argv,
|
|
1579
|
+
structuredInput,
|
|
1580
|
+
preview,
|
|
1581
|
+
fingerprint,
|
|
1582
|
+
});
|
|
1583
|
+
requireConfirmation(false, {
|
|
1584
|
+
...preview,
|
|
1585
|
+
details: {
|
|
1586
|
+
...preview.details,
|
|
1587
|
+
operationKey,
|
|
1588
|
+
retry: "Confirm with the same command path plus --confirm --operation-key <operationKey>. The hosted service will execute the stored exact payload; do not retransmit structured input.",
|
|
1589
|
+
},
|
|
1590
|
+
});
|
|
1591
|
+
}
|
|
1592
|
+
if (!suppliedOperationKey) {
|
|
1593
|
+
throw new CliError("confirmation_required", "A confirmed hosted write requires --operation-key from the preview.", 2, { preview, requiredFlag: "--operation-key" });
|
|
1594
|
+
}
|
|
1595
|
+
const begun = await store.begin(suppliedOperationKey, fingerprint);
|
|
1596
|
+
if (begun.state === "completed")
|
|
1597
|
+
return begun.result;
|
|
1598
|
+
if (begun.state === "failed") {
|
|
1599
|
+
throw new CliError(begun.error.code, begun.error.message, 1, begun.error.details);
|
|
1600
|
+
}
|
|
1601
|
+
try {
|
|
1602
|
+
const result = await withIdempotencyContext({ operationKey: suppliedOperationKey, fingerprint }, execute);
|
|
1603
|
+
await store.complete(suppliedOperationKey, result);
|
|
1604
|
+
return result;
|
|
1605
|
+
}
|
|
1606
|
+
catch (error) {
|
|
1607
|
+
await store.fail(suppliedOperationKey, cliErrorShape(error));
|
|
1608
|
+
throw error;
|
|
1609
|
+
}
|
|
1610
|
+
};
|
|
1611
|
+
}
|
|
1612
|
+
export async function executeRecessCommand(argv, options = {}) {
|
|
1558
1613
|
const parsed = parseArgs(argv);
|
|
1559
1614
|
const [noun, verb] = parsed.positionals;
|
|
1560
1615
|
const commands = buildCommandSchema(HELP);
|
|
1616
|
+
const isRemote = options.transport === "remote";
|
|
1617
|
+
if (isRemote &&
|
|
1618
|
+
(!options.api || !options.config || !options.operationStore)) {
|
|
1619
|
+
throw new CliError("remote_context_required", "Hosted execution requires injected API, configuration, and operation-store adapters.");
|
|
1620
|
+
}
|
|
1621
|
+
if (isRemote) {
|
|
1622
|
+
const forbiddenTransportFlag = ["deliver", "profile", "json"].find((name) => hasFlag(parsed, name));
|
|
1623
|
+
if (forbiddenTransportFlag) {
|
|
1624
|
+
throw new CliError("remote_flag_unavailable", `--${forbiddenTransportFlag} is not available over hosted MCP.`);
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
const writeCommand = options.operationStore
|
|
1628
|
+
? remoteWriteCommand(argv, options.structuredInput, options.operationStore)
|
|
1629
|
+
: localWriteCommand;
|
|
1561
1630
|
// Before the help branch: `--version` parses as a FLAG, so `noun` is
|
|
1562
1631
|
// undefined and `!noun` would return help instead. (Found by running it.)
|
|
1563
|
-
if (noun === "version" || hasFlag(parsed, "version")) {
|
|
1632
|
+
if (noun === "version" || (!noun && hasFlag(parsed, "version"))) {
|
|
1633
|
+
if (isRemote) {
|
|
1634
|
+
throw new CliError("remote_command_unavailable", "The version command is not exposed by the hosted Recess CLI.");
|
|
1635
|
+
}
|
|
1564
1636
|
const unknown = Array.from(parsed.flags.keys()).filter((name) => !["deliver", "json", "version"].includes(name));
|
|
1565
1637
|
if (unknown.length > 0) {
|
|
1566
1638
|
throw new CliError("unknown_flag", `Unknown flag${unknown.length === 1 ? "" : "s"}: ${unknown
|
|
@@ -1579,28 +1651,70 @@ export async function runCommand(argv) {
|
|
|
1579
1651
|
.map((name) => `--${name}`)
|
|
1580
1652
|
.join(", ")}.`, 1, { validFlags: ["--deliver", "--help", "--json", "--profile"] });
|
|
1581
1653
|
}
|
|
1582
|
-
const config = await resolveConfig(flagString(parsed, "profile"));
|
|
1583
|
-
const discovery = resolveCommandDiscovery(config);
|
|
1584
|
-
|
|
1654
|
+
const config = options.config ?? (await resolveConfig(flagString(parsed, "profile")));
|
|
1655
|
+
const discovery = options.discovery ?? resolveCommandDiscovery(config);
|
|
1656
|
+
const visibleCommands = isRemote ? remoteCommands(commands) : commands;
|
|
1657
|
+
return {
|
|
1658
|
+
help: scopedHelp(HELP, visibleCommands, [], discovery, {
|
|
1659
|
+
remoteOnly: isRemote,
|
|
1660
|
+
}),
|
|
1661
|
+
};
|
|
1585
1662
|
}
|
|
1586
1663
|
if (noun === "help" || hasFlag(parsed, "help")) {
|
|
1587
1664
|
const scope = noun === "help" ? parsed.positionals.slice(1) : parsed.positionals;
|
|
1588
|
-
const config = await resolveConfig(flagString(parsed, "profile"));
|
|
1589
|
-
const discovery = resolveCommandDiscovery(config);
|
|
1590
|
-
|
|
1665
|
+
const config = options.config ?? (await resolveConfig(flagString(parsed, "profile")));
|
|
1666
|
+
const discovery = options.discovery ?? resolveCommandDiscovery(config);
|
|
1667
|
+
const visibleCommands = isRemote ? remoteCommands(commands) : commands;
|
|
1668
|
+
return {
|
|
1669
|
+
help: scopedHelp(HELP, visibleCommands, scope, discovery, {
|
|
1670
|
+
remoteOnly: isRemote,
|
|
1671
|
+
}),
|
|
1672
|
+
};
|
|
1673
|
+
}
|
|
1674
|
+
validateInvocation(parsed, commands, { remote: isRemote });
|
|
1675
|
+
if (isRemote) {
|
|
1676
|
+
const command = findCommandSchema(commands, parsed.positionals);
|
|
1677
|
+
if (!command?.remoteCapable) {
|
|
1678
|
+
throw new CliError("remote_command_unavailable", `\`${parsed.positionals.join(" ")}\` is not exposed by the hosted Recess CLI. Use help or agent-context to discover the remote catalog.`);
|
|
1679
|
+
}
|
|
1680
|
+
if (options.structuredInput !== undefined &&
|
|
1681
|
+
![
|
|
1682
|
+
"apps validate",
|
|
1683
|
+
"goal-templates validate-spec",
|
|
1684
|
+
"goal-templates create",
|
|
1685
|
+
].includes(command.path.join(" "))) {
|
|
1686
|
+
throw new CliError("invalid_arguments", "Structured input is accepted only by hosted apps validate and goal-templates validate-spec/create.");
|
|
1687
|
+
}
|
|
1688
|
+
if ((noun === "goal-templates" || noun === "skills") &&
|
|
1689
|
+
options.session?.user.role !== "GUIDE" &&
|
|
1690
|
+
options.session?.user.role !== "ADMIN") {
|
|
1691
|
+
throw new CliError("forbidden", "Hosted goal-template authoring requires a guide or admin.");
|
|
1692
|
+
}
|
|
1693
|
+
if (noun === "skills" &&
|
|
1694
|
+
parsed.positionals[3] !== "recess-goal-authoring") {
|
|
1695
|
+
throw new CliError("remote_command_unavailable", "Hosted skills only exposes recess-goal-authoring.");
|
|
1696
|
+
}
|
|
1697
|
+
const granted = options.grantedScopes ?? new Set();
|
|
1698
|
+
const missing = command.oauthScopes.filter((scope) => !granted.has(scope));
|
|
1699
|
+
if (missing.length > 0) {
|
|
1700
|
+
throw new CliError("insufficient_scope", `This command requires OAuth scope${missing.length === 1 ? "" : "s"}: ${missing.join(", ")}.`, 1, { requiredScopes: command.oauthScopes });
|
|
1701
|
+
}
|
|
1591
1702
|
}
|
|
1592
|
-
validateInvocation(parsed, commands);
|
|
1593
1703
|
if (noun === "agent-context") {
|
|
1594
|
-
const [profiles, config] =
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1704
|
+
const [profiles, config] = isRemote
|
|
1705
|
+
? [{ profiles: [] }, options.config ?? (await resolveConfig(undefined))]
|
|
1706
|
+
: await Promise.all([
|
|
1707
|
+
listProfiles(),
|
|
1708
|
+
options.config ?? resolveConfig(flagString(parsed, "profile")),
|
|
1709
|
+
]);
|
|
1710
|
+
const discovery = options.discovery ?? resolveCommandDiscovery(config);
|
|
1599
1711
|
return agentContext(commands, {
|
|
1600
|
-
cliVersion:
|
|
1712
|
+
cliVersion: options.versions?.cliVersion ??
|
|
1713
|
+
(isRemote ? "hosted" : await readCliVersion()),
|
|
1601
1714
|
availableProfiles: profiles.profiles.map((profile) => profile.name),
|
|
1602
1715
|
feedbackUpstreamConfigured: Boolean(process.env.RECESS_CLI_FEEDBACK_ENDPOINT),
|
|
1603
1716
|
discovery,
|
|
1717
|
+
remoteOnly: isRemote,
|
|
1604
1718
|
});
|
|
1605
1719
|
}
|
|
1606
1720
|
if (noun === "profile") {
|
|
@@ -1704,8 +1818,17 @@ export async function runCommand(argv) {
|
|
|
1704
1818
|
}));
|
|
1705
1819
|
}
|
|
1706
1820
|
}
|
|
1707
|
-
const config = await resolveConfig(flagString(parsed, "profile"));
|
|
1821
|
+
const config = options.config ?? (await resolveConfig(flagString(parsed, "profile")));
|
|
1708
1822
|
const requestReason = noun === "auth" ? undefined : requiredRequestReason(parsed);
|
|
1823
|
+
if (noun === "doctor" && isRemote) {
|
|
1824
|
+
return {
|
|
1825
|
+
transport: "streamable-http-mcp",
|
|
1826
|
+
resource: options.remoteResource ?? config.apiOrigin.replace(/\/$/, "") + "/mcp",
|
|
1827
|
+
authenticated: true,
|
|
1828
|
+
session: options.session ?? null,
|
|
1829
|
+
scopes: Array.from(options.grantedScopes ?? []).sort(),
|
|
1830
|
+
};
|
|
1831
|
+
}
|
|
1709
1832
|
if (noun === "doctor")
|
|
1710
1833
|
return doctor(config, requestReason);
|
|
1711
1834
|
if (noun === "setup") {
|
|
@@ -1775,7 +1898,7 @@ export async function runCommand(argv) {
|
|
|
1775
1898
|
}
|
|
1776
1899
|
throw new CliError("invalid_arguments", "Use auth login, request, poll, status, or logout.");
|
|
1777
1900
|
}
|
|
1778
|
-
const api = new RecessAdminApi(config, requestReason);
|
|
1901
|
+
const api = options.api ?? new RecessAdminApi(config, requestReason);
|
|
1779
1902
|
api.requireAuth();
|
|
1780
1903
|
if (noun === "village") {
|
|
1781
1904
|
const targetWorldId = flagString(parsed, "world") ?? "village-1";
|
|
@@ -2539,7 +2662,16 @@ export async function runCommand(argv) {
|
|
|
2539
2662
|
return runApplicationsCommand({ parsed, api, writeCommand });
|
|
2540
2663
|
}
|
|
2541
2664
|
if (noun === "apps") {
|
|
2542
|
-
return runAppsCommand({
|
|
2665
|
+
return runAppsCommand({
|
|
2666
|
+
parsed,
|
|
2667
|
+
api,
|
|
2668
|
+
writeCommand,
|
|
2669
|
+
transport: options.transport,
|
|
2670
|
+
structuredInput: options.structuredInput,
|
|
2671
|
+
});
|
|
2672
|
+
}
|
|
2673
|
+
if (noun === "mastery") {
|
|
2674
|
+
return runMasteryCommand({ parsed, api, writeCommand });
|
|
2543
2675
|
}
|
|
2544
2676
|
if (noun === "school") {
|
|
2545
2677
|
return runSchoolCommand({ parsed, api, writeCommand });
|
|
@@ -3130,7 +3262,7 @@ export async function runCommand(argv) {
|
|
|
3130
3262
|
// infers whether `get` means guardian guidance or staff-only operations.
|
|
3131
3263
|
const audience = assertChoice(verb, ["admin", "guardian"], "skill audience");
|
|
3132
3264
|
const action = positional(parsed, 2, "skills action");
|
|
3133
|
-
const refresh = hasFlag(parsed, "refresh");
|
|
3265
|
+
const refresh = isRemote || hasFlag(parsed, "refresh");
|
|
3134
3266
|
if (action === "list") {
|
|
3135
3267
|
const query = flagString(parsed, "query");
|
|
3136
3268
|
const category = flagString(parsed, "category");
|
|
@@ -3149,7 +3281,8 @@ export async function runCommand(argv) {
|
|
|
3149
3281
|
},
|
|
3150
3282
|
},
|
|
3151
3283
|
}));
|
|
3152
|
-
|
|
3284
|
+
if (!isRemote)
|
|
3285
|
+
await writeSkillCache(config.apiOrigin, cacheKey, data);
|
|
3153
3286
|
return { ...data, cached: false };
|
|
3154
3287
|
}
|
|
3155
3288
|
if (action === "get") {
|
|
@@ -3174,7 +3307,8 @@ export async function runCommand(argv) {
|
|
|
3174
3307
|
},
|
|
3175
3308
|
},
|
|
3176
3309
|
}));
|
|
3177
|
-
|
|
3310
|
+
if (!isRemote)
|
|
3311
|
+
await writeSkillCache(config.apiOrigin, cacheKey, data);
|
|
3178
3312
|
return { ...data, cached: false };
|
|
3179
3313
|
}
|
|
3180
3314
|
throw new CliError("invalid_arguments", "Use skills guardian list|get or skills admin list|get.");
|
|
@@ -3261,7 +3395,12 @@ export async function runCommand(argv) {
|
|
|
3261
3395
|
timeoutSeconds > 7200) {
|
|
3262
3396
|
throw new CliError("invalid_arguments", "--timeout must be an integer number of seconds from 10 through 7200.");
|
|
3263
3397
|
}
|
|
3264
|
-
const
|
|
3398
|
+
const allowPossibleDuplicate = hasFlag(parsed, "allow-possible-duplicate");
|
|
3399
|
+
const body = {
|
|
3400
|
+
stage,
|
|
3401
|
+
items: input.items,
|
|
3402
|
+
...(allowPossibleDuplicate ? { allowPossibleDuplicate: true } : {}),
|
|
3403
|
+
};
|
|
3265
3404
|
const preview = {
|
|
3266
3405
|
action: "content-library.submit",
|
|
3267
3406
|
target: { stage, resourceCount: input.items.length },
|
|
@@ -3270,7 +3409,9 @@ export async function runCommand(argv) {
|
|
|
3270
3409
|
admission: stage === "polish"
|
|
3271
3410
|
? "Starts automatic decoration; the island promotes each successful resource to LIVE after the polish and deterministic tail finish."
|
|
3272
3411
|
: "Holds each new resource in REVIEW until an admin approves it.",
|
|
3273
|
-
duplicateBehavior:
|
|
3412
|
+
duplicateBehavior: allowPossibleDuplicate
|
|
3413
|
+
? "Exact duplicates and previously rejected URLs are still returned as duplicates; fuzzy possible-duplicate candidates are admitted to REVIEW for a curator to settle."
|
|
3414
|
+
: "Existing URLs are returned as duplicates and are not overwritten.",
|
|
3274
3415
|
deployOrderFence: "The server verifies the island's review/polish lifecycle capability before its first write.",
|
|
3275
3416
|
waitForLive: wait,
|
|
3276
3417
|
...(wait ? { timeoutSeconds } : {}),
|
|
@@ -3292,6 +3433,21 @@ export async function runCommand(argv) {
|
|
|
3292
3433
|
throw new CliError("invalid_arguments", "Use content-library search, status, set-stage, or submit.");
|
|
3293
3434
|
}
|
|
3294
3435
|
if (noun === "goal-templates") {
|
|
3436
|
+
const readTemplateDocument = async () => {
|
|
3437
|
+
if (!isRemote) {
|
|
3438
|
+
return readJsonFile(flagString(parsed, "file", { required: true }), "Template file");
|
|
3439
|
+
}
|
|
3440
|
+
const input = options.structuredInput;
|
|
3441
|
+
const template = input?.template;
|
|
3442
|
+
if (!input ||
|
|
3443
|
+
Object.keys(input).some((key) => key !== "template") ||
|
|
3444
|
+
!template ||
|
|
3445
|
+
typeof template !== "object" ||
|
|
3446
|
+
Array.isArray(template)) {
|
|
3447
|
+
throw new CliError("invalid_arguments", "Supply the complete template document as input.template.");
|
|
3448
|
+
}
|
|
3449
|
+
return template;
|
|
3450
|
+
};
|
|
3295
3451
|
if (verb === "list") {
|
|
3296
3452
|
const query = flagString(parsed, "query");
|
|
3297
3453
|
const kind = flagString(parsed, "kind");
|
|
@@ -3365,7 +3521,7 @@ export async function runCommand(argv) {
|
|
|
3365
3521
|
return version;
|
|
3366
3522
|
}
|
|
3367
3523
|
if (verb === "validate-spec") {
|
|
3368
|
-
const document = parseGoalTemplateDocument(await
|
|
3524
|
+
const document = parseGoalTemplateDocument(await readTemplateDocument());
|
|
3369
3525
|
// A read-only validation: no gate, and iterating on it is the whole point.
|
|
3370
3526
|
return {
|
|
3371
3527
|
slug: document.slug,
|
|
@@ -3379,8 +3535,10 @@ export async function runCommand(argv) {
|
|
|
3379
3535
|
};
|
|
3380
3536
|
}
|
|
3381
3537
|
if (verb === "create") {
|
|
3382
|
-
const filePath =
|
|
3383
|
-
|
|
3538
|
+
const filePath = isRemote
|
|
3539
|
+
? undefined
|
|
3540
|
+
: flagString(parsed, "file", { required: true });
|
|
3541
|
+
const authoredDocument = parseGoalTemplateDocument(await readTemplateDocument());
|
|
3384
3542
|
const coinAmountOverride = flagNumber(parsed, "coin-amount");
|
|
3385
3543
|
if (coinAmountOverride !== undefined &&
|
|
3386
3544
|
(!Number.isInteger(coinAmountOverride) || coinAmountOverride < 1)) {
|
|
@@ -3405,13 +3563,15 @@ export async function runCommand(argv) {
|
|
|
3405
3563
|
},
|
|
3406
3564
|
}));
|
|
3407
3565
|
if (!validation.valid) {
|
|
3408
|
-
throw new CliError("invalid_spec", `The setupWorkflowSpec is invalid, so nothing was created: ${validation.error}`, 1, { file: path.resolve(filePath) });
|
|
3566
|
+
throw new CliError("invalid_spec", `The setupWorkflowSpec is invalid, so nothing was created: ${validation.error}`, 1, filePath ? { file: path.resolve(filePath) } : { input: "template" });
|
|
3409
3567
|
}
|
|
3410
3568
|
return writeCommand(parsed, {
|
|
3411
3569
|
action: "create a NEW global goal template (DETERMINISTIC_WORKFLOW — this cannot be converted back)",
|
|
3412
3570
|
target: { slug: document.slug, title: document.title },
|
|
3413
3571
|
request: {
|
|
3414
|
-
|
|
3572
|
+
...(filePath
|
|
3573
|
+
? { file: path.resolve(filePath) }
|
|
3574
|
+
: { template: document }),
|
|
3415
3575
|
kind: document.kind,
|
|
3416
3576
|
setupAudience: document.setupAudience,
|
|
3417
3577
|
category: document.category ?? null,
|
|
@@ -4086,7 +4246,7 @@ export async function runCommand(argv) {
|
|
|
4086
4246
|
// answers 401. A preview is a promise about what --confirm will do.
|
|
4087
4247
|
const sessionRole = await resolveSessionRole(api);
|
|
4088
4248
|
if (sessionRole && sessionRole !== "ADMIN") {
|
|
4089
|
-
throw new CliError("forbidden", "Deleting a goal is
|
|
4249
|
+
throw new CliError("forbidden", "Deleting a goal is admin-only. To free an open-goal slot for a finished course, use `recess goals archive <goal-id> --student <kid-id>` — it sets the terminal status, keeps the goal and its history, and needs no staff involvement.", 1, { sessionRole, requiredRole: "ADMIN", alternative: "goals archive" });
|
|
4090
4250
|
}
|
|
4091
4251
|
const current = unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
|
|
4092
4252
|
params: { path: { userId: studentId } },
|
|
@@ -4104,7 +4264,7 @@ export async function runCommand(argv) {
|
|
|
4104
4264
|
},
|
|
4105
4265
|
request: { expectedUpdatedAt: current.updatedAt },
|
|
4106
4266
|
details: {
|
|
4107
|
-
note: "Soft-deletes the goal and its current/future dated todos. Historical todos
|
|
4267
|
+
note: "Soft-deletes the goal and its current/future dated todos. Historical todos, the deletion audit, and any Mesa workspace remain available; `goals restore` reverses this tombstone.",
|
|
4108
4268
|
},
|
|
4109
4269
|
};
|
|
4110
4270
|
return previewBoundWrite(parsed, preview, async () => {
|
|
@@ -4114,6 +4274,39 @@ export async function runCommand(argv) {
|
|
|
4114
4274
|
return { deleted: true, goalId, studentUserId: studentId };
|
|
4115
4275
|
});
|
|
4116
4276
|
}
|
|
4277
|
+
if (verb === "restore") {
|
|
4278
|
+
const goalId = positional(parsed, 2, "goal ID");
|
|
4279
|
+
const studentId = flagString(parsed, "student", { required: true });
|
|
4280
|
+
const sessionRole = await resolveSessionRole(api);
|
|
4281
|
+
if (sessionRole && sessionRole !== "ADMIN") {
|
|
4282
|
+
throw new CliError("forbidden", "Restoring a deleted goal is admin-only.", 1, { sessionRole, requiredRole: "ADMIN" });
|
|
4283
|
+
}
|
|
4284
|
+
const state = unwrap(await api.client.GET("/admin/browser/students/goals/{goalId}/deletion-state/", { params: { path: { goalId } } }));
|
|
4285
|
+
if (state.userId !== studentId) {
|
|
4286
|
+
throw new CliError("not_found", `Goal ${goalId} was not found for student ${studentId}.`);
|
|
4287
|
+
}
|
|
4288
|
+
if (!state.deletedAt) {
|
|
4289
|
+
throw new CliError("not_deleted", `Goal ${goalId} is not deleted.`);
|
|
4290
|
+
}
|
|
4291
|
+
if (!state.restorable) {
|
|
4292
|
+
throw new CliError("restore_unavailable", state.restoreBlockedReason ?? "This goal cannot be restored safely.");
|
|
4293
|
+
}
|
|
4294
|
+
const body = { expectedDeletedAt: state.deletedAt };
|
|
4295
|
+
const preview = {
|
|
4296
|
+
action: "restore a soft-deleted goal for a managed student",
|
|
4297
|
+
target: {
|
|
4298
|
+
goalId,
|
|
4299
|
+
studentUserId: studentId,
|
|
4300
|
+
title: state.title,
|
|
4301
|
+
status: state.status,
|
|
4302
|
+
},
|
|
4303
|
+
request: body,
|
|
4304
|
+
details: {
|
|
4305
|
+
note: "Clears the goal tombstone and restores only linked todos that were tombstoned by the same goal deletion. Restoring an ACTIVE or PAUSED goal rechecks the student's open-goal capacity.",
|
|
4306
|
+
},
|
|
4307
|
+
};
|
|
4308
|
+
return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/admin/browser/students/goals/{goalId}/restore/", { params: { path: { goalId } }, body })));
|
|
4309
|
+
}
|
|
4117
4310
|
if (verb === "queue") {
|
|
4118
4311
|
const subverb = positional(parsed, 2, "queue action (get|set)");
|
|
4119
4312
|
const goalId = positional(parsed, 3, "goal ID");
|
|
@@ -4205,12 +4398,29 @@ export async function runCommand(argv) {
|
|
|
4205
4398
|
}
|
|
4206
4399
|
throw new CliError("invalid_arguments", "Use goals queue get|set.");
|
|
4207
4400
|
}
|
|
4208
|
-
throw new CliError("invalid_arguments", "Use goals list|create|edit|delete|archive|unarchive|complete|undo-completion|queue|files|pdf.");
|
|
4401
|
+
throw new CliError("invalid_arguments", "Use goals list|create|edit|delete|restore|archive|unarchive|complete|undo-completion|queue|files|pdf.");
|
|
4209
4402
|
}
|
|
4210
4403
|
if (noun === "students") {
|
|
4211
4404
|
if (verb === "list") {
|
|
4212
|
-
|
|
4213
|
-
|
|
4405
|
+
const requestedScope = flagString(parsed, "scope");
|
|
4406
|
+
const scope = requestedScope
|
|
4407
|
+
? assertChoice(requestedScope, ["mine", "family"], "--scope")
|
|
4408
|
+
: isRemote
|
|
4409
|
+
? "mine"
|
|
4410
|
+
: undefined;
|
|
4411
|
+
if (scope === "mine") {
|
|
4412
|
+
return unwrap(await api.client.GET("/tutor/students", {
|
|
4413
|
+
params: { query: { scope: "mine" } },
|
|
4414
|
+
}));
|
|
4415
|
+
}
|
|
4416
|
+
if (scope === "family") {
|
|
4417
|
+
return unwrap(await api.client.GET("/family/kids", {
|
|
4418
|
+
params: { query: { includeSelf: "false" } },
|
|
4419
|
+
}));
|
|
4420
|
+
}
|
|
4421
|
+
// A guide has no family — their roster is the tutor surface's, which the
|
|
4422
|
+
// server scopes per surface (`STUDENT_SCOPE_POLICY_BY_SURFACE`: manual
|
|
4423
|
+
// assignments plus live cohorts, never the institution roster). The
|
|
4214
4424
|
// stored identity is absent on an env-cookie session, so fall back to
|
|
4215
4425
|
// asking the server who this session belongs to.
|
|
4216
4426
|
// The stored identity only describes a `auth login` session; an
|
|
@@ -4248,13 +4458,38 @@ export async function runCommand(argv) {
|
|
|
4248
4458
|
}));
|
|
4249
4459
|
}
|
|
4250
4460
|
if (verb === "todos") {
|
|
4251
|
-
const
|
|
4461
|
+
const student = flagString(parsed, "student", { required: true });
|
|
4462
|
+
const date = flagString(parsed, "date");
|
|
4463
|
+
if (date !== undefined &&
|
|
4464
|
+
date !== "today" &&
|
|
4465
|
+
(!/^\d{4}-\d{2}-\d{2}$/.test(date) ||
|
|
4466
|
+
!Number.isFinite(Date.parse(`${date}T00:00:00.000Z`)) ||
|
|
4467
|
+
new Date(`${date}T00:00:00.000Z`).toISOString().slice(0, 10) !== date)) {
|
|
4468
|
+
throw new CliError("invalid_arguments", "--date must be today or a valid YYYY-MM-DD calendar date.");
|
|
4469
|
+
}
|
|
4470
|
+
const cursor = flagString(parsed, "cursor");
|
|
4252
4471
|
const limit = flagNumber(parsed, "limit");
|
|
4472
|
+
let studentId = student;
|
|
4473
|
+
if (!/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(student)) {
|
|
4474
|
+
const roster = unwrap(await api.client.GET("/tutor/students", {
|
|
4475
|
+
params: { query: { scope: "mine" } },
|
|
4476
|
+
}));
|
|
4477
|
+
const normalize = (name) => name.trim().replace(/\s+/g, " ").toLowerCase();
|
|
4478
|
+
const matches = roster.items.filter((item) => normalize(item.name) === normalize(student));
|
|
4479
|
+
if (matches.length !== 1) {
|
|
4480
|
+
throw new CliError(matches.length ? "ambiguous_student" : "student_not_found", matches.length
|
|
4481
|
+
? "Multiple students have that name. Ask which student, then retry with their ID."
|
|
4482
|
+
: "No exact name match in your roster. Use students list to find the student's ID.", 1, { matches: matches.map(({ userId, name }) => ({ userId, name })) });
|
|
4483
|
+
}
|
|
4484
|
+
studentId = matches[0].userId;
|
|
4485
|
+
}
|
|
4253
4486
|
return unwrap(await api.client.GET("/studio/students/{studentId}/todos", {
|
|
4254
4487
|
params: {
|
|
4255
4488
|
path: { studentId },
|
|
4256
4489
|
query: {
|
|
4257
4490
|
...(limit === undefined ? {} : { limit }),
|
|
4491
|
+
...(date === undefined ? {} : { date }),
|
|
4492
|
+
...(cursor === undefined ? {} : { cursor }),
|
|
4258
4493
|
...(hasFlag(parsed, "analyzed") ? { analyzed: true } : {}),
|
|
4259
4494
|
},
|
|
4260
4495
|
},
|
|
@@ -4397,7 +4632,7 @@ export async function runCommand(argv) {
|
|
|
4397
4632
|
params: { path: { id: todoId } },
|
|
4398
4633
|
}));
|
|
4399
4634
|
const preview = {
|
|
4400
|
-
action: "
|
|
4635
|
+
action: "soft-delete a todo for a managed student",
|
|
4401
4636
|
target: {
|
|
4402
4637
|
todoId,
|
|
4403
4638
|
studentUserId: current.userId,
|
|
@@ -4411,13 +4646,91 @@ export async function runCommand(argv) {
|
|
|
4411
4646
|
creationSource: current.creationSource,
|
|
4412
4647
|
creationSourceId: current.creationSourceId,
|
|
4413
4648
|
url: "url" in current ? current.url : null,
|
|
4414
|
-
note: "
|
|
4649
|
+
note: "Sets deletedAt without awarding XP or running todo-completion side effects. The todo, its work evidence, and related records remain available; `todos restore` reverses the tombstone.",
|
|
4415
4650
|
},
|
|
4416
4651
|
};
|
|
4417
4652
|
return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.DELETE("/admin/todos/{id}/", {
|
|
4418
4653
|
params: { path: { id: todoId } },
|
|
4419
4654
|
})));
|
|
4420
4655
|
}
|
|
4656
|
+
if (verb === "restore") {
|
|
4657
|
+
const todoId = positional(parsed, 2, "todo ID");
|
|
4658
|
+
const state = unwrap(await api.client.GET("/admin/todos/{id}/deletion-state", {
|
|
4659
|
+
params: { path: { id: todoId } },
|
|
4660
|
+
}));
|
|
4661
|
+
if (!state.deletedAt) {
|
|
4662
|
+
throw new CliError("not_deleted", `Todo ${todoId} is not deleted.`);
|
|
4663
|
+
}
|
|
4664
|
+
if (state.goal?.deletedAt) {
|
|
4665
|
+
throw new CliError("restore_parent_goal", `Todo ${todoId} belongs to deleted goal ${state.goal.id}. Restore the goal instead so its deletion set is reversed together.`);
|
|
4666
|
+
}
|
|
4667
|
+
const body = { expectedDeletedAt: state.deletedAt };
|
|
4668
|
+
const preview = {
|
|
4669
|
+
action: "restore a soft-deleted todo for a managed student",
|
|
4670
|
+
target: {
|
|
4671
|
+
todoId,
|
|
4672
|
+
studentUserId: state.userId,
|
|
4673
|
+
title: state.title,
|
|
4674
|
+
status: state.status,
|
|
4675
|
+
goalId: state.goal?.id ?? null,
|
|
4676
|
+
},
|
|
4677
|
+
request: body,
|
|
4678
|
+
details: {
|
|
4679
|
+
dueDate: state.dueDate,
|
|
4680
|
+
note: "Clears deletedAt only when the tombstone still matches this preview. All preserved work evidence and related records become visible with the todo again.",
|
|
4681
|
+
},
|
|
4682
|
+
};
|
|
4683
|
+
return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/admin/todos/{id}/restore", {
|
|
4684
|
+
params: { path: { id: todoId } },
|
|
4685
|
+
body,
|
|
4686
|
+
})));
|
|
4687
|
+
}
|
|
4688
|
+
if (verb === "generate-learning-analysis") {
|
|
4689
|
+
const todoId = positional(parsed, 2, "todo ID");
|
|
4690
|
+
const sessionRole = await resolveSessionRole(api);
|
|
4691
|
+
if (sessionRole !== "ADMIN") {
|
|
4692
|
+
throw new CliError("forbidden", "Generating a learning analysis is admin-only.", 1, { sessionRole: sessionRole ?? null, requiredRole: "ADMIN" });
|
|
4693
|
+
}
|
|
4694
|
+
const [todoResponse, detailsResponse, geminiResponse] = await Promise.all([
|
|
4695
|
+
api.client.GET("/tutor/browser/todos/{id}/", {
|
|
4696
|
+
params: { path: { id: todoId } },
|
|
4697
|
+
}),
|
|
4698
|
+
api.client.GET("/admin/todos/{id}/details", {
|
|
4699
|
+
params: { path: { id: todoId } },
|
|
4700
|
+
}),
|
|
4701
|
+
api.client.GET("/admin/todos/{id}/gemini-analysis", {
|
|
4702
|
+
params: { path: { id: todoId } },
|
|
4703
|
+
}),
|
|
4704
|
+
]);
|
|
4705
|
+
const currentTodo = unwrap(todoResponse);
|
|
4706
|
+
const details = unwrap(detailsResponse);
|
|
4707
|
+
const gemini = unwrap(geminiResponse);
|
|
4708
|
+
const latestCompletedGeminiAnalysis = gemini.newPipelineAnalyses.find((analysis) => analysis.status === "completed");
|
|
4709
|
+
if (!latestCompletedGeminiAnalysis) {
|
|
4710
|
+
throw new CliError("not_found", "This todo does not have a completed Gemini analysis to use.");
|
|
4711
|
+
}
|
|
4712
|
+
const body = {
|
|
4713
|
+
geminiAnalysisId: latestCompletedGeminiAnalysis.id,
|
|
4714
|
+
};
|
|
4715
|
+
const preview = {
|
|
4716
|
+
action: "queue a learning analysis from a todo's latest completed Gemini analysis",
|
|
4717
|
+
target: {
|
|
4718
|
+
todoId,
|
|
4719
|
+
studentUserId: currentTodo.userId,
|
|
4720
|
+
title: currentTodo.title,
|
|
4721
|
+
status: currentTodo.status,
|
|
4722
|
+
},
|
|
4723
|
+
request: body,
|
|
4724
|
+
details: {
|
|
4725
|
+
existingLearningAnalysisId: details.learningAnalysis?.id ?? null,
|
|
4726
|
+
note: "The forensic learning pipeline is idempotent for this Gemini analysis. The command does not rerun completion or change rewards.",
|
|
4727
|
+
},
|
|
4728
|
+
};
|
|
4729
|
+
return writeCommand(parsed, preview, async () => unwrap(await api.client.POST("/admin/learning-pipeline/{todoId}/start/", {
|
|
4730
|
+
params: { path: { todoId } },
|
|
4731
|
+
body,
|
|
4732
|
+
})));
|
|
4733
|
+
}
|
|
4421
4734
|
if (verb === "generate-applet") {
|
|
4422
4735
|
const todoId = positional(parsed, 2, "todo ID");
|
|
4423
4736
|
const studentId = flagString(parsed, "student", { required: true });
|
|
@@ -4459,7 +4772,7 @@ export async function runCommand(argv) {
|
|
|
4459
4772
|
body: targetDueDateISO ? { targetDueDateISO } : {},
|
|
4460
4773
|
})));
|
|
4461
4774
|
}
|
|
4462
|
-
throw new CliError("invalid_arguments", "Use todos create|edit|complete|delete|generate-applet.");
|
|
4775
|
+
throw new CliError("invalid_arguments", "Use todos create|edit|complete|delete|restore|generate-learning-analysis|generate-applet.");
|
|
4463
4776
|
}
|
|
4464
4777
|
if (noun === "memories") {
|
|
4465
4778
|
const studentId = flagString(parsed, "student", { required: true });
|
|
@@ -4533,7 +4846,8 @@ export async function runCommand(argv) {
|
|
|
4533
4846
|
throw new CliError("invalid_arguments", `PDF size must be between 1 byte and ${GOAL_PDF_UPLOAD_MAX_BYTES} bytes.`);
|
|
4534
4847
|
}
|
|
4535
4848
|
const fileName = path.basename(absolutePath);
|
|
4536
|
-
const workspacePath = flagString(parsed, "path") ??
|
|
4849
|
+
const workspacePath = flagString(parsed, "path") ??
|
|
4850
|
+
`uploads/${sanitizeWorkspaceUploadName(fileName)}`;
|
|
4537
4851
|
if (!workspacePath.startsWith("uploads/")) {
|
|
4538
4852
|
throw new CliError("invalid_arguments", "Textbook PDFs must be attached under uploads/.");
|
|
4539
4853
|
}
|
|
@@ -4862,4 +5176,7 @@ export async function runCommand(argv) {
|
|
|
4862
5176
|
}
|
|
4863
5177
|
throw new CliError("unknown_command", `Unknown command: ${parsed.positionals.join(" ")}\n\n${HELP}`);
|
|
4864
5178
|
}
|
|
5179
|
+
export async function runCommand(argv) {
|
|
5180
|
+
return executeRecessCommand(argv);
|
|
5181
|
+
}
|
|
4865
5182
|
//# sourceMappingURL=cli.js.map
|