@soltaoverbo/cli 0.2.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.
@@ -0,0 +1,13 @@
1
+
2
+ > @soltaoverbo/cli@0.2.0 build C:\Users\aresn\OneDrive\Documentos\GitHub\verbo\packages\cli
3
+ > tsup
4
+
5
+ CLI Building entry: src/bin.ts
6
+ CLI Using tsconfig: tsconfig.json
7
+ CLI tsup v8.5.1
8
+ CLI Using tsup config: C:\Users\aresn\OneDrive\Documentos\GitHub\verbo\packages\cli\tsup.config.ts
9
+ CLI Target: node20
10
+ CLI Cleaning output folder
11
+ ESM Build start
12
+ ESM dist\bin.js 11.08 KB
13
+ ESM ⚡️ Build success in 26ms
package/dist/bin.js ADDED
@@ -0,0 +1,345 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/bin.ts
4
+ import { execSync } from "child_process";
5
+
6
+ // src/commands/install.ts
7
+ import { existsSync, readFileSync, writeFileSync } from "fs";
8
+ import { homedir } from "os";
9
+ import { join } from "path";
10
+ import { buildTermIndex, loadTerms, SpacedRepetition } from "@soltaoverbo/core";
11
+
12
+ // src/ui.ts
13
+ var R = "\x1B[0m";
14
+ var bold = (s) => `\x1B[1m${s}${R}`;
15
+ var dim = (s) => `\x1B[2m${s}${R}`;
16
+ var green = (s) => `\x1B[32m${s}${R}`;
17
+ var yellow = (s) => `\x1B[33m${s}${R}`;
18
+ var cyan = (s) => `\x1B[36m${s}${R}`;
19
+ function progressBar(pct, width) {
20
+ const filled = Math.round(pct / 100 * width);
21
+ const empty = width - filled;
22
+ return `\x1B[32m${"\u2588".repeat(filled)}\x1B[2m${"\u2591".repeat(empty)}${R}`;
23
+ }
24
+
25
+ // src/commands/install.ts
26
+ var CLAUDE_SETTINGS = join(homedir(), ".claude", "settings.json");
27
+ var VERBO_PREFIX = "verbo \xB7 ";
28
+ function runInstall() {
29
+ if (!existsSync(CLAUDE_SETTINGS)) {
30
+ console.error(`Claude Code n\xE3o encontrado em ${CLAUDE_SETTINGS}`);
31
+ process.exit(1);
32
+ }
33
+ const settings = JSON.parse(readFileSync(CLAUDE_SETTINGS, "utf-8"));
34
+ const sr = new SpacedRepetition();
35
+ const allTerms = buildTermIndex(loadTerms());
36
+ const absorbed = sr.absorbedIds();
37
+ const inProgress = sr.inProgressIds();
38
+ const inProgressTerms = allTerms.filter((t) => inProgress.has(t.id));
39
+ const newTerms = allTerms.filter((t) => !absorbed.has(t.id) && !inProgress.has(t.id));
40
+ const candidates = [...inProgressTerms, ...newTerms].slice(0, 10);
41
+ const verbSpinners = candidates.map((t) => `${VERBO_PREFIX}${t.term} \u2192 ${t.translation}`);
42
+ const existing = (settings.spinnerVerbs?.verbs ?? []).filter(
43
+ (v) => !v.startsWith(VERBO_PREFIX)
44
+ );
45
+ settings.spinnerVerbs = {
46
+ mode: settings.spinnerVerbs?.mode ?? "replace",
47
+ verbs: [...existing, ...verbSpinners]
48
+ };
49
+ writeFileSync(CLAUDE_SETTINGS, JSON.stringify(settings, null, 2), "utf-8");
50
+ console.log();
51
+ console.log(bold(" verbo instalado no Claude Code!"));
52
+ console.log(dim(" " + "\u2500".repeat(42)));
53
+ for (const v of verbSpinners) {
54
+ console.log(` ${green("\u2713")} ${v}`);
55
+ }
56
+ console.log();
57
+ console.log(dim(" Esses termos aparecem enquanto o Claude pensa."));
58
+ console.log(dim(" Rode 'verbo install' novamente para atualizar."));
59
+ console.log();
60
+ }
61
+
62
+ // src/commands/list.ts
63
+ import { loadTerms as loadTerms2, SpacedRepetition as SpacedRepetition2 } from "@soltaoverbo/core";
64
+ var CATEGORIES = ["general", "backend", "frontend", "devops", "data", "ai"];
65
+ function runList(args) {
66
+ const catIdx = args.indexOf("--category");
67
+ const catFilter = catIdx !== -1 ? args[catIdx + 1]?.toLowerCase() : void 0;
68
+ const showAbsorbed = args.includes("--absorbed");
69
+ if (catFilter && !CATEGORIES.includes(catFilter)) {
70
+ console.error(`Categoria inv\xE1lida: "${catFilter}". V\xE1lidas: ${CATEGORIES.join(", ")}`);
71
+ process.exit(1);
72
+ }
73
+ const sr = new SpacedRepetition2();
74
+ const absorbed = sr.absorbedIds();
75
+ let terms = loadTerms2();
76
+ if (catFilter) terms = terms.filter((t) => t.category === catFilter);
77
+ if (!showAbsorbed) terms = terms.filter((t) => !absorbed.has(t.id));
78
+ if (terms.length === 0) {
79
+ const hint = showAbsorbed ? "" : ` ${dim("(use --absorbed para ver absorvidos)")}`;
80
+ console.log(dim(`
81
+ Nenhum termo encontrado.${hint}
82
+ `));
83
+ return;
84
+ }
85
+ const grouped = terms.reduce((acc, t) => {
86
+ ;
87
+ (acc[t.category] ??= []).push(t);
88
+ return acc;
89
+ }, {});
90
+ console.log();
91
+ for (const cat of CATEGORIES) {
92
+ const group = grouped[cat];
93
+ if (!group) continue;
94
+ console.log(bold(` ${cat.toUpperCase()} (${group.length})`));
95
+ console.log(dim(" " + "\u2500".repeat(44)));
96
+ for (const term of group) {
97
+ const tag = absorbed.has(term.id) ? yellow(" \u2713") : "";
98
+ console.log(
99
+ ` ${cyan(term.term.padEnd(22))}${tag} ${green(term.translation)}`
100
+ );
101
+ if (term.explanation) {
102
+ const short = term.explanation.length > 60 ? term.explanation.slice(0, 57) + "..." : term.explanation;
103
+ console.log(` ${"".padEnd(22)} ${dim(short)}`);
104
+ }
105
+ }
106
+ console.log();
107
+ }
108
+ console.log(dim(` Total: ${terms.length} termos
109
+ `));
110
+ }
111
+
112
+ // src/commands/reset.ts
113
+ import { createInterface } from "readline";
114
+ import { SpacedRepetition as SpacedRepetition3 } from "@soltaoverbo/core";
115
+ async function runReset() {
116
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
117
+ await new Promise((resolve) => {
118
+ rl.question(
119
+ `${yellow("\u26A0")} Isso apagar\xE1 todo o hist\xF3rico de aprendizado. Confirmar? ${dim("[s/N]")} `,
120
+ (answer) => {
121
+ rl.close();
122
+ if (answer.trim().toLowerCase() === "s") {
123
+ new SpacedRepetition3().reset();
124
+ console.log(bold("\u2713 Hist\xF3rico resetado."));
125
+ } else {
126
+ console.log(dim("Cancelado."));
127
+ }
128
+ resolve();
129
+ }
130
+ );
131
+ });
132
+ }
133
+
134
+ // src/commands/start.ts
135
+ import { createInterface as createInterface2 } from "readline";
136
+ import { readFileSync as readFileSync2 } from "fs";
137
+ import { extname } from "path";
138
+ import {
139
+ buildTermIndex as buildTermIndex2,
140
+ injectComments,
141
+ loadTerms as loadTerms3,
142
+ SpacedRepetition as SpacedRepetition4
143
+ } from "@soltaoverbo/core";
144
+ var CODE_EXTS = /* @__PURE__ */ new Set([
145
+ ".ts",
146
+ ".tsx",
147
+ ".js",
148
+ ".jsx",
149
+ ".mjs",
150
+ ".cjs",
151
+ ".py",
152
+ ".go",
153
+ ".rs",
154
+ ".java",
155
+ ".kt",
156
+ ".rb",
157
+ ".php",
158
+ ".cs",
159
+ ".swift",
160
+ ".dart",
161
+ ".scala",
162
+ ".ex",
163
+ ".exs",
164
+ ".sh",
165
+ ".bash",
166
+ ".zsh",
167
+ ".fish",
168
+ ".sql",
169
+ ".lua",
170
+ ".c",
171
+ ".cpp",
172
+ ".h",
173
+ ".hpp"
174
+ ]);
175
+ async function runStart(args) {
176
+ const watchIdx = args.indexOf("--watch");
177
+ const watchDir = watchIdx !== -1 ? args[watchIdx + 1] ?? "." : void 0;
178
+ const extIdx = args.indexOf("--ext");
179
+ const extArg = extIdx !== -1 ? args[extIdx + 1] : void 0;
180
+ const extensions = extArg ? new Set(extArg.split(",").map((e) => e.startsWith(".") ? e : `.${e}`)) : CODE_EXTS;
181
+ const sr = new SpacedRepetition4();
182
+ const terms = buildTermIndex2(loadTerms3());
183
+ if (watchDir) {
184
+ await watchMode(watchDir, extensions, sr, terms);
185
+ } else {
186
+ await pipeMode(sr, terms);
187
+ }
188
+ }
189
+ async function pipeMode(sr, terms) {
190
+ const rl = createInterface2({ input: process.stdin, terminal: false });
191
+ const lines = [];
192
+ for await (const line of rl) lines.push(line);
193
+ const seenTerms = sr.absorbedIds();
194
+ const { code, injected } = injectComments(lines.join("\n"), seenTerms, terms);
195
+ process.stdout.write(code + "\n");
196
+ for (const match of injected) sr.markSeen(match.id);
197
+ if (injected.length > 0) {
198
+ process.stderr.write(dim(`
199
+ [verbo] ${injected.length} termo(s) anotado(s)
200
+ `));
201
+ }
202
+ }
203
+ async function watchMode(dir, extensions, sr, terms) {
204
+ const { default: chokidar } = await import("chokidar");
205
+ console.error(bold(`[verbo] Observando ${dir}`) + dim(" \u2014 Ctrl+C para parar"));
206
+ process.stderr.write(renderStatusLine(dir, sr));
207
+ const watcher = chokidar.watch(dir, {
208
+ ignoreInitial: true,
209
+ ignored: /([\\/])(\.git|node_modules|dist|\.turbo)([\\/])/,
210
+ persistent: true,
211
+ awaitWriteFinish: { stabilityThreshold: 150, pollInterval: 50 }
212
+ });
213
+ const handle = (filePath) => {
214
+ if (!extensions.has(extname(filePath))) return;
215
+ let source;
216
+ try {
217
+ source = readFileSync2(filePath, "utf-8");
218
+ } catch {
219
+ return;
220
+ }
221
+ const seenTerms = sr.absorbedIds();
222
+ const { injected } = injectComments(source, seenTerms, terms);
223
+ if (injected.length > 0) {
224
+ process.stderr.write("\r\x1B[2K");
225
+ for (const match of injected) {
226
+ sr.markSeen(match.id);
227
+ logTerm(filePath, match);
228
+ }
229
+ }
230
+ process.stderr.write(renderStatusLine(dir, sr));
231
+ };
232
+ watcher.on("add", handle).on("change", handle);
233
+ await new Promise((resolve) => {
234
+ process.on("SIGINT", () => {
235
+ process.stderr.write(dim("\n[verbo] Encerrando...\n"));
236
+ void watcher.close().then(resolve);
237
+ });
238
+ });
239
+ }
240
+ function renderStatusLine(dir, sr) {
241
+ const s = sr.stats();
242
+ return `\r\x1B[2K ${bold("verbo")} ${dim(dir)} ${dim("\xB7")} ${cyan(String(s.newToday))} hoje ${dim("\xB7")} ${s.total} vistos ${dim("\xB7")} ${green(String(s.absorbed))} absorvidos`;
243
+ }
244
+ function logTerm(filePath, match) {
245
+ const short = filePath.replace(/\\/g, "/").split("/").slice(-2).join("/");
246
+ process.stderr.write(
247
+ `${dim(short)} ${cyan(match.term)} ${dim("\u2192")} ${green(match.translation)}
248
+ ${"".padEnd(short.length + 2)} ${dim(match.explanation)}
249
+ `
250
+ );
251
+ }
252
+
253
+ // src/commands/stats.ts
254
+ import { SpacedRepetition as SpacedRepetition5 } from "@soltaoverbo/core";
255
+ function runStats(args = []) {
256
+ const sr = new SpacedRepetition5();
257
+ const s = sr.stats();
258
+ if (args.includes("--short")) {
259
+ process.stdout.write(`verbo \xB7 ${s.newToday} hoje \xB7 ${s.total} vistos \xB7 ${s.absorbed} abs
260
+ `);
261
+ return;
262
+ }
263
+ console.log();
264
+ console.log(bold(" verbo \u2014 progresso de aprendizado"));
265
+ console.log(dim(" " + "\u2500".repeat(38)));
266
+ console.log(` ${cyan("Total visto")}: ${bold(String(s.total))}`);
267
+ console.log(` ${green("Absorvidos")}: ${bold(String(s.absorbed))}`);
268
+ console.log(` ${yellow("Em progresso")}: ${bold(String(s.inProgress))}`);
269
+ console.log(` ${cyan("Novos hoje")}: ${bold(String(s.newToday))}`);
270
+ if (s.total > 0) {
271
+ const pct = Math.round(s.absorbed / s.total * 100);
272
+ console.log();
273
+ console.log(` ${progressBar(pct, 32)} ${pct}% absorvido`);
274
+ }
275
+ console.log();
276
+ }
277
+
278
+ // src/bin.ts
279
+ if (process.platform === "win32") {
280
+ try {
281
+ execSync("chcp 65001", { stdio: "ignore" });
282
+ } catch {
283
+ }
284
+ }
285
+ var VERSION = "0.1.0";
286
+ var [, , sub, ...rest] = process.argv;
287
+ function help() {
288
+ console.log(`
289
+ ${bold("verbo")} v${VERSION} \u2014 aprenda ingl\xEAs t\xE9cnico passivamente
290
+
291
+ ${bold("USO")}
292
+ verbo <comando> [op\xE7\xF5es]
293
+
294
+ ${bold("COMANDOS")}
295
+ ${bold("start")} Modo pipe: l\xEA stdin e injeta coment\xE1rios em pt-BR
296
+ ${bold("start --watch")} ${dim("<dir>")} Observa diret\xF3rio e loga termos novos no terminal
297
+ ${bold("stats")} Mostra seu progresso de aprendizado
298
+ ${bold("stats --short")} Sa\xEDda compacta para status bars
299
+ ${bold("install")} Injeta termos no Claude Code (barra de pensamento)
300
+ ${bold("reset")} Limpa todo o hist\xF3rico
301
+ ${bold("list")} Lista todos os termos dispon\xEDveis
302
+ ${bold("list --category")} ${dim("<cat>")} Filtra por: general, backend, frontend, devops, data
303
+ ${bold("list --absorbed")} Inclui termos j\xE1 absorvidos
304
+
305
+ ${bold("EXEMPLOS")}
306
+ cat handler.ts | verbo start
307
+ verbo start --watch ./src
308
+ verbo stats
309
+ verbo install
310
+ verbo list --category backend
311
+
312
+ ${bold("OP\xC7\xD5ES GLOBAIS")}
313
+ ${bold("--version")}, ${bold("-v")} Vers\xE3o atual
314
+ ${bold("--help")}, ${bold("-h")} Esta ajuda
315
+ `);
316
+ }
317
+ switch (sub) {
318
+ case "start":
319
+ await runStart(rest);
320
+ break;
321
+ case "stats":
322
+ runStats(rest);
323
+ break;
324
+ case "install":
325
+ runInstall();
326
+ break;
327
+ case "reset":
328
+ await runReset();
329
+ break;
330
+ case "list":
331
+ runList(rest);
332
+ break;
333
+ case "--version":
334
+ case "-v":
335
+ console.log(VERSION);
336
+ break;
337
+ case "--help":
338
+ case "-h":
339
+ case void 0:
340
+ help();
341
+ break;
342
+ default:
343
+ console.error(`Comando desconhecido: "${sub}". Use "verbo --help" para ver os comandos.`);
344
+ process.exit(1);
345
+ }
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@soltaoverbo/cli",
3
+ "version": "0.2.0",
4
+ "description": "CLI para verbo.dev — aprenda inglês técnico passivamente",
5
+ "bin": {
6
+ "verbo": "./dist/bin.js"
7
+ },
8
+ "type": "module",
9
+ "scripts": {
10
+ "build": "tsup",
11
+ "dev": "tsup --watch",
12
+ "lint": "eslint src"
13
+ },
14
+ "dependencies": {
15
+ "@soltaoverbo/core": "workspace:*",
16
+ "chokidar": "^3.6.0"
17
+ },
18
+ "devDependencies": {
19
+ "tsup": "^8.3.5",
20
+ "@types/node": "^22.10.1"
21
+ }
22
+ }
package/src/bin.ts ADDED
@@ -0,0 +1,75 @@
1
+ import { execSync } from "node:child_process"
2
+ import { runInstall } from "./commands/install.ts"
3
+ import { runList } from "./commands/list.ts"
4
+ import { runReset } from "./commands/reset.ts"
5
+ import { runStart } from "./commands/start.ts"
6
+ import { runStats } from "./commands/stats.ts"
7
+ import { bold, dim } from "./ui.ts"
8
+
9
+ if (process.platform === "win32") {
10
+ try { execSync("chcp 65001", { stdio: "ignore" }) } catch {}
11
+ }
12
+
13
+ const VERSION = "0.1.0"
14
+ const [, , sub, ...rest] = process.argv
15
+
16
+ function help(): void {
17
+ console.log(`
18
+ ${bold("verbo")} v${VERSION} — aprenda inglês técnico passivamente
19
+
20
+ ${bold("USO")}
21
+ verbo <comando> [opções]
22
+
23
+ ${bold("COMANDOS")}
24
+ ${bold("start")} Modo pipe: lê stdin e injeta comentários em pt-BR
25
+ ${bold("start --watch")} ${dim("<dir>")} Observa diretório e loga termos novos no terminal
26
+ ${bold("stats")} Mostra seu progresso de aprendizado
27
+ ${bold("stats --short")} Saída compacta para status bars
28
+ ${bold("install")} Injeta termos no Claude Code (barra de pensamento)
29
+ ${bold("reset")} Limpa todo o histórico
30
+ ${bold("list")} Lista todos os termos disponíveis
31
+ ${bold("list --category")} ${dim("<cat>")} Filtra por: general, backend, frontend, devops, data
32
+ ${bold("list --absorbed")} Inclui termos já absorvidos
33
+
34
+ ${bold("EXEMPLOS")}
35
+ cat handler.ts | verbo start
36
+ verbo start --watch ./src
37
+ verbo stats
38
+ verbo install
39
+ verbo list --category backend
40
+
41
+ ${bold("OPÇÕES GLOBAIS")}
42
+ ${bold("--version")}, ${bold("-v")} Versão atual
43
+ ${bold("--help")}, ${bold("-h")} Esta ajuda
44
+ `)
45
+ }
46
+
47
+ switch (sub) {
48
+ case "start":
49
+ await runStart(rest)
50
+ break
51
+ case "stats":
52
+ runStats(rest)
53
+ break
54
+ case "install":
55
+ runInstall()
56
+ break
57
+ case "reset":
58
+ await runReset()
59
+ break
60
+ case "list":
61
+ runList(rest)
62
+ break
63
+ case "--version":
64
+ case "-v":
65
+ console.log(VERSION)
66
+ break
67
+ case "--help":
68
+ case "-h":
69
+ case undefined:
70
+ help()
71
+ break
72
+ default:
73
+ console.error(`Comando desconhecido: "${sub}". Use "verbo --help" para ver os comandos.`)
74
+ process.exit(1)
75
+ }
@@ -0,0 +1,51 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs"
2
+ import { homedir } from "node:os"
3
+ import { join } from "node:path"
4
+ import { buildTermIndex, loadTerms, SpacedRepetition } from "@soltaoverbo/core"
5
+ import { bold, dim, green } from "../ui.ts"
6
+
7
+ const CLAUDE_SETTINGS = join(homedir(), ".claude", "settings.json")
8
+ const VERBO_PREFIX = "verbo · "
9
+
10
+ export function runInstall(): void {
11
+ if (!existsSync(CLAUDE_SETTINGS)) {
12
+ console.error(`Claude Code não encontrado em ${CLAUDE_SETTINGS}`)
13
+ process.exit(1)
14
+ }
15
+
16
+ const settings = JSON.parse(readFileSync(CLAUDE_SETTINGS, "utf-8"))
17
+
18
+ const sr = new SpacedRepetition()
19
+ const allTerms = buildTermIndex(loadTerms())
20
+ const absorbed = sr.absorbedIds()
21
+ const inProgress = sr.inProgressIds()
22
+
23
+ // Prefer terms already in progress (reforço), complete com novos se precisar
24
+ const inProgressTerms = allTerms.filter((t) => inProgress.has(t.id))
25
+ const newTerms = allTerms.filter((t) => !absorbed.has(t.id) && !inProgress.has(t.id))
26
+ const candidates = [...inProgressTerms, ...newTerms].slice(0, 10)
27
+
28
+ const verbSpinners = candidates.map((t) => `${VERBO_PREFIX}${t.term} → ${t.translation}`)
29
+
30
+ const existing: string[] = (settings.spinnerVerbs?.verbs ?? []).filter(
31
+ (v: string) => !v.startsWith(VERBO_PREFIX),
32
+ )
33
+
34
+ settings.spinnerVerbs = {
35
+ mode: settings.spinnerVerbs?.mode ?? "replace",
36
+ verbs: [...existing, ...verbSpinners],
37
+ }
38
+
39
+ writeFileSync(CLAUDE_SETTINGS, JSON.stringify(settings, null, 2), "utf-8")
40
+
41
+ console.log()
42
+ console.log(bold(" verbo instalado no Claude Code!"))
43
+ console.log(dim(" " + "─".repeat(42)))
44
+ for (const v of verbSpinners) {
45
+ console.log(` ${green("✓")} ${v}`)
46
+ }
47
+ console.log()
48
+ console.log(dim(" Esses termos aparecem enquanto o Claude pensa."))
49
+ console.log(dim(" Rode 'verbo install' novamente para atualizar."))
50
+ console.log()
51
+ }
@@ -0,0 +1,59 @@
1
+ import { loadTerms, SpacedRepetition } from "@soltaoverbo/core"
2
+ import { bold, cyan, dim, green, yellow } from "../ui.ts"
3
+
4
+ const CATEGORIES = ["general", "backend", "frontend", "devops", "data", "ai"]
5
+
6
+ export function runList(args: string[]): void {
7
+ const catIdx = args.indexOf("--category")
8
+ const catFilter = catIdx !== -1 ? args[catIdx + 1]?.toLowerCase() : undefined
9
+ const showAbsorbed = args.includes("--absorbed")
10
+
11
+ if (catFilter && !CATEGORIES.includes(catFilter)) {
12
+ console.error(`Categoria inválida: "${catFilter}". Válidas: ${CATEGORIES.join(", ")}`)
13
+ process.exit(1)
14
+ }
15
+
16
+ const sr = new SpacedRepetition()
17
+ const absorbed = sr.absorbedIds()
18
+
19
+ let terms = loadTerms()
20
+ if (catFilter) terms = terms.filter((t) => t.category === catFilter)
21
+ if (!showAbsorbed) terms = terms.filter((t) => !absorbed.has(t.id))
22
+
23
+ if (terms.length === 0) {
24
+ const hint = showAbsorbed ? "" : ` ${dim("(use --absorbed para ver absorvidos)")}`
25
+ console.log(dim(`\n Nenhum termo encontrado.${hint}\n`))
26
+ return
27
+ }
28
+
29
+ // group by category (Node 20 compat — no Map.groupBy)
30
+ const grouped = terms.reduce<Record<string, typeof terms>>((acc, t) => {
31
+ ;(acc[t.category] ??= []).push(t)
32
+ return acc
33
+ }, {})
34
+
35
+ console.log()
36
+ for (const cat of CATEGORIES) {
37
+ const group = grouped[cat]
38
+ if (!group) continue
39
+
40
+ console.log(bold(` ${cat.toUpperCase()} (${group.length})`))
41
+ console.log(dim(" " + "─".repeat(44)))
42
+
43
+ for (const term of group) {
44
+ const tag = absorbed.has(term.id) ? yellow(" ✓") : ""
45
+ console.log(
46
+ ` ${cyan(term.term.padEnd(22))}${tag} ${green(term.translation)}`,
47
+ )
48
+ if (term.explanation) {
49
+ const short = term.explanation.length > 60
50
+ ? term.explanation.slice(0, 57) + "..."
51
+ : term.explanation
52
+ console.log(` ${"".padEnd(22)} ${dim(short)}`)
53
+ }
54
+ }
55
+ console.log()
56
+ }
57
+
58
+ console.log(dim(` Total: ${terms.length} termos\n`))
59
+ }
@@ -0,0 +1,23 @@
1
+ import { createInterface } from "node:readline"
2
+ import { SpacedRepetition } from "@soltaoverbo/core"
3
+ import { bold, dim, yellow } from "../ui.ts"
4
+
5
+ export async function runReset(): Promise<void> {
6
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
7
+
8
+ await new Promise<void>((resolve) => {
9
+ rl.question(
10
+ `${yellow("⚠")} Isso apagará todo o histórico de aprendizado. Confirmar? ${dim("[s/N]")} `,
11
+ (answer) => {
12
+ rl.close()
13
+ if (answer.trim().toLowerCase() === "s") {
14
+ new SpacedRepetition().reset()
15
+ console.log(bold("✓ Histórico resetado."))
16
+ } else {
17
+ console.log(dim("Cancelado."))
18
+ }
19
+ resolve()
20
+ },
21
+ )
22
+ })
23
+ }
@@ -0,0 +1,133 @@
1
+ import { createInterface } from "node:readline"
2
+ import { readFileSync } from "node:fs"
3
+ import { extname } from "node:path"
4
+ import {
5
+ buildTermIndex,
6
+ injectComments,
7
+ loadTerms,
8
+ SpacedRepetition,
9
+ type TermMatch,
10
+ } from "@soltaoverbo/core"
11
+ import { bold, cyan, dim, green } from "../ui.ts"
12
+
13
+ const CODE_EXTS = new Set([
14
+ ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
15
+ ".py", ".go", ".rs", ".java", ".kt", ".rb", ".php", ".cs",
16
+ ".swift", ".dart", ".scala", ".ex", ".exs",
17
+ ".sh", ".bash", ".zsh", ".fish",
18
+ ".sql", ".lua", ".c", ".cpp", ".h", ".hpp",
19
+ ])
20
+
21
+ export async function runStart(args: string[]): Promise<void> {
22
+ const watchIdx = args.indexOf("--watch")
23
+ const watchDir = watchIdx !== -1 ? (args[watchIdx + 1] ?? ".") : undefined
24
+ const extIdx = args.indexOf("--ext")
25
+ const extArg = extIdx !== -1 ? args[extIdx + 1] : undefined
26
+ const extensions = extArg
27
+ ? new Set(extArg.split(",").map((e) => (e.startsWith(".") ? e : `.${e}`)))
28
+ : CODE_EXTS
29
+
30
+ const sr = new SpacedRepetition()
31
+ const terms = buildTermIndex(loadTerms())
32
+
33
+ if (watchDir) {
34
+ await watchMode(watchDir, extensions, sr, terms)
35
+ } else {
36
+ await pipeMode(sr, terms)
37
+ }
38
+ }
39
+
40
+ // ─── Pipe mode ──────────────────────────────────────────────────────────────
41
+ // Usage: cat file.ts | verbo start
42
+ // Reads stdin, injects pt-BR comments, writes to stdout.
43
+
44
+ async function pipeMode(
45
+ sr: SpacedRepetition,
46
+ terms: ReturnType<typeof buildTermIndex>,
47
+ ): Promise<void> {
48
+ const rl = createInterface({ input: process.stdin, terminal: false })
49
+ const lines: string[] = []
50
+ for await (const line of rl) lines.push(line)
51
+
52
+ const seenTerms = sr.absorbedIds()
53
+ const { code, injected } = injectComments(lines.join("\n"), seenTerms, terms)
54
+
55
+ process.stdout.write(code + "\n")
56
+
57
+ for (const match of injected) sr.markSeen(match.id)
58
+
59
+ if (injected.length > 0) {
60
+ process.stderr.write(dim(`\n[verbo] ${injected.length} termo(s) anotado(s)\n`))
61
+ }
62
+ }
63
+
64
+ // ─── Watch mode ──────────────────────────────────────────────────────────────
65
+ // Usage: verbo start --watch ./src
66
+ // Watches a directory; logs newly detected terms to stderr without touching files.
67
+
68
+ async function watchMode(
69
+ dir: string,
70
+ extensions: Set<string>,
71
+ sr: SpacedRepetition,
72
+ terms: ReturnType<typeof buildTermIndex>,
73
+ ): Promise<void> {
74
+ const { default: chokidar } = await import("chokidar")
75
+
76
+ console.error(bold(`[verbo] Observando ${dir}`) + dim(" — Ctrl+C para parar"))
77
+ process.stderr.write(renderStatusLine(dir, sr))
78
+
79
+ const watcher = chokidar.watch(dir, {
80
+ ignoreInitial: true,
81
+ ignored: /([\\/])(\.git|node_modules|dist|\.turbo)([\\/])/,
82
+ persistent: true,
83
+ awaitWriteFinish: { stabilityThreshold: 150, pollInterval: 50 },
84
+ })
85
+
86
+ const handle = (filePath: string) => {
87
+ if (!extensions.has(extname(filePath))) return
88
+ let source: string
89
+ try { source = readFileSync(filePath, "utf-8") } catch { return }
90
+
91
+ const seenTerms = sr.absorbedIds()
92
+ const { injected } = injectComments(source, seenTerms, terms)
93
+
94
+ if (injected.length > 0) {
95
+ process.stderr.write("\r\x1b[2K")
96
+ for (const match of injected) {
97
+ sr.markSeen(match.id)
98
+ logTerm(filePath, match)
99
+ }
100
+ }
101
+
102
+ process.stderr.write(renderStatusLine(dir, sr))
103
+ }
104
+
105
+ watcher.on("add", handle).on("change", handle)
106
+
107
+ await new Promise<void>((resolve) => {
108
+ process.on("SIGINT", () => {
109
+ process.stderr.write(dim("\n[verbo] Encerrando...\n"))
110
+ void watcher.close().then(resolve)
111
+ })
112
+ })
113
+ }
114
+
115
+ function renderStatusLine(dir: string, sr: SpacedRepetition): string {
116
+ const s = sr.stats()
117
+ return (
118
+ `\r\x1b[2K` +
119
+ ` ${bold("verbo")} ${dim(dir)} ${dim("·")}` +
120
+ ` ${cyan(String(s.newToday))} hoje` +
121
+ ` ${dim("·")} ${s.total} vistos` +
122
+ ` ${dim("·")} ${green(String(s.absorbed))} absorvidos`
123
+ )
124
+ }
125
+
126
+ function logTerm(filePath: string, match: TermMatch): void {
127
+ // Show only last 2 path segments to keep lines short
128
+ const short = filePath.replace(/\\/g, "/").split("/").slice(-2).join("/")
129
+ process.stderr.write(
130
+ `${dim(short)} ${cyan(match.term)} ${dim("→")} ${green(match.translation)}\n` +
131
+ `${"".padEnd(short.length + 2)} ${dim(match.explanation)}\n`,
132
+ )
133
+ }
@@ -0,0 +1,28 @@
1
+ import { SpacedRepetition } from "@soltaoverbo/core"
2
+ import { bold, cyan, dim, green, progressBar, yellow } from "../ui.ts"
3
+
4
+ export function runStats(args: string[] = []): void {
5
+ const sr = new SpacedRepetition()
6
+ const s = sr.stats()
7
+
8
+ if (args.includes("--short")) {
9
+ process.stdout.write(`verbo · ${s.newToday} hoje · ${s.total} vistos · ${s.absorbed} abs\n`)
10
+ return
11
+ }
12
+
13
+ console.log()
14
+ console.log(bold(" verbo — progresso de aprendizado"))
15
+ console.log(dim(" " + "─".repeat(38)))
16
+ console.log(` ${cyan("Total visto")}: ${bold(String(s.total))}`)
17
+ console.log(` ${green("Absorvidos")}: ${bold(String(s.absorbed))}`)
18
+ console.log(` ${yellow("Em progresso")}: ${bold(String(s.inProgress))}`)
19
+ console.log(` ${cyan("Novos hoje")}: ${bold(String(s.newToday))}`)
20
+
21
+ if (s.total > 0) {
22
+ const pct = Math.round((s.absorbed / s.total) * 100)
23
+ console.log()
24
+ console.log(` ${progressBar(pct, 32)} ${pct}% absorvido`)
25
+ }
26
+
27
+ console.log()
28
+ }
package/src/ui.ts ADDED
@@ -0,0 +1,14 @@
1
+ // ANSI helpers — thin wrappers so we never need chalk
2
+ const R = "\x1b[0m"
3
+
4
+ export const bold = (s: string) => `\x1b[1m${s}${R}`
5
+ export const dim = (s: string) => `\x1b[2m${s}${R}`
6
+ export const green = (s: string) => `\x1b[32m${s}${R}`
7
+ export const yellow = (s: string) => `\x1b[33m${s}${R}`
8
+ export const cyan = (s: string) => `\x1b[36m${s}${R}`
9
+
10
+ export function progressBar(pct: number, width: number): string {
11
+ const filled = Math.round((pct / 100) * width)
12
+ const empty = width - filled
13
+ return `\x1b[32m${"█".repeat(filled)}\x1b[2m${"░".repeat(empty)}${R}`
14
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src"]
8
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,13 @@
1
+ import { defineConfig } from "tsup"
2
+
3
+ export default defineConfig({
4
+ entry: ["src/bin.ts"],
5
+ format: ["esm"],
6
+ target: "node20",
7
+ outDir: "dist",
8
+ clean: true,
9
+ dts: false,
10
+ // shebang injected by tsup banner so chmod +x works after install
11
+ banner: { js: "#!/usr/bin/env node" },
12
+ external: ["chokidar", "@soltaoverbo/core"],
13
+ })