@novedu/cli 0.3.0 → 0.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/README.md +9 -1
- package/dist/main.js +254 -22
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
# @novedu/cli
|
|
2
2
|
|
|
3
3
|
Command-line companion for the Novedu chat app (installed command: `novedu-cli`).
|
|
4
|
-
Today it validates **tutor YAML** definitions
|
|
4
|
+
Today it validates **tutor YAML** definitions and **fragment libraries**; more
|
|
5
|
+
commands will follow. Validating a tutor also fully validates every fragment
|
|
6
|
+
library it references; pass `--kind fragment` to validate a fragment library on
|
|
7
|
+
its own.
|
|
5
8
|
|
|
6
9
|
It reuses the app's exact validation pipeline (`lib/tutors`), so a tutor that
|
|
7
10
|
passes here is the same tutor the app would accept — no separate, drifting rules.
|
|
@@ -15,10 +18,15 @@ npx @novedu/cli validate ./tutors/simple-tutor.yaml
|
|
|
15
18
|
# Validate a published tutor by URL
|
|
16
19
|
npx @novedu/cli validate https://raw.githubusercontent.com/Teaching-HTL-Leonding/novedu-chat-mvp/refs/heads/main/tutors/simple-tutor.yaml
|
|
17
20
|
|
|
21
|
+
# Validate a fragment library on its own
|
|
22
|
+
npx @novedu/cli validate ./tutors/simple-fragments.yaml --kind fragment
|
|
23
|
+
|
|
18
24
|
# Machine-readable output (the raw validation result)
|
|
19
25
|
npx @novedu/cli validate ./tutors/simple-tutor.yaml --json
|
|
20
26
|
```
|
|
21
27
|
|
|
28
|
+
`--kind` defaults to `tutor`; it is caller-declared, not auto-detected.
|
|
29
|
+
|
|
22
30
|
Exit code is `0` when the tutor is valid and `1` when it has errors, so it works
|
|
23
31
|
as a pre-commit / CI gate.
|
|
24
32
|
|
package/dist/main.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
2
3
|
import { Command } from "commander";
|
|
3
4
|
import { resolve } from "node:path";
|
|
4
5
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
@@ -44,7 +45,7 @@ function warning(code, message, extra = {}) {
|
|
|
44
45
|
* Flattens a treeified Zod error into `path: message` lines so a generic
|
|
45
46
|
* "Document does not match the expected structure" becomes actionable — e.g.
|
|
46
47
|
* `Unrecognized key: "nae"` and `name: Invalid input: expected string`.
|
|
47
|
-
* Framework-agnostic: shared by the web UI (`ErrorList`), the
|
|
48
|
+
* Framework-agnostic: shared by the web UI (`ErrorList`), the tutor-code
|
|
48
49
|
* action, and the CLI formatter, so a schema error reads the same everywhere.
|
|
49
50
|
*/
|
|
50
51
|
function formatZodIssues(zodIssues) {
|
|
@@ -428,6 +429,18 @@ function checkFragmentFileValue(parsed, url) {
|
|
|
428
429
|
};
|
|
429
430
|
}
|
|
430
431
|
//#endregion
|
|
432
|
+
//#region ../lib/relative-url.ts
|
|
433
|
+
/**
|
|
434
|
+
* Resolve a reference to an absolute URL. An absolute http(s) ref is used as-is;
|
|
435
|
+
* anything else is treated as relative to `baseUrl` — standard URL resolution drops
|
|
436
|
+
* the base's filename and appends the relative path (so `./` / `../` segments work too).
|
|
437
|
+
* Throws if a relative ref is unparseable.
|
|
438
|
+
*/
|
|
439
|
+
function resolveRelativeUrl(ref, baseUrl) {
|
|
440
|
+
if (/^https?:\/\//i.test(ref)) return ref;
|
|
441
|
+
return new URL(ref, baseUrl).href;
|
|
442
|
+
}
|
|
443
|
+
//#endregion
|
|
431
444
|
//#region ../lib/tutors/load.ts
|
|
432
445
|
/**
|
|
433
446
|
* Resolve a fragment-file reference to an absolute URL. An absolute http(s) ref is used
|
|
@@ -438,8 +451,7 @@ function checkFragmentFileValue(parsed, url) {
|
|
|
438
451
|
* already guarantees the only inputs here are http(s) URLs or relative paths.
|
|
439
452
|
*/
|
|
440
453
|
function resolveFragmentUrl(ref, tutorUrl) {
|
|
441
|
-
|
|
442
|
-
return new URL(ref, tutorUrl).href;
|
|
454
|
+
return resolveRelativeUrl(ref, tutorUrl);
|
|
443
455
|
}
|
|
444
456
|
async function fetchText(url, fetchImpl) {
|
|
445
457
|
try {
|
|
@@ -466,8 +478,9 @@ const DEFAULT_ALLOWED_SCHEMES = ["http:", "https:"];
|
|
|
466
478
|
/**
|
|
467
479
|
* The shared front of every load: enforce the URL scheme allow-list (SSRF guard),
|
|
468
480
|
* fetch the document, and parse it as YAML — returning the parsed-but-not-yet-schema-
|
|
469
|
-
* validated value or the first structured error. Reused by the tutor builder
|
|
470
|
-
* standalone fragment checker so
|
|
481
|
+
* validated value or the first structured error. Reused by the tutor builder, the
|
|
482
|
+
* standalone fragment checker, and the quiz/writing validators so they all gate
|
|
483
|
+
* schemes identically.
|
|
471
484
|
*/
|
|
472
485
|
async function loadYaml(url, fetchImpl, opts = {}) {
|
|
473
486
|
const allowedSchemes = opts.allowedSchemes ?? DEFAULT_ALLOWED_SCHEMES;
|
|
@@ -615,6 +628,150 @@ async function loadAndCheckFragmentFile(url, fetchImpl, opts = {}) {
|
|
|
615
628
|
return checkFragmentFileValue(yaml.value, url);
|
|
616
629
|
}
|
|
617
630
|
//#endregion
|
|
631
|
+
//#region ../lib/quiz-schema.ts
|
|
632
|
+
/** An optional content image attached to a question (carries no secret). */
|
|
633
|
+
const ImageRefSchema = z.strictObject({
|
|
634
|
+
hosted: z.boolean().optional(),
|
|
635
|
+
src: z.string().min(1),
|
|
636
|
+
alt: z.string().optional(),
|
|
637
|
+
credit: z.string().optional()
|
|
638
|
+
});
|
|
639
|
+
/**
|
|
640
|
+
* One question. `id` keys the per-question stats (must be unique — see
|
|
641
|
+
* `lib/quiz-validate.ts`); `question` is the Markdown shown to the student;
|
|
642
|
+
* `evaluation` is the server-only grading prompt.
|
|
643
|
+
*/
|
|
644
|
+
const QuizQuestionSchema = z.strictObject({
|
|
645
|
+
id: z.string().min(1),
|
|
646
|
+
title: z.string().optional(),
|
|
647
|
+
question: z.string().min(1),
|
|
648
|
+
evaluation: z.string().min(1),
|
|
649
|
+
image: ImageRefSchema.optional()
|
|
650
|
+
});
|
|
651
|
+
const QuizYamlSchema = z.strictObject({
|
|
652
|
+
id: z.string().min(1),
|
|
653
|
+
name: z.string().optional(),
|
|
654
|
+
title: z.string().optional(),
|
|
655
|
+
description: z.string().optional(),
|
|
656
|
+
anonymous: z.boolean().optional(),
|
|
657
|
+
shuffle: z.boolean().optional(),
|
|
658
|
+
llm: z.strictObject({ model: z.string().min(1) }),
|
|
659
|
+
discussion: z.strictObject({ instructions: z.string().min(1) }).optional(),
|
|
660
|
+
questions: z.array(QuizQuestionSchema).min(1)
|
|
661
|
+
});
|
|
662
|
+
//#endregion
|
|
663
|
+
//#region ../lib/quiz-validate.ts
|
|
664
|
+
/** Quizzes default to anonymous — answers are recorded for stats but not attributed. */
|
|
665
|
+
const DEFAULT_ANONYMOUS$1 = true;
|
|
666
|
+
/** Question ids declared on more than one question (the per-question stats key). */
|
|
667
|
+
function findDuplicateQuestionIds(quiz) {
|
|
668
|
+
const errors = [];
|
|
669
|
+
const seen = /* @__PURE__ */ new Set();
|
|
670
|
+
for (const question of quiz.questions) {
|
|
671
|
+
if (seen.has(question.id)) {
|
|
672
|
+
errors.push(error("DUPLICATE_QUIZ_QUESTION_ID", `Question id "${question.id}" is declared more than once`, { questionId: question.id }));
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
seen.add(question.id);
|
|
676
|
+
}
|
|
677
|
+
return errors;
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Validate an already-parsed quiz value: schema → unique question ids → metadata.
|
|
681
|
+
* Pure (the parsed value is passed in); `loadAndCheckQuiz` wraps it with fetch +
|
|
682
|
+
* YAML parse.
|
|
683
|
+
*/
|
|
684
|
+
function checkQuizValue(parsed, url) {
|
|
685
|
+
const valid = validate(parsed, QuizYamlSchema, "QUIZ_SCHEMA_ERROR", url);
|
|
686
|
+
if (!valid.ok) return {
|
|
687
|
+
ok: false,
|
|
688
|
+
errors: [valid.error],
|
|
689
|
+
warnings: []
|
|
690
|
+
};
|
|
691
|
+
const quiz = valid.data;
|
|
692
|
+
const errors = findDuplicateQuestionIds(quiz);
|
|
693
|
+
if (errors.length > 0) return {
|
|
694
|
+
ok: false,
|
|
695
|
+
errors,
|
|
696
|
+
warnings: []
|
|
697
|
+
};
|
|
698
|
+
return {
|
|
699
|
+
ok: true,
|
|
700
|
+
quizId: quiz.id,
|
|
701
|
+
model: quiz.llm.model,
|
|
702
|
+
questionCount: quiz.questions.length,
|
|
703
|
+
anonymous: quiz.anonymous ?? DEFAULT_ANONYMOUS$1,
|
|
704
|
+
title: quiz.title ?? null,
|
|
705
|
+
warnings: []
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* Validate a quiz FILE: scheme-gate + fetch + parse (shared `loadYaml`), then the
|
|
710
|
+
* pure `checkQuizValue`. The web app passes the default http(s)-only schemes; the
|
|
711
|
+
* CLI adds `file:` so a local quiz YAML on disk validates too.
|
|
712
|
+
*/
|
|
713
|
+
async function loadAndCheckQuiz(url, fetchImpl, opts = {}) {
|
|
714
|
+
const yaml = await loadYaml(url, fetchImpl, opts);
|
|
715
|
+
if (!yaml.ok) return {
|
|
716
|
+
ok: false,
|
|
717
|
+
errors: [yaml.error],
|
|
718
|
+
warnings: []
|
|
719
|
+
};
|
|
720
|
+
return checkQuizValue(yaml.value, url);
|
|
721
|
+
}
|
|
722
|
+
//#endregion
|
|
723
|
+
//#region ../lib/writing-schema.ts
|
|
724
|
+
const WritingYamlSchema = z.strictObject({
|
|
725
|
+
id: z.string().min(1),
|
|
726
|
+
name: z.string().optional(),
|
|
727
|
+
title: z.string().optional(),
|
|
728
|
+
description: z.string().optional(),
|
|
729
|
+
anonymous: z.boolean().optional(),
|
|
730
|
+
llm: z.strictObject({ model: z.string().min(1) }),
|
|
731
|
+
instructions: z.string().min(1),
|
|
732
|
+
placeholder: z.string().optional()
|
|
733
|
+
});
|
|
734
|
+
//#endregion
|
|
735
|
+
//#region ../lib/writing-validate.ts
|
|
736
|
+
/** Writing DIVERGES from tutor/quiz: it defaults to attributed (`anonymous: false`). */
|
|
737
|
+
const DEFAULT_ANONYMOUS = false;
|
|
738
|
+
/**
|
|
739
|
+
* Validate an already-parsed writing value against its schema, then extract metadata.
|
|
740
|
+
* Pure (the parsed value is passed in); `loadAndCheckWriting` wraps it with fetch +
|
|
741
|
+
* YAML parse.
|
|
742
|
+
*/
|
|
743
|
+
function checkWritingValue(parsed, url) {
|
|
744
|
+
const valid = validate(parsed, WritingYamlSchema, "WRITING_SCHEMA_ERROR", url);
|
|
745
|
+
if (!valid.ok) return {
|
|
746
|
+
ok: false,
|
|
747
|
+
errors: [valid.error],
|
|
748
|
+
warnings: []
|
|
749
|
+
};
|
|
750
|
+
const writing = valid.data;
|
|
751
|
+
return {
|
|
752
|
+
ok: true,
|
|
753
|
+
writingId: writing.id,
|
|
754
|
+
model: writing.llm.model,
|
|
755
|
+
anonymous: writing.anonymous ?? DEFAULT_ANONYMOUS,
|
|
756
|
+
title: writing.title ?? null,
|
|
757
|
+
warnings: []
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Validate a writing FILE: scheme-gate + fetch + parse (shared `loadYaml`), then the
|
|
762
|
+
* pure `checkWritingValue`. The web app passes the default http(s)-only schemes; the
|
|
763
|
+
* CLI adds `file:` so a local writing YAML on disk validates too.
|
|
764
|
+
*/
|
|
765
|
+
async function loadAndCheckWriting(url, fetchImpl, opts = {}) {
|
|
766
|
+
const yaml = await loadYaml(url, fetchImpl, opts);
|
|
767
|
+
if (!yaml.ok) return {
|
|
768
|
+
ok: false,
|
|
769
|
+
errors: [yaml.error],
|
|
770
|
+
warnings: []
|
|
771
|
+
};
|
|
772
|
+
return checkWritingValue(yaml.value, url);
|
|
773
|
+
}
|
|
774
|
+
//#endregion
|
|
618
775
|
//#region src/file-fetcher.ts
|
|
619
776
|
const cliFetcher = async (url) => {
|
|
620
777
|
if (url.startsWith("file:")) try {
|
|
@@ -646,6 +803,7 @@ function context(item) {
|
|
|
646
803
|
const parts = [];
|
|
647
804
|
if (item.fileAlias) parts.push(`file=${item.fileAlias}`);
|
|
648
805
|
if (item.fragmentId) parts.push(`fragment=${item.fragmentId}`);
|
|
806
|
+
if ("questionId" in item && item.questionId) parts.push(`question=${item.questionId}`);
|
|
649
807
|
if (item.variable) parts.push(`variable=${item.variable}`);
|
|
650
808
|
if ("url" in item && item.url) parts.push(`url=${item.url}`);
|
|
651
809
|
if ("expectedType" in item && item.expectedType) parts.push(`expected=${item.expectedType}`);
|
|
@@ -719,8 +877,58 @@ function formatFragmentResult(result, source) {
|
|
|
719
877
|
}
|
|
720
878
|
return lines.join("\n");
|
|
721
879
|
}
|
|
880
|
+
/**
|
|
881
|
+
* Shared tail for the quiz/writing renderers: on failure, the error list (with any
|
|
882
|
+
* flattened Zod issues); plus any warnings on either branch.
|
|
883
|
+
*/
|
|
884
|
+
function renderFailureAndWarnings(result, label, source) {
|
|
885
|
+
const lines = [red(`✘ Invalid ${label}`) + dim(` — ${source}`), ""];
|
|
886
|
+
lines.push(red(`${result.errors.length} error(s):`));
|
|
887
|
+
lines.push(...renderErrors(result.errors));
|
|
888
|
+
if (result.warnings.length) {
|
|
889
|
+
lines.push("");
|
|
890
|
+
lines.push(yellow(`${result.warnings.length} warning(s):`));
|
|
891
|
+
lines.push(...renderWarnings(result.warnings));
|
|
892
|
+
}
|
|
893
|
+
return lines.join("\n");
|
|
894
|
+
}
|
|
895
|
+
/** Renderer for a quiz check (`--kind quiz`). */
|
|
896
|
+
function formatQuizResult(result, source) {
|
|
897
|
+
if (!result.ok) return renderFailureAndWarnings(result, "quiz", source);
|
|
898
|
+
const lines = [green(`✔ Valid quiz`) + dim(` — ${source}`)];
|
|
899
|
+
lines.push(` id: ${result.quizId}`);
|
|
900
|
+
lines.push(` model: ${result.model}`);
|
|
901
|
+
lines.push(` questions: ${result.questionCount} anonymous: ${result.anonymous}`);
|
|
902
|
+
if (result.warnings.length) {
|
|
903
|
+
lines.push("");
|
|
904
|
+
lines.push(yellow(`${result.warnings.length} warning(s):`));
|
|
905
|
+
lines.push(...renderWarnings(result.warnings));
|
|
906
|
+
}
|
|
907
|
+
return lines.join("\n");
|
|
908
|
+
}
|
|
909
|
+
/** Renderer for a writing-activity check (`--kind writing`). */
|
|
910
|
+
function formatWritingResult(result, source) {
|
|
911
|
+
if (!result.ok) return renderFailureAndWarnings(result, "writing activity", source);
|
|
912
|
+
const lines = [green(`✔ Valid writing activity`) + dim(` — ${source}`)];
|
|
913
|
+
lines.push(` id: ${result.writingId}`);
|
|
914
|
+
lines.push(` model: ${result.model}`);
|
|
915
|
+
lines.push(` anonymous: ${result.anonymous}`);
|
|
916
|
+
if (result.warnings.length) {
|
|
917
|
+
lines.push("");
|
|
918
|
+
lines.push(yellow(`${result.warnings.length} warning(s):`));
|
|
919
|
+
lines.push(...renderWarnings(result.warnings));
|
|
920
|
+
}
|
|
921
|
+
return lines.join("\n");
|
|
922
|
+
}
|
|
722
923
|
//#endregion
|
|
723
924
|
//#region src/commands/validate.ts
|
|
925
|
+
/** Every kind the `--kind` flag accepts (used for the option help + guard). */
|
|
926
|
+
const VALIDATE_KINDS = [
|
|
927
|
+
"tutor",
|
|
928
|
+
"fragment",
|
|
929
|
+
"quiz",
|
|
930
|
+
"writing"
|
|
931
|
+
];
|
|
724
932
|
/**
|
|
725
933
|
* Turn the CLI argument into a URL the tutor core understands: an http(s) URL is
|
|
726
934
|
* used as-is; anything else is treated as a filesystem path and converted to an
|
|
@@ -744,20 +952,30 @@ function runValidate(pathOrUrl, kind) {
|
|
|
744
952
|
"https:",
|
|
745
953
|
"file:"
|
|
746
954
|
];
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
allowedSchemes
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
result
|
|
757
|
-
|
|
955
|
+
switch (kind) {
|
|
956
|
+
case "fragment": return loadAndCheckFragmentFile(url, cliFetcher, { allowedSchemes }).then((result) => ({
|
|
957
|
+
kind,
|
|
958
|
+
result
|
|
959
|
+
}));
|
|
960
|
+
case "quiz": return loadAndCheckQuiz(url, cliFetcher, { allowedSchemes }).then((result) => ({
|
|
961
|
+
kind,
|
|
962
|
+
result
|
|
963
|
+
}));
|
|
964
|
+
case "writing": return loadAndCheckWriting(url, cliFetcher, { allowedSchemes }).then((result) => ({
|
|
965
|
+
kind,
|
|
966
|
+
result
|
|
967
|
+
}));
|
|
968
|
+
default: return loadAndBuildTutorPrompt(url, cliFetcher, {
|
|
969
|
+
allowedSchemes,
|
|
970
|
+
validateLibraries: true
|
|
971
|
+
}).then((result) => ({
|
|
972
|
+
kind,
|
|
973
|
+
result
|
|
974
|
+
}));
|
|
975
|
+
}
|
|
758
976
|
}
|
|
759
977
|
function registerValidate(program) {
|
|
760
|
-
program.command("validate").description("Validate a tutor
|
|
978
|
+
program.command("validate").description("Validate a tutor (default), fragment library, quiz or writing YAML by local path or public http(s) URL").argument("<pathOrUrl>", "path to a tutor, fragment, quiz or writing YAML file, or a public http(s) URL").option("--kind <kind>", `what the file is: ${VALIDATE_KINDS.map((k) => `'${k}'`).join(", ")} ('tutor' is the default)`, "tutor").option("--json", "print the raw validation result as JSON").addHelpText("after", `
|
|
761
979
|
Examples:
|
|
762
980
|
# Validate a tutor (also strict-renders every fragment in every referenced library)
|
|
763
981
|
$ novedu-cli validate ./tutors/my-tutor.yaml
|
|
@@ -765,23 +983,37 @@ Examples:
|
|
|
765
983
|
# Validate a fragment library on its own
|
|
766
984
|
$ novedu-cli validate ./tutors/my-fragments.yaml --kind fragment
|
|
767
985
|
|
|
986
|
+
# Validate a quiz or a writing activity
|
|
987
|
+
$ novedu-cli validate ./quizzes/my-quiz.yaml --kind quiz
|
|
988
|
+
$ novedu-cli validate ./writings/my-writing.yaml --kind writing
|
|
989
|
+
|
|
768
990
|
# Machine-readable output for CI
|
|
769
991
|
$ novedu-cli validate https://example.com/tutor.yaml --json`).action(async (pathOrUrl, options) => {
|
|
770
|
-
if (options.kind !== void 0 &&
|
|
771
|
-
console.error(`Invalid --kind "${options.kind}": expected "
|
|
992
|
+
if (options.kind !== void 0 && !VALIDATE_KINDS.includes(options.kind)) {
|
|
993
|
+
console.error(`Invalid --kind "${options.kind}": expected ${VALIDATE_KINDS.map((k) => `"${k}"`).join(", ")}.`);
|
|
772
994
|
process.exitCode = 1;
|
|
773
995
|
return;
|
|
774
996
|
}
|
|
775
|
-
const outcome = await runValidate(pathOrUrl, options.kind
|
|
997
|
+
const outcome = await runValidate(pathOrUrl, options.kind ?? "tutor");
|
|
776
998
|
if (options.json) console.log(JSON.stringify(outcome.result, null, 2));
|
|
777
|
-
else console.log(
|
|
999
|
+
else console.log(formatOutcome(outcome, pathOrUrl));
|
|
778
1000
|
process.exitCode = outcome.result.ok ? 0 : 1;
|
|
779
1001
|
});
|
|
780
1002
|
}
|
|
1003
|
+
/** Pick the formatter for the outcome's kind (each result type has its own renderer). */
|
|
1004
|
+
function formatOutcome(outcome, source) {
|
|
1005
|
+
switch (outcome.kind) {
|
|
1006
|
+
case "fragment": return formatFragmentResult(outcome.result, source);
|
|
1007
|
+
case "quiz": return formatQuizResult(outcome.result, source);
|
|
1008
|
+
case "writing": return formatWritingResult(outcome.result, source);
|
|
1009
|
+
default: return formatResult(outcome.result, source);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
781
1012
|
//#endregion
|
|
782
1013
|
//#region src/main.ts
|
|
1014
|
+
const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
783
1015
|
const program = new Command();
|
|
784
|
-
program.name("novedu-cli").description("Command-line companion for the Novedu chat app").version(
|
|
1016
|
+
program.name("novedu-cli").description("Command-line companion for the Novedu chat app").version(version);
|
|
785
1017
|
registerValidate(program);
|
|
786
1018
|
program.parseAsync().catch((err) => {
|
|
787
1019
|
console.error(err instanceof Error ? err.message : err);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@novedu/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Command-line companion for the Novedu chat app. Validates tutor
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Command-line companion for the Novedu chat app. Validates tutor, fragment, quiz and writing YAML definitions (more commands to follow).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|