@tostudy-ai/cli 0.18.27 → 0.18.33

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 ADDED
@@ -0,0 +1,98 @@
1
+ # ToStudy CLI
2
+
3
+ Terminal study client for [ToStudy](https://tostudy.ai) — navigate courses, read lessons, get hints, and validate exercises from the command line. Published to npm as [`@tostudy-ai/cli`](https://www.npmjs.com/package/@tostudy-ai/cli). Since v0.18.0 it also carries the `creator` group, which lets a creator's own AI agent author a course and upload it as a draft.
4
+
5
+ **Requires:** Node.js >= 22 (bundle loads on >= 20.18.1 with a warning)
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install -g @tostudy-ai/cli
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ```bash
16
+ tostudy login # Authenticate (also installs the /tostudy command for your IDE)
17
+ tostudy courses # List enrolled courses
18
+ tostudy select # Select the active course
19
+ tostudy lesson # Read the current lesson
20
+ tostudy next # Advance to the next lesson
21
+ tostudy validate # Validate an exercise solution
22
+ tostudy progress # Show study progress
23
+ ```
24
+
25
+ ## Commands
26
+
27
+ Source of truth: `src/commands/` (one file per command). Every command supports `--json` output designed for LLM agent consumption.
28
+
29
+ | Command | Description |
30
+ | --------------- | -------------------------------------------------------------------------------------------------- |
31
+ | `login` | Authenticate with ToStudy (`--creator` requests the creator scope) |
32
+ | `logout` | Revoke the server session and clear local authentication |
33
+ | `doctor` | Diagnostic health check |
34
+ | `init` | Initialize course workspace |
35
+ | `profile` | Show or manage CLI profile |
36
+ | `courses` | List enrolled courses (`--mine`: courses you authored) |
37
+ | `select` | Select active course |
38
+ | `progress` | Show study progress |
39
+ | `grades` | Show grades and exercise scores |
40
+ | `review` | Review past exercise attempts |
41
+ | `start` | Start a module |
42
+ | `start-next` | Start next available module |
43
+ | `next` | Advance to next lesson |
44
+ | `lesson` | Show current lesson |
45
+ | `knowledge` | Show the active course's knowledge base |
46
+ | `hint` | Get hint for current exercise |
47
+ | `theory` | Request additional explanation in current lesson |
48
+ | `validate` | Validate exercise solution |
49
+ | `retry` | Revalidate a completed lesson (grade keeps the best attempt) |
50
+ | `level` | Adjust exercise difficulty in real time |
51
+ | `menu` | Interactive menu |
52
+ | `sync` | Regenerate instruction files (`--all-runtimes` for every supported runtime) |
53
+ | `brief` | Show base learner brief (T1) status |
54
+ | `brief-create` | Create base learner brief via interactive prompts |
55
+ | `brief-open` | Open the learner brief editor in the browser |
56
+ | `export` | Extract exercise to workspace |
57
+ | `export-grades` | Export exercise history in structured format |
58
+ | `open` | Open workspace in IDE |
59
+ | `workspace` | Workspace operations (`workspace setup`) |
60
+ | `vault` | Obsidian vault operations (`vault init`, `vault sync`) |
61
+ | `compact` | Save module study summary from stdin |
62
+ | `context` | Load session context (workspace + summaries) |
63
+ | `memory` | Load accumulated student memory |
64
+ | `insight` | Record a student insight (difficulty/breakthrough/question) |
65
+ | `creator` | Author a course with your AI agent (`init`, `validate`, `push`, `pull`, `audit`, `status`, `open`) |
66
+
67
+ > `tostudy setup` is deprecated — IDE detection and `/tostudy` installation are part of `tostudy login`.
68
+
69
+ ## IDE Integration
70
+
71
+ `tostudy login` / `select` / `init` generate instruction files that teach AI assistants how to tutor: Claude Code slash commands, Cursor rules, AGENTS.md blocks, OpenCode/Grok commands, and Antigravity skills. See [AGENTS.md](./AGENTS.md) for the full runtime matrix.
72
+
73
+ ## Development
74
+
75
+ ```bash
76
+ # Run from source (no dev server — CLI tool)
77
+ tsx bin/tostudy.ts <command>
78
+
79
+ # Tests / lint / typecheck
80
+ pnpm -C apps/tostudy-cli test
81
+ pnpm -C apps/tostudy-cli lint
82
+ pnpm -C apps/tostudy-cli type-check
83
+ ```
84
+
85
+ ## Build & Publish
86
+
87
+ | Target | Command | Output |
88
+ | ----------- | ----------------------------------------------- | --------------------------------------------- |
89
+ | npm package | `pnpm -C apps/tostudy-cli build` | `dist/cli.js` + `dist/main.js` (esbuild, ESM) |
90
+ | Standalone | `pnpm -C apps/tostudy-cli build:standalone` | 4 binaries (macOS arm64/x64, Linux, Windows) |
91
+ | Publish | `pnpm -C apps/tostudy-cli build && npm publish` | `@tostudy-ai/cli` on npm (public) |
92
+
93
+ > Always build by hand right before publishing and verify `dist/main.js` holds the new version string — with `ignore-scripts=true` in `~/.npmrc`, npm silently skips `prepublishOnly` and packs a stale `dist/`.
94
+
95
+ ## Related
96
+
97
+ - [AGENTS.md](./AGENTS.md) - Agent instructions (architecture, creator authoring, context pipeline)
98
+ - [@repo/tostudy-core](../../packages/tostudy-core/README.md) - Shared business logic (bundled at build time)
package/dist/main.js CHANGED
@@ -657,6 +657,12 @@ function parseOtlpResourceAttributes(raw2) {
657
657
  }
658
658
  return out;
659
659
  }
660
+ function resolveOtlpResourceAttributes(env) {
661
+ const attributes = parseOtlpResourceAttributes(env.OTEL_RESOURCE_ATTRIBUTES);
662
+ const railwayEnvironment = env.RAILWAY_ENVIRONMENT_NAME?.trim();
663
+ if (!attributes["deployment.environment"] && railwayEnvironment) attributes["deployment.environment"] = railwayEnvironment;
664
+ return attributes;
665
+ }
660
666
  function parseOtlpHeaders(raw2) {
661
667
  if (!raw2) return {};
662
668
  const out = {};
@@ -975,7 +981,7 @@ var init_dist = __esm({
975
981
  if (otlpEndpoint) this.transports.push(getOrCreateOtlpLogsTransport({
976
982
  endpoint: otlpEndpoint,
977
983
  serviceName: process.env.OTEL_SERVICE_NAME || getDefaultService(),
978
- resourceAttributes: parseOtlpResourceAttributes(process.env.OTEL_RESOURCE_ATTRIBUTES),
984
+ resourceAttributes: resolveOtlpResourceAttributes(process.env),
979
985
  headers: {
980
986
  ...parseOtlpHeaders(process.env.OTEL_EXPORTER_OTLP_HEADERS),
981
987
  ...parseOtlpHeaders(process.env.OTEL_EXPORTER_OTLP_LOGS_HEADERS)
@@ -1092,18 +1098,22 @@ var init_errors_pt_br = __esm({
1092
1098
  pathOutsideRoot: "caminho fora da pasta do curso: {path}. Use s\xF3 caminhos relativos dentro do workspace.",
1093
1099
  payloadTooLarge: "curso grande demais para um push: {bytes} bytes (limite {max}). Divida m\xF3dulos ou encurte li\xE7\xF5es.",
1094
1100
  validateOk: "\u2713 Workspace v\xE1lido: {lessons} li\xE7\xF5es, {bytes} bytes.",
1095
- validateFailed: "\u2717 {count} erro(s). Corrija e rode `tostudy creator validate` de novo.",
1101
+ validateFailedOne: "\u2717 {count} erro. Corrija e rode `tostudy creator validate` de novo.",
1102
+ validateFailedOther: "\u2717 {count} erros. Corrija e rode `tostudy creator validate` de novo.",
1096
1103
  pushCreated: "\u2713 Curso criado como rascunho. Revise no portal: {url}",
1097
1104
  pushUpdated: "\u2713 Curso atualizado. A revis\xE3o no portal foi reiniciada: {url}",
1098
1105
  pushUnchanged: "\u2713 Nada mudou desde o \xFAltimo push: {url}",
1099
- pullDone: "\u2713 {count} arquivo(s) atualizado(s) a partir do servidor:",
1106
+ pullDoneOne: "\u2713 {count} arquivo atualizado a partir do servidor:",
1107
+ pullDoneOther: "\u2713 {count} arquivos atualizados a partir do servidor:",
1100
1108
  pullStructureMismatch: "A estrutura local ({local}) difere da do servidor ({remote}). Nada foi escrito. `tostudy creator push --force` torna o workspace a fonte da verdade.",
1101
1109
  pullInvalidLesson: "O servidor devolveu li\xE7\xF5es que n\xE3o passam no contrato ({slots}). Nada foi escrito.",
1102
- auditResult: "Auditoria: {verdict} \xB7 nota {score}/100",
1110
+ auditResult: "Checagem de formato: {verdict} \xB7 nota {score}/100",
1111
+ formatOnly: "Esta checagem avalia o formato, n\xE3o a qualidade do ensino.",
1103
1112
  auditStale: "\u26A0 O conte\xFAdo mudou durante a auditoria: este relat\xF3rio j\xE1 est\xE1 desatualizado. Rode `tostudy creator audit` de novo.",
1104
1113
  statusLine: "Status: {status} \xB7 revis\xE3o: {review}",
1105
- lastAudit: "\xDAltima auditoria: {verdict} \xB7 nota {score}/100",
1106
- noAudit: "\xDAltima auditoria: nenhuma",
1114
+ lastAudit: "\xDAltima checagem de formato: {verdict} \xB7 nota {score}/100",
1115
+ noAudit: "\xDAltima checagem de formato: nenhuma",
1116
+ pedagogicalNotRun: "! A revis\xE3o pedag\xF3gica n\xE3o rodou \u2014 a checagem de formato n\xE3o avalia a qualidade do ensino.",
1107
1117
  statusReady: "\u2713 Pronto para enviar para revis\xE3o pelo portal.",
1108
1118
  statusNotReady: "Falta para enviar para revis\xE3o:",
1109
1119
  portal: "Portal: {url}",
@@ -1206,18 +1216,22 @@ var init_errors_en_us = __esm({
1206
1216
  pathOutsideRoot: "path outside the course folder: {path}. Use only relative paths inside the workspace.",
1207
1217
  payloadTooLarge: "course too large for one push: {bytes} bytes (limit {max}). Split modules or shorten lessons.",
1208
1218
  validateOk: "\u2713 Workspace is valid: {lessons} lessons, {bytes} bytes.",
1209
- validateFailed: "\u2717 {count} error(s). Fix them and run `tostudy creator validate` again.",
1219
+ validateFailedOne: "\u2717 {count} error. Fix it and run `tostudy creator validate` again.",
1220
+ validateFailedOther: "\u2717 {count} errors. Fix them and run `tostudy creator validate` again.",
1210
1221
  pushCreated: "\u2713 Course created as a draft. Review it in the portal: {url}",
1211
1222
  pushUpdated: "\u2713 Course updated. The portal review was reset: {url}",
1212
1223
  pushUnchanged: "\u2713 Nothing changed since the last push: {url}",
1213
- pullDone: "\u2713 {count} file(s) updated from the server:",
1224
+ pullDoneOne: "\u2713 {count} file updated from the server:",
1225
+ pullDoneOther: "\u2713 {count} files updated from the server:",
1214
1226
  pullStructureMismatch: "The local structure ({local}) differs from the server's ({remote}). Nothing was written. `tostudy creator push --force` makes the workspace authoritative.",
1215
1227
  pullInvalidLesson: "The server returned lessons that do not pass the contract ({slots}). Nothing was written.",
1216
- auditResult: "Audit: {verdict} \xB7 score {score}/100",
1228
+ auditResult: "Format check: {verdict} \xB7 score {score}/100",
1229
+ formatOnly: "This checks format, not teaching quality.",
1217
1230
  auditStale: "\u26A0 The content changed while the audit ran: this report is already stale. Run `tostudy creator audit` again.",
1218
1231
  statusLine: "Status: {status} \xB7 review: {review}",
1219
- lastAudit: "Last audit: {verdict} \xB7 score {score}/100",
1220
- noAudit: "Last audit: none",
1232
+ lastAudit: "Last format check: {verdict} \xB7 score {score}/100",
1233
+ noAudit: "Last format check: none",
1234
+ pedagogicalNotRun: "! Pedagogical review not run \u2014 the format check does not judge teaching quality.",
1221
1235
  statusReady: "\u2713 Ready to submit for review from the portal.",
1222
1236
  statusNotReady: "Still missing before submitting for review:",
1223
1237
  portal: "Portal: {url}",
@@ -3755,8 +3769,10 @@ var loginCommand = new Command("login").description("Autentica na ToStudy via br
3755
3769
  const data = createHttpProvider(apiUrl, token);
3756
3770
  const courses = await listCourses({ userId }, { data, logger: logger4 });
3757
3771
  if (courses.length > 0) {
3758
- console.log(`
3759
- \u2713 ${courses.length} curso(s) matriculado(s)`);
3772
+ console.log(
3773
+ `
3774
+ \u2713 ${courses.length} ${courses.length === 1 ? "curso matriculado" : "cursos matriculados"}`
3775
+ );
3760
3776
  } else {
3761
3777
  console.log(`
3762
3778
  \u2192 Nenhum curso matriculado. Rode: tostudy suggest`);
@@ -3828,7 +3844,7 @@ import { spawn } from "node:child_process";
3828
3844
  import { hostname } from "node:os";
3829
3845
 
3830
3846
  // src/version.ts
3831
- var CLI_VERSION = true ? "0.18.27" : "0.7.1";
3847
+ var CLI_VERSION = true ? "0.18.33" : "0.7.1";
3832
3848
 
3833
3849
  // src/installer/mcp-setup.ts
3834
3850
  function safeServerError(text2) {
@@ -4431,7 +4447,9 @@ function normalizeReadiness(raw2) {
4431
4447
  const ready = typeof block["isReady"] === "boolean" ? block["isReady"] : missing.length === 0;
4432
4448
  const audit = block["lastAudit"];
4433
4449
  const report = audit && typeof audit === "object" ? audit : null;
4434
- return { ready, missing, report };
4450
+ const review = block["pedagogicalReview"];
4451
+ const pedagogicalReview = review === "not_run" || review === "approved" || review === "needs_revision" ? review : null;
4452
+ return { ready, missing, report, pedagogicalReview };
4435
4453
  }
4436
4454
 
4437
4455
  // src/commands/courses.ts
@@ -5317,7 +5335,9 @@ var selectCommand = new Command8("select").description("Activate a course by ID
5317
5335
  if (/^\d+$/.test(course)) {
5318
5336
  const idx = parseInt(course, 10) - 1;
5319
5337
  if (idx < 0 || idx >= courses.length) {
5320
- error(`\xCDndice ${course} inv\xE1lido. Voc\xEA tem ${courses.length} curso(s).`);
5338
+ error(
5339
+ `\xCDndice ${course} inv\xE1lido. Voc\xEA tem ${courses.length} ${courses.length === 1 ? "curso" : "cursos"}.`
5340
+ );
5321
5341
  }
5322
5342
  const found = courses[idx];
5323
5343
  courseId = found.courseId;
@@ -5398,7 +5418,7 @@ var selectCommand = new Command8("select").description("Activate a course by ID
5398
5418
  }
5399
5419
  const lines = [
5400
5420
  `\u2713 Curso ativado: ${detail.courseTitle}`,
5401
- ` Progresso: ${detail.progress}% | ${detail.moduleCount} m\xF3dulos | ${detail.lessonCount} li\xE7\xF5es`,
5421
+ ` Progresso: ${detail.progress}% | ${detail.moduleCount} ${detail.moduleCount === 1 ? "m\xF3dulo" : "m\xF3dulos"} | ${detail.lessonCount} ${detail.lessonCount === 1 ? "li\xE7\xE3o" : "li\xE7\xF5es"}`,
5402
5422
  wsLine,
5403
5423
  "",
5404
5424
  pipelineError ? `\u26A0 Arquivos de contexto N\xC3O foram criados (${pipelineError}).
@@ -5855,7 +5875,10 @@ function formatKnowledge(data) {
5855
5875
  );
5856
5876
  return lines.join("\n");
5857
5877
  }
5858
- lines.push(`${data.resources.length} fonte(s) de conhecimento:`, "");
5878
+ lines.push(
5879
+ `${data.resources.length} ${data.resources.length === 1 ? "fonte" : "fontes"} de conhecimento:`,
5880
+ ""
5881
+ );
5859
5882
  data.resources.forEach((resource, idx) => {
5860
5883
  lines.push(`${idx + 1}. ${resource.title} [${resource.type}]`, "", resource.content, "");
5861
5884
  });
@@ -19885,6 +19908,316 @@ async function setEnrollmentLevel(input2, deps) {
19885
19908
  return deps.data.exercises.setEnrollmentLevel(input2.enrollmentId, input2.level);
19886
19909
  }
19887
19910
 
19911
+ // ../../packages/validators/src/file-upload.ts
19912
+ var FILE_SIZE_LIMITS = {
19913
+ /** Images: profile photos, thumbnails, etc. */
19914
+ IMAGE: 5 * 1024 * 1024,
19915
+ // 5MB
19916
+ /** Documents: PDFs, Word docs, text files */
19917
+ DOCUMENT: 10 * 1024 * 1024,
19918
+ // 10MB
19919
+ /** Videos: course content, recordings */
19920
+ VIDEO: 500 * 1024 * 1024,
19921
+ // 500MB
19922
+ /** Audio: podcast, voice recordings */
19923
+ AUDIO: 50 * 1024 * 1024,
19924
+ // 50MB
19925
+ /** Profile photos (smaller limit for optimization) */
19926
+ PROFILE_PHOTO: 2 * 1024 * 1024,
19927
+ // 2MB
19928
+ /** Session room attachments */
19929
+ SESSION_CONTENT: 10 * 1024 * 1024,
19930
+ // 10MB
19931
+ /** Knowledge base files */
19932
+ KNOWLEDGE_BASE: 50 * 1024 * 1024,
19933
+ // 50MB
19934
+ /** R2/S3 general uploads */
19935
+ R2_DEFAULT: 50 * 1024 * 1024,
19936
+ // 50MB
19937
+ /** MCP knowledge base file reading (local project files) */
19938
+ MCP_KNOWLEDGE_BASE: 500 * 1024,
19939
+ // 500KB
19940
+ /** Default fallback */
19941
+ DEFAULT: 10 * 1024 * 1024
19942
+ // 10MB
19943
+ };
19944
+ var ALLOWED_EXTENSIONS = {
19945
+ IMAGE: [".jpg", ".jpeg", ".png", ".gif", ".webp"],
19946
+ DOCUMENT: [".pdf", ".doc", ".docx", ".xlsx", ".zip", ".txt", ".rtf", ".md", ".csv"],
19947
+ VIDEO: [".mp4", ".mov", ".avi", ".webm", ".flv"],
19948
+ AUDIO: [".mp3", ".wav", ".aac", ".ogg", ".flac", ".m4a"],
19949
+ SESSION_CONTENT: [".pdf", ".png", ".jpg", ".jpeg"]
19950
+ };
19951
+ var STUDY_DOCUMENT_EXTENSIONS = [".md", ".csv", ".xlsx"];
19952
+ var STUDY_CHAT_CONTENT_MAX_CHARS = 1e4;
19953
+ var ALL_ALLOWED_EXTENSIONS = [
19954
+ ...ALLOWED_EXTENSIONS.IMAGE,
19955
+ ...ALLOWED_EXTENSIONS.DOCUMENT,
19956
+ ...ALLOWED_EXTENSIONS.VIDEO,
19957
+ ...ALLOWED_EXTENSIONS.AUDIO
19958
+ ];
19959
+ function isStudyDocumentExtension(filename) {
19960
+ const ext = getFileExtension(filename);
19961
+ if (!ext) return false;
19962
+ return STUDY_DOCUMENT_EXTENSIONS.includes(ext);
19963
+ }
19964
+ function extractStudyDocumentText(filename, buffer, maxChars = STUDY_CHAT_CONTENT_MAX_CHARS) {
19965
+ if (!isStudyDocumentExtension(filename)) {
19966
+ return { ok: false, error: "unsupported_extension" };
19967
+ }
19968
+ const ext = getFileExtension(filename);
19969
+ if (ext === ".xlsx") {
19970
+ return extractXlsxDocumentText(buffer, maxChars);
19971
+ }
19972
+ if (!buffer || buffer.includes(0)) {
19973
+ return { ok: false, error: "invalid_encoding" };
19974
+ }
19975
+ let decoded;
19976
+ try {
19977
+ decoded = new TextDecoder("utf-8", { fatal: true }).decode(buffer);
19978
+ } catch {
19979
+ return { ok: false, error: "invalid_encoding" };
19980
+ }
19981
+ const normalized = decoded.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
19982
+ if (!normalized.trim()) {
19983
+ return { ok: false, error: "empty_document" };
19984
+ }
19985
+ return finalizeExtractedStudyText(normalized, maxChars);
19986
+ }
19987
+ function finalizeExtractedStudyText(normalized, maxChars) {
19988
+ const originalLength = normalized.length;
19989
+ const truncated = originalLength > maxChars;
19990
+ const text2 = truncated ? normalized.slice(0, maxChars) : normalized;
19991
+ return {
19992
+ ok: true,
19993
+ text: text2,
19994
+ truncated,
19995
+ originalLength,
19996
+ extractedLength: text2.length
19997
+ };
19998
+ }
19999
+ var MAX_XLSX_UNCOMPRESSED_BYTES = 16 * 1024 * 1024;
20000
+ var MAX_XLSX_ENTRIES = 256;
20001
+ function getInflateRawSync() {
20002
+ const proc = globalThis.process;
20003
+ return proc?.getBuiltinModule?.("node:zlib")?.inflateRawSync;
20004
+ }
20005
+ function readZipXmlEntries(buffer) {
20006
+ if (!buffer || buffer.byteLength < 22) return null;
20007
+ const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
20008
+ if (view.getUint32(0, true) !== 67324752) return null;
20009
+ let eocdOffset = -1;
20010
+ const minEocdOffset = Math.max(0, buffer.byteLength - 65557);
20011
+ for (let i = buffer.byteLength - 22; i >= minEocdOffset; i--) {
20012
+ if (view.getUint32(i, true) === 101010256) {
20013
+ eocdOffset = i;
20014
+ break;
20015
+ }
20016
+ }
20017
+ if (eocdOffset < 0) return null;
20018
+ const totalEntries = view.getUint16(eocdOffset + 10, true);
20019
+ const cdSize = view.getUint32(eocdOffset + 12, true);
20020
+ const cdOffset = view.getUint32(eocdOffset + 16, true);
20021
+ if (totalEntries === 0 || totalEntries > MAX_XLSX_ENTRIES || cdOffset + cdSize > buffer.byteLength) {
20022
+ return null;
20023
+ }
20024
+ const inflateRawSync = getInflateRawSync();
20025
+ const decoder = new TextDecoder("utf-8", { fatal: false });
20026
+ const entries = /* @__PURE__ */ new Map();
20027
+ let totalUncompressed = 0;
20028
+ let pos = cdOffset;
20029
+ for (let i = 0; i < totalEntries && pos + 46 <= cdOffset + cdSize; i++) {
20030
+ if (view.getUint32(pos, true) !== 33639248) return null;
20031
+ const compressionMethod = view.getUint16(pos + 10, true);
20032
+ const compressedSize = view.getUint32(pos + 20, true);
20033
+ const uncompressedSize = view.getUint32(pos + 24, true);
20034
+ const fileNameLen = view.getUint16(pos + 28, true);
20035
+ const extraLen = view.getUint16(pos + 30, true);
20036
+ const commentLen = view.getUint16(pos + 32, true);
20037
+ const localHeaderOffset = view.getUint32(pos + 42, true);
20038
+ const headerEnd = pos + 46 + fileNameLen + extraLen + commentLen;
20039
+ if (headerEnd > buffer.byteLength) return null;
20040
+ const rawName = decoder.decode(buffer.subarray(pos + 46, pos + 46 + fileNameLen));
20041
+ const normalizedName = rawName.replace(/^\/+/, "");
20042
+ pos = headerEnd;
20043
+ if (!normalizedName.startsWith("xl/") || !normalizedName.endsWith(".xml") && !normalizedName.endsWith(".rels")) {
20044
+ continue;
20045
+ }
20046
+ if (uncompressedSize > MAX_XLSX_UNCOMPRESSED_BYTES || totalUncompressed + uncompressedSize > MAX_XLSX_UNCOMPRESSED_BYTES || localHeaderOffset + 30 > buffer.byteLength) {
20047
+ return null;
20048
+ }
20049
+ if (view.getUint32(localHeaderOffset, true) !== 67324752) return null;
20050
+ const localNameLen = view.getUint16(localHeaderOffset + 26, true);
20051
+ const localExtraLen = view.getUint16(localHeaderOffset + 28, true);
20052
+ const dataStart = localHeaderOffset + 30 + localNameLen + localExtraLen;
20053
+ const dataEnd = dataStart + compressedSize;
20054
+ if (dataEnd > buffer.byteLength) return null;
20055
+ const compressedSlice = buffer.subarray(dataStart, dataEnd);
20056
+ let rawBytes;
20057
+ if (compressionMethod === 0) {
20058
+ rawBytes = compressedSlice;
20059
+ } else if (compressionMethod === 8) {
20060
+ if (!inflateRawSync) return null;
20061
+ rawBytes = inflateRawSync(compressedSlice, {
20062
+ maxOutputLength: MAX_XLSX_UNCOMPRESSED_BYTES - totalUncompressed
20063
+ });
20064
+ } else {
20065
+ return null;
20066
+ }
20067
+ totalUncompressed += rawBytes.byteLength;
20068
+ if (totalUncompressed > MAX_XLSX_UNCOMPRESSED_BYTES) return null;
20069
+ entries.set(normalizedName, decoder.decode(rawBytes));
20070
+ }
20071
+ return entries;
20072
+ }
20073
+ function decodeXmlText(input2) {
20074
+ return input2.replace(/_x([0-9A-Fa-f]{4})_/g, (_, hex3) => String.fromCharCode(parseInt(hex3, 16))).replace(/&#x([0-9A-Fa-f]+);/g, (_, hex3) => String.fromCodePoint(parseInt(hex3, 16))).replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10))).replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
20075
+ }
20076
+ function extractXmlAttr(tagAttrs, attrName) {
20077
+ const pattern = new RegExp(`(?:^|\\s)${attrName}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`);
20078
+ const match = pattern.exec(tagAttrs);
20079
+ if (!match) return void 0;
20080
+ return decodeXmlText(match[1] ?? match[2] ?? "");
20081
+ }
20082
+ function collectTextNodes(xmlFragment) {
20083
+ const withoutPhonetic = xmlFragment.replace(/<rPh\b[^>]*>[\s\S]*?<\/rPh>/g, "");
20084
+ const parts = [];
20085
+ const tRegex = /<t(?:\s[^>]*)?>([\s\S]*?)<\/t>/g;
20086
+ let match;
20087
+ while ((match = tRegex.exec(withoutPhonetic)) !== null) {
20088
+ parts.push(decodeXmlText(match[1] ?? ""));
20089
+ }
20090
+ return parts.join("");
20091
+ }
20092
+ function parseSharedStrings(xml) {
20093
+ if (!xml) return [];
20094
+ const strings = [];
20095
+ const siRegex = /<si\b[^>]*>([\s\S]*?)<\/si>/g;
20096
+ let match;
20097
+ while ((match = siRegex.exec(xml)) !== null) {
20098
+ strings.push(collectTextNodes(match[1] ?? ""));
20099
+ }
20100
+ return strings;
20101
+ }
20102
+ function resolveWorkbookSheets(entries) {
20103
+ const workbookXml = entries.get("xl/workbook.xml");
20104
+ const relsXml = entries.get("xl/_rels/workbook.xml.rels");
20105
+ const relTargets = /* @__PURE__ */ new Map();
20106
+ if (relsXml) {
20107
+ const relRegex = /<Relationship\b([^>]*?)\/?>/g;
20108
+ let match;
20109
+ while ((match = relRegex.exec(relsXml)) !== null) {
20110
+ const attrs = match[1] ?? "";
20111
+ const id = extractXmlAttr(attrs, "Id");
20112
+ const target = extractXmlAttr(attrs, "Target");
20113
+ if (id && target) {
20114
+ const normalizedTarget = target.startsWith("/") ? target.replace(/^\/+/, "") : `xl/${target.replace(/^\.?\//, "")}`;
20115
+ relTargets.set(id, normalizedTarget);
20116
+ }
20117
+ }
20118
+ }
20119
+ const resolved = [];
20120
+ const usedPaths = /* @__PURE__ */ new Set();
20121
+ if (workbookXml) {
20122
+ const sheetRegex = /<sheet\b([^>]*?)\/?>/g;
20123
+ let match;
20124
+ let idx = 1;
20125
+ while ((match = sheetRegex.exec(workbookXml)) !== null) {
20126
+ const attrs = match[1] ?? "";
20127
+ const name = extractXmlAttr(attrs, "name") || `Sheet${idx}`;
20128
+ const rId = extractXmlAttr(attrs, "r:id") ?? extractXmlAttr(attrs, "id");
20129
+ const targetPath = (rId ? relTargets.get(rId) : void 0) ?? `xl/worksheets/sheet${idx}.xml`;
20130
+ const sheetXml = entries.get(targetPath);
20131
+ if (sheetXml) {
20132
+ resolved.push({ name, xml: sheetXml });
20133
+ usedPaths.add(targetPath);
20134
+ }
20135
+ idx++;
20136
+ }
20137
+ }
20138
+ if (resolved.length === 0) {
20139
+ const fallbackPaths = Array.from(entries.keys()).filter((k) => /^xl\/worksheets\/[^/]+\.xml$/i.test(k) && !usedPaths.has(k)).sort((a, b) => a.localeCompare(b, void 0, { numeric: true }));
20140
+ fallbackPaths.forEach((p, i) => {
20141
+ const xml = entries.get(p);
20142
+ if (xml) resolved.push({ name: `Sheet${i + 1}`, xml });
20143
+ });
20144
+ }
20145
+ return resolved;
20146
+ }
20147
+ function extractXlsxDocumentText(buffer, maxChars) {
20148
+ let entries;
20149
+ try {
20150
+ entries = readZipXmlEntries(buffer);
20151
+ } catch {
20152
+ return { ok: false, error: "invalid_encoding" };
20153
+ }
20154
+ if (!entries) {
20155
+ return { ok: false, error: "invalid_encoding" };
20156
+ }
20157
+ const sheets = resolveWorkbookSheets(entries);
20158
+ if (sheets.length === 0) {
20159
+ return { ok: false, error: "invalid_encoding" };
20160
+ }
20161
+ const sharedStrings = parseSharedStrings(entries.get("xl/sharedStrings.xml"));
20162
+ const sheetSections = [];
20163
+ for (const sheet of sheets) {
20164
+ const cellLines = [];
20165
+ const cellRegex = /<c\b([^>]*?)(?:\/>|>([\s\S]*?)<\/c>)/g;
20166
+ let match;
20167
+ let fallbackIndex = 1;
20168
+ while ((match = cellRegex.exec(sheet.xml)) !== null) {
20169
+ const attrs = match[1] ?? "";
20170
+ const inner = match[2] ?? "";
20171
+ if (!inner) continue;
20172
+ const ref = extractXmlAttr(attrs, "r") || `Cell${fallbackIndex++}`;
20173
+ const cellType = extractXmlAttr(attrs, "t");
20174
+ const fMatch = /<f(?:\s[^>]*)?>([\s\S]*?)<\/f>/.exec(inner);
20175
+ const rawFormula = fMatch ? decodeXmlText(fMatch[1] ?? "").trim() : "";
20176
+ const formula = rawFormula ? rawFormula.startsWith("=") ? rawFormula : `=${rawFormula}` : "";
20177
+ let value = "";
20178
+ if (cellType === "inlineStr") {
20179
+ const isMatch = /<is\b[^>]*>([\s\S]*?)<\/is>/.exec(inner);
20180
+ value = isMatch ? collectTextNodes(isMatch[1] ?? "").trim() : "";
20181
+ } else {
20182
+ const vMatch = /<v(?:\s[^>]*)?>([\s\S]*?)<\/v>/.exec(inner);
20183
+ const rawV = vMatch ? decodeXmlText(vMatch[1] ?? "").trim() : "";
20184
+ if (rawV !== "") {
20185
+ if (cellType === "s") {
20186
+ const sstIdx = Number.parseInt(rawV, 10);
20187
+ value = (Number.isFinite(sstIdx) ? sharedStrings[sstIdx] : void 0) ?? rawV;
20188
+ } else if (cellType === "b") {
20189
+ value = rawV === "1" ? "TRUE" : "FALSE";
20190
+ } else {
20191
+ value = rawV;
20192
+ }
20193
+ }
20194
+ }
20195
+ if (!formula && !value) continue;
20196
+ if (formula && value) {
20197
+ cellLines.push(`- ${ref}: ${formula} (valor: ${value})`);
20198
+ } else if (formula) {
20199
+ cellLines.push(`- ${ref}: ${formula}`);
20200
+ } else {
20201
+ cellLines.push(`- ${ref}: ${value}`);
20202
+ }
20203
+ }
20204
+ if (cellLines.length > 0) {
20205
+ sheetSections.push(`### Aba: ${sheet.name}
20206
+ ${cellLines.join("\n")}`);
20207
+ }
20208
+ }
20209
+ const combined = sheetSections.join("\n\n").trim();
20210
+ if (!combined) {
20211
+ return { ok: false, error: "empty_document" };
20212
+ }
20213
+ return finalizeExtractedStudyText(combined, maxChars);
20214
+ }
20215
+ function getFileExtension(filename) {
20216
+ if (!filename || !filename.includes(".")) return null;
20217
+ const ext = filename.substring(filename.lastIndexOf(".")).toLowerCase();
20218
+ return ext || null;
20219
+ }
20220
+
19888
20221
  // src/commands/validate.ts
19889
20222
  init_guards();
19890
20223
  init_course_state();
@@ -20171,7 +20504,34 @@ var validateCommand = new Command16("validate").description("Validate your solut
20171
20504
  jsonError("file_not_found", { message: `Arquivo n\xE3o encontrado: ${file2}` });
20172
20505
  error(`Arquivo n\xE3o encontrado: ${file2}`);
20173
20506
  }
20174
- solution = fs12.readFileSync(file2, "utf-8");
20507
+ if (path15.extname(file2).toLowerCase() === ".xlsx") {
20508
+ const rawBytes = fs12.readFileSync(file2);
20509
+ if (rawBytes.byteLength === 0 || rawBytes.byteLength > FILE_SIZE_LIMITS.DOCUMENT) {
20510
+ const msg = `Arquivo muito grande ou vazio: ${file2} (m\xE1ximo: 10 MB)`;
20511
+ if (opts.json) jsonError("file_too_large", { message: msg });
20512
+ error(msg);
20513
+ }
20514
+ const extracted = extractStudyDocumentText(
20515
+ path15.basename(file2),
20516
+ rawBytes,
20517
+ STUDY_CHAT_CONTENT_MAX_CHARS
20518
+ );
20519
+ if (!extracted.ok) {
20520
+ const msg = extracted.error === "empty_document" ? `Planilha vazia: ${file2}` : `Arquivo .xlsx inv\xE1lido ou corrompido: ${file2}`;
20521
+ if (opts.json) jsonError("invalid_xlsx", { message: msg });
20522
+ error(msg);
20523
+ }
20524
+ if (extracted.truncated) {
20525
+ process.stderr.write(
20526
+ `\u26A0\uFE0F O conte\xFAdo extra\xEDdo de ${path15.basename(file2)} foi truncado para o limite de ${STUDY_CHAT_CONTENT_MAX_CHARS} caracteres.
20527
+
20528
+ `
20529
+ );
20530
+ }
20531
+ solution = extracted.text;
20532
+ } else {
20533
+ solution = fs12.readFileSync(file2, "utf-8");
20534
+ }
20175
20535
  } else {
20176
20536
  if (opts.json)
20177
20537
  jsonError("no_solution_provided", { message: "Forne\xE7a um arquivo ou use --stdin" });
@@ -22636,7 +22996,10 @@ function renderReport(report) {
22636
22996
  return [
22637
22997
  ...report.errors.map((issue2) => `\u2717 ${issue2.path}: ${issue2.message}`),
22638
22998
  ...report.warnings.map((issue2) => `\u26A0 ${issue2.path}: ${issue2.message}`),
22639
- report.ok ? fillTemplate(copy.validateOk, { lessons: report.lessonCount, bytes: report.payloadBytes }) : fillTemplate(copy.validateFailed, { count: report.errors.length })
22999
+ report.ok ? fillTemplate(copy.validateOk, { lessons: report.lessonCount, bytes: report.payloadBytes }) : fillTemplate(
23000
+ report.errors.length === 1 ? copy.validateFailedOne : copy.validateFailedOther,
23001
+ { count: report.errors.length }
23002
+ )
22640
23003
  ].join("\n");
22641
23004
  }
22642
23005
  function printReport(report, opts) {
@@ -22750,7 +23113,9 @@ creatorCommand.command("pull").description("Bring lessons edited in the portal i
22750
23113
  }
22751
23114
  output(
22752
23115
  [
22753
- fillTemplate(copy.pullDone, { count: outcome.files.length }),
23116
+ fillTemplate(outcome.files.length === 1 ? copy.pullDoneOne : copy.pullDoneOther, {
23117
+ count: outcome.files.length
23118
+ }),
22754
23119
  ...outcome.files.map((file2) => ` ${file2}`)
22755
23120
  ].join("\n"),
22756
23121
  { json: false }
@@ -22761,7 +23126,10 @@ creatorCommand.command("pull").description("Bring lessons edited in the portal i
22761
23126
  });
22762
23127
  function renderAudit(result) {
22763
23128
  const copy = getErrors().creator;
22764
- const lines = [fillTemplate(copy.auditResult, { verdict: result.verdict, score: result.score })];
23129
+ const lines = [
23130
+ fillTemplate(copy.auditResult, { verdict: result.verdict, score: result.score }),
23131
+ copy.formatOnly
23132
+ ];
22765
23133
  if (result.stale) lines.push(copy.auditStale);
22766
23134
  for (const raw2 of result.findings) {
22767
23135
  const finding = raw2 ?? {};
@@ -22813,6 +23181,7 @@ function renderStatus(remote, readiness, url2) {
22813
23181
  score: readiness.report.score ?? "?"
22814
23182
  }) : copy.noAudit
22815
23183
  );
23184
+ if (readiness.pedagogicalReview === "not_run") lines.push(copy.pedagogicalNotRun);
22816
23185
  if (readiness.ready) {
22817
23186
  lines.push(copy.statusReady);
22818
23187
  } else {
@@ -23613,12 +23982,17 @@ function formatGradesMarkdown(attempts) {
23613
23982
  lines.push("", "**Feedback:**", "", a.feedback);
23614
23983
  }
23615
23984
  if (a.solution) {
23616
- lines.push("", "**Solu\xE7\xE3o:**", "", "```", a.solution, "```");
23985
+ const fence2 = fenceFor(a.solution);
23986
+ lines.push("", "**Solu\xE7\xE3o:**", "", fence2, a.solution, fence2);
23617
23987
  }
23618
23988
  });
23619
23989
  lines.push("");
23620
23990
  return lines.join("\n");
23621
23991
  }
23992
+ function fenceFor(text2) {
23993
+ const longest = Math.max(0, ...(text2.match(/`+/g) ?? []).map((run) => run.length));
23994
+ return "`".repeat(Math.max(3, longest + 1));
23995
+ }
23622
23996
  function renderTableRow(cells, widths) {
23623
23997
  return cells.map((cell, idx) => {
23624
23998
  const w = widths[idx] ?? cell.length;
@@ -23673,7 +24047,7 @@ function formatGradesTable(attempts, options = {}) {
23673
24047
  const failed = sorted.length - passed;
23674
24048
  return [
23675
24049
  `Curso: ${title2}`,
23676
- `Total: ${sorted.length} tentativa(s) \u2014 ${passed} aprovada(s), ${failed} reprovada(s)`,
24050
+ `Total: ${sorted.length} ${sorted.length === 1 ? "tentativa" : "tentativas"} \u2014 ${passed} ${passed === 1 ? "aprovada" : "aprovadas"}, ${failed} ${failed === 1 ? "reprovada" : "reprovadas"}`,
23677
24051
  "",
23678
24052
  headerLine,
23679
24053
  sepLine,
@@ -23748,7 +24122,8 @@ function formatLessonReview(attempt, options = {}) {
23748
24122
  lines.push("", "Feedback:", attempt.feedback);
23749
24123
  }
23750
24124
  if (attempt.solution) {
23751
- lines.push("", "Solu\xE7\xE3o submetida:", "```", attempt.solution, "```");
24125
+ const fence2 = fenceFor(attempt.solution);
24126
+ lines.push("", "Solu\xE7\xE3o submetida:", fence2, attempt.solution, fence2);
23752
24127
  }
23753
24128
  return lines.join("\n");
23754
24129
  }