terminal-smart-cli 0.94.2 → 0.97.1

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,118 @@
1
+ // lib/providers.js — CATÁLOGO de provedores de IA compatíveis com a API OpenAI.
2
+ // Base do BYOK ("traga sua própria chave"): o CLI já fala `POST {baseUrl}/chat/completions`
3
+ // (agent.llm), então qualquer provedor OpenAI-compat entra sem tocar no loop do agente.
4
+ //
5
+ // PORQUÊ: no plano Free todo token passa pelo gateway do TS e consome crédito — o usuário
6
+ // conhece só uma amostra do produto. Com chave própria ele usa o TS INTEIRO de graça
7
+ // (agente, missão, diagnóstico) e migra pro TS Cloud pela conveniência, não pelo teto.
8
+ //
9
+ // Por que tier gratuito FUNCIONA aqui: uma requisição de agente dura 2-3 minutos, então
10
+ // 40 req/min é inatingível na prática. Um chatbot web estouraria; um agente de terminal não.
11
+ 'use strict';
12
+
13
+ // `prefixo` valida a chave ANTES de gastar uma chamada (erro de copiar/colar é o mais comum).
14
+ // `gratuito` só descreve o tier de entrada — não é promessa, provedor muda política.
15
+ const PROVEDORES = {
16
+ nvidia: {
17
+ nome: 'NVIDIA NIM',
18
+ baseUrl: 'https://integrate.api.nvidia.com/v1',
19
+ // MEDIDO na API real (28/07/2026): o 70b estourou 120s de COLD START e o
20
+ // nemotron-70b é listado em /v1/models mas devolve 404 no /chat/completions.
21
+ // O v4-flash respondeu em 16s E chamou ferramenta — é o mesmo executor que o
22
+ // resto do TS já usa, então é o padrão sensato aqui.
23
+ modeloPadrao: 'deepseek-ai/deepseek-v4-flash',
24
+ sugestoes: [
25
+ ['deepseek-ai/deepseek-v4-flash', 'equilíbrio — chama ferramenta, ~16s'],
26
+ ['meta/llama-3.1-8b-instruct', 'o mais rápido — ~1s, bom pra chat/roteamento'],
27
+ ['mistralai/mistral-medium-3.5-128b', 'mais capaz — ~43s'],
28
+ ],
29
+ prefixo: /^nvapi-/,
30
+ gratuito: true,
31
+ limite: '40 req/min',
32
+ cadastro: 'https://build.nvidia.com — cadastro por telefone, sem cartão',
33
+ },
34
+ google: {
35
+ nome: 'Google AI Studio',
36
+ baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',
37
+ modeloPadrao: 'gemini-2.5-flash',
38
+ prefixo: /^AIza/,
39
+ gratuito: true,
40
+ limite: 'cota diária generosa',
41
+ cadastro: 'https://aistudio.google.com/apikey — sem cartão',
42
+ },
43
+ cerebras: {
44
+ nome: 'Cerebras',
45
+ baseUrl: 'https://api.cerebras.ai/v1',
46
+ modeloPadrao: 'llama-3.3-70b',
47
+ prefixo: /^csk-/,
48
+ gratuito: true,
49
+ limite: 'todos os modelos no tier free',
50
+ cadastro: 'https://cloud.cerebras.ai — sem cartão',
51
+ },
52
+ groq: {
53
+ nome: 'Groq',
54
+ baseUrl: 'https://api.groq.com/openai/v1',
55
+ modeloPadrao: 'llama-3.3-70b-versatile',
56
+ prefixo: /^gsk_/,
57
+ gratuito: true,
58
+ limite: 'cota diária',
59
+ cadastro: 'https://console.groq.com/keys — sem cartão',
60
+ },
61
+ openrouter: {
62
+ nome: 'OpenRouter',
63
+ baseUrl: 'https://openrouter.ai/api/v1',
64
+ modeloPadrao: 'deepseek/deepseek-chat',
65
+ prefixo: /^sk-or-/,
66
+ gratuito: false,
67
+ limite: 'free tier com rate limit frequente',
68
+ cadastro: 'https://openrouter.ai/keys',
69
+ },
70
+ ollama: {
71
+ nome: 'Ollama (local)',
72
+ baseUrl: 'http://localhost:11434/v1',
73
+ modeloPadrao: 'qwen2.5-coder:7b',
74
+ prefixo: null, // servidor local não exige chave
75
+ semChave: true,
76
+ gratuito: true,
77
+ limite: 'limitado pela sua máquina',
78
+ cadastro: 'https://ollama.com — roda offline',
79
+ },
80
+ custom: {
81
+ nome: 'Outro (OpenAI-compat)',
82
+ baseUrl: '', // o usuário informa
83
+ modeloPadrao: '',
84
+ prefixo: null,
85
+ gratuito: false,
86
+ limite: 'depende do provedor',
87
+ cadastro: 'informe a baseUrl que termina em /v1',
88
+ },
89
+ };
90
+
91
+ function info(id) { return PROVEDORES[String(id || '').toLowerCase()] || null; }
92
+ function ids() { return Object.keys(PROVEDORES); }
93
+
94
+ // Valida o formato da chave contra o prefixo conhecido. Retorna '' se OK, senão o motivo.
95
+ // NÃO prova que a chave funciona (isso é o keyring.testar, que faz chamada real) — só
96
+ // mata o erro bobo de colar a chave do provedor errado.
97
+ function validarChave(id, chave) {
98
+ const p = info(id);
99
+ if (!p) return 'provedor desconhecido: ' + id;
100
+ if (p.semChave) return '';
101
+ const k = String(chave || '').trim();
102
+ if (!k) return 'chave vazia';
103
+ if (p.prefixo && !p.prefixo.test(k)) {
104
+ return 'essa chave não parece ser do ' + p.nome + ' (esperado começar com "' +
105
+ String(p.prefixo).replace(/[/^]/g, '') + '")';
106
+ }
107
+ return '';
108
+ }
109
+
110
+ // baseUrl efetiva: o provedor custom (e o ollama, se a porta mudar) traz a sua.
111
+ function baseUrlDe(id, override) {
112
+ const o = String(override || '').trim().replace(/\/+$/, '');
113
+ if (o) return o;
114
+ const p = info(id);
115
+ return p ? p.baseUrl : '';
116
+ }
117
+
118
+ module.exports = { PROVEDORES, info, ids, validarChave, baseUrlDe };
package/lib/router.js CHANGED
@@ -5,6 +5,7 @@
5
5
  // Errar é barato de propósito: agente tem gate de destrutivo, orquestração tem
6
6
  // aprovação de plano com custo — o roteador nunca dispara gasto grande sozinho.
7
7
  const { api } = require('./api');
8
+ const keyring = require('./keyring');
8
9
 
9
10
  // Pergunta/conversa: forma interrogativa ou pedido de explicação
10
11
  const Q_RE = /^(como|por ?qu[eê]|o ?que|qual|quais|quando|onde|quem|ser[aá] que|devo|posso|vale a pena|existe|tem como|é melhor|what|how|why|which|when|where|explique|me explica|explica|o que significa|diferen[çc]a entre)\b|\?\s*$/i;
@@ -52,7 +53,7 @@ function scope(msg) {
52
53
  let _keyCache = null;
53
54
  async function _aiKey(token) {
54
55
  if (_keyCache) return _keyCache;
55
- _keyCache = await api('/api/ai/key', { token, timeoutMs: 15000 });
56
+ _keyCache = await keyring.resolve(token, { feature: 'cli_router', timeoutMs: 15000 });
56
57
  return _keyCache;
57
58
  }
58
59
 
@@ -66,7 +67,7 @@ async function classify(msg, token) {
66
67
  method: 'POST', signal: ctrl.signal,
67
68
  headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + k.key },
68
69
  body: JSON.stringify({
69
- model: 'gemini-2.5-flash-lite', stream: false, max_completion_tokens: 5,
70
+ model: keyring.modeloPara(k, 'gemini-2.5-flash-lite'), stream: false, max_completion_tokens: 5,
70
71
  messages: [
71
72
  { role: 'system', content: 'Classifique a intenção da mensagem de um usuário de um CLI DevOps. Responda APENAS uma palavra: "chat" (pergunta, conversa, pedido de explicação), "agente" (quer que algo seja FEITO nesta máquina: comandos, arquivos, diagnóstico, instalação) ou "orquestrar" (objetivo GRANDE com vários entregáveis para uma equipe de agentes).' },
72
73
  { role: 'user', content: String(msg).slice(0, 500) },
@@ -0,0 +1,414 @@
1
+ // lib/skill-index.js — o agente SABE que existe skill pra isso.
2
+ //
3
+ // Antes: o prompt listava só as skills INSTALADAS (~/.ts/skills). O agente era cego para
4
+ // o que existe na galeria e não foi instalado, e para o que outros agentes instalaram na
5
+ // mesma máquina. Resultado: refazia do zero um procedimento que já estava escrito.
6
+ //
7
+ // Listar tudo não resolve — 323 skills de um catálogo dariam ~48k chars em TODA conversa.
8
+ // A saída é BUSCA por relevância, e o gatilho é DETERMINÍSTICO: o harness consulta o índice
9
+ // com a tarefa antes da primeira iteração e injeta no máximo 3 candidatas acima de um
10
+ // limiar. Sem match, o bloco não existe e o custo é zero.
11
+ //
12
+ // Por que não deixar o modelo decidir chamar uma tool de busca: regra sozinha não segura
13
+ // comportamento (o filtro de segredos precisou virar código porque o agente ignorou a
14
+ // regra 3x). A tool `buscar_skill` existe, mas como complemento pro pedido explícito.
15
+ //
16
+ // A busca REUSA o BM25 de indice.js: `search(dir, query, k, idxIn)` aceita um índice já
17
+ // montado, então basta montar os chunks no formato dele — zero matemática duplicada.
18
+ 'use strict';
19
+ const fs = require('fs');
20
+ const os = require('os');
21
+ const path = require('path');
22
+ const indice = require('./indice');
23
+
24
+ const CACHE_DIR = path.join(os.homedir(), '.ts', 'cache');
25
+ const CACHE = path.join(CACHE_DIR, 'skills-index.json');
26
+ const TTL_MS = 24 * 60 * 60 * 1000;
27
+
28
+ // Onde OUTROS agentes instalam skills. O formato é o mesmo (<slug>/SKILL.md com
29
+ // frontmatter name/description) — confirmado contra o catálogo público da NVIDIA. Ler
30
+ // daqui é o mesmo princípio de já ler AGENTS.md/CLAUDE.md: aproveitar o que o ecossistema
31
+ // instalou, sem pedir configuração nenhuma ao usuário.
32
+ const DIRS_AGENTES = [
33
+ ['ts', path.join(os.homedir(), '.ts', 'skills')],
34
+ ['claude-code', path.join(os.homedir(), '.claude', 'skills')],
35
+ ['codex', path.join(os.homedir(), '.codex', 'skills')],
36
+ ['cursor', path.join(os.homedir(), '.cursor', 'skills')],
37
+ ['agents', path.join(os.homedir(), '.agents', 'skills')],
38
+ ];
39
+
40
+ // ── leitura local ────────────────────────────────────────────────────────────
41
+ // A DESCRIÇÃO é o que alimenta a busca: skill com descrição vazia fica invisível. Por isso
42
+ // aqui trata também o bloco literal YAML (`description: |` com linhas indentadas embaixo),
43
+ // que catálogos reais usam — sem isso, `aiq-deploy` e `aiq-research` da NVIDIA entravam com
44
+ // descrição "|" e nunca seriam encontradas.
45
+ function _valorYaml(raw, campo) {
46
+ const re = new RegExp('^' + campo + '\\s*:\\s*(.*)$', 'mi');
47
+ const m = raw.match(re);
48
+ if (!m) return '';
49
+ const primeira = m[1].trim();
50
+ if (!/^[|>]-?\+?$/.test(primeira)) return primeira.replace(/^["']|["']$/g, '');
51
+ // bloco literal: junta as linhas indentadas que vêm logo depois
52
+ const resto = raw.slice(m.index + m[0].length).split(/\r?\n/);
53
+ const linhas = [];
54
+ for (const l of resto) {
55
+ if (!l.trim()) { if (linhas.length) break; continue; }
56
+ if (!/^\s+/.test(l)) break; // acabou a indentação = acabou o bloco
57
+ linhas.push(l.trim());
58
+ if (linhas.join(' ').length > 400) break;
59
+ }
60
+ return linhas.join(' ');
61
+ }
62
+
63
+ function _frontmatter(md) {
64
+ const raw = String(md || '').slice(0, 4000);
65
+ const tags = (raw.match(/^\s*tags\s*:\s*\[?([^\]\n]+)\]?/mi) || [])[1];
66
+ return {
67
+ nome: _valorYaml(raw, 'name'),
68
+ descricao: _valorYaml(raw, 'description'),
69
+ tags: (tags || '').split(/[,\s]+/).map(s => s.trim().replace(/^["'-]+|["']+$/g, '')).filter(Boolean).slice(0, 8),
70
+ };
71
+ }
72
+
73
+ // Varre os diretórios de skills de TODOS os agentes conhecidos.
74
+ function locais({ dirs = DIRS_AGENTES, lerDir = null, lerArq = null } = {}) {
75
+ const _ls = lerDir || ((d) => fs.readdirSync(d));
76
+ const _read = lerArq || ((p) => fs.readFileSync(p, 'utf8'));
77
+ const out = [];
78
+ const vistos = new Set();
79
+ for (const [agente, dir] of dirs) {
80
+ let slugs = [];
81
+ try { slugs = _ls(dir); } catch (_) { continue; }
82
+ for (const slug of slugs) {
83
+ if (vistos.has(slug)) continue; // a do TS ganha (é a 1ª da lista)
84
+ const md = path.join(dir, slug, 'SKILL.md');
85
+ let raw; try { raw = _read(md); } catch (_) { continue; }
86
+ const fm = _frontmatter(raw);
87
+ vistos.add(slug);
88
+ // `corpo` (só das INSTALADAS) entra no índice de busca para dar recall: a descrição
89
+ // sozinha é curta e perde sinônimo ("redesenhar a interface" × "redesign de UI").
90
+ // Só das instaladas porque é conteúdo já aprovado pelo usuário — o corpo de skill de
91
+ // terceiro não entra nem no índice nem no prompt.
92
+ const corpo = String(raw).replace(/^---[\s\S]*?---/, '').replace(/\s+/g, ' ').trim().slice(0, 800);
93
+ out.push({
94
+ slug, nome: fm.nome || slug, descricao: fm.descricao, tags: fm.tags, corpo,
95
+ origem: agente, caminho: md, instalada: true,
96
+ });
97
+ }
98
+ }
99
+ return out;
100
+ }
101
+
102
+ // ── normalização de português ────────────────────────────────────────────────
103
+ // O tokenize do indice.js foi feito pra CÓDIGO (identificadores ASCII): ele parte em
104
+ // acento ("fórmulas" vira "rmulas") e não trata plural. Descrição de skill é prosa em
105
+ // português, então aqui a busca precisa de mais: "planilha" tem que achar "planilhas".
106
+ // Normalizo NESTA camada em vez de mexer no indice.js — alterar o tokenize mudaria o
107
+ // ranking da busca de código, que é outra feature e já está calibrada.
108
+ // STOPWORDS: sem isso, "abrir a porta NO firewall" casava `commit-semantico` — porque
109
+ // "no" aparece nas duas frases e o BM25 conta como termo. Palavra vazia casa com tudo e
110
+ // produz exatamente o falso-positivo que mais atrapalha aqui. O tokenize do indice.js não
111
+ // filtra (foi feito pra código, onde não existe artigo).
112
+ const STOPWORDS = new Set([
113
+ 'a', 'o', 'as', 'os', 'um', 'uma', 'uns', 'umas', 'de', 'do', 'da', 'dos', 'das', 'em',
114
+ 'no', 'na', 'nos', 'nas', 'por', 'para', 'pra', 'pro', 'com', 'sem', 'ao', 'aos', 'e',
115
+ 'ou', 'que', 'se', 'ja', 'meu', 'minha', 'seu', 'sua', 'este', 'esta', 'isso', 'esse',
116
+ 'essa', 'aqui', 'ali', 'quero', 'preciso', 'fazer', 'faz', 'ter', 'tem', 'the', 'of',
117
+ 'to', 'in', 'on', 'for', 'and', 'or', 'with', 'my', 'this', 'that', 'is', 'are', 'it',
118
+ ]);
119
+
120
+ const ACENTOS = new RegExp('[\\u0300-\\u036f]', 'g');
121
+ function _semStopwords(texto) {
122
+ return String(texto || '').split(/\s+/).filter(w => w && !STOPWORDS.has(w)).join(' ');
123
+ }
124
+ function _normalizar(texto) {
125
+ return _semStopwords(String(texto || '')
126
+ .normalize('NFD').replace(ACENTOS, '') // fórmulas → formulas
127
+ .toLowerCase()
128
+ // plural simples do português: -oes/-aes/-ais/-eis → -ao/-al/-el, e -s final.
129
+ // Aplicado dos DOIS lados (índice e query), então basta ser consistente, não perfeito.
130
+ .replace(/\b(\w{3,}?)(?:oes|aes)\b/g, '$1ao')
131
+ .replace(/\b(\w{3,}?)(?:ais)\b/g, '$1al')
132
+ .replace(/\b(\w{4,})s\b/g, '$1')
133
+ // RADICAL de 6: BM25 é casamento exato de token, então "revisar" (query) nunca acharia
134
+ // "revisa" (descrição) — flexão verbal mata o recall em prosa portuguesa. Truncar dos
135
+ // DOIS lados resolve sem stemmer de verdade. 6 e não 4/5 de propósito: é o menor corte
136
+ // que resolveu os casos reais sem colar palavras não relacionadas.
137
+ .replace(/\b(\w{7,})\b/g, (m) => m.slice(0, 6)));
138
+ }
139
+
140
+ // NEGAÇÃO vira falso-positivo em busca lexical. Caso real: a skill `public-relations`
141
+ // descreve "...media strategy (NOT pull requests)" — o autor escreveu a negação justamente
142
+ // pra evitar confusão, e o BM25, que não entende "not", passou a casá-la com "revisar um
143
+ // pull request" acima de qualquer skill de code review. Tirar o trecho negado antes de
144
+ // indexar resolve na origem.
145
+ const NEGACOES = [
146
+ /\((?:not|não|nao)\s[^)]{0,80}\)/gi, // "(not pull requests)"
147
+ /\bnot\s+for\s+[^.;]{0,80}/gi, // "Not for prod monitoring"
148
+ /\bdo\s+not\s+use\s+(?:for|to|when)\s+[^.;]{0,80}/gi,
149
+ /\b(?:não|nao)\s+use\s+(?:para|quando)\s+[^.;]{0,80}/gi,
150
+ ];
151
+ function _semNegacao(texto) {
152
+ let s = String(texto || '');
153
+ for (const re of NEGACOES) s = s.replace(re, ' ');
154
+ return s;
155
+ }
156
+
157
+ // ── índice pesquisável (formato de indice.js) ────────────────────────────────
158
+ // Cada skill vira um "chunk". O campo `file` é o slug — o dedup por arquivo do search()
159
+ // então garante uma linha por skill, que é justamente o que se quer aqui.
160
+ function montarIndice(skills) {
161
+ const df = Object.create(null);
162
+ const chunks = [];
163
+ for (const s of skills || []) {
164
+ const texto = _normalizar(_semNegacao([s.slug, s.nome, s.descricao, (s.tags || []).join(' '), s.corpo || ''].filter(Boolean).join(' ')));
165
+ const toks = indice.tokenize(texto);
166
+ if (!toks.length) continue;
167
+ const tf = Object.create(null);
168
+ for (const t of toks) tf[t] = (tf[t] || 0) + 1;
169
+ for (const t in tf) df[t] = (df[t] || 0) + 1;
170
+ chunks.push({ file: s.slug, l0: 1, l1: 1, tf, len: toks.length, snippet: s.descricao || s.nome, sig: [], _skill: s });
171
+ }
172
+ const totalLen = chunks.reduce((a, c) => a + c.len, 0);
173
+ return { v: 1, root: '', built: null, N: chunks.length, avgdl: chunks.length ? totalLen / chunks.length : 0, df, chunks, files: chunks.length };
174
+ }
175
+
176
+ // LIMIAR: BM25 não é normalizado — o score depende do corpus (medido no catálogo real:
177
+ // 16.8 pra "mensagem de commit", 5.5 pra "revisar pull request"). Por isso são DOIS
178
+ // limiares, e não um:
179
+ //
180
+ // LIMIAR (1.0) — busca EXPLÍCITA (`ts skills buscar`). O usuário está lendo a lista e
181
+ // julga sozinho; recall vale mais que precisão.
182
+ //
183
+ // LIMIAR_PROMPT (3.5) — injeção AUTOMÁTICA no prompt. Aqui um falso-positivo empurra o
184
+ // agente pro caminho errado sem ninguém revisar. Medido: "fazer deploy do container
185
+ // docker" casa `doca-container-deployment` (que é DPU BlueField, não Docker) com 6.6 —
186
+ // ou seja, nem 3.5 elimina todo falso-positivo em catálogo de domínio alheio. Daí o
187
+ // texto do bloco dizer "se servir CLARAMENTE" e nunca autorizar instalação sozinho.
188
+ const LIMIAR = 1.0;
189
+ const LIMIAR_PROMPT = 3.5;
190
+ // destaque mínimo do 1º sobre o 2º pra sugerir no prompt (empate técnico = ruído).
191
+ // Medido em corpus de 486 skills: 1.15 mantém os bons matches e corta 5 dos 6 falsos.
192
+ const DESTAQUE_PROMPT = 1.15;
193
+
194
+ // `destaque` = quanto o 1º precisa superar o 2º pra a sugestão valer. Limiar absoluto
195
+ // sozinho não escala: com 486 skills os scores dobraram e QUASE TODA tarefa passava a
196
+ // receber duas sugestões, a maioria irrelevante ("criar um dockerfile" casava
197
+ // `tao-run-on-local-docker` com 7.56 × 7.52 do segundo — empate técnico = ruído).
198
+ // Quando vários resultados empatam, é sinal de que a query casou por palavra genérica;
199
+ // quando um se destaca de verdade, aí sim há skill pra aquilo.
200
+ function buscar(skills, consulta, { k = 3, limiar = LIMIAR, destaque = 0, idx = null } = {}) {
201
+ const texto = String(consulta || '').trim();
202
+ if (!texto) return [];
203
+ const _idx = idx || montarIndice(skills);
204
+ if (!_idx.N) return [];
205
+ const consultaNorm = _normalizar(texto);
206
+ const brutos = indice.search('', consultaNorm, Math.max(k * 3, 9), _idx) || [];
207
+
208
+ // BÔNUS DE COBERTURA: o BM25 soma IDF×tf, então UM termo raro pode ganhar de dois termos
209
+ // certeiros. Medido: "criar componente react" ranqueava `pdf` (cobre só "criar") junto de
210
+ // `web-artifacts-builder` (cobre "componente"+"react"). Cobrir mais da pergunta é sinal
211
+ // forte de aderência. É BÔNUS e não filtro: como filtro, eliminaria `xlsx` em "gerar
212
+ // planilha de custos", onde o match legítimo cobre um termo só.
213
+ const termosQuery = [...new Set(indice.tokenize(consultaNorm))];
214
+ const porChunk = new Map(_idx.chunks.map(c => [c.file, c]));
215
+ const comBonus = brutos.map(r => {
216
+ const c = porChunk.get(r.file);
217
+ const cobertos = c ? termosQuery.filter(t => c.tf[t]).length : 0;
218
+ const cobertura = termosQuery.length ? cobertos / termosQuery.length : 0;
219
+ return Object.assign({}, r, { score: r.score * (1 + 0.6 * cobertura), cobertura });
220
+ }).sort((a, b) => b.score - a.score);
221
+
222
+ const acima = comBonus.filter(r => r.score >= limiar);
223
+ if (!acima.length) return [];
224
+ if (destaque > 1 && acima.length > 1 && acima[0].score < acima[1].score * destaque) return [];
225
+ const porSlug = new Map(_idx.chunks.map(c => [c.file, c._skill]));
226
+ return acima
227
+ .slice(0, k)
228
+ .map(r => Object.assign({ score: Math.round(r.score * 100) / 100 }, porSlug.get(r.file) || { slug: r.file }));
229
+ }
230
+
231
+ // ── cache do catálogo remoto ─────────────────────────────────────────────────
232
+ function lerCache({ agora = Date.now() } = {}) {
233
+ try {
234
+ const j = JSON.parse(fs.readFileSync(CACHE, 'utf8'));
235
+ if (!j || !Array.isArray(j.skills)) return null;
236
+ if (agora - (j.em || 0) > TTL_MS) return { ...j, vencido: true };
237
+ return j;
238
+ } catch (_) { return null; }
239
+ }
240
+
241
+ function gravarCache(skills, { agora = Date.now() } = {}) {
242
+ try {
243
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
244
+ fs.writeFileSync(CACHE, JSON.stringify({ v: 1, em: agora, skills }));
245
+ return true;
246
+ } catch (_) { return false; }
247
+ }
248
+
249
+ // Catálogo remoto = galeria do TS + fontes externas. `fetchGaleria` e `fetchExternas` são
250
+ // INJETADOS pra este módulo continuar testável sem rede.
251
+ async function atualizar({ fetchGaleria = null, fetchExternas = null, agora = Date.now() } = {}) {
252
+ const out = [];
253
+ if (fetchGaleria) {
254
+ try {
255
+ for (const s of (await fetchGaleria()) || []) {
256
+ out.push({ slug: s.slug, nome: s.name || s.nome || s.slug, descricao: s.description || s.descricao || '',
257
+ tags: s.tags || [], origem: 'galeria', instalada: false });
258
+ }
259
+ } catch (_) { /* galeria fora do ar não pode derrubar a busca local */ }
260
+ }
261
+ if (fetchExternas) {
262
+ try {
263
+ for (const s of (await fetchExternas()) || []) {
264
+ // `caminhoRepo` PRECISA sobreviver ao cache: é por ele que `ts skills add` baixa o
265
+ // SKILL.md depois. Sem esse campo, a instalação caía na galeria e dava 404 — ou
266
+ // seja, o comando que o próprio agente sugere não funcionaria.
267
+ out.push({ slug: s.slug, nome: s.nome || s.slug, descricao: s.descricao || '', tags: s.tags || [],
268
+ origem: s.origem || 'externa', url: s.url || '', caminhoRepo: s.caminhoRepo || '', instalada: false });
269
+ }
270
+ } catch (_) {}
271
+ }
272
+ if (out.length) gravarCache(out, { agora });
273
+ return out;
274
+ }
275
+
276
+ // A visão completa: instaladas (de qualquer agente) + catálogo em cache, sem duplicar.
277
+ function todas({ locaisIn = null, cache = null } = {}) {
278
+ const inst = locaisIn || locais();
279
+ const slugs = new Set(inst.map(s => s.slug));
280
+ const c = cache !== null ? cache : (lerCache() || { skills: [] });
281
+ const remotas = (c.skills || []).filter(s => !slugs.has(s.slug));
282
+ return inst.concat(remotas);
283
+ }
284
+
285
+ // ── bloco do prompt ──────────────────────────────────────────────────────────
286
+ // Só entra o que passou no limiar. Skill NÃO instalada aparece apenas com nome, descrição
287
+ // e origem — o CORPO dela nunca entra sem instalação aprovada, porque skill de terceiro é
288
+ // conteúdo não-confiável (mesma razão de AGENTS.md ser lido como dado, não como ordem).
289
+ function blocoSugestao(candidatas, lang = 'pt') {
290
+ const naoInstaladas = (candidatas || []).filter(s => !s.instalada);
291
+ if (!naoInstaladas.length) return '';
292
+ const linhas = naoInstaladas.map(s =>
293
+ `- ${s.slug}${s.nome && s.nome !== s.slug ? ' (' + s.nome + ')' : ''}: ${String(s.descricao || '').slice(0, 160)}` +
294
+ (s.origem ? ` [${s.origem}]` : ''));
295
+ return lang === 'en'
296
+ ? '\n\nSKILLS AVAILABLE BUT NOT INSTALLED (they match this task):\n' + linhas.join('\n') +
297
+ '\nThese are NOT loaded. If one clearly fits, TELL the user it exists and that `ts skills add <slug>` installs it — never install on your own.'
298
+ : '\n\nSKILLS QUE EXISTEM MAS NÃO ESTÃO INSTALADAS (casaram com esta tarefa):\n' + linhas.join('\n') +
299
+ '\nElas NÃO estão carregadas. Se uma servir claramente, AVISE o usuário que ela existe e que `ts skills add <slug>` instala — nunca instale por conta própria.';
300
+ }
301
+
302
+ // ── fontes externas (repos no formato Agent Skills) ──────────────────────────
303
+ // Registro em ARQUIVO (~/.ts/skill-sources.json) pra somar catálogo sem release novo.
304
+ // O default vem vazio de propósito: nada de terceiro entra no índice sem o usuário pedir.
305
+ const FONTES = path.join(os.homedir(), '.ts', 'skill-sources.json');
306
+
307
+ function fontes({ ler = null } = {}) {
308
+ try {
309
+ const j = JSON.parse((ler || ((p) => fs.readFileSync(p, 'utf8')))(FONTES));
310
+ return Array.isArray(j) ? j : (j && Array.isArray(j.fontes) ? j.fontes : []);
311
+ } catch (_) { return []; }
312
+ }
313
+
314
+ function addFonte(repo, { pasta = 'skills' } = {}) {
315
+ const r = String(repo || '').trim().replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '').replace(/\/+$/, '');
316
+ if (!/^[\w.-]+\/[\w.-]+$/.test(r)) throw new Error('formato esperado: <org>/<repo> (ex: NVIDIA/skills)');
317
+ const atuais = fontes();
318
+ if (atuais.some(f => f.repo === r)) return atuais;
319
+ atuais.push({ repo: r, pasta });
320
+ fs.mkdirSync(path.dirname(FONTES), { recursive: true });
321
+ fs.writeFileSync(FONTES, JSON.stringify({ fontes: atuais }, null, 2));
322
+ return atuais;
323
+ }
324
+
325
+ function rmFonte(repo) {
326
+ const restantes = fontes().filter(f => f.repo !== repo);
327
+ fs.writeFileSync(FONTES, JSON.stringify({ fontes: restantes }, null, 2));
328
+ return restantes;
329
+ }
330
+
331
+ // Repos costumam DUPLICAR as mesmas skills por agente (`.gemini/`, `.claude/`, `.cursor/`).
332
+ // Indexar tudo encheria o catálogo com o mesmo conteúdo 3x — puro ruído na busca.
333
+ const PASTAS_DUPLICATA = /^\.(gemini|claude|cursor|codex|agents|kiro)/i;
334
+
335
+ // Descobre em que pasta o repo guarda as skills (nem todo mundo usa `skills/`) escolhendo
336
+ // a de maior contagem, ignorando as pastas de duplicata por agente.
337
+ function _pastaPrincipal(caminhos) {
338
+ const cont = {};
339
+ for (const p of caminhos) {
340
+ const raiz = p.split('/')[0];
341
+ if (PASTAS_DUPLICATA.test(raiz)) continue;
342
+ cont[raiz] = (cont[raiz] || 0) + 1;
343
+ }
344
+ const top = Object.entries(cont).sort((a, b) => b[1] - a[1])[0];
345
+ return top ? top[0] : null;
346
+ }
347
+
348
+ // Baixa o índice de UM repositório no formato Agent Skills.
349
+ // UMA chamada à Git Trees API traz a árvore inteira; os SKILL.md vêm do raw.githubusercontent,
350
+ // que é CDN e NÃO consome o rate limit da API (60/h anônimo) — é isso que permite catálogo
351
+ // grande. O que limita é o tempo, então os downloads vão em paralelo com concorrência fixa.
352
+ async function indexarRepo(repo, { pasta = null, teto = 500, concorrencia = 8, fetchImpl = null, onProgresso = null } = {}) {
353
+ const _fetch = fetchImpl || fetch;
354
+ const arvore = await _fetch('https://api.github.com/repos/' + repo + '/git/trees/HEAD?recursive=1',
355
+ { headers: { 'User-Agent': 'terminal-smart-cli', 'Accept': 'application/vnd.github+json' } });
356
+ if (!arvore || !arvore.ok) {
357
+ const st = (arvore && arvore.status) || '?';
358
+ throw new Error(st === 403
359
+ ? 'GitHub recusou (HTTP 403) — provável rate limit da API; tente de novo em alguns minutos'
360
+ : 'não consegui ler o repositório ' + repo + ' (HTTP ' + st + ')');
361
+ }
362
+ const j = await arvore.json();
363
+ const todosMd = (j.tree || []).filter(n => n.type === 'blob' && /(^|\/)SKILL\.md$/i.test(n.path)).map(n => n.path);
364
+ const _pasta = pasta || _pastaPrincipal(todosMd);
365
+ if (!_pasta) return { skills: [], total: todosMd.length, lidas: 0, pasta: null };
366
+
367
+ const alvos = todosMd.filter(p => p.startsWith(_pasta + '/')).slice(0, teto);
368
+ const out = [];
369
+ let feitos = 0;
370
+
371
+ // fila com N workers: 500 downloads sequenciais levariam minutos
372
+ const fila = alvos.slice();
373
+ const worker = async () => {
374
+ for (;;) {
375
+ const p = fila.shift();
376
+ if (!p) return;
377
+ const slug = p.slice(_pasta.length + 1, -('/SKILL.md'.length)).replace(/\//g, '-');
378
+ try {
379
+ const r = await _fetch('https://raw.githubusercontent.com/' + repo + '/HEAD/' + p, { headers: { 'User-Agent': 'terminal-smart-cli' } });
380
+ if (r && r.ok) {
381
+ const fm = _frontmatter(await r.text());
382
+ // sem descrição a skill é invisível na busca — não vale ocupar espaço no índice
383
+ if (fm.descricao || fm.nome) {
384
+ out.push({ slug, nome: fm.nome || slug, descricao: fm.descricao, tags: fm.tags,
385
+ origem: repo, caminhoRepo: p, url: 'https://github.com/' + repo + '/blob/HEAD/' + p, instalada: false });
386
+ }
387
+ }
388
+ } catch (_) { /* uma skill ilegível não invalida o catálogo inteiro */ }
389
+ feitos++;
390
+ if (onProgresso) { try { onProgresso(feitos, alvos.length); } catch (_) {} }
391
+ }
392
+ };
393
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(concorrencia, 16)) }, worker));
394
+ return { skills: out, total: todosMd.length, lidas: out.length, pasta: _pasta, truncado: todosMd.filter(p => p.startsWith(_pasta + '/')).length > teto };
395
+ }
396
+
397
+ // Baixa o SKILL.md de uma skill EXTERNA já indexada (pra `ts skills add` funcionar com
398
+ // catálogo, não só com a galeria — sem isto, a sugestão que o agente dá não funciona).
399
+ async function baixarSkill(skill, { fetchImpl = null } = {}) {
400
+ const _fetch = fetchImpl || fetch;
401
+ if (!skill || !skill.origem || !skill.caminhoRepo) throw new Error('skill sem origem rastreável');
402
+ const url = 'https://raw.githubusercontent.com/' + skill.origem + '/HEAD/' + skill.caminhoRepo;
403
+ const r = await _fetch(url, { headers: { 'User-Agent': 'terminal-smart-cli' } });
404
+ if (!r || !r.ok) throw new Error('não consegui baixar (HTTP ' + ((r && r.status) || '?') + ')');
405
+ return await r.text();
406
+ }
407
+
408
+ module.exports = {
409
+ CACHE, TTL_MS, DIRS_AGENTES, LIMIAR, LIMIAR_PROMPT, DESTAQUE_PROMPT, FONTES,
410
+ locais, montarIndice, buscar, todas, _normalizar,
411
+ lerCache, gravarCache, atualizar, blocoSugestao,
412
+ fontes, addFonte, rmFonte, indexarRepo, baixarSkill, _pastaPrincipal, _semNegacao,
413
+ _frontmatter,
414
+ };
package/lib/temas.js ADDED
@@ -0,0 +1,101 @@
1
+ // lib/temas.js — paletas do CLI.
2
+ //
3
+ // Trocar tema é irracional e é exatamente por isso que engaja: dev gosta de escolher tema.
4
+ // Custa pouco e é a primeira coisa que se compara entre CLIs.
5
+ //
6
+ // A regra que separa isto de enfeite: as cores são SEMÂNTICAS, não decorativas.
7
+ // ok = comprovado / passou no gate (o TS prova entrega — esta cor significa prova)
8
+ // warn = precisa da sua aprovação
9
+ // err = bloqueado / falhou
10
+ // dim = leitura, contexto, coisa inativa
11
+ // Um tema pode mudar o TOM de cada uma; nenhum pode trocar o significado. Por isso a
12
+ // paleta define os quatro papéis, e não "uma cor pra cada coisa que aparecer".
13
+ //
14
+ // `terminal` é o tema que NÃO usa RGB: emite os códigos ANSI básicos (30-37), que o
15
+ // emulador pinta com o esquema do próprio usuário. É o "herda do terminal".
16
+ 'use strict';
17
+
18
+ const TEMAS = {
19
+ padrao: {
20
+ nome: 'Padrão — cyan → indigo',
21
+ primaria: [34, 211, 238], secundaria: [129, 140, 248],
22
+ ok: [52, 211, 153], warn: [251, 191, 36], err: [248, 113, 113],
23
+ },
24
+ oceano: {
25
+ nome: 'Oceano — azul profundo',
26
+ primaria: [56, 189, 248], secundaria: [59, 130, 246],
27
+ ok: [45, 212, 191], warn: [250, 204, 21], err: [244, 114, 182],
28
+ },
29
+ ambar: {
30
+ nome: 'Âmbar — quente, alto contraste',
31
+ primaria: [251, 191, 36], secundaria: [249, 115, 22],
32
+ ok: [163, 230, 53], warn: [253, 224, 71], err: [239, 68, 68],
33
+ },
34
+ matrix: {
35
+ nome: 'Matrix — verde fósforo',
36
+ primaria: [74, 222, 128], secundaria: [34, 197, 94],
37
+ ok: [134, 239, 172], warn: [250, 204, 21], err: [248, 113, 113],
38
+ },
39
+ violeta: {
40
+ nome: 'Violeta — roxo/rosa',
41
+ primaria: [167, 139, 250], secundaria: [236, 72, 153],
42
+ ok: [110, 231, 183], warn: [252, 211, 77], err: [251, 113, 133],
43
+ },
44
+ mono: {
45
+ nome: 'Mono — sem cor decorativa, só os estados',
46
+ primaria: [156, 163, 175], secundaria: [107, 114, 128],
47
+ ok: [156, 163, 175], warn: [209, 213, 219], err: [248, 113, 113],
48
+ },
49
+ terminal: {
50
+ nome: 'Terminal — herda o esquema do seu emulador',
51
+ ansi: { primaria: 36, secundaria: 34, ok: 32, warn: 33, err: 31 },
52
+ },
53
+ };
54
+
55
+ const PADRAO = 'padrao';
56
+ function nomes() { return Object.keys(TEMAS); }
57
+ function info(id) { return TEMAS[String(id || '').toLowerCase()] || null; }
58
+ function existe(id) { return !!info(id); }
59
+
60
+ // Constrói as funções de cor do tema. `tty=false` (pipe/NO_COLOR) devolve texto puro —
61
+ // a saída do CLI continua parseável por script, que é o comportamento de sempre.
62
+ function montar(id, tty) {
63
+ const t = info(id) || TEMAS[PADRAO];
64
+ const puro = (s) => String(s);
65
+ if (!tty) {
66
+ return { cyan: puro, indigo: puro, ok: puro, warn: puro, err: puro, bold: puro, dim: puro, _tema: id || PADRAO };
67
+ }
68
+ const rgb = (c) => (s) => `\x1b[38;2;${c[0]};${c[1]};${c[2]}m${s}\x1b[39m`;
69
+ const ansi = (n) => (s) => `\x1b[${n}m${s}\x1b[39m`;
70
+ const cor = t.ansi
71
+ ? { primaria: ansi(t.ansi.primaria), secundaria: ansi(t.ansi.secundaria), ok: ansi(t.ansi.ok), warn: ansi(t.ansi.warn), err: ansi(t.ansi.err) }
72
+ : { primaria: rgb(t.primaria), secundaria: rgb(t.secundaria), ok: rgb(t.ok), warn: rgb(t.warn), err: rgb(t.err) };
73
+ return {
74
+ // `cyan`/`indigo` mantêm o NOME antigo de propósito: são usados em centenas de pontos
75
+ // do CLI. O que muda é o valor — renomear tudo seria um diff enorme sem ganho.
76
+ cyan: cor.primaria,
77
+ indigo: cor.secundaria,
78
+ ok: cor.ok, warn: cor.warn, err: cor.err,
79
+ bold: (s) => `\x1b[1m${s}\x1b[22m`,
80
+ dim: (s) => `\x1b[2m${s}\x1b[22m`,
81
+ _tema: id || PADRAO,
82
+ };
83
+ }
84
+
85
+ // Gradiente primária→secundária, caractere a caractere (a assinatura visual do ts).
86
+ // Tema ANSI não interpola (só tem 16 cores): devolve tudo na primária.
87
+ function gradienteDe(id, texto, tty) {
88
+ const t = info(id) || TEMAS[PADRAO];
89
+ if (!tty) return String(texto);
90
+ if (t.ansi) return `\x1b[${t.ansi.primaria}m${texto}\x1b[39m`;
91
+ const a = t.primaria, b = t.secundaria;
92
+ const chars = [...String(texto)];
93
+ const n = Math.max(1, chars.length - 1);
94
+ return chars.map((ch, i) => {
95
+ const k = i / n;
96
+ const [r, g, bl] = a.map((v, j) => Math.round(v + (b[j] - v) * k));
97
+ return `\x1b[38;2;${r};${g};${bl}m${ch}`;
98
+ }).join('') + '\x1b[39m';
99
+ }
100
+
101
+ module.exports = { TEMAS, PADRAO, nomes, info, existe, montar, gradienteDe };