@novedu/cli 0.24.0 → 0.25.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.
Files changed (3) hide show
  1. package/README.md +2 -1
  2. package/dist/main.js +62 -31
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -331,7 +331,8 @@ images list [--search <q>] [--all]
331
331
  shareable `url`. `--start`/`--end` must be ISO 8601 **with an explicit
332
332
  offset or `Z`** (e.g. `2026-07-07T08:00:00Z`); the
333
333
  `--llm-provider`/`--llm-model` override pair is both-or-nothing, and
334
- `--llm-reasoning <level>` (`minimal`, `low`, `medium` or `high`) rides on top of
334
+ `--llm-reasoning <level>` (`none`, `minimal`, `low`, `medium`, `high` or
335
+ `xhigh`) rides on top of
335
336
  the pair — it is rejected without it. The override replaces the activity's whole
336
337
  `llm:` block, so leaving the level out also drops the file's.
337
338
  - `codes sync <registry-file>` mints codes for a whole **course** at once — see
package/dist/main.js CHANGED
@@ -2,15 +2,35 @@
2
2
  import { Command } from "commander";
3
3
  import { readFile, writeFile } from "node:fs/promises";
4
4
  import { basename, dirname, join, resolve } from "node:path";
5
+ import { z } from "zod";
5
6
  import { spawn } from "node:child_process";
6
7
  import { globSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7
8
  import { createServer } from "node:http";
8
9
  import { homedir } from "node:os";
9
10
  import { CryptoProvider, PublicClientApplication } from "@azure/msal-node";
10
11
  import { parse, stringify } from "yaml";
11
- import { z } from "zod";
12
12
  import Handlebars from "handlebars";
13
13
  import { fileURLToPath, pathToFileURL } from "node:url";
14
+ //#region ../lib/llm/provider.ts
15
+ const LLM_PROVIDERS = ["SCCH", "Azure Foundry"];
16
+ const DEFAULT_PROVIDER = "SCCH";
17
+ const providerSchema = z.enum(LLM_PROVIDERS).default(DEFAULT_PROVIDER).meta({ description: "The LLM provider serving the model. For Azure Foundry, model is the deployment name." });
18
+ const REASONING_LEVELS = [
19
+ "none",
20
+ "minimal",
21
+ "low",
22
+ "medium",
23
+ "high",
24
+ "xhigh"
25
+ ];
26
+ const reasoningLevelSchema = z.enum(REASONING_LEVELS).optional().meta({ description: "Optional reasoning effort for reasoning models. Not every model accepts every level. Omit to let the model decide (the parameter is then not sent); \"none\" instead turns a reasoning model off." });
27
+ function parseLenientProvider(value) {
28
+ return value === "SCCH" || value === "Azure Foundry" ? value : void 0;
29
+ }
30
+ function parseLenientReasoningLevel(value) {
31
+ return typeof value === "string" && REASONING_LEVELS.includes(value) ? value : void 0;
32
+ }
33
+ //#endregion
14
34
  //#region src/auth.ts
15
35
  const DEFAULT_TENANT_ID = "91fc072c-edef-4f97-bdc5-cfb67718ae3a";
16
36
  const DEFAULT_CLIENT_ID = "4d44fc4b-0434-4981-9765-62e2074ceecb";
@@ -85,17 +105,46 @@ async function acquireSilent(pca) {
85
105
  return null;
86
106
  }
87
107
  }
108
+ /**
109
+ * How the system browser is launched per platform. Exported for tests.
110
+ *
111
+ * Windows goes through `cmd /c start`, and cmd RE-PARSES the command line it
112
+ * receives: an unquoted URL is cut at the first `&`, so Entra only ever saw
113
+ * `authorize?client_id=…` and answered AADSTS900144 ("the request body must
114
+ * contain the following parameter: 'scope'"). Node quotes an argument only
115
+ * when it contains whitespace, so the URL is quoted here explicitly and the
116
+ * command line handed over verbatim. The empty `""` is `start`'s window title
117
+ * — without it, `start` would take the quoted URL as the title.
118
+ */
119
+ function browserCommand(url, platform = process.platform) {
120
+ if (platform === "darwin") return {
121
+ command: "open",
122
+ args: [url],
123
+ verbatim: false
124
+ };
125
+ if (platform === "win32") return {
126
+ command: "cmd",
127
+ args: [
128
+ "/c",
129
+ "start",
130
+ "\"\"",
131
+ `"${url}"`
132
+ ],
133
+ verbatim: true
134
+ };
135
+ return {
136
+ command: "xdg-open",
137
+ args: [url],
138
+ verbatim: false
139
+ };
140
+ }
88
141
  function defaultOpenBrowser(url) {
89
- const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", [
90
- "/c",
91
- "start",
92
- "",
93
- url
94
- ]] : ["xdg-open", [url]];
142
+ const { command, args, verbatim } = browserCommand(url);
95
143
  try {
96
144
  spawn(command, args, {
97
145
  stdio: "ignore",
98
- detached: true
146
+ detached: true,
147
+ windowsVerbatimArguments: verbatim
99
148
  }).unref();
100
149
  } catch {}
101
150
  }
@@ -280,24 +329,6 @@ async function runApiRequest(options) {
280
329
  if (result.ok) printJson(result.payload ?? null);
281
330
  }
282
331
  //#endregion
283
- //#region ../lib/llm/provider.ts
284
- const LLM_PROVIDERS = ["SCCH", "Azure Foundry"];
285
- const DEFAULT_PROVIDER = "SCCH";
286
- const providerSchema = z.enum(LLM_PROVIDERS).default(DEFAULT_PROVIDER).meta({ description: "The LLM provider serving the model. For Azure Foundry, model is the deployment name." });
287
- const REASONING_LEVELS = [
288
- "minimal",
289
- "low",
290
- "medium",
291
- "high"
292
- ];
293
- const reasoningLevelSchema = z.enum(REASONING_LEVELS).optional().meta({ description: "Optional reasoning effort for reasoning models. Omit to let the model decide (the parameter is then not sent)." });
294
- function parseLenientProvider(value) {
295
- return value === "SCCH" || value === "Azure Foundry" ? value : void 0;
296
- }
297
- function parseLenientReasoningLevel(value) {
298
- return typeof value === "string" && REASONING_LEVELS.includes(value) ? value : void 0;
299
- }
300
- //#endregion
301
332
  //#region ../lib/registry-schema.ts
302
333
  /** The fixed group names and the code module each one mints for. */
303
334
  const GROUP_MODULES = {
@@ -867,7 +898,7 @@ async function readLock(lockPath) {
867
898
  }
868
899
  function registerCodes(program) {
869
900
  const codes = program.command("codes").description("Manage activity codes on the Novedu server");
870
- codes.command("create").description("Create a code for an activity YAML (validated server-side before storing)").requiredOption("--module <module>", "activity module: tutor, quiz, writing or coding").requiredOption("--file <url>", "public http(s) URL of the activity YAML").option("--start <iso>", "window start, ISO 8601 with explicit offset (e.g. 2026-07-07T08:00:00Z)").option("--end <iso>", "window end, ISO 8601 with explicit offset").option("--note <text>", "note shown in the codes list").option("--llm-provider <provider>", "LLM override provider (\"SCCH\" or \"Azure Foundry\"; needs --llm-model)").option("--llm-model <model>", "LLM override model id (needs --llm-provider)").option("--llm-reasoning <level>", "LLM override reasoning effort (\"minimal\", \"low\", \"medium\" or \"high\"; needs the provider/model pair)").option(...SERVER_OPTION$3).action(async (options) => {
901
+ codes.command("create").description("Create a code for an activity YAML (validated server-side before storing)").requiredOption("--module <module>", "activity module: tutor, quiz, writing or coding").requiredOption("--file <url>", "public http(s) URL of the activity YAML").option("--start <iso>", "window start, ISO 8601 with explicit offset (e.g. 2026-07-07T08:00:00Z)").option("--end <iso>", "window end, ISO 8601 with explicit offset").option("--note <text>", "note shown in the codes list").option("--llm-provider <provider>", "LLM override provider (\"SCCH\" or \"Azure Foundry\"; needs --llm-model)").option("--llm-model <model>", "LLM override model id (needs --llm-provider)").option("--llm-reasoning <level>", `LLM override reasoning effort (${REASONING_LEVELS.join(", ")}; needs the provider/model pair)`).option(...SERVER_OPTION$3).action(async (options) => {
871
902
  const llmGiven = options.llmProvider !== void 0 || options.llmModel !== void 0 || options.llmReasoning !== void 0;
872
903
  await runApiRequest({
873
904
  server: options.server,
@@ -1139,7 +1170,7 @@ function parseCoding(content) {
1139
1170
  const reasoning = llm?.reasoning === void 0 ? void 0 : parseLenientReasoningLevel(llm.reasoning);
1140
1171
  if (llm?.reasoning !== void 0 && !reasoning) return {
1141
1172
  ok: false,
1142
- message: "This coding activity uses an unsupported llm.reasoning (use \"minimal\", \"low\", \"medium\" or \"high\")."
1173
+ message: `This coding activity uses an unsupported llm.reasoning (one of ${REASONING_LEVELS.join(", ")}).`
1143
1174
  };
1144
1175
  const instructions = asString$2(root.instructions);
1145
1176
  if (!instructions) return {
@@ -2448,7 +2479,7 @@ function parseQuiz(content) {
2448
2479
  const reasoning = llm?.reasoning === void 0 ? void 0 : parseLenientReasoningLevel(llm.reasoning);
2449
2480
  if (llm?.reasoning !== void 0 && !reasoning) return {
2450
2481
  ok: false,
2451
- message: "This quiz uses an unsupported llm.reasoning (use \"minimal\", \"low\", \"medium\" or \"high\")."
2482
+ message: `This quiz uses an unsupported llm.reasoning (one of ${REASONING_LEVELS.join(", ")}).`
2452
2483
  };
2453
2484
  const quizFiles = Array.isArray(root.quiz_files) ? root.quiz_files : [];
2454
2485
  const rawQuestions = Array.isArray(root.questions) ? root.questions : [];
@@ -2829,7 +2860,7 @@ function parseWriting(content) {
2829
2860
  const reasoning = llm?.reasoning === void 0 ? void 0 : parseLenientReasoningLevel(llm.reasoning);
2830
2861
  if (llm?.reasoning !== void 0 && !reasoning) return {
2831
2862
  ok: false,
2832
- message: "This writing activity uses an unsupported llm.reasoning (use \"minimal\", \"low\", \"medium\" or \"high\")."
2863
+ message: `This writing activity uses an unsupported llm.reasoning (one of ${REASONING_LEVELS.join(", ")}).`
2833
2864
  };
2834
2865
  const instructions = asString(root.instructions);
2835
2866
  if (!instructions) return {
@@ -5781,7 +5812,7 @@ async function runEvalCommand(pathsOrUrls, options, seams = {}) {
5781
5812
  process.exitCode = batchPassed(batch) ? 0 : 1;
5782
5813
  }
5783
5814
  function registerEval(program) {
5784
- program.command("eval").description("Run an eval file (quiz golden answers, or tutor conversations) against the real activity path and report the result").argument("<evalPathOrUrl...>", "one or more eval YAML files (paths, http(s)/file URLs, or a quoted glob pattern)").option("--server <url>", "Novedu server base URL (defaults to the NOVEDU_SERVER env var, then production)").option("--concurrency <n>", "cases in flight per file", String(CONCURRENCY_DEFAULT)).option("--repeats <n>", "run every case N times (quiz: take the majority verdict)", "1").option("--llm-provider <provider>", "run with this provider instead of the activity's (\"SCCH\" or \"Azure Foundry\"; needs --llm-model)").option("--llm-model <model>", "run with this model instead of the activity's (needs --llm-provider)").option("--llm-reasoning <level>", "run at this reasoning effort (\"minimal\", \"low\", \"medium\" or \"high\"); on its own it keeps the activity's model").option("--no-judge-feedback", "skip the LLM audit of what the model wrote (halves the LLM calls)").option("--judge-llm-provider <provider>", "judge with this provider (\"SCCH\" or \"Azure Foundry\"; needs --judge-llm-model)").option("--judge-llm-model <model>", "judge with this model instead of the one under test (needs --judge-llm-provider)").option("--judge-llm-reasoning <level>", "judge at this reasoning effort (\"minimal\", \"low\", \"medium\" or \"high\"); on its own it keeps the judge's model").option("--json", "print the machine-readable batch report on stdout").option("--out <file>", "additionally write the machine-readable batch report to a file").option("--report <file>", "additionally write a readable Markdown report to a file").addHelpText("after", `
5815
+ program.command("eval").description("Run an eval file (quiz golden answers, or tutor conversations) against the real activity path and report the result").argument("<evalPathOrUrl...>", "one or more eval YAML files (paths, http(s)/file URLs, or a quoted glob pattern)").option("--server <url>", "Novedu server base URL (defaults to the NOVEDU_SERVER env var, then production)").option("--concurrency <n>", "cases in flight per file", String(CONCURRENCY_DEFAULT)).option("--repeats <n>", "run every case N times (quiz: take the majority verdict)", "1").option("--llm-provider <provider>", "run with this provider instead of the activity's (\"SCCH\" or \"Azure Foundry\"; needs --llm-model)").option("--llm-model <model>", "run with this model instead of the activity's (needs --llm-provider)").option("--llm-reasoning <level>", `run at this reasoning effort (${REASONING_LEVELS.join(", ")}); on its own it keeps the activity's model`).option("--no-judge-feedback", "skip the LLM audit of what the model wrote (halves the LLM calls)").option("--judge-llm-provider <provider>", "judge with this provider (\"SCCH\" or \"Azure Foundry\"; needs --judge-llm-model)").option("--judge-llm-model <model>", "judge with this model instead of the one under test (needs --judge-llm-provider)").option("--judge-llm-reasoning <level>", `judge at this reasoning effort (${REASONING_LEVELS.join(", ")}); on its own it keeps the judge's model`).option("--json", "print the machine-readable batch report on stdout").option("--out <file>", "additionally write the machine-readable batch report to a file").option("--report <file>", "additionally write a readable Markdown report to a file").addHelpText("after", `
5785
5816
  Examples:
5786
5817
  # Evaluate one quiz's golden answers
5787
5818
  $ novedu-cli eval ./0010-welcome-quiz.eval.yaml
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@novedu/cli",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "Command-line companion for the Novedu chat app. Validates tutor, fragment, quiz, writing, coding and eval YAML definitions, dumps the exact LLM prompts an activity produces, evaluates a quiz's grading rubric against golden answers and replays scripted conversations against a tutor; signs in with Entra ID and manages codes, app-hosted files and images over the app's API.",
5
5
  "type": "module",
6
6
  "repository": {