@novedu/cli 0.1.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 +36 -0
- package/dist/main.js +568 -0
- package/package.json +30 -0
package/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# @novedu/cli
|
|
2
|
+
|
|
3
|
+
Command-line companion for the Novedu chat app (installed command: `novedu-cli`).
|
|
4
|
+
Today it validates **tutor YAML** definitions; more commands will follow.
|
|
5
|
+
|
|
6
|
+
It reuses the app's exact validation pipeline (`lib/tutors`), so a tutor that
|
|
7
|
+
passes here is the same tutor the app would accept — no separate, drifting rules.
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# Validate a local file (relative fragment_files resolve from the same folder)
|
|
13
|
+
npx @novedu/cli validate ./tutors/simple-tutor.yaml
|
|
14
|
+
|
|
15
|
+
# Validate a published tutor by URL
|
|
16
|
+
npx @novedu/cli validate https://raw.githubusercontent.com/Teaching-HTL-Leonding/novedu-chat-mvp/refs/heads/main/tutors/simple-tutor.yaml
|
|
17
|
+
|
|
18
|
+
# Machine-readable output (the raw validation result)
|
|
19
|
+
npx @novedu/cli validate ./tutors/simple-tutor.yaml --json
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Exit code is `0` when the tutor is valid and `1` when it has errors, so it works
|
|
23
|
+
as a pre-commit / CI gate.
|
|
24
|
+
|
|
25
|
+
## Development
|
|
26
|
+
|
|
27
|
+
The CLI lives in the app repo as an npm workspace.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm run cli -- validate ./tutors/simple-tutor.yaml # run from source via tsx
|
|
31
|
+
npm run cli:build # bundle to cli/dist via tsdown
|
|
32
|
+
npm run test:cli # build + integration tests (local & live URLs)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The fast in-process unit test (`cli/src/commands/validate.unit.test.ts`) runs in
|
|
36
|
+
CI; the integration tests hit the network and are local-only.
|
package/dist/main.js
ADDED
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
|
+
import Handlebars from "handlebars";
|
|
6
|
+
import { parse } from "yaml";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { readFile } from "node:fs/promises";
|
|
9
|
+
//#region ../lib/tutors/assemble.ts
|
|
10
|
+
const COMPILE_OPTIONS = {
|
|
11
|
+
strict: true,
|
|
12
|
+
noEscape: true
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Render each fragment in priority order and append the tutor-specific
|
|
16
|
+
* instructions last (they carry no priority, so "after everything" is the only
|
|
17
|
+
* deterministic position). May throw if a template references a missing variable.
|
|
18
|
+
*/
|
|
19
|
+
function assembleSystemPrompt(plan, tutor) {
|
|
20
|
+
const parts = plan.map((fragment) => {
|
|
21
|
+
return Handlebars.compile(fragment.content, COMPILE_OPTIONS)(fragment.variables).trimEnd();
|
|
22
|
+
});
|
|
23
|
+
parts.push(tutor.prompt.tutor_instructions.trimEnd());
|
|
24
|
+
return `${parts.join("\n\n")}\n`;
|
|
25
|
+
}
|
|
26
|
+
//#endregion
|
|
27
|
+
//#region ../lib/tutors/errors.ts
|
|
28
|
+
/** Small helper to build an error object tersely at call sites. */
|
|
29
|
+
function error(code, message, extra = {}) {
|
|
30
|
+
return {
|
|
31
|
+
code,
|
|
32
|
+
message,
|
|
33
|
+
...extra
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function warning(code, message, extra = {}) {
|
|
37
|
+
return {
|
|
38
|
+
code,
|
|
39
|
+
message,
|
|
40
|
+
...extra
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
//#endregion
|
|
44
|
+
//#region ../lib/tutors/consistency.ts
|
|
45
|
+
/** Compare a supplied value against its declared property type. Returns null when it matches. */
|
|
46
|
+
function typeMismatch(prop, value) {
|
|
47
|
+
const actual = Array.isArray(value) ? "array" : typeof value;
|
|
48
|
+
switch (prop.type) {
|
|
49
|
+
case "string": return typeof value === "string" ? null : {
|
|
50
|
+
expected: "string",
|
|
51
|
+
actual
|
|
52
|
+
};
|
|
53
|
+
case "boolean": return typeof value === "boolean" ? null : {
|
|
54
|
+
expected: "boolean",
|
|
55
|
+
actual
|
|
56
|
+
};
|
|
57
|
+
case "array": return Array.isArray(value) && value.every((v) => typeof v === "string") ? null : {
|
|
58
|
+
expected: "array<string>",
|
|
59
|
+
actual
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function checkConsistency(tutor, fragmentFilesByAlias) {
|
|
64
|
+
const errors = [];
|
|
65
|
+
const warnings = [];
|
|
66
|
+
const aliasCounts = /* @__PURE__ */ new Map();
|
|
67
|
+
for (const ref of tutor.prompt.fragment_files) aliasCounts.set(ref.id, (aliasCounts.get(ref.id) ?? 0) + 1);
|
|
68
|
+
for (const [alias, count] of aliasCounts) if (count > 1) errors.push(error("DUPLICATE_FRAGMENT_FILE_ALIAS", `Fragment-file alias "${alias}" is declared ${count} times`, { fileAlias: alias }));
|
|
69
|
+
const fragmentIndex = /* @__PURE__ */ new Map();
|
|
70
|
+
for (const [alias, file] of fragmentFilesByAlias) {
|
|
71
|
+
const byId = /* @__PURE__ */ new Map();
|
|
72
|
+
for (const frag of file.fragments) {
|
|
73
|
+
if (byId.has(frag.id)) {
|
|
74
|
+
errors.push(error("DUPLICATE_FRAGMENT_ID_IN_FILE", `Fragment "${frag.id}" is declared more than once in file "${alias}"`, {
|
|
75
|
+
fileAlias: alias,
|
|
76
|
+
fragmentId: frag.id
|
|
77
|
+
}));
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
byId.set(frag.id, frag);
|
|
81
|
+
}
|
|
82
|
+
fragmentIndex.set(alias, byId);
|
|
83
|
+
}
|
|
84
|
+
const resolved = [];
|
|
85
|
+
const seenRefs = /* @__PURE__ */ new Set();
|
|
86
|
+
for (const ref of tutor.prompt.fragments) {
|
|
87
|
+
const refKey = `${ref.file}::${ref.id}`;
|
|
88
|
+
if (seenRefs.has(refKey)) warnings.push(warning("DUPLICATE_FRAGMENT_REFERENCE", `Fragment "${ref.id}" from "${ref.file}" is referenced more than once`, {
|
|
89
|
+
fileAlias: ref.file,
|
|
90
|
+
fragmentId: ref.id
|
|
91
|
+
}));
|
|
92
|
+
seenRefs.add(refKey);
|
|
93
|
+
const byId = fragmentIndex.get(ref.file);
|
|
94
|
+
if (!byId) {
|
|
95
|
+
errors.push(error("UNKNOWN_FRAGMENT_FILE_ALIAS", `Fragment reference uses unknown file alias "${ref.file}"`, {
|
|
96
|
+
fileAlias: ref.file,
|
|
97
|
+
fragmentId: ref.id
|
|
98
|
+
}));
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const fragment = byId.get(ref.id);
|
|
102
|
+
if (!fragment) {
|
|
103
|
+
errors.push(error("FRAGMENT_NOT_FOUND", `Fragment "${ref.id}" not found in file "${ref.file}"`, {
|
|
104
|
+
fileAlias: ref.file,
|
|
105
|
+
fragmentId: ref.id
|
|
106
|
+
}));
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const variables = ref.variables ?? {};
|
|
110
|
+
const schema = fragment.input_schema;
|
|
111
|
+
const merged = { ...variables };
|
|
112
|
+
if (schema) {
|
|
113
|
+
for (const name of schema.required) if (!(name in variables)) errors.push(error("MISSING_REQUIRED_VARIABLE", `Fragment "${ref.id}" requires variable "${name}", which is not supplied`, {
|
|
114
|
+
fileAlias: ref.file,
|
|
115
|
+
fragmentId: ref.id,
|
|
116
|
+
variable: name
|
|
117
|
+
}));
|
|
118
|
+
for (const [name, value] of Object.entries(variables)) {
|
|
119
|
+
const prop = schema.properties[name];
|
|
120
|
+
if (!prop) {
|
|
121
|
+
warnings.push(warning("UNDECLARED_VARIABLE", `Variable "${name}" supplied to "${ref.id}" is not declared in its input schema`, {
|
|
122
|
+
fileAlias: ref.file,
|
|
123
|
+
fragmentId: ref.id,
|
|
124
|
+
variable: name
|
|
125
|
+
}));
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const mismatch = typeMismatch(prop, value);
|
|
129
|
+
if (mismatch) errors.push(error("VARIABLE_TYPE_MISMATCH", `Variable "${name}" of "${ref.id}" should be ${mismatch.expected} but got ${mismatch.actual}`, {
|
|
130
|
+
fileAlias: ref.file,
|
|
131
|
+
fragmentId: ref.id,
|
|
132
|
+
variable: name,
|
|
133
|
+
expectedType: mismatch.expected,
|
|
134
|
+
actualType: mismatch.actual
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
const requiredSet = new Set(schema.required);
|
|
138
|
+
for (const [name, prop] of Object.entries(schema.properties)) {
|
|
139
|
+
if (prop.default === void 0) continue;
|
|
140
|
+
if (requiredSet.has(name)) {
|
|
141
|
+
warnings.push(warning("REQUIRED_PROPERTY_HAS_DEFAULT", `Variable "${name}" of "${ref.id}" is required, so its default is never used`, {
|
|
142
|
+
fileAlias: ref.file,
|
|
143
|
+
fragmentId: ref.id,
|
|
144
|
+
variable: name
|
|
145
|
+
}));
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (!(name in merged)) merged[name] = prop.default;
|
|
149
|
+
}
|
|
150
|
+
} else for (const name of Object.keys(variables)) warnings.push(warning("UNDECLARED_VARIABLE", `Variable "${name}" supplied to "${ref.id}", which declares no input schema`, {
|
|
151
|
+
fileAlias: ref.file,
|
|
152
|
+
fragmentId: ref.id,
|
|
153
|
+
variable: name
|
|
154
|
+
}));
|
|
155
|
+
resolved.push({
|
|
156
|
+
fileAlias: ref.file,
|
|
157
|
+
fragmentId: ref.id,
|
|
158
|
+
priority: fragment.priority,
|
|
159
|
+
content: fragment.content,
|
|
160
|
+
variables: merged
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
const plan = [...resolved].sort((a, b) => a.priority - b.priority);
|
|
164
|
+
const priorityOwners = /* @__PURE__ */ new Map();
|
|
165
|
+
for (const r of resolved) {
|
|
166
|
+
const owners = priorityOwners.get(r.priority) ?? [];
|
|
167
|
+
owners.push(r.fragmentId);
|
|
168
|
+
priorityOwners.set(r.priority, owners);
|
|
169
|
+
}
|
|
170
|
+
for (const [priority, owners] of priorityOwners) if (owners.length > 1) errors.push(error("DUPLICATE_PRIORITY", `Priority ${priority} is shared by fragments: ${owners.join(", ")} — ordering is ambiguous`));
|
|
171
|
+
return {
|
|
172
|
+
errors,
|
|
173
|
+
warnings,
|
|
174
|
+
plan
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
//#endregion
|
|
178
|
+
//#region ../lib/tutors/fetcher.ts
|
|
179
|
+
const DEFAULT_TIMEOUT_MS = 1e4;
|
|
180
|
+
/** Production fetcher: global `fetch` with an abort-based timeout so a slow host can't hang the request. */
|
|
181
|
+
const defaultFetcher = async (url) => {
|
|
182
|
+
const controller = new AbortController();
|
|
183
|
+
const timeout = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
|
|
184
|
+
try {
|
|
185
|
+
return await fetch(url, {
|
|
186
|
+
signal: controller.signal,
|
|
187
|
+
redirect: "follow"
|
|
188
|
+
});
|
|
189
|
+
} finally {
|
|
190
|
+
clearTimeout(timeout);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
//#endregion
|
|
194
|
+
//#region ../lib/tutors/parse.ts
|
|
195
|
+
/** Parse a YAML document, mapping syntax errors to a structured `YAML_PARSE_ERROR`. */
|
|
196
|
+
function parseYaml(text, url) {
|
|
197
|
+
try {
|
|
198
|
+
return {
|
|
199
|
+
ok: true,
|
|
200
|
+
value: parse(text)
|
|
201
|
+
};
|
|
202
|
+
} catch (e) {
|
|
203
|
+
return {
|
|
204
|
+
ok: false,
|
|
205
|
+
error: error("YAML_PARSE_ERROR", `Invalid YAML: ${e instanceof Error ? e.message : String(e)}`, { url })
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** Validate an already-parsed value against a Zod schema, attaching treeified issues on failure. */
|
|
210
|
+
function validate(value, schema, code, url) {
|
|
211
|
+
const result = schema.safeParse(value);
|
|
212
|
+
if (result.success) return {
|
|
213
|
+
ok: true,
|
|
214
|
+
data: result.data
|
|
215
|
+
};
|
|
216
|
+
return {
|
|
217
|
+
ok: false,
|
|
218
|
+
error: error(code, "Document does not match the expected structure", {
|
|
219
|
+
url,
|
|
220
|
+
zodIssues: z.treeifyError(result.error)
|
|
221
|
+
})
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
//#endregion
|
|
225
|
+
//#region ../lib/tutors/schemas.ts
|
|
226
|
+
/**
|
|
227
|
+
* A fragment-file reference: either an absolute http(s) URL or a relative path that
|
|
228
|
+
* `load.ts` resolves against the tutor YAML's own URL. We reject any *other* absolute
|
|
229
|
+
* scheme (`ftp:`, `mailto:`, …) so a typo can't smuggle in a non-http(s) target — the
|
|
230
|
+
* refine reads as "if it carries a URI scheme at all, that scheme must be http(s)".
|
|
231
|
+
* Strings without a scheme (relative paths) pass through and are resolved at load time.
|
|
232
|
+
*/
|
|
233
|
+
const FragmentUrlRef = z.string().min(1).refine((u) => !/^[a-z][a-z0-9+.-]*:/i.test(u) || /^https?:\/\//i.test(u), { message: "Must be an http(s) URL or a relative path" });
|
|
234
|
+
/**
|
|
235
|
+
* A declared property is a string, a boolean, or an array of strings. Each may carry an
|
|
236
|
+
* optional `default`, typed to match its `type` (a string default on a boolean property
|
|
237
|
+
* is a schema error). When the tutor omits the variable, the default is used; supplying
|
|
238
|
+
* a value overrides it. See `consistency.ts` for where defaults are injected.
|
|
239
|
+
*/
|
|
240
|
+
const PropertySchema = z.discriminatedUnion("type", [
|
|
241
|
+
z.strictObject({
|
|
242
|
+
type: z.literal("string"),
|
|
243
|
+
default: z.string().optional()
|
|
244
|
+
}),
|
|
245
|
+
z.strictObject({
|
|
246
|
+
type: z.literal("boolean"),
|
|
247
|
+
default: z.boolean().optional()
|
|
248
|
+
}),
|
|
249
|
+
z.strictObject({
|
|
250
|
+
type: z.literal("array"),
|
|
251
|
+
items: z.strictObject({ type: z.literal("string") }),
|
|
252
|
+
default: z.array(z.string()).optional()
|
|
253
|
+
})
|
|
254
|
+
]);
|
|
255
|
+
const InputSchema = z.strictObject({
|
|
256
|
+
type: z.literal("object"),
|
|
257
|
+
required: z.array(z.string()).default([]),
|
|
258
|
+
properties: z.record(z.string(), PropertySchema).default({})
|
|
259
|
+
});
|
|
260
|
+
const ClassificationSchema = z.strictObject({
|
|
261
|
+
type: z.string(),
|
|
262
|
+
override_allowed: z.boolean().optional()
|
|
263
|
+
});
|
|
264
|
+
const FragmentSchema = z.strictObject({
|
|
265
|
+
id: z.string(),
|
|
266
|
+
version: z.number(),
|
|
267
|
+
priority: z.number(),
|
|
268
|
+
input_schema: InputSchema.optional(),
|
|
269
|
+
classification: ClassificationSchema.optional(),
|
|
270
|
+
content: z.string()
|
|
271
|
+
});
|
|
272
|
+
const FragmentFileSchema = z.strictObject({
|
|
273
|
+
id: z.string(),
|
|
274
|
+
fragments: z.array(FragmentSchema).min(1)
|
|
275
|
+
});
|
|
276
|
+
/** A supplied variable value mirrors what `input_schema` can declare. */
|
|
277
|
+
const VariableValueSchema = z.union([
|
|
278
|
+
z.string(),
|
|
279
|
+
z.boolean(),
|
|
280
|
+
z.array(z.string())
|
|
281
|
+
]);
|
|
282
|
+
const FragmentFileRefSchema = z.strictObject({
|
|
283
|
+
id: z.string(),
|
|
284
|
+
url: FragmentUrlRef
|
|
285
|
+
});
|
|
286
|
+
const FragmentRefSchema = z.strictObject({
|
|
287
|
+
file: z.string(),
|
|
288
|
+
id: z.string(),
|
|
289
|
+
variables: z.record(z.string(), VariableValueSchema).optional(),
|
|
290
|
+
bind: z.record(z.string(), z.string()).optional(),
|
|
291
|
+
required: z.boolean().optional()
|
|
292
|
+
});
|
|
293
|
+
/**
|
|
294
|
+
* An example question offered to students on the welcome screen: the `title` is
|
|
295
|
+
* the clickable label, the `question` is the full text placed into the chat
|
|
296
|
+
* input on click. Tutors may define any number; the UI samples at most 5.
|
|
297
|
+
*/
|
|
298
|
+
const ExampleQuestionSchema = z.strictObject({
|
|
299
|
+
title: z.string().min(1),
|
|
300
|
+
question: z.string().min(1)
|
|
301
|
+
});
|
|
302
|
+
const TutorSchema = z.strictObject({
|
|
303
|
+
id: z.string(),
|
|
304
|
+
name: z.string(),
|
|
305
|
+
title: z.string().optional(),
|
|
306
|
+
description: z.string(),
|
|
307
|
+
exampleQuestions: z.array(ExampleQuestionSchema).optional(),
|
|
308
|
+
anonymous: z.boolean().optional(),
|
|
309
|
+
llm: z.strictObject({
|
|
310
|
+
model: z.string(),
|
|
311
|
+
imageInput: z.boolean().optional()
|
|
312
|
+
}),
|
|
313
|
+
prompt: z.strictObject({
|
|
314
|
+
fragment_files: z.array(FragmentFileRefSchema).default([]),
|
|
315
|
+
fragments: z.array(FragmentRefSchema).default([]),
|
|
316
|
+
tutor_instructions: z.string()
|
|
317
|
+
})
|
|
318
|
+
});
|
|
319
|
+
//#endregion
|
|
320
|
+
//#region ../lib/tutors/load.ts
|
|
321
|
+
/**
|
|
322
|
+
* Resolve a fragment-file reference to an absolute URL. An absolute http(s) ref is used
|
|
323
|
+
* as-is; anything else is treated as relative to the tutor URL — standard URL resolution
|
|
324
|
+
* drops the tutor's filename and appends the relative path (so `general-fragments.yaml`
|
|
325
|
+
* next to `.../tutors/linked-list-tutor.yaml` becomes `.../tutors/general-fragments.yaml`,
|
|
326
|
+
* and `./` / `../` segments work too). Throws if a relative ref is unparseable; the schema
|
|
327
|
+
* already guarantees the only inputs here are http(s) URLs or relative paths.
|
|
328
|
+
*/
|
|
329
|
+
function resolveFragmentUrl(ref, tutorUrl) {
|
|
330
|
+
if (/^https?:\/\//i.test(ref)) return ref;
|
|
331
|
+
return new URL(ref, tutorUrl).href;
|
|
332
|
+
}
|
|
333
|
+
async function fetchText(url, fetchImpl) {
|
|
334
|
+
try {
|
|
335
|
+
const res = await fetchImpl(url);
|
|
336
|
+
if (!res.ok) return {
|
|
337
|
+
ok: false,
|
|
338
|
+
error: error("FETCH_FAILED", `Failed to fetch ${url} (HTTP ${res.status})`, {
|
|
339
|
+
url,
|
|
340
|
+
status: res.status
|
|
341
|
+
})
|
|
342
|
+
};
|
|
343
|
+
return {
|
|
344
|
+
ok: true,
|
|
345
|
+
text: await res.text()
|
|
346
|
+
};
|
|
347
|
+
} catch (e) {
|
|
348
|
+
return {
|
|
349
|
+
ok: false,
|
|
350
|
+
error: error("FETCH_FAILED", `Failed to fetch ${url}: ${e instanceof Error ? e.message : String(e)}`, { url })
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
const DEFAULT_ALLOWED_SCHEMES = ["http:", "https:"];
|
|
355
|
+
async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
|
|
356
|
+
const warnings = [];
|
|
357
|
+
const allowedSchemes = opts.allowedSchemes ?? DEFAULT_ALLOWED_SCHEMES;
|
|
358
|
+
let scheme;
|
|
359
|
+
try {
|
|
360
|
+
scheme = new URL(url).protocol;
|
|
361
|
+
} catch {
|
|
362
|
+
scheme = "";
|
|
363
|
+
}
|
|
364
|
+
if (!allowedSchemes.includes(scheme)) return {
|
|
365
|
+
ok: false,
|
|
366
|
+
errors: [error("INVALID_URL", `Provide a valid ${allowedSchemes.map((s) => s.replace(/:$/, "")).join("/")} URL`, { url })],
|
|
367
|
+
warnings
|
|
368
|
+
};
|
|
369
|
+
const tutorFetch = await fetchText(url, fetchImpl);
|
|
370
|
+
if (!tutorFetch.ok) return {
|
|
371
|
+
ok: false,
|
|
372
|
+
errors: [tutorFetch.error],
|
|
373
|
+
warnings
|
|
374
|
+
};
|
|
375
|
+
const tutorYaml = parseYaml(tutorFetch.text, url);
|
|
376
|
+
if (!tutorYaml.ok) return {
|
|
377
|
+
ok: false,
|
|
378
|
+
errors: [tutorYaml.error],
|
|
379
|
+
warnings
|
|
380
|
+
};
|
|
381
|
+
const tutorValid = validate(tutorYaml.value, TutorSchema, "TUTOR_SCHEMA_ERROR", url);
|
|
382
|
+
if (!tutorValid.ok) return {
|
|
383
|
+
ok: false,
|
|
384
|
+
errors: [tutorValid.error],
|
|
385
|
+
warnings
|
|
386
|
+
};
|
|
387
|
+
const tutor = tutorValid.data;
|
|
388
|
+
const settled = await Promise.all(tutor.prompt.fragment_files.map(async (ref) => {
|
|
389
|
+
let fragmentUrl;
|
|
390
|
+
try {
|
|
391
|
+
fragmentUrl = resolveFragmentUrl(ref.url, url);
|
|
392
|
+
} catch {
|
|
393
|
+
return {
|
|
394
|
+
alias: ref.id,
|
|
395
|
+
error: error("INVALID_URL", `Invalid fragment URL: ${ref.url}`, {
|
|
396
|
+
url: ref.url,
|
|
397
|
+
fileAlias: ref.id
|
|
398
|
+
})
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
const fetched = await fetchText(fragmentUrl, fetchImpl);
|
|
402
|
+
if (!fetched.ok) return {
|
|
403
|
+
alias: ref.id,
|
|
404
|
+
error: fetched.error
|
|
405
|
+
};
|
|
406
|
+
const parsed = parseYaml(fetched.text, fragmentUrl);
|
|
407
|
+
if (!parsed.ok) return {
|
|
408
|
+
alias: ref.id,
|
|
409
|
+
error: parsed.error
|
|
410
|
+
};
|
|
411
|
+
const valid = validate(parsed.value, FragmentFileSchema, "FRAGMENT_FILE_SCHEMA_ERROR", fragmentUrl);
|
|
412
|
+
if (!valid.ok) return {
|
|
413
|
+
alias: ref.id,
|
|
414
|
+
error: {
|
|
415
|
+
...valid.error,
|
|
416
|
+
fileAlias: ref.id
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
return {
|
|
420
|
+
alias: ref.id,
|
|
421
|
+
file: valid.data
|
|
422
|
+
};
|
|
423
|
+
}));
|
|
424
|
+
const fragmentFilesByAlias = /* @__PURE__ */ new Map();
|
|
425
|
+
const fileErrors = [];
|
|
426
|
+
for (const result of settled) if ("error" in result) fileErrors.push(result.error);
|
|
427
|
+
else fragmentFilesByAlias.set(result.alias, result.file);
|
|
428
|
+
if (fileErrors.length > 0) return {
|
|
429
|
+
ok: false,
|
|
430
|
+
errors: fileErrors,
|
|
431
|
+
warnings
|
|
432
|
+
};
|
|
433
|
+
const consistency = checkConsistency(tutor, fragmentFilesByAlias);
|
|
434
|
+
warnings.push(...consistency.warnings);
|
|
435
|
+
if (consistency.errors.length > 0) return {
|
|
436
|
+
ok: false,
|
|
437
|
+
errors: consistency.errors,
|
|
438
|
+
warnings
|
|
439
|
+
};
|
|
440
|
+
try {
|
|
441
|
+
return {
|
|
442
|
+
ok: true,
|
|
443
|
+
prompt: assembleSystemPrompt(consistency.plan, tutor),
|
|
444
|
+
model: tutor.llm.model,
|
|
445
|
+
imageInput: tutor.llm.imageInput ?? true,
|
|
446
|
+
anonymous: tutor.anonymous ?? true,
|
|
447
|
+
title: tutor.title,
|
|
448
|
+
description: tutor.description,
|
|
449
|
+
exampleQuestions: tutor.exampleQuestions ?? [],
|
|
450
|
+
warnings
|
|
451
|
+
};
|
|
452
|
+
} catch (e) {
|
|
453
|
+
return {
|
|
454
|
+
ok: false,
|
|
455
|
+
errors: [error("ASSEMBLY_ERROR", `Failed to render system prompt: ${e instanceof Error ? e.message : String(e)}`)],
|
|
456
|
+
warnings
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
//#endregion
|
|
461
|
+
//#region src/file-fetcher.ts
|
|
462
|
+
const cliFetcher = async (url) => {
|
|
463
|
+
if (url.startsWith("file:")) try {
|
|
464
|
+
const text = await readFile(fileURLToPath(url), "utf8");
|
|
465
|
+
return {
|
|
466
|
+
ok: true,
|
|
467
|
+
status: 200,
|
|
468
|
+
text: async () => text
|
|
469
|
+
};
|
|
470
|
+
} catch {
|
|
471
|
+
return {
|
|
472
|
+
ok: false,
|
|
473
|
+
status: 404,
|
|
474
|
+
text: async () => ""
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
return defaultFetcher(url);
|
|
478
|
+
};
|
|
479
|
+
//#endregion
|
|
480
|
+
//#region src/format.ts
|
|
481
|
+
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
482
|
+
const paint = (code, s) => useColor ? `\x1b[${code}m${s}\x1b[0m` : s;
|
|
483
|
+
const green = (s) => paint("32", s);
|
|
484
|
+
const red = (s) => paint("31", s);
|
|
485
|
+
const yellow = (s) => paint("33", s);
|
|
486
|
+
const dim = (s) => paint("2", s);
|
|
487
|
+
/** Append the context fields an error/warning carries, when present. */
|
|
488
|
+
function context(item) {
|
|
489
|
+
const parts = [];
|
|
490
|
+
if (item.fileAlias) parts.push(`file=${item.fileAlias}`);
|
|
491
|
+
if (item.fragmentId) parts.push(`fragment=${item.fragmentId}`);
|
|
492
|
+
if (item.variable) parts.push(`variable=${item.variable}`);
|
|
493
|
+
if ("url" in item && item.url) parts.push(`url=${item.url}`);
|
|
494
|
+
if ("expectedType" in item && item.expectedType) parts.push(`expected=${item.expectedType}`);
|
|
495
|
+
if ("actualType" in item && item.actualType) parts.push(`actual=${item.actualType}`);
|
|
496
|
+
return parts.length ? dim(` (${parts.join(", ")})`) : "";
|
|
497
|
+
}
|
|
498
|
+
function renderWarnings(warnings) {
|
|
499
|
+
return warnings.map((w) => ` ${yellow("⚠")} ${yellow(w.code)} ${w.message}${context(w)}`);
|
|
500
|
+
}
|
|
501
|
+
function formatResult(result, source) {
|
|
502
|
+
const lines = [];
|
|
503
|
+
if (result.ok) {
|
|
504
|
+
lines.push(green(`✔ Valid tutor`) + dim(` — ${source}`));
|
|
505
|
+
lines.push(` model: ${result.model}`);
|
|
506
|
+
lines.push(` system prompt: ${result.prompt.length} chars`);
|
|
507
|
+
lines.push(` imageInput: ${result.imageInput} anonymous: ${result.anonymous}` + (result.exampleQuestions.length ? ` exampleQuestions: ${result.exampleQuestions.length}` : ""));
|
|
508
|
+
if (result.warnings.length) {
|
|
509
|
+
lines.push("");
|
|
510
|
+
lines.push(yellow(`${result.warnings.length} warning(s):`));
|
|
511
|
+
lines.push(...renderWarnings(result.warnings));
|
|
512
|
+
}
|
|
513
|
+
return lines.join("\n");
|
|
514
|
+
}
|
|
515
|
+
lines.push(red(`✘ Invalid tutor`) + dim(` — ${source}`));
|
|
516
|
+
lines.push("");
|
|
517
|
+
lines.push(red(`${result.errors.length} error(s):`));
|
|
518
|
+
for (const e of result.errors) lines.push(` ${red("✗")} ${red(e.code)} ${e.message}${context(e)}`);
|
|
519
|
+
if (result.warnings.length) {
|
|
520
|
+
lines.push("");
|
|
521
|
+
lines.push(yellow(`${result.warnings.length} warning(s):`));
|
|
522
|
+
lines.push(...renderWarnings(result.warnings));
|
|
523
|
+
}
|
|
524
|
+
return lines.join("\n");
|
|
525
|
+
}
|
|
526
|
+
//#endregion
|
|
527
|
+
//#region src/commands/validate.ts
|
|
528
|
+
/**
|
|
529
|
+
* Turn the CLI argument into a URL the tutor core understands: an http(s) URL is
|
|
530
|
+
* used as-is; anything else is treated as a filesystem path and converted to an
|
|
531
|
+
* absolute `file://` URL.
|
|
532
|
+
*/
|
|
533
|
+
function toUrl(pathOrUrl) {
|
|
534
|
+
if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl;
|
|
535
|
+
return pathToFileURL(resolve(pathOrUrl)).href;
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* The validate command's pure core: run the existing tutor pipeline over a local
|
|
539
|
+
* file or public URL. Kept separate from the commander wiring so it can be unit
|
|
540
|
+
* tested in-process. `file:` is allowed in addition to http(s) so local YAML can
|
|
541
|
+
* be validated (the web app deliberately stays http(s)-only).
|
|
542
|
+
*/
|
|
543
|
+
function runValidate(pathOrUrl) {
|
|
544
|
+
return loadAndBuildTutorPrompt(toUrl(pathOrUrl), cliFetcher, { allowedSchemes: [
|
|
545
|
+
"http:",
|
|
546
|
+
"https:",
|
|
547
|
+
"file:"
|
|
548
|
+
] });
|
|
549
|
+
}
|
|
550
|
+
function registerValidate(program) {
|
|
551
|
+
program.command("validate").description("Validate a tutor YAML by local path or public http(s) URL").argument("<pathOrUrl>", "path to a tutor YAML file, or a public http(s) URL").option("--json", "print the raw validation result as JSON").action(async (pathOrUrl, options) => {
|
|
552
|
+
const result = await runValidate(pathOrUrl);
|
|
553
|
+
if (options.json) console.log(JSON.stringify(result, null, 2));
|
|
554
|
+
else console.log(formatResult(result, pathOrUrl));
|
|
555
|
+
process.exitCode = result.ok ? 0 : 1;
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
//#endregion
|
|
559
|
+
//#region src/main.ts
|
|
560
|
+
const program = new Command();
|
|
561
|
+
program.name("novedu-cli").description("Command-line companion for the Novedu chat app").version("0.1.0");
|
|
562
|
+
registerValidate(program);
|
|
563
|
+
program.parseAsync().catch((err) => {
|
|
564
|
+
console.error(err instanceof Error ? err.message : err);
|
|
565
|
+
process.exitCode = 1;
|
|
566
|
+
});
|
|
567
|
+
//#endregion
|
|
568
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@novedu/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Command-line companion for the Novedu chat app. Validates tutor YAML definitions (more commands to follow).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"novedu-cli": "dist/main.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsdown"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"commander": "^15.0.0",
|
|
23
|
+
"handlebars": "^4.7.9",
|
|
24
|
+
"yaml": "^2.9.0",
|
|
25
|
+
"zod": "^4.4.3"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"tsdown": "^0.22.2"
|
|
29
|
+
}
|
|
30
|
+
}
|