@memoring/cli 0.0.0-stage → 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 CHANGED
@@ -1,3 +1,99 @@
1
- # Temporary Holding Version
1
+ # @memoring/cli
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ CLI do [Memoring](https://memoring.com.br) — envie flashcards e notas gerados por IA para a sua caixa de revisão, direto do terminal.
4
+
5
+ A CLI não tem LLM própria: quem lê o material e gera os flashcards é o seu agente de IA (Kimi Code, Claude Code, etc.). A CLI cuida da parte mecânica — autenticar, expor o contrato JSON e enviar o resultado para o app, onde você revisa e aceita cada item.
6
+
7
+ ## Install
8
+
9
+ ```
10
+ npm install -g @memoring/cli
11
+ ```
12
+
13
+ ## Quickstart
14
+
15
+ Autentique (abre o browser — sem senha no terminal):
16
+
17
+ ```
18
+ memoring auth login
19
+ ```
20
+
21
+ Peça ao seu agente de IA:
22
+
23
+ > use a cli do memoring pra criar flashcards dos materiais na pasta ./apostilas
24
+
25
+ Ou faça na mão:
26
+
27
+ ```
28
+ memoring stage schema # o contrato JSON dos itens
29
+ memoring stage create --file itens.json # envia o lote para a caixa de revisão
30
+ memoring inbox list # o que está pendente
31
+ memoring inbox accept-all # aceita tudo (entra na fila FSRS)
32
+ ```
33
+
34
+ Os itens enviados nascem **PENDING_REVIEW** — nada entra nas suas listagens nem na fila de revisão espaçada até você aceitar em [memoring.com.br/cards/inbox](https://memoring.com.br/cards/inbox) ou via `memoring inbox accept`.
35
+
36
+ ## Como funciona
37
+
38
+ 1. **Você** roda `memoring auth login` uma vez (device flow do Keycloak — o token fica em `~/.config/memoring/config.json`, ou `%APPDATA%\memoring` no Windows).
39
+ 2. **O agente** lê seus PDFs/textos/anotações, gera os itens no contrato (`memoring stage schema`) e envia com `memoring stage create`.
40
+ 3. **Você** revisa e aceita no app (ou pelo terminal). FLASHCARDs e ONE_SHOTs aceitos entram na fila de revisão espaçada FSRS.
41
+
42
+ ## Commands
43
+
44
+ | Command | Purpose |
45
+ | --- | --- |
46
+ | `memoring auth` | login / logout / status |
47
+ | `memoring stage` | schema (o contrato) / create (envia o lote, `--file` ou stdin, `--replace` esvazia a caixa antes) |
48
+ | `memoring inbox` | list / accept `<id>` / accept-all / discard `<id>` |
49
+ | `memoring version` | versão da CLI |
50
+
51
+ ## Flags
52
+
53
+ | Flag | Purpose |
54
+ | --- | --- |
55
+ | `--json` | saída legível por máquina (para agentes e pipes) |
56
+ | `--no-color` | desativa cores |
57
+ | `--no-open` | (auth login) não abre o browser automaticamente |
58
+ | `--api-base` | sobrescreve a URL da API |
59
+
60
+ Exemplo de pipeline:
61
+
62
+ ```
63
+ memoring inbox list --json | jq -r '.[].id' | xargs -n1 memoring inbox discard
64
+ ```
65
+
66
+ ## O contrato (`memoring stage schema`)
67
+
68
+ Cada item do lote (1 a 50, all-or-nothing):
69
+
70
+ ```json
71
+ {
72
+ "type": "FLASHCARD",
73
+ "title": "CF/88 — objetivos fundamentais",
74
+ "front": "Quais são os quatro objetivos fundamentais da República (art. 3º da CF/88)?",
75
+ "back": "I — construir uma sociedade livre, justa e solidária; II — garantir o desenvolvimento nacional; III — erradicar a pobreza e reduzir as desigualdades; IV — promover o bem de todos.",
76
+ "subjectName": "Direito Constitucional",
77
+ "tags": ["cf88", "art-3"],
78
+ "source": "Apostila constitucional.pdf"
79
+ }
80
+ ```
81
+
82
+ - `type`: `NORMAL` (nota, exige `content` em markdown), `FLASHCARD` ou `ONE_SHOT` (exigem `front` + `back`).
83
+ - `source`: origem do material — default `"Importação assistida (CLI)"`.
84
+
85
+ ## Para agentes de IA
86
+
87
+ Este pacote inclui uma skill em `skills/memoring-cli/SKILL.md` com o fluxo completo (auth → schema → gerar → stage → avisar o usuário). Aponte o agente para ela, ou simplesmente peça "use a cli do memoring" — os comandos são auto-descritivos (`--help` em todos).
88
+
89
+ ## Dev (backend local)
90
+
91
+ ```
92
+ MEMORING_API_BASE=http://localhost:8085/api \
93
+ MEMORING_KEYCLOAK_URL=http://localhost:8080 \
94
+ memoring auth login
95
+ ```
96
+
97
+ Variáveis: `MEMORING_API_BASE`, `MEMORING_KEYCLOAK_URL`, `MEMORING_KC_REALM`, `MEMORING_KC_CLIENT`, `MEMORING_CONFIG_HOME` (isola o config.json).
98
+
99
+ Build a partir do repo: `cd cli && npm install && npm run build && node dist/main.js --help`.
package/dist/api.js ADDED
@@ -0,0 +1,36 @@
1
+ import { getAccessToken } from './auth.js';
2
+ import { loadConfig } from './config.js';
3
+ export class ApiError extends Error {
4
+ status;
5
+ body;
6
+ constructor(status, body) {
7
+ super(`HTTP ${status}: ${body}`);
8
+ this.status = status;
9
+ this.body = body;
10
+ }
11
+ }
12
+ /** fetch autenticado contra a API do Memoring; em 401, força refresh e tenta uma vez mais. */
13
+ export async function apiFetch(path, options = {}) {
14
+ const cfg = loadConfig();
15
+ const url = `${cfg.apiBase}${path}`;
16
+ const doFetch = async (token) => fetch(url, {
17
+ method: options.method ?? 'GET',
18
+ headers: {
19
+ Authorization: `Bearer ${token}`,
20
+ ...(options.body !== undefined ? { 'Content-Type': 'application/json' } : {}),
21
+ },
22
+ body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
23
+ });
24
+ let res = await doFetch(await getAccessToken());
25
+ if (res.status === 401) {
26
+ res = await doFetch(await getAccessToken({ forceRefresh: true }));
27
+ }
28
+ if (res.status === 204) {
29
+ return null;
30
+ }
31
+ const text = await res.text();
32
+ if (!res.ok) {
33
+ throw new ApiError(res.status, text);
34
+ }
35
+ return text ? JSON.parse(text) : null;
36
+ }
package/dist/args.js ADDED
@@ -0,0 +1,36 @@
1
+ const BOOLEAN_FLAGS = new Set(['json', 'no-color', 'replace', 'help', 'version', 'no-open']);
2
+ export function parseArgs(argv) {
3
+ const positional = [];
4
+ const flags = {};
5
+ for (let i = 0; i < argv.length; i++) {
6
+ const arg = argv[i];
7
+ if (arg.startsWith('--')) {
8
+ const eq = arg.indexOf('=');
9
+ if (eq !== -1) {
10
+ flags[arg.slice(2, eq)] = arg.slice(eq + 1);
11
+ continue;
12
+ }
13
+ const name = arg.slice(2);
14
+ if (BOOLEAN_FLAGS.has(name)) {
15
+ flags[name] = true;
16
+ continue;
17
+ }
18
+ const next = argv[i + 1];
19
+ if (next !== undefined && !next.startsWith('--')) {
20
+ flags[name] = next;
21
+ i++;
22
+ }
23
+ else {
24
+ flags[name] = true;
25
+ }
26
+ }
27
+ else {
28
+ positional.push(arg);
29
+ }
30
+ }
31
+ return { _: positional, flags };
32
+ }
33
+ export function stringFlag(flags, name) {
34
+ const value = flags[name];
35
+ return typeof value === 'string' ? value : undefined;
36
+ }
package/dist/auth.js ADDED
@@ -0,0 +1,153 @@
1
+ import { exec } from 'node:child_process';
2
+ import { loadConfig, saveSession, saveTokens } from './config.js';
3
+ import { bold, cyan, dim, fail } from './output.js';
4
+ const EXPIRY_MARGIN_MS = 30_000;
5
+ const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
6
+ function realmBase(cfg) {
7
+ return `${cfg.keycloakUrl}/realms/${cfg.realm}`;
8
+ }
9
+ function deviceUrl(cfg) {
10
+ return `${realmBase(cfg)}/protocol/openid-connect/auth/device`;
11
+ }
12
+ function tokenUrl(cfg) {
13
+ return `${realmBase(cfg)}/protocol/openid-connect/token`;
14
+ }
15
+ function sleep(ms) {
16
+ return new Promise((resolve) => setTimeout(resolve, ms));
17
+ }
18
+ function decodeJwtEmail(token) {
19
+ try {
20
+ const payload = token.split('.')[1];
21
+ if (!payload) {
22
+ return undefined;
23
+ }
24
+ const json = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
25
+ return (json.email ?? json.preferred_username);
26
+ }
27
+ catch {
28
+ return undefined;
29
+ }
30
+ }
31
+ function openBrowser(url) {
32
+ const command = process.platform === 'win32'
33
+ ? `start "" "${url}"`
34
+ : process.platform === 'darwin'
35
+ ? `open "${url}"`
36
+ : `xdg-open "${url}"`;
37
+ exec(command, () => {
38
+ // melhor esforço — a URL já está impressa no terminal
39
+ });
40
+ }
41
+ async function requestToken(cfg, params) {
42
+ return fetch(tokenUrl(cfg), {
43
+ method: 'POST',
44
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
45
+ body: new URLSearchParams({ client_id: cfg.clientId, ...params }),
46
+ });
47
+ }
48
+ /** Device Authorization Grant (RFC 8628): mostra o código, abre o browser e faz poll até aprovar. */
49
+ export async function deviceLogin(options = {}) {
50
+ const cfg = loadConfig();
51
+ const res = await fetch(deviceUrl(cfg), {
52
+ method: 'POST',
53
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
54
+ body: new URLSearchParams({ client_id: cfg.clientId, scope: 'openid profile email' }),
55
+ });
56
+ if (!res.ok) {
57
+ fail(`não foi possível iniciar o login (${res.status}): ${await res.text()}`);
58
+ }
59
+ const device = (await res.json());
60
+ process.stdout.write('\nPara entrar no Memoring, acesse:\n\n');
61
+ process.stdout.write(` ${cyan(device.verification_uri)}\n\n`);
62
+ process.stdout.write(`e informe o código: ${bold(device.user_code)}\n`);
63
+ if (device.verification_uri_complete) {
64
+ process.stdout.write(dim(`\n(ou abra direto: ${device.verification_uri_complete})\n`));
65
+ if (!options.noOpen) {
66
+ openBrowser(device.verification_uri_complete);
67
+ }
68
+ }
69
+ process.stdout.write(dim('\nAguardando aprovação…\n'));
70
+ let intervalMs = (device.interval ?? 5) * 1000;
71
+ const deadline = Date.now() + device.expires_in * 1000;
72
+ for (;;) {
73
+ if (Date.now() >= deadline) {
74
+ fail('o código expirou — rode `memoring auth login` de novo.');
75
+ }
76
+ await sleep(intervalMs);
77
+ const poll = await requestToken(cfg, { grant_type: DEVICE_GRANT, device_code: device.device_code });
78
+ if (poll.ok) {
79
+ const tokens = (await poll.json());
80
+ const email = decodeJwtEmail(tokens.access_token);
81
+ saveSession({
82
+ keycloakUrl: cfg.keycloakUrl,
83
+ realm: cfg.realm,
84
+ clientId: cfg.clientId,
85
+ apiBase: cfg.apiBase,
86
+ }, {
87
+ accessToken: tokens.access_token,
88
+ refreshToken: tokens.refresh_token,
89
+ accessTokenExpiresAt: Date.now() + tokens.expires_in * 1000,
90
+ email,
91
+ });
92
+ return email;
93
+ }
94
+ const err = (await poll.json().catch(() => ({})));
95
+ switch (err.error) {
96
+ case 'authorization_pending':
97
+ continue;
98
+ case 'slow_down':
99
+ intervalMs += 5000;
100
+ continue;
101
+ case 'access_denied':
102
+ fail('login negado no navegador.');
103
+ break;
104
+ case 'expired_token':
105
+ fail('o código expirou — rode `memoring auth login` de novo.');
106
+ break;
107
+ default:
108
+ fail(`falha no login: ${err.error_description ?? err.error ?? `HTTP ${poll.status}`}`);
109
+ }
110
+ }
111
+ }
112
+ /** Access token válido — renova via refresh token quando perto de expirar. */
113
+ export async function getAccessToken(options = {}) {
114
+ const cfg = loadConfig();
115
+ if (!options.forceRefresh &&
116
+ cfg.accessToken &&
117
+ cfg.accessTokenExpiresAt &&
118
+ cfg.accessTokenExpiresAt > Date.now() + EXPIRY_MARGIN_MS) {
119
+ return cfg.accessToken;
120
+ }
121
+ if (!cfg.refreshToken) {
122
+ fail('você não está autenticado. Rode `memoring auth login`.');
123
+ }
124
+ const res = await requestToken(cfg, { grant_type: 'refresh_token', refresh_token: cfg.refreshToken });
125
+ if (!res.ok) {
126
+ fail('sessão expirada. Rode `memoring auth login`.');
127
+ }
128
+ const tokens = (await res.json());
129
+ saveTokens({
130
+ accessToken: tokens.access_token,
131
+ refreshToken: tokens.refresh_token ?? cfg.refreshToken,
132
+ accessTokenExpiresAt: Date.now() + tokens.expires_in * 1000,
133
+ email: decodeJwtEmail(tokens.access_token) ?? cfg.email,
134
+ });
135
+ return tokens.access_token;
136
+ }
137
+ /** Logout no Keycloak (melhor esforço) — revoga o refresh token. */
138
+ export async function serverLogout() {
139
+ const cfg = loadConfig();
140
+ if (!cfg.refreshToken) {
141
+ return;
142
+ }
143
+ try {
144
+ await fetch(`${realmBase(cfg)}/protocol/openid-connect/logout`, {
145
+ method: 'POST',
146
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
147
+ body: new URLSearchParams({ client_id: cfg.clientId, refresh_token: cfg.refreshToken }),
148
+ });
149
+ }
150
+ catch {
151
+ // melhor esforço — a sessão local já será removida
152
+ }
153
+ }
@@ -0,0 +1,77 @@
1
+ import { clearSession, hasSession, loadConfig } from '../config.js';
2
+ import { deviceLogin, serverLogout } from '../auth.js';
3
+ import { apiFetch } from '../api.js';
4
+ import { bold, dim, green, printJson, red, yellow } from './shared.js';
5
+ export async function authCommand(parsed) {
6
+ const [sub] = parsed._;
7
+ switch (sub) {
8
+ case 'login': {
9
+ if (hasSession()) {
10
+ process.stdout.write(yellow('Já existe uma sessão salva — ela será substituída.\n'));
11
+ }
12
+ const email = await deviceLogin({ noOpen: Boolean(parsed.flags['no-open']) });
13
+ process.stdout.write(green(`\nAutenticado${email ? ` como ${bold(email)}` : ''}.\n`));
14
+ return;
15
+ }
16
+ case 'logout': {
17
+ await serverLogout();
18
+ clearSession();
19
+ process.stdout.write('Sessão removida.\n');
20
+ return;
21
+ }
22
+ case 'status': {
23
+ const cfg = loadConfig();
24
+ if (!hasSession()) {
25
+ if (parsed.flags.json) {
26
+ printJson({ authenticated: false });
27
+ return;
28
+ }
29
+ process.stdout.write('Não autenticado. Rode `memoring auth login`.\n');
30
+ process.exitCode = 1;
31
+ return;
32
+ }
33
+ let pending;
34
+ let valid = true;
35
+ try {
36
+ const inbox = (await apiFetch('/notes/review-inbox'));
37
+ pending = inbox.length;
38
+ }
39
+ catch {
40
+ valid = false;
41
+ }
42
+ const status = {
43
+ authenticated: valid,
44
+ email: cfg.email,
45
+ apiBase: cfg.apiBase,
46
+ keycloakUrl: cfg.keycloakUrl,
47
+ realm: cfg.realm,
48
+ clientId: cfg.clientId,
49
+ pendingReview: pending,
50
+ };
51
+ if (parsed.flags.json) {
52
+ printJson(status);
53
+ if (!valid) {
54
+ process.exitCode = 1;
55
+ }
56
+ return;
57
+ }
58
+ if (!valid) {
59
+ process.stdout.write(red('Sessão inválida ou expirada. Rode `memoring auth login`.\n'));
60
+ process.exitCode = 1;
61
+ return;
62
+ }
63
+ process.stdout.write(`${green('Autenticado')}${cfg.email ? ` como ${bold(cfg.email)}` : ''}\n`);
64
+ process.stdout.write(dim(`API: ${cfg.apiBase} · Keycloak: ${cfg.keycloakUrl}/realms/${cfg.realm} · client: ${cfg.clientId}\n`));
65
+ process.stdout.write(dim(`Caixa de revisão: ${pending ?? 0} item(ns) pendente(s)\n`));
66
+ return;
67
+ }
68
+ default:
69
+ process.stdout.write('Uso: memoring auth <login|logout|status> [--no-open] [--json]\n' +
70
+ ' login autentica via browser (device flow)\n' +
71
+ ' logout remove a sessão local e revoga o token\n' +
72
+ ' status mostra a sessão atual e itens pendentes de revisão\n');
73
+ if (sub) {
74
+ process.exitCode = 1;
75
+ }
76
+ }
77
+ }
@@ -0,0 +1,78 @@
1
+ import { apiFetch, ApiError } from '../api.js';
2
+ import { fail, green, printJson, printTable } from './shared.js';
3
+ async function listInbox() {
4
+ return (await apiFetch('/notes/review-inbox'));
5
+ }
6
+ export async function inboxCommand(parsed) {
7
+ const [sub, idArg] = parsed._;
8
+ switch (sub) {
9
+ case 'list': {
10
+ const notes = await listInbox();
11
+ if (parsed.flags.json) {
12
+ printJson(notes);
13
+ return;
14
+ }
15
+ if (notes.length === 0) {
16
+ process.stdout.write('Caixa de revisão vazia.\n');
17
+ return;
18
+ }
19
+ printTable(notes.map((n) => ({ id: `#${n.id}`, type: n.type, title: n.title })), [
20
+ { key: 'id', label: 'ID' },
21
+ { key: 'type', label: 'TIPO' },
22
+ { key: 'title', label: 'TÍTULO' },
23
+ ]);
24
+ process.stdout.write(`\n${notes.length} item(ns) pendente(s) — aceite em /cards/inbox ou com \`memoring inbox accept <id>\`.\n`);
25
+ return;
26
+ }
27
+ case 'accept':
28
+ case 'discard': {
29
+ const id = Number(idArg);
30
+ if (!idArg || !Number.isInteger(id) || id <= 0) {
31
+ fail(`informe o id numérico do item: memoring inbox ${sub} <id> (veja os ids com \`memoring inbox list\`).`);
32
+ }
33
+ try {
34
+ if (sub === 'accept') {
35
+ const note = (await apiFetch(`/notes/review-inbox/${id}/accept`, { method: 'POST' }));
36
+ if (parsed.flags.json) {
37
+ printJson(note);
38
+ return;
39
+ }
40
+ process.stdout.write(green(`Item #${note.id} [${note.type}] "${note.title}" aceito — entrou nas listagens e na fila de revisão.\n`));
41
+ }
42
+ else {
43
+ await apiFetch(`/notes/${id}`, { method: 'DELETE' });
44
+ if (parsed.flags.json) {
45
+ printJson({ discarded: id });
46
+ return;
47
+ }
48
+ process.stdout.write(green(`Item #${id} descartado.\n`));
49
+ }
50
+ }
51
+ catch (e) {
52
+ if (e instanceof ApiError && e.status === 404) {
53
+ fail(`item #${id} não encontrado — confira \`memoring inbox list\`.`);
54
+ }
55
+ throw e;
56
+ }
57
+ return;
58
+ }
59
+ case 'accept-all': {
60
+ const res = (await apiFetch('/notes/review-inbox/accept-all', { method: 'POST' }));
61
+ if (parsed.flags.json) {
62
+ printJson(res);
63
+ return;
64
+ }
65
+ process.stdout.write(green(`${res.accepted} item(ns) aceito(s).\n`));
66
+ return;
67
+ }
68
+ default:
69
+ process.stdout.write('Uso: memoring inbox <list|accept|accept-all|discard> [id] [--json]\n' +
70
+ ' list lista os itens pendentes de revisão\n' +
71
+ ' accept <id> aceita um item (entra nas listagens e na fila FSRS)\n' +
72
+ ' accept-all aceita todos os itens pendentes\n' +
73
+ ' discard <id> descarta um item pendente\n');
74
+ if (sub) {
75
+ process.exitCode = 1;
76
+ }
77
+ }
78
+ }
@@ -0,0 +1,3 @@
1
+ // Re-exports compartilhados para os comandos (evita import circular com main).
2
+ export { parseArgs, stringFlag } from '../args.js';
3
+ export { bold, dim, red, green, yellow, cyan, printJson, printTable, fail, setColorEnabled } from '../output.js';
@@ -0,0 +1,161 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { apiFetch, ApiError } from '../api.js';
3
+ import { dim, fail, green, printJson, stringFlag } from './shared.js';
4
+ const TYPES = ['NORMAL', 'FLASHCARD', 'ONE_SHOT'];
5
+ const MAX_ITEMS = 50;
6
+ const DEFAULT_SOURCE = 'Importação assistida (CLI)';
7
+ const SCHEMA_JSON = {
8
+ description: 'Lote de 1..50 itens para POST /api/notes/review-inbox. All-or-nothing: qualquer item inválido rejeita o lote. Os itens nascem PENDING_REVIEW — o usuário aceita/edita/recusa em /cards/inbox.',
9
+ item: {
10
+ type: 'NORMAL | FLASHCARD | ONE_SHOT (obrigatório)',
11
+ title: 'string (obrigatório)',
12
+ content: 'string — corpo da nota em markdown (obrigatório quando type=NORMAL)',
13
+ front: 'string — pergunta (obrigatório quando type=FLASHCARD|ONE_SHOT)',
14
+ back: 'string — resposta (obrigatório quando type=FLASHCARD|ONE_SHOT)',
15
+ subjectName: 'string? — matéria (ex.: "Direito Constitucional")',
16
+ subtopicName: 'string? — subtópico',
17
+ tags: 'string[]?',
18
+ source: `string? — origem do material (default: "${DEFAULT_SOURCE}")`,
19
+ },
20
+ example: [
21
+ {
22
+ type: 'FLASHCARD',
23
+ title: 'CF/88 — objetivos fundamentais',
24
+ front: 'Quais são os quatro objetivos fundamentais da República (art. 3º da CF/88)?',
25
+ back: 'I — construir uma sociedade livre, justa e solidária; II — garantir o desenvolvimento nacional; III — erradicar a pobreza e reduzir as desigualdades; IV — promover o bem de todos.',
26
+ subjectName: 'Direito Constitucional',
27
+ tags: ['cf88', 'art-3'],
28
+ source: 'Apostila constitucional.pdf',
29
+ },
30
+ ],
31
+ };
32
+ const SCHEMA_TEXT = `Contrato dos itens da caixa de revisão (POST /api/notes/review-inbox):
33
+
34
+ type NORMAL | FLASHCARD | ONE_SHOT (obrigatório)
35
+ title string (obrigatório)
36
+ content string — corpo da nota (markdown) (obrigatório quando type=NORMAL)
37
+ front, back string — pergunta e resposta (obrigatório quando type=FLASHCARD|ONE_SHOT)
38
+ subjectName? string — matéria (ex.: "Direito Constitucional")
39
+ subtopicName? string — subtópico
40
+ tags? string[]
41
+ source? string — origem do material (default: "${DEFAULT_SOURCE}")
42
+
43
+ Lote: 1 a ${MAX_ITEMS} itens, all-or-nothing — qualquer item inválido rejeita o lote inteiro.
44
+ Os itens nascem PENDING_REVIEW: o usuário aceita, edita ou recusa em /cards/inbox.
45
+ Dica: FLASHCARD entra na fila de revisão espaçada (FSRS); ONE_SHOT é pergunta de resposta única.`;
46
+ async function readStdin() {
47
+ const chunks = [];
48
+ for await (const chunk of process.stdin) {
49
+ chunks.push(chunk);
50
+ }
51
+ return Buffer.concat(chunks).toString('utf8');
52
+ }
53
+ function validateItems(raw) {
54
+ const items = Array.isArray(raw) ? raw : raw?.items;
55
+ if (!Array.isArray(items) || items.length === 0) {
56
+ fail('JSON inválido: esperado array não-vazio de itens ou { "items": [...] }. Rode `memoring stage schema` para ver o contrato.');
57
+ }
58
+ if (items.length > MAX_ITEMS) {
59
+ fail(`lote acima do limite: ${items.length} itens (máximo ${MAX_ITEMS}). Divida em mais de um lote.`);
60
+ }
61
+ return items.map((item, index) => {
62
+ const where = `item #${index + 1}`;
63
+ if (typeof item !== 'object' || item === null) {
64
+ fail(`${where}: esperado objeto, veio ${typeof item}.`);
65
+ }
66
+ const it = item;
67
+ if (typeof it.type !== 'string' || !TYPES.includes(it.type)) {
68
+ fail(`${where}: type deve ser ${TYPES.join(' | ')} (veio ${JSON.stringify(it.type)}).`);
69
+ }
70
+ if (typeof it.title !== 'string' || it.title.trim() === '') {
71
+ fail(`${where}: title é obrigatório.`);
72
+ }
73
+ if (it.type === 'NORMAL' && (typeof it.content !== 'string' || it.content.trim() === '')) {
74
+ fail(`${where} (${it.title}): type=NORMAL exige content.`);
75
+ }
76
+ if ((it.type === 'FLASHCARD' || it.type === 'ONE_SHOT') &&
77
+ (typeof it.front !== 'string' || it.front.trim() === '' || typeof it.back !== 'string' || it.back.trim() === '')) {
78
+ fail(`${where} (${it.title}): type=${it.type} exige front e back.`);
79
+ }
80
+ if (it.tags !== undefined && (!Array.isArray(it.tags) || it.tags.some((t) => typeof t !== 'string'))) {
81
+ fail(`${where} (${it.title}): tags deve ser um array de strings.`);
82
+ }
83
+ return { ...it, source: typeof it.source === 'string' && it.source.trim() !== '' ? it.source : DEFAULT_SOURCE };
84
+ });
85
+ }
86
+ async function emptyInbox() {
87
+ const pending = (await apiFetch('/notes/review-inbox'));
88
+ for (const note of pending) {
89
+ await apiFetch(`/notes/${note.id}`, { method: 'DELETE' });
90
+ }
91
+ return pending.length;
92
+ }
93
+ export async function stageCommand(parsed) {
94
+ const [sub] = parsed._;
95
+ switch (sub) {
96
+ case 'schema': {
97
+ if (parsed.flags.json) {
98
+ printJson(SCHEMA_JSON);
99
+ }
100
+ else {
101
+ process.stdout.write(SCHEMA_TEXT + '\n');
102
+ }
103
+ return;
104
+ }
105
+ case 'create': {
106
+ const file = stringFlag(parsed.flags, 'file');
107
+ let raw;
108
+ if (file && file !== '-') {
109
+ raw = readFileSync(file, 'utf8');
110
+ }
111
+ else if (!process.stdin.isTTY) {
112
+ raw = await readStdin();
113
+ }
114
+ else {
115
+ fail('informe o arquivo: memoring stage create --file itens.json (ou pipe via stdin).');
116
+ }
117
+ let parsedJson;
118
+ try {
119
+ parsedJson = JSON.parse(raw);
120
+ }
121
+ catch (e) {
122
+ fail(`JSON inválido: ${e.message}`);
123
+ }
124
+ const items = validateItems(parsedJson);
125
+ if (parsed.flags.replace) {
126
+ const removed = await emptyInbox();
127
+ if (!parsed.flags.json) {
128
+ process.stdout.write(dim(`${removed} item(ns) pendente(s) removido(s) da caixa de revisão.\n`));
129
+ }
130
+ }
131
+ let created;
132
+ try {
133
+ const res = (await apiFetch('/notes/review-inbox', { method: 'POST', body: { items } }));
134
+ created = res.created;
135
+ }
136
+ catch (e) {
137
+ if (e instanceof ApiError) {
138
+ fail(`o servidor rejeitou o lote (${e.status}): ${e.body}`);
139
+ }
140
+ throw e;
141
+ }
142
+ if (parsed.flags.json) {
143
+ printJson({ created });
144
+ return;
145
+ }
146
+ process.stdout.write(green(`OK — ${created.length} item(ns) aguardando revisão em /cards/inbox:\n`));
147
+ for (const note of created) {
148
+ process.stdout.write(` #${note.id} [${note.type}] ${note.title}\n`);
149
+ }
150
+ return;
151
+ }
152
+ default:
153
+ process.stdout.write('Uso: memoring stage <schema|create> [--file itens.json] [--replace] [--json]\n' +
154
+ ' schema imprime o contrato JSON dos itens (para humanos e agentes)\n' +
155
+ ' create envia o lote para a caixa de revisão (arquivo ou stdin)\n' +
156
+ ' --replace esvazia a caixa antes de enviar\n');
157
+ if (sub) {
158
+ process.exitCode = 1;
159
+ }
160
+ }
161
+ }
package/dist/config.js ADDED
@@ -0,0 +1,69 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ const DEFAULTS = {
5
+ keycloakUrl: 'https://auth.memoring.com.br',
6
+ realm: 'memoring',
7
+ clientId: 'memoring-cli',
8
+ apiBase: 'https://memoring.com.br/api',
9
+ };
10
+ function configDir() {
11
+ if (process.env.MEMORING_CONFIG_HOME) {
12
+ return process.env.MEMORING_CONFIG_HOME;
13
+ }
14
+ if (process.platform === 'win32') {
15
+ return join(process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'), 'memoring');
16
+ }
17
+ return join(homedir(), '.config', 'memoring');
18
+ }
19
+ function configPath() {
20
+ return join(configDir(), 'config.json');
21
+ }
22
+ function readStored() {
23
+ try {
24
+ return JSON.parse(readFileSync(configPath(), 'utf8'));
25
+ }
26
+ catch {
27
+ return {};
28
+ }
29
+ }
30
+ /** Config efetiva: variáveis de ambiente sempre vencem o arquivo. */
31
+ export function loadConfig() {
32
+ const stored = readStored();
33
+ return {
34
+ keycloakUrl: process.env.MEMORING_KEYCLOAK_URL ?? stored.keycloakUrl ?? DEFAULTS.keycloakUrl,
35
+ realm: process.env.MEMORING_KC_REALM ?? stored.realm ?? DEFAULTS.realm,
36
+ clientId: process.env.MEMORING_KC_CLIENT ?? stored.clientId ?? DEFAULTS.clientId,
37
+ apiBase: process.env.MEMORING_API_BASE ?? stored.apiBase ?? DEFAULTS.apiBase,
38
+ accessToken: stored.accessToken,
39
+ refreshToken: stored.refreshToken,
40
+ accessTokenExpiresAt: stored.accessTokenExpiresAt,
41
+ email: stored.email,
42
+ };
43
+ }
44
+ function writeStored(patch) {
45
+ const merged = { ...readStored(), ...patch };
46
+ mkdirSync(configDir(), { recursive: true });
47
+ writeFileSync(configPath(), JSON.stringify(merged, null, 2), { mode: 0o600 });
48
+ }
49
+ export function saveSession(endpoints, tokens) {
50
+ writeStored({ ...endpoints, ...tokens });
51
+ }
52
+ export function saveTokens(tokens) {
53
+ writeStored(tokens);
54
+ }
55
+ export function clearSession() {
56
+ const existed = existsSync(configPath());
57
+ const { accessToken, refreshToken, accessTokenExpiresAt, email, ...rest } = readStored();
58
+ void accessToken;
59
+ void refreshToken;
60
+ void accessTokenExpiresAt;
61
+ void email;
62
+ mkdirSync(configDir(), { recursive: true });
63
+ writeFileSync(configPath(), JSON.stringify(rest, null, 2), { mode: 0o600 });
64
+ return existed;
65
+ }
66
+ export function hasSession() {
67
+ const stored = readStored();
68
+ return Boolean(stored.accessToken ?? stored.refreshToken);
69
+ }
package/dist/main.js ADDED
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ import { ApiError } from './api.js';
3
+ import { setColorEnabled } from './output.js';
4
+ import { authCommand } from './commands/auth.js';
5
+ import { inboxCommand } from './commands/inbox.js';
6
+ import { stageCommand } from './commands/stage.js';
7
+ import { fail, parseArgs as parse } from './commands/shared.js';
8
+ const VERSION = '0.1.0';
9
+ const HELP = `Memoring CLI — flashcards e notas por IA, direto do terminal.
10
+
11
+ Uso: memoring <comando> [subcomando] [flags]
12
+
13
+ Comandos:
14
+ auth login|logout|status autenticação (device flow — sem senha no terminal)
15
+ stage schema contrato JSON dos itens da caixa de revisão
16
+ stage create [--file f] envia itens (arquivo ou stdin) para a caixa de revisão
17
+ [--replace] esvazia a caixa antes de enviar
18
+ inbox list itens pendentes de revisão
19
+ inbox accept <id>|accept-all aceita itens (entram nas listagens e na fila FSRS)
20
+ inbox discard <id> descarta um item pendente
21
+ version versão da CLI
22
+
23
+ Flags globais:
24
+ --json saída legível por máquina
25
+ --no-color desativa cores
26
+ --api-base sobrescreve a URL da API (default: https://memoring.com.br/api)
27
+ --help, -h ajuda
28
+
29
+ Ambiente (dev): MEMORING_API_BASE, MEMORING_KEYCLOAK_URL, MEMORING_KC_REALM, MEMORING_KC_CLIENT
30
+
31
+ Exemplos:
32
+ memoring auth login
33
+ memoring stage schema
34
+ cat itens.json | memoring stage create --json
35
+ memoring inbox list
36
+ memoring inbox accept-all
37
+ `;
38
+ async function main() {
39
+ const argv = process.argv.slice(2).map((a) => (a === '-h' ? '--help' : a));
40
+ const parsed = parse(argv);
41
+ if (parsed.flags['no-color'] || !process.stdout.isTTY) {
42
+ setColorEnabled(false);
43
+ }
44
+ const apiBase = typeof parsed.flags['api-base'] === 'string' ? parsed.flags['api-base'] : undefined;
45
+ if (apiBase) {
46
+ process.env.MEMORING_API_BASE = apiBase;
47
+ }
48
+ const [command, ...rest] = parsed._;
49
+ const sub = { _: rest, flags: parsed.flags };
50
+ if (parsed.flags.help || !command) {
51
+ process.stdout.write(HELP);
52
+ return;
53
+ }
54
+ switch (command) {
55
+ case 'auth':
56
+ await authCommand(sub);
57
+ return;
58
+ case 'stage':
59
+ await stageCommand(sub);
60
+ return;
61
+ case 'inbox':
62
+ await inboxCommand(sub);
63
+ return;
64
+ case 'version':
65
+ process.stdout.write(`memoring-cli ${VERSION}\n`);
66
+ return;
67
+ default:
68
+ process.stderr.write(`Comando desconhecido: ${command}\n\n`);
69
+ process.stdout.write(HELP);
70
+ process.exitCode = 1;
71
+ }
72
+ }
73
+ main().catch((error) => {
74
+ if (error instanceof ApiError) {
75
+ fail(`falha na API (${error.status}): ${error.body}`);
76
+ }
77
+ fail(error instanceof Error ? error.message : String(error));
78
+ });
package/dist/output.js ADDED
@@ -0,0 +1,31 @@
1
+ let colorEnabled = true;
2
+ export function setColorEnabled(enabled) {
3
+ colorEnabled = enabled;
4
+ }
5
+ function paint(code) {
6
+ return (text) => (colorEnabled ? `[${code}m${text}` : text);
7
+ }
8
+ export const bold = paint(1);
9
+ export const dim = paint(2);
10
+ export const red = paint(31);
11
+ export const green = paint(32);
12
+ export const yellow = paint(33);
13
+ export const cyan = paint(36);
14
+ export function printJson(data) {
15
+ process.stdout.write(JSON.stringify(data, null, 2) + '\n');
16
+ }
17
+ export function printTable(rows, columns) {
18
+ if (rows.length === 0) {
19
+ return;
20
+ }
21
+ const widths = columns.map((col) => Math.max(col.label.length, ...rows.map((row) => String(row[col.key] ?? '').length)));
22
+ const renderRow = (cells) => cells.map((cell, i) => cell.padEnd(widths[i])).join(' ').trimEnd();
23
+ process.stdout.write(dim(renderRow(columns.map((c) => c.label))) + '\n');
24
+ for (const row of rows) {
25
+ process.stdout.write(renderRow(columns.map((c) => String(row[c.key] ?? ''))) + '\n');
26
+ }
27
+ }
28
+ export function fail(message) {
29
+ process.stderr.write(red(`Erro: ${message}`) + '\n');
30
+ process.exit(1);
31
+ }
package/package.json CHANGED
@@ -1,6 +1,27 @@
1
1
  {
2
2
  "name": "@memoring/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "CLI do Memoring — envie flashcards/notas gerados por IA para a sua caixa de revisão, direto do terminal.",
5
+ "type": "module",
6
+ "bin": {
7
+ "memoring": "dist/main.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "skills",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=20"
16
+ },
17
+ "scripts": {
18
+ "build": "tsc -p tsconfig.json",
19
+ "start": "node dist/main.js",
20
+ "prepack": "npm run build"
21
+ },
22
+ "devDependencies": {
23
+ "@types/node": "^24.0.0",
24
+ "typescript": "^5.8.0"
25
+ },
26
+ "license": "UNLICENSED"
27
+ }
@@ -0,0 +1,37 @@
1
+ ---
2
+ name: memoring-cli
3
+ description: Gere flashcards e notas a partir de materiais de estudo locais (PDFs, textos, anotações) e envie para a caixa de revisão do Memoring usando a CLI `memoring`. Use quando o usuário pedir para "usar a cli do memoring", criar flashcards/notas de uma pasta ou arquivo, ou enviar material de estudo para o Memoring.
4
+ ---
5
+
6
+ # Memoring CLI — geração de flashcards/notas por agente
7
+
8
+ O Memoring é um app de estudos com revisão espaçada (FSRS). A CLI `memoring` é a ponte entre você (o agente, que lê o material e gera o conteúdo) e a conta do usuário no app. Você NÃO chama a API HTTP diretamente — use sempre a CLI.
9
+
10
+ ## Pré-requisitos
11
+
12
+ 1. CLI instalada: `memoring version` deve responder. Se não, peça ao usuário: `npm install -g @memoring/cli`.
13
+ 2. Sessão ativa: `memoring auth status --json` → `authenticated: true`. Se não, peça ao usuário para rodar `memoring auth login` (device flow — ele aprova no browser; você não consegue fazer isso por ele).
14
+
15
+ ## Fluxo
16
+
17
+ 1. **Entenda o pedido** — qual pasta/arquivo/material virar flashcards/notas.
18
+ 2. **Leia o contrato**: `memoring stage schema --json`.
19
+ 3. **Leia os materiais** e gere os itens no contrato, num arquivo JSON (array ou `{ "items": [...] }`).
20
+ 4. **Envie**: `memoring stage create --file <arquivo> --json`.
21
+ 5. **Avise o usuário**: quantos itens foram para a caixa de revisão e onde aceitar — no app em `/cards/inbox` ou `memoring inbox list` / `memoring inbox accept-all`.
22
+
23
+ ## Regras do contrato (resumo — a fonte da verdade é `stage schema`)
24
+
25
+ - `type`: `NORMAL` (nota, exige `content` markdown), `FLASHCARD` ou `ONE_SHOT` (exigem `front` + `back`).
26
+ - `title` sempre obrigatório; lote de 1 a 50 itens, all-or-nothing.
27
+ - `ONE_SHOT` = pergunta de resposta única; prefira `FLASHCARD` para memorização.
28
+ - Preencha `source` com a origem real do material (nome do arquivo/pasta) — não use o default quando souber a origem.
29
+ - `subjectName`/`subtopicName` quando o material tiver matéria/subtópico claros; `tags` curtas e em minúsculas.
30
+ - NUNCA invente campos fora do contrato — o servidor rejeita o lote inteiro.
31
+
32
+ ## Armadilhas
33
+
34
+ - **Nunca** use `--replace` sem o usuário pedir explicitamente — ele apaga os itens pendentes existentes da caixa.
35
+ - Se `stage create` falhar com erro de validação do servidor, corrija os itens indicados e reenvie — não relaxe o contrato.
36
+ - Lotes grandes: divida em vários arquivos de até 50 itens e envie sequencialmente.
37
+ - Flashcards bons são atômicos: uma ideia por cartão, pergunta direta, resposta completa mas curta.