terminal-smart-cli 0.94.2 → 0.97.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/bin/ts.js +501 -8
- package/lib/agent.js +142 -9
- package/lib/checkpoint.js +155 -0
- package/lib/conhecimento.js +224 -0
- package/lib/core.js +4 -1
- package/lib/device-tools.js +226 -0
- package/lib/diagnose.js +8 -4
- package/lib/doctor.js +17 -0
- package/lib/eval.js +4 -2
- package/lib/i18n.js +28 -2
- package/lib/keyring.js +235 -0
- package/lib/meta.js +11 -5
- package/lib/office-readers.js +106 -0
- package/lib/policy.js +162 -0
- package/lib/providers.js +118 -0
- package/lib/router.js +3 -2
- package/lib/skill-index.js +398 -0
- package/lib/temas.js +101 -0
- package/lib/tools.js +100 -3
- package/lib/ui.js +19 -23
- package/package.json +3 -2
package/lib/providers.js
ADDED
|
@@ -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
|
|
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,398 @@
|
|
|
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 brutos = indice.search('', _normalizar(texto), Math.max(k * 3, 9), _idx) || [];
|
|
206
|
+
const acima = brutos.filter(r => r.score >= limiar);
|
|
207
|
+
if (!acima.length) return [];
|
|
208
|
+
if (destaque > 1 && acima.length > 1 && acima[0].score < acima[1].score * destaque) return [];
|
|
209
|
+
const porSlug = new Map(_idx.chunks.map(c => [c.file, c._skill]));
|
|
210
|
+
return acima
|
|
211
|
+
.slice(0, k)
|
|
212
|
+
.map(r => Object.assign({ score: Math.round(r.score * 100) / 100 }, porSlug.get(r.file) || { slug: r.file }));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── cache do catálogo remoto ─────────────────────────────────────────────────
|
|
216
|
+
function lerCache({ agora = Date.now() } = {}) {
|
|
217
|
+
try {
|
|
218
|
+
const j = JSON.parse(fs.readFileSync(CACHE, 'utf8'));
|
|
219
|
+
if (!j || !Array.isArray(j.skills)) return null;
|
|
220
|
+
if (agora - (j.em || 0) > TTL_MS) return { ...j, vencido: true };
|
|
221
|
+
return j;
|
|
222
|
+
} catch (_) { return null; }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function gravarCache(skills, { agora = Date.now() } = {}) {
|
|
226
|
+
try {
|
|
227
|
+
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
228
|
+
fs.writeFileSync(CACHE, JSON.stringify({ v: 1, em: agora, skills }));
|
|
229
|
+
return true;
|
|
230
|
+
} catch (_) { return false; }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Catálogo remoto = galeria do TS + fontes externas. `fetchGaleria` e `fetchExternas` são
|
|
234
|
+
// INJETADOS pra este módulo continuar testável sem rede.
|
|
235
|
+
async function atualizar({ fetchGaleria = null, fetchExternas = null, agora = Date.now() } = {}) {
|
|
236
|
+
const out = [];
|
|
237
|
+
if (fetchGaleria) {
|
|
238
|
+
try {
|
|
239
|
+
for (const s of (await fetchGaleria()) || []) {
|
|
240
|
+
out.push({ slug: s.slug, nome: s.name || s.nome || s.slug, descricao: s.description || s.descricao || '',
|
|
241
|
+
tags: s.tags || [], origem: 'galeria', instalada: false });
|
|
242
|
+
}
|
|
243
|
+
} catch (_) { /* galeria fora do ar não pode derrubar a busca local */ }
|
|
244
|
+
}
|
|
245
|
+
if (fetchExternas) {
|
|
246
|
+
try {
|
|
247
|
+
for (const s of (await fetchExternas()) || []) {
|
|
248
|
+
// `caminhoRepo` PRECISA sobreviver ao cache: é por ele que `ts skills add` baixa o
|
|
249
|
+
// SKILL.md depois. Sem esse campo, a instalação caía na galeria e dava 404 — ou
|
|
250
|
+
// seja, o comando que o próprio agente sugere não funcionaria.
|
|
251
|
+
out.push({ slug: s.slug, nome: s.nome || s.slug, descricao: s.descricao || '', tags: s.tags || [],
|
|
252
|
+
origem: s.origem || 'externa', url: s.url || '', caminhoRepo: s.caminhoRepo || '', instalada: false });
|
|
253
|
+
}
|
|
254
|
+
} catch (_) {}
|
|
255
|
+
}
|
|
256
|
+
if (out.length) gravarCache(out, { agora });
|
|
257
|
+
return out;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// A visão completa: instaladas (de qualquer agente) + catálogo em cache, sem duplicar.
|
|
261
|
+
function todas({ locaisIn = null, cache = null } = {}) {
|
|
262
|
+
const inst = locaisIn || locais();
|
|
263
|
+
const slugs = new Set(inst.map(s => s.slug));
|
|
264
|
+
const c = cache !== null ? cache : (lerCache() || { skills: [] });
|
|
265
|
+
const remotas = (c.skills || []).filter(s => !slugs.has(s.slug));
|
|
266
|
+
return inst.concat(remotas);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ── bloco do prompt ──────────────────────────────────────────────────────────
|
|
270
|
+
// Só entra o que passou no limiar. Skill NÃO instalada aparece apenas com nome, descrição
|
|
271
|
+
// e origem — o CORPO dela nunca entra sem instalação aprovada, porque skill de terceiro é
|
|
272
|
+
// conteúdo não-confiável (mesma razão de AGENTS.md ser lido como dado, não como ordem).
|
|
273
|
+
function blocoSugestao(candidatas, lang = 'pt') {
|
|
274
|
+
const naoInstaladas = (candidatas || []).filter(s => !s.instalada);
|
|
275
|
+
if (!naoInstaladas.length) return '';
|
|
276
|
+
const linhas = naoInstaladas.map(s =>
|
|
277
|
+
`- ${s.slug}${s.nome && s.nome !== s.slug ? ' (' + s.nome + ')' : ''}: ${String(s.descricao || '').slice(0, 160)}` +
|
|
278
|
+
(s.origem ? ` [${s.origem}]` : ''));
|
|
279
|
+
return lang === 'en'
|
|
280
|
+
? '\n\nSKILLS AVAILABLE BUT NOT INSTALLED (they match this task):\n' + linhas.join('\n') +
|
|
281
|
+
'\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.'
|
|
282
|
+
: '\n\nSKILLS QUE EXISTEM MAS NÃO ESTÃO INSTALADAS (casaram com esta tarefa):\n' + linhas.join('\n') +
|
|
283
|
+
'\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.';
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ── fontes externas (repos no formato Agent Skills) ──────────────────────────
|
|
287
|
+
// Registro em ARQUIVO (~/.ts/skill-sources.json) pra somar catálogo sem release novo.
|
|
288
|
+
// O default vem vazio de propósito: nada de terceiro entra no índice sem o usuário pedir.
|
|
289
|
+
const FONTES = path.join(os.homedir(), '.ts', 'skill-sources.json');
|
|
290
|
+
|
|
291
|
+
function fontes({ ler = null } = {}) {
|
|
292
|
+
try {
|
|
293
|
+
const j = JSON.parse((ler || ((p) => fs.readFileSync(p, 'utf8')))(FONTES));
|
|
294
|
+
return Array.isArray(j) ? j : (j && Array.isArray(j.fontes) ? j.fontes : []);
|
|
295
|
+
} catch (_) { return []; }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function addFonte(repo, { pasta = 'skills' } = {}) {
|
|
299
|
+
const r = String(repo || '').trim().replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '').replace(/\/+$/, '');
|
|
300
|
+
if (!/^[\w.-]+\/[\w.-]+$/.test(r)) throw new Error('formato esperado: <org>/<repo> (ex: NVIDIA/skills)');
|
|
301
|
+
const atuais = fontes();
|
|
302
|
+
if (atuais.some(f => f.repo === r)) return atuais;
|
|
303
|
+
atuais.push({ repo: r, pasta });
|
|
304
|
+
fs.mkdirSync(path.dirname(FONTES), { recursive: true });
|
|
305
|
+
fs.writeFileSync(FONTES, JSON.stringify({ fontes: atuais }, null, 2));
|
|
306
|
+
return atuais;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function rmFonte(repo) {
|
|
310
|
+
const restantes = fontes().filter(f => f.repo !== repo);
|
|
311
|
+
fs.writeFileSync(FONTES, JSON.stringify({ fontes: restantes }, null, 2));
|
|
312
|
+
return restantes;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Repos costumam DUPLICAR as mesmas skills por agente (`.gemini/`, `.claude/`, `.cursor/`).
|
|
316
|
+
// Indexar tudo encheria o catálogo com o mesmo conteúdo 3x — puro ruído na busca.
|
|
317
|
+
const PASTAS_DUPLICATA = /^\.(gemini|claude|cursor|codex|agents|kiro)/i;
|
|
318
|
+
|
|
319
|
+
// Descobre em que pasta o repo guarda as skills (nem todo mundo usa `skills/`) escolhendo
|
|
320
|
+
// a de maior contagem, ignorando as pastas de duplicata por agente.
|
|
321
|
+
function _pastaPrincipal(caminhos) {
|
|
322
|
+
const cont = {};
|
|
323
|
+
for (const p of caminhos) {
|
|
324
|
+
const raiz = p.split('/')[0];
|
|
325
|
+
if (PASTAS_DUPLICATA.test(raiz)) continue;
|
|
326
|
+
cont[raiz] = (cont[raiz] || 0) + 1;
|
|
327
|
+
}
|
|
328
|
+
const top = Object.entries(cont).sort((a, b) => b[1] - a[1])[0];
|
|
329
|
+
return top ? top[0] : null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Baixa o índice de UM repositório no formato Agent Skills.
|
|
333
|
+
// UMA chamada à Git Trees API traz a árvore inteira; os SKILL.md vêm do raw.githubusercontent,
|
|
334
|
+
// que é CDN e NÃO consome o rate limit da API (60/h anônimo) — é isso que permite catálogo
|
|
335
|
+
// grande. O que limita é o tempo, então os downloads vão em paralelo com concorrência fixa.
|
|
336
|
+
async function indexarRepo(repo, { pasta = null, teto = 500, concorrencia = 8, fetchImpl = null, onProgresso = null } = {}) {
|
|
337
|
+
const _fetch = fetchImpl || fetch;
|
|
338
|
+
const arvore = await _fetch('https://api.github.com/repos/' + repo + '/git/trees/HEAD?recursive=1',
|
|
339
|
+
{ headers: { 'User-Agent': 'terminal-smart-cli', 'Accept': 'application/vnd.github+json' } });
|
|
340
|
+
if (!arvore || !arvore.ok) {
|
|
341
|
+
const st = (arvore && arvore.status) || '?';
|
|
342
|
+
throw new Error(st === 403
|
|
343
|
+
? 'GitHub recusou (HTTP 403) — provável rate limit da API; tente de novo em alguns minutos'
|
|
344
|
+
: 'não consegui ler o repositório ' + repo + ' (HTTP ' + st + ')');
|
|
345
|
+
}
|
|
346
|
+
const j = await arvore.json();
|
|
347
|
+
const todosMd = (j.tree || []).filter(n => n.type === 'blob' && /(^|\/)SKILL\.md$/i.test(n.path)).map(n => n.path);
|
|
348
|
+
const _pasta = pasta || _pastaPrincipal(todosMd);
|
|
349
|
+
if (!_pasta) return { skills: [], total: todosMd.length, lidas: 0, pasta: null };
|
|
350
|
+
|
|
351
|
+
const alvos = todosMd.filter(p => p.startsWith(_pasta + '/')).slice(0, teto);
|
|
352
|
+
const out = [];
|
|
353
|
+
let feitos = 0;
|
|
354
|
+
|
|
355
|
+
// fila com N workers: 500 downloads sequenciais levariam minutos
|
|
356
|
+
const fila = alvos.slice();
|
|
357
|
+
const worker = async () => {
|
|
358
|
+
for (;;) {
|
|
359
|
+
const p = fila.shift();
|
|
360
|
+
if (!p) return;
|
|
361
|
+
const slug = p.slice(_pasta.length + 1, -('/SKILL.md'.length)).replace(/\//g, '-');
|
|
362
|
+
try {
|
|
363
|
+
const r = await _fetch('https://raw.githubusercontent.com/' + repo + '/HEAD/' + p, { headers: { 'User-Agent': 'terminal-smart-cli' } });
|
|
364
|
+
if (r && r.ok) {
|
|
365
|
+
const fm = _frontmatter(await r.text());
|
|
366
|
+
// sem descrição a skill é invisível na busca — não vale ocupar espaço no índice
|
|
367
|
+
if (fm.descricao || fm.nome) {
|
|
368
|
+
out.push({ slug, nome: fm.nome || slug, descricao: fm.descricao, tags: fm.tags,
|
|
369
|
+
origem: repo, caminhoRepo: p, url: 'https://github.com/' + repo + '/blob/HEAD/' + p, instalada: false });
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
} catch (_) { /* uma skill ilegível não invalida o catálogo inteiro */ }
|
|
373
|
+
feitos++;
|
|
374
|
+
if (onProgresso) { try { onProgresso(feitos, alvos.length); } catch (_) {} }
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(concorrencia, 16)) }, worker));
|
|
378
|
+
return { skills: out, total: todosMd.length, lidas: out.length, pasta: _pasta, truncado: todosMd.filter(p => p.startsWith(_pasta + '/')).length > teto };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Baixa o SKILL.md de uma skill EXTERNA já indexada (pra `ts skills add` funcionar com
|
|
382
|
+
// catálogo, não só com a galeria — sem isto, a sugestão que o agente dá não funciona).
|
|
383
|
+
async function baixarSkill(skill, { fetchImpl = null } = {}) {
|
|
384
|
+
const _fetch = fetchImpl || fetch;
|
|
385
|
+
if (!skill || !skill.origem || !skill.caminhoRepo) throw new Error('skill sem origem rastreável');
|
|
386
|
+
const url = 'https://raw.githubusercontent.com/' + skill.origem + '/HEAD/' + skill.caminhoRepo;
|
|
387
|
+
const r = await _fetch(url, { headers: { 'User-Agent': 'terminal-smart-cli' } });
|
|
388
|
+
if (!r || !r.ok) throw new Error('não consegui baixar (HTTP ' + ((r && r.status) || '?') + ')');
|
|
389
|
+
return await r.text();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
module.exports = {
|
|
393
|
+
CACHE, TTL_MS, DIRS_AGENTES, LIMIAR, LIMIAR_PROMPT, DESTAQUE_PROMPT, FONTES,
|
|
394
|
+
locais, montarIndice, buscar, todas, _normalizar,
|
|
395
|
+
lerCache, gravarCache, atualizar, blocoSugestao,
|
|
396
|
+
fontes, addFonte, rmFonte, indexarRepo, baixarSkill, _pastaPrincipal, _semNegacao,
|
|
397
|
+
_frontmatter,
|
|
398
|
+
};
|
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 };
|