terminal-smart-cli 0.32.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/lib/tools.js ADDED
@@ -0,0 +1,397 @@
1
+ // Ferramentas LOCAIS do agente ts (Fase 2) — executam NA máquina onde o CLI roda.
2
+ // Segurança: comando destrutivo passa por aprovação (e é RECUSADO em --yes).
3
+ // Economia: saída de comando passa pelo compactor (ideia RTK) antes do contexto.
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const crypto = require('crypto');
8
+ const compactor = require('./compactor');
9
+
10
+ // ── SNAPSHOT antes de sobrescrever ──────────────────────────────────────────
11
+ // Toda escrita/edição faz uma cópia do original em ~/.ts/backups/<hash>/<nome>.<ts>.bak
12
+ // ANTES de gravar. Uma edição malfeita do modelo (comum em missão noturna) passa a ser
13
+ // REVERSÍVEL via ferramenta restaurar_arquivo. Best-effort: nunca bloqueia/derruba a escrita.
14
+ const BK_DIR = path.join(os.homedir(), '.ts', 'backups');
15
+ function _bkKey(absPath) { return crypto.createHash('md5').update(String(absPath).toLowerCase()).digest('hex').slice(0, 12); }
16
+ function _snapshot(absPath) {
17
+ try {
18
+ if (!fs.existsSync(absPath) || !fs.statSync(absPath).isFile()) return null;
19
+ if (fs.statSync(absPath).size > 4 * 1024 * 1024) return null; // não versiona binário/enorme
20
+ const dir = path.join(BK_DIR, _bkKey(absPath));
21
+ fs.mkdirSync(dir, { recursive: true });
22
+ // timestamp seguro (ISO com : e . trocados) — ordena lexicograficamente = cronológico
23
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
24
+ const dest = path.join(dir, path.basename(absPath) + '.' + stamp + '.bak');
25
+ fs.copyFileSync(absPath, dest);
26
+ try { fs.writeFileSync(path.join(dir, '_path.txt'), absPath); } catch (_) {}
27
+ // poda: mantém os 10 backups mais recentes DESTE arquivo
28
+ try {
29
+ const pref = path.basename(absPath) + '.';
30
+ const baks = fs.readdirSync(dir).filter(f => f.startsWith(pref) && f.endsWith('.bak')).sort();
31
+ while (baks.length > 10) { try { fs.unlinkSync(path.join(dir, baks.shift())); } catch (_) {} }
32
+ } catch (_) {}
33
+ return dest;
34
+ } catch (_) { return null; }
35
+ }
36
+
37
+ // Padrões de comando destrutivo/irreversível — SEMPRE pedem aprovação humana.
38
+ const DESTRUCTIVE = [
39
+ /\brm\s+(-[a-z]*[rf][a-z]*\s+)/i, /\brm\s+.*\*/, /\brmdir\b/i, /\bdel\s+\/[sq]/i, /\brd\s+\/s/i,
40
+ /\bmkfs\b/i, /\bdd\s+if=/i, /(?:^|[\s&;|])format\s+[a-z]:/i, /\bdiskpart\b/i, /> ?\/dev\/sd/i,
41
+ /\bshutdown\b/i, /\breboot\b/i, /\bhalt\b/i, /\bpoweroff\b/i,
42
+ /\bDROP\s+(TABLE|DATABASE)\b/i, /\bTRUNCATE\b/i, /\bDELETE\s+FROM\b[^;]*(;|$)(?![^]*WHERE)/i,
43
+ /\bchmod\s+(-R\s+)?777\b/i, /\bgit\s+push\s+.*--force(?!-with-lease)/i, /\bgit\s+reset\s+--hard/i,
44
+ /:\(\)\s*\{/, /\bcurl\b[^|]*\|\s*(sudo\s+)?(ba)?sh/i, /\bwget\b[^|]*\|\s*(sudo\s+)?(ba)?sh/i,
45
+ /\bkillall\b/i, /\bpkill\b/i, /\buserdel\b/i, /\bpasswd\b/i,
46
+ /\btaskkill\b.*\/im\b/i, /\btaskkill\b.*\/f\b.*\/im/i, // taskkill /IM mata TODOS os processos daquele nome (ex: node.exe = a própria missão)
47
+ /\bStop-Process\b[^|;&]*-Name\b/i, /\bGet-Process\b[^|]*\|[^|]*\bStop-Process\b/i, // PowerShell: matar por NOME mata todos (=taskkill /IM); Stop-Process -Id <pid> segue liberado
48
+ /\bsystemctl\s+(stop|disable|mask)\b/i, /\bdocker\s+(rm|rmi|system\s+prune|volume\s+rm)\b/i,
49
+ ];
50
+ function isDestructive(cmd) { return DESTRUCTIVE.some(re => re.test(String(cmd || ''))); }
51
+
52
+ // Definições no formato OpenAI function-calling — schemas SIMPLES de propósito
53
+ // (Gemini via CDC rejeita propertyNames/additionalProperties; só type/properties/required).
54
+ const DEFS = [
55
+ { type: 'function', function: { name: 'executar_comando',
56
+ description: 'Executa um comando de shell NESTA máquina e retorna stdout/stderr/código. Use pra diagnosticar, instalar, construir, testar.',
57
+ parameters: { type: 'object', properties: {
58
+ comando: { type: 'string', description: 'o comando completo' },
59
+ timeout_s: { type: 'number', description: 'timeout em segundos (padrão 60, máx 300)' },
60
+ }, required: ['comando'] } } },
61
+ { type: 'function', function: { name: 'ler_arquivo',
62
+ description: 'Lê um arquivo de TEXTO local. Arquivo grande vem paginado (use inicio/fim pra ler por partes).',
63
+ parameters: { type: 'object', properties: {
64
+ caminho: { type: 'string' },
65
+ inicio: { type: 'number', description: 'linha inicial (1-based, opcional)' },
66
+ fim: { type: 'number', description: 'linha final (opcional)' },
67
+ }, required: ['caminho'] } } },
68
+ { type: 'function', function: { name: 'escrever_arquivo',
69
+ description: 'Cria ou sobrescreve um arquivo local com o conteúdo dado (cria as pastas se preciso). Para MUDAR UM TRECHO de arquivo que JÁ EXISTE, prefira editar_arquivo (mais barato e preciso).',
70
+ parameters: { type: 'object', properties: {
71
+ caminho: { type: 'string' },
72
+ conteudo: { type: 'string' },
73
+ }, required: ['caminho', 'conteudo'] } } },
74
+ { type: 'function', function: { name: 'editar_arquivo',
75
+ description: 'Edita UM TRECHO de um arquivo existente: acha o texto EXATO de "buscar" e troca por "substituir" — sem reescrever o arquivo inteiro (economia + zero risco de perder o resto). "buscar" deve ser ÚNICO no arquivo (inclua 2-3 linhas de contexto ao redor); se ambíguo/não achado, a ferramenta explica como corrigir.',
76
+ parameters: { type: 'object', properties: {
77
+ caminho: { type: 'string' },
78
+ buscar: { type: 'string', description: 'trecho EXATO atual (com contexto suficiente pra ser único no arquivo)' },
79
+ substituir: { type: 'string', description: 'novo trecho que substitui o buscado' },
80
+ todas: { type: 'boolean', description: 'true = substitui TODAS as ocorrências (padrão: exige ocorrência única)' },
81
+ }, required: ['caminho', 'buscar', 'substituir'] } } },
82
+ { type: 'function', function: { name: 'restaurar_arquivo',
83
+ description: 'Desfaz a última escrita/edição de um arquivo, restaurando o backup automático mais recente (feito ANTES de sobrescrever). Use quando uma edição ficou errada e você quer voltar o arquivo ao estado anterior.',
84
+ parameters: { type: 'object', properties: {
85
+ caminho: { type: 'string', description: 'o arquivo a restaurar ao estado anterior' },
86
+ }, required: ['caminho'] } } },
87
+ { type: 'function', function: { name: 'listar_diretorio',
88
+ description: 'Lista arquivos e pastas de um diretório local (nome, tipo, tamanho).',
89
+ parameters: { type: 'object', properties: {
90
+ caminho: { type: 'string', description: 'padrão: diretório atual' },
91
+ }, required: [] } } },
92
+ { type: 'function', function: { name: 'mudar_diretorio',
93
+ description: 'Muda o DIRETÓRIO DE TRABALHO da sessão (equivale a "cd"). A partir daí, caminhos RELATIVOS (ex: "js/input.js") e comandos de shell rodam a partir desta pasta. Use assim que souber em qual projeto/pasta trabalhar, para não ficar buscando arquivos pela máquina toda.',
94
+ parameters: { type: 'object', properties: {
95
+ caminho: { type: 'string', description: 'pasta destino (absoluta ou relativa à pasta atual)' },
96
+ }, required: ['caminho'] } } },
97
+ { type: 'function', function: { name: 'buscar_arquivos',
98
+ description: 'Procura arquivos por nome (parte do nome) a partir de um diretório, recursivo.',
99
+ parameters: { type: 'object', properties: {
100
+ padrao: { type: 'string', description: 'trecho do nome do arquivo' },
101
+ diretorio: { type: 'string', description: 'padrão: diretório atual' },
102
+ }, required: ['padrao'] } } },
103
+ { type: 'function', function: { name: 'info_sistema',
104
+ description: 'Informações desta máquina: SO, memória, CPU, uptime, usuário, diretório atual.',
105
+ parameters: { type: 'object', properties: {}, required: [] } } },
106
+ { type: 'function', function: { name: 'conectar_vps',
107
+ description: 'Loga num SERVIDOR REMOTO por SSH (usa o perfil salvo em ~/.ts/config.json OU host/user/chave passados). Depois disso, use executar_remoto pra rodar comandos NO servidor. Ideal pra encontrar/inspecionar/editar projetos numa VPS.',
108
+ parameters: { type: 'object', properties: {
109
+ host: { type: 'string', description: 'IP ou hostname (opcional se já tem perfil salvo)' },
110
+ usuario: { type: 'string', description: 'ex: ubuntu, root (opcional)' },
111
+ chave: { type: 'string', description: 'caminho da chave privada .pem (opcional)' },
112
+ }, required: [] } } },
113
+ { type: 'function', function: { name: 'executar_remoto',
114
+ description: 'Executa um comando de shell NO SERVIDOR REMOTO já conectado (via conectar_vps) e retorna stdout/stderr/código. Use pra listar/ler/editar arquivos e diagnosticar na VPS. Comando destrutivo passa por aprovação, igual ao local.',
115
+ parameters: { type: 'object', properties: {
116
+ comando: { type: 'string', description: 'o comando completo a rodar no servidor' },
117
+ }, required: ['comando'] } } },
118
+ { type: 'function', function: { name: 'preciso_de_voce',
119
+ description: 'Use APENAS quando estiver bloqueado por uma AÇÃO EXTERNA que SÓ O USUÁRIO pode fazer (criar conta/projeto num site, fornecer uma chave de API/senha, aprovar um pagamento, conectar um aparelho, mexer num painel web). NUNCA use pra coisas que você mesmo consegue (instalar pacote, escrever arquivo, rodar comando). A missão vai PAUSAR e pedir a ação ao usuário; ela retoma quando ele terminar.',
120
+ parameters: { type: 'object', properties: {
121
+ motivo: { type: 'string', description: 'por que você está bloqueado (1 linha)' },
122
+ o_que_fazer: { type: 'string', description: 'o passo a passo EXATO que o usuário precisa fazer' },
123
+ }, required: ['motivo', 'o_que_fazer'] } } },
124
+ { type: 'function', function: { name: 'lembrar',
125
+ description: 'Salva um FATO PERSISTENTE na memória (o usuário e você mesmo verão nas próximas vezes neste projeto). Use pra decisões importantes, armadilhas descobertas, como rodar/testar, escolhas de arquitetura, credenciais NÃO-secretas (ex: porta, nome do banco). NÃO salve segredo/senha/chave. Fato curto e objetivo, 1 linha.',
126
+ parameters: { type: 'object', properties: {
127
+ fato: { type: 'string', description: 'o fato a lembrar (1 linha, objetivo)' },
128
+ global: { type: 'boolean', description: 'true = vale em QUALQUER projeto (preferência do usuário); padrão false = só este projeto' },
129
+ }, required: ['fato'] } } },
130
+ { type: 'function', function: { name: 'mapa_projeto',
131
+ description: 'Devolve o MAPA de dependências do projeto (quais arquivos importam quais) SEM ler todo o código — rápido e barato. Use pra entender a ESTRUTURA de um projeto grande e achar o arquivo certo antes de editar, em vez de abrir vários arquivos.',
132
+ parameters: { type: 'object', properties: {
133
+ diretorio: { type: 'string', description: 'raiz do projeto (padrão: diretório atual)' },
134
+ }, required: [] } } },
135
+ { type: 'function', function: { name: 'explorar',
136
+ description: 'Delega uma INVESTIGAÇÃO a um sub-agente SÓ-LEITURA que roda em contexto SEPARADO e devolve APENAS um resumo. Use quando precisar LER/vasculhar VÁRIOS arquivos pra responder algo (ex: "onde está a lógica de login?", "resuma como o projeto trata erros") — o sub-agente faz as leituras pesadas sem poluir o SEU contexto (economia de tokens). Não pode editar nada.',
137
+ parameters: { type: 'object', properties: {
138
+ tarefa: { type: 'string', description: 'a pergunta/investigação a delegar (clara e específica)' },
139
+ }, required: ['tarefa'] } } },
140
+ ];
141
+
142
+ // GRAFO DE IMPORTS (ideia do Graphfy/Knowledge Graph): parser DETERMINÍSTICO — lê só as
143
+ // linhas de import/require/include de cada arquivo (não o código todo) e monta "quem usa quem".
144
+ // Custo ZERO de tokens pra construir; o agente só gasta ao PEDIR o mapa. Suporta js/ts, py, kt/java, dart, go, rb, php.
145
+ const _MAP_SKIP = new Set(['node_modules', '.git', 'build', 'dist', '.gradle', '.dart_tool', 'vendor', '__pycache__', '.next', 'target', 'bin', 'obj', 'coverage']);
146
+ const _CODE_RE = /\.(m?[jt]sx?|py|kt|java|dart|go|rb|php|vue|svelte)$/i;
147
+ function buildProjectMap(root, max = 400) {
148
+ const files = [];
149
+ const walk = (d, rel, depth) => {
150
+ if (depth > 8 || files.length >= max) return;
151
+ let list; try { list = fs.readdirSync(d, { withFileTypes: true }); } catch (_) { return; }
152
+ for (const e of list) {
153
+ if (files.length >= max) return;
154
+ if (_MAP_SKIP.has(e.name) || e.name.startsWith('.')) continue;
155
+ const r = rel ? rel + '/' + e.name : e.name;
156
+ if (e.isDirectory()) walk(path.join(d, e.name), r, depth + 1);
157
+ else if (_CODE_RE.test(e.name)) files.push({ rel: r.replace(/\\/g, '/'), abs: path.join(d, e.name) });
158
+ }
159
+ };
160
+ try { walk(root, '', 0); } catch (_) {}
161
+ const importRe = /(?:import\s+(?:.+?\s+from\s+)?|export\s+.*?\s+from\s+|require\s*\(\s*|from\s+|include\s+|__import__\s*\(\s*)['"]([^'"]+)['"]/g;
162
+ const known = new Set(files.map(f => f.rel));
163
+ const edges = [];
164
+ const deg = {}; // in-degree por arquivo (quantos dependem dele)
165
+ for (const f of files) {
166
+ let src = ''; try { src = fs.readFileSync(f.abs, 'utf8').slice(0, 40000); } catch (_) { continue; }
167
+ const heads = src.split('\n').filter(l => /^\s*(import|from|const|let|var|require|export|include|use|package)\b/.test(l) || /require\s*\(/.test(l)).join('\n');
168
+ const seen = new Set(); let m;
169
+ while ((m = importRe.exec(heads))) {
170
+ let spec = m[1];
171
+ if (/^[a-z@]/i.test(spec) && !spec.startsWith('.') && !spec.startsWith('/')) continue; // pacote externo — ignora
172
+ // resolve relativo → arquivo conhecido do projeto
173
+ const base = path.posix.normalize(path.posix.join(path.posix.dirname(f.rel), spec));
174
+ const cand = [base, base + '.js', base + '.ts', base + '.jsx', base + '.tsx', base + '.py', base + '.dart', base + '/index.js', base + '/index.ts'];
175
+ const hit = cand.find(c => known.has(c));
176
+ if (hit && hit !== f.rel && !seen.has(hit)) { seen.add(hit); edges.push([f.rel, hit]); deg[hit] = (deg[hit] || 0) + 1; }
177
+ }
178
+ }
179
+ const hubs = Object.entries(deg).sort((a, b) => b[1] - a[1]).slice(0, 8).map(([f, n]) => `${f} (usado por ${n})`);
180
+ return { arquivos: files.length, dependencias: edges.length, hubs, edges: edges.slice(0, 200) };
181
+ }
182
+
183
+ const BIN_RE = /\.(xlsx?|docx?|pptx?|pdf|zip|rar|7z|png|jpe?g|gif|webp|ico|mp3|mp4|avi|exe|dll|bin|db|sqlite|tgz|gz)$/i;
184
+
185
+ // Resolve um caminho do modelo contra a BASE de trabalho da sessão (o cwd do
186
+ // agente), não contra o process.cwd() do Node. Assim "js/input.js" cai na pasta
187
+ // certa mesmo com o ts iniciado do home. Caminho absoluto é respeitado; "~" expande.
188
+ function _abs(p, base) {
189
+ let s = String(p == null ? '' : p);
190
+ if (s === '~' || s.startsWith('~/') || s.startsWith('~\\')) s = path.join(os.homedir(), s.slice(1));
191
+ return path.resolve(base || process.cwd(), s);
192
+ }
193
+
194
+ async function execute(name, input, opts = {}) {
195
+ // BASE de trabalho: cwd da sessão do agente (opts.baseDir) → pasta confinada da
196
+ // missão (opts.confineDir) → process.cwd(). É a raiz de todo caminho relativo.
197
+ const baseDir = opts.baseDir || opts.confineDir || process.cwd();
198
+ try {
199
+ switch (name) {
200
+ case 'executar_comando': {
201
+ const comando = String(input.comando || '');
202
+ const timeout = Math.min(300, Math.max(5, Number(input.timeout_s) || 60)) * 1000;
203
+ const cmd = process.platform === 'win32' ? `chcp 65001>nul & ${comando}` : comando;
204
+ return await new Promise((res) => {
205
+ require('child_process').exec(cmd, { timeout, shell: true, windowsHide: true, maxBuffer: 4 * 1024 * 1024, cwd: fs.existsSync(baseDir) ? baseDir : undefined }, (e, out, err) => {
206
+ const c = compactor.compact(comando, out || '', { codigo: e?.code ?? 0 });
207
+ const r = { stdout: c.out, stderr: compactor.stripAnsi(err || '').slice(0, 1500), codigo: e?.code ?? 0 };
208
+ if (c.note) r.aviso = c.note;
209
+ if (!r.stdout.trim() && !r.stderr.trim() && r.codigo === 0 && /python3? -c/.test(comando) && comando.includes('\n'))
210
+ r.aviso = 'stdout VAZIO: no Windows, python -c multi-linha falha em silêncio. ESCREVA um .py com escrever_arquivo e rode "python arquivo.py".';
211
+ res(r);
212
+ });
213
+ });
214
+ }
215
+ case 'conectar_vps': {
216
+ const ssh = require('./ssh');
217
+ try {
218
+ const r = await ssh.connect({ host: input.host, user: input.usuario, keyPath: input.chave });
219
+ const who = await ssh.exec('echo "$(whoami)@$(hostname) | $(. /etc/os-release 2>/dev/null; echo $PRETTY_NAME) | pwd=$(pwd)"');
220
+ return { conectado: true, servidor: `${r.user}@${r.host}:${r.port}`, info: (who.stdout || '').trim() };
221
+ } catch (e) { return { conectado: false, erro: String(e.message).slice(0, 300) + ' — configure com "ts vps set" ou passe host/usuario/chave.' }; }
222
+ }
223
+ case 'executar_remoto': {
224
+ const ssh = require('./ssh');
225
+ if (!ssh.isConnected()) return { erro: 'Não conectado a nenhum servidor. Chame conectar_vps primeiro.' };
226
+ const comando = String(input.comando || '');
227
+ const out = await ssh.exec(comando, { timeoutMs: 180000 });
228
+ const c = compactor.compact(comando, out.stdout || '', { codigo: out.code });
229
+ const r = { remoto: (ssh.info() && ssh.info().host) || '', stdout: c.out, stderr: compactor.stripAnsi(out.stderr || '').slice(0, 1500), codigo: out.code };
230
+ if (c.note) r.aviso = c.note;
231
+ return r;
232
+ }
233
+ case 'ler_arquivo': {
234
+ const p = _abs(input.caminho, baseDir);
235
+ if (BIN_RE.test(p)) return { erro: 'Arquivo binário — ler_arquivo só lê texto. Use executar_comando com uma ferramenta adequada.' };
236
+ const txt = fs.readFileSync(p, 'utf8');
237
+ const linhas = txt.split('\n'); const total = linhas.length; const MAXL = 400;
238
+ const ini = parseInt(input.inicio) || 0;
239
+ if (ini > 0) {
240
+ const a = Math.max(1, ini) - 1; const b = (parseInt(input.fim) || 0) > 0 ? parseInt(input.fim) : Math.min(total, a + MAXL);
241
+ return { conteudo: linhas.slice(a, b).join('\n'), linhas_total: total, intervalo: `${a + 1}-${Math.min(b, total)}` };
242
+ }
243
+ if (total > MAXL) return { conteudo: linhas.slice(0, MAXL).join('\n'), linhas_total: total, aviso: `Arquivo grande (${total} linhas): mostrando 1-${MAXL}. Chame de novo com inicio/fim.` };
244
+ return { conteudo: txt, linhas_total: total };
245
+ }
246
+ case 'escrever_arquivo': {
247
+ let p = _abs(input.caminho, baseDir);
248
+ // Confinamento (missões): modelos fracos inventam pastas absolutas
249
+ // (AndroidStudioProjects, Sdk/projects…). Em vez de RECUSAR (o que empurra o
250
+ // modelo a burlar via shell "echo >"), RE-BASEIA silenciosamente pra dentro
251
+ // do diretório de trabalho, preservando a estrutura do projeto (app/src/…).
252
+ let _rebased = null;
253
+ if (opts.confineDir) {
254
+ const base = path.resolve(opts.confineDir);
255
+ if (p !== base && !p.startsWith(base + path.sep)) {
256
+ const parts = p.split(path.sep);
257
+ const i = parts.findIndex(s => ['app', 'src', 'main', 'gradle'].includes(s.toLowerCase()));
258
+ const rel = i >= 0 ? parts.slice(i).join(path.sep) : parts[parts.length - 1];
259
+ p = path.join(base, rel);
260
+ _rebased = p;
261
+ }
262
+ }
263
+ fs.mkdirSync(path.dirname(p), { recursive: true });
264
+ const existed = fs.existsSync(p);
265
+ if (existed) _snapshot(p); // backup antes de sobrescrever (reversível via restaurar_arquivo)
266
+ fs.writeFileSync(p, String(input.conteudo ?? ''), 'utf8');
267
+ const r = { ok: true, caminho: p, bytes: Buffer.byteLength(String(input.conteudo ?? '')), sobrescreveu: existed };
268
+ if (_rebased) r.aviso = 'Caminho fora do diretório de trabalho foi RE-BASEADO para dentro dele. Use ESTE caminho a partir de agora: ' + p;
269
+ return r;
270
+ }
271
+ case 'editar_arquivo': {
272
+ // Edição por ÂNCORA (ideia do hashline do OMYP): troca SÓ o trecho buscado, sem
273
+ // reescrever o arquivo — modelo barato edita com precisão, gasta menos tokens de
274
+ // saída e não corre o risco de "perder" o resto do arquivo num rewrite.
275
+ let p = _abs(input.caminho, baseDir);
276
+ if (opts.confineDir) { // mesmo confinamento por rebase do escrever_arquivo
277
+ const base = path.resolve(opts.confineDir);
278
+ if (p !== base && !p.startsWith(base + path.sep)) {
279
+ const parts = p.split(path.sep);
280
+ const i = parts.findIndex(s => ['app', 'src', 'main', 'gradle'].includes(s.toLowerCase()));
281
+ p = path.join(base, i >= 0 ? parts.slice(i).join(path.sep) : parts[parts.length - 1]);
282
+ }
283
+ }
284
+ if (!fs.existsSync(p)) return { erro: 'Arquivo não existe: ' + p + '. Pra criar arquivo NOVO use escrever_arquivo.' };
285
+ const orig = fs.readFileSync(p, 'utf8');
286
+ let buscar = String(input.buscar ?? ''), substituir = String(input.substituir ?? '');
287
+ if (!buscar) return { erro: 'buscar vazio.' };
288
+ // tolerância CRLF: o modelo manda \n mas arquivo Windows tem \r\n — normaliza o par
289
+ if (!orig.includes(buscar) && orig.includes('\r\n')) {
290
+ const b2 = buscar.replace(/\r?\n/g, '\r\n');
291
+ if (orig.includes(b2)) { buscar = b2; substituir = substituir.replace(/\r?\n/g, '\r\n'); }
292
+ }
293
+ const count = orig.split(buscar).length - 1; // split com string é literal (sem regex)
294
+ if (count === 0) {
295
+ const alvo = buscar.split(/\r?\n/)[0].trim().slice(0, 40);
296
+ const lines = orig.split(/\r?\n/);
297
+ const idx = alvo ? lines.findIndex(l => l.includes(alvo.slice(0, 25))) : -1;
298
+ return { erro: 'Trecho de "buscar" NÃO encontrado — tem que ser EXATO (mesmos espaços/indentação). '
299
+ + (idx >= 0 ? `Linha parecida (linha ${idx + 1}): ${lines[idx].slice(0, 160)} — releia com ler_arquivo e copie exato.` : 'Releia o arquivo com ler_arquivo e copie o trecho exato.') };
300
+ }
301
+ if (count > 1 && !input.todas) return { erro: `Trecho AMBÍGUO: ${count} ocorrências. Inclua mais linhas de contexto em "buscar" pra ficar único, ou passe todas:true.` };
302
+ const novo = input.todas ? orig.split(buscar).join(substituir) : orig.replace(buscar, substituir);
303
+ _snapshot(p); // backup antes de editar (reversível via restaurar_arquivo)
304
+ fs.writeFileSync(p, novo, 'utf8');
305
+ return { ok: true, caminho: p, ocorrencias: input.todas ? count : 1, bytes_antes: Buffer.byteLength(orig), bytes_depois: Buffer.byteLength(novo) };
306
+ }
307
+ case 'restaurar_arquivo': {
308
+ const p = _abs(input.caminho, baseDir);
309
+ const dir = path.join(BK_DIR, _bkKey(p));
310
+ let baks = [];
311
+ try { baks = fs.readdirSync(dir).filter(f => f.endsWith('.bak')).sort(); } catch (_) {}
312
+ if (!baks.length) return { erro: 'Sem backup automático para ' + p + ' — nada a restaurar (só há backup de arquivos que o agente já sobrescreveu/editou nesta máquina).' };
313
+ const ultimo = path.join(dir, baks[baks.length - 1]); // mais recente = versão de antes da última escrita
314
+ _snapshot(p); // guarda o estado ATUAL antes de reverter (permite "refazer")
315
+ fs.copyFileSync(ultimo, p);
316
+ return { ok: true, caminho: p, restaurado: true, de_backup: path.basename(ultimo) };
317
+ }
318
+ case 'listar_diretorio': {
319
+ const p = _abs(input.caminho || '.', baseDir);
320
+ const items = fs.readdirSync(p, { withFileTypes: true }).slice(0, 200).map(d => {
321
+ let size = null; try { if (d.isFile()) size = fs.statSync(path.join(p, d.name)).size; } catch (_) {}
322
+ return { nome: d.name, tipo: d.isDirectory() ? 'dir' : 'arquivo', bytes: size };
323
+ });
324
+ return { caminho: p, total: items.length, itens: items };
325
+ }
326
+ case 'mudar_diretorio': {
327
+ // Muda o cwd da SESSÃO. O loop do agente lê _setCwd e passa a resolver os
328
+ // próximos caminhos relativos (e comandos) a partir daqui.
329
+ const alvo = _abs(input.caminho || input.diretorio || '.', baseDir);
330
+ let st; try { st = fs.statSync(alvo); } catch (_) { return { erro: 'Pasta não existe: ' + alvo + '. Confira o caminho (use listar_diretorio/buscar_arquivos).' }; }
331
+ if (!st.isDirectory()) return { erro: 'Não é uma pasta: ' + alvo };
332
+ return { ok: true, cwd: alvo, _setCwd: alvo };
333
+ }
334
+ case 'buscar_arquivos': {
335
+ const base = _abs(input.diretorio || '.', baseDir);
336
+ const alvo = String(input.padrao || '').toLowerCase();
337
+ if (!alvo) return { erro: 'padrao vazio' };
338
+ const hits = [];
339
+ const walk = (dir, depth) => {
340
+ if (depth > 6 || hits.length >= 100) return;
341
+ let list; try { list = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; }
342
+ for (const d of list) {
343
+ if (hits.length >= 100) return;
344
+ if (d.name === 'node_modules' || d.name === '.git' || d.name.startsWith('$')) continue;
345
+ const full = path.join(dir, d.name);
346
+ if (d.name.toLowerCase().includes(alvo)) hits.push(full);
347
+ if (d.isDirectory()) walk(full, depth + 1);
348
+ }
349
+ };
350
+ walk(base, 0);
351
+ return { base, total: hits.length, arquivos: hits, ...(hits.length >= 100 ? { aviso: 'parou em 100 resultados' } : {}) };
352
+ }
353
+ case 'info_sistema': {
354
+ return {
355
+ so: `${process.platform} ${os.release()}`, host: os.hostname(), usuario: os.userInfo().username,
356
+ diretorio_atual: baseDir, home: os.homedir(),
357
+ cpus: os.cpus().length, mem_total_gb: +(os.totalmem() / 1e9).toFixed(1), mem_livre_gb: +(os.freemem() / 1e9).toFixed(1),
358
+ uptime_h: +(os.uptime() / 3600).toFixed(1), node: process.version,
359
+ };
360
+ }
361
+ case 'preciso_de_voce': {
362
+ // BLOQUEIO DETERMINÍSTICO (não confia no modelo obedecer o prompt): recusa pedidos
363
+ // que o SISTEMA já resolve — caminho de SDK/Android/Flutter/toolchain, testar/instalar
364
+ // o app, ou info que o executor deveria pegar do objetivo. Devolve como resultado de
365
+ // ferramenta (não pausa) → o agente continua na mesma rodada com o que tem.
366
+ const _m = (String(input.motivo || '') + ' ' + String(input.o_que_fazer || '')).toLowerCase();
367
+ if (/\bsdk\b|android sdk|flutter|toolchain|caminho.*(sdk|android|flutter|gradle|java|jdk)|gradle|jdk|java.?home|instalar? o app|testar? o app|rodar? o app|emulador/.test(_m)) {
368
+ return { erro: 'RECUSADO: isso o SISTEMA já resolve sozinho — o SDK/toolchain é provisionado automaticamente pelo build gate (local.properties, gradle, JDK) e o app é compilado/instalado/testado a cada rodada. O caminho do SDK já está no objetivo. NÃO use preciso_de_voce pra isso: CONTINUE o item com o que você tem, apenas escrevendo os arquivos de código. Aja agora.' };
369
+ }
370
+ // Sentinela real: o loop do agente detecta e PAUSA a missão pedindo a ação humana.
371
+ return { _needHuman: true, motivo: String(input.motivo || '').slice(0, 300), o_que_fazer: String(input.o_que_fazer || '').slice(0, 1200) };
372
+ }
373
+ case 'lembrar': {
374
+ // memória de projeto/global — o agente registra fatos que persistem entre execuções
375
+ const memoria = require('./memoria');
376
+ const dir = opts.confineDir || opts.baseDir || process.cwd();
377
+ // guarda leve: não deixa vazar segredo pra memória (a memória não é lugar de senha)
378
+ if (/senha|password|secret|api[_-]?key|token|sk-[a-z0-9]/i.test(String(input.fato || ''))) {
379
+ return { erro: 'RECUSADO: isso parece um segredo (senha/chave/token). A memória NÃO guarda segredo. Reformule sem o valor secreto.' };
380
+ }
381
+ const f = memoria.append(dir, input.fato, !!input.global);
382
+ if (!f) return { erro: 'fato vazio.' };
383
+ return { ok: true, salvo_em: f, escopo: input.global ? 'global' : 'projeto' };
384
+ }
385
+ case 'mapa_projeto': {
386
+ const base = _abs(input.diretorio || '.', baseDir);
387
+ const m = buildProjectMap(base);
388
+ if (!m.arquivos) return { aviso: 'Nenhum arquivo de código reconhecido em ' + base + ' (js/ts/py/kt/java/dart/go/rb/php).', arquivos: 0 };
389
+ return { diretorio: base, arquivos: m.arquivos, dependencias: m.dependencias,
390
+ hubs: m.hubs, mapa: m.edges.map(([a, b]) => a + ' -> ' + b) };
391
+ }
392
+ default: return { erro: 'Ferramenta desconhecida: ' + name };
393
+ }
394
+ } catch (e) { return { erro: String((e && e.message) || e).slice(0, 400) }; }
395
+ }
396
+
397
+ module.exports = { DEFS, execute, isDestructive, buildProjectMap };
package/lib/ui.js ADDED
@@ -0,0 +1,106 @@
1
+ // UI do CLI — identidade Terminal Smart no terminal: cyan→indigo, cantos
2
+ // arredondados, spinner braille. Respeita NO_COLOR e saída não-TTY (pipe = texto puro).
3
+ const TTY = !!process.stdout.isTTY && !process.env.NO_COLOR;
4
+
5
+ const esc = (s) => TTY ? s : '';
6
+ const rgb = (r, g, b) => (s) => TTY ? `\x1b[38;2;${r};${g};${b}m${s}\x1b[39m` : String(s);
7
+
8
+ const C = {
9
+ cyan: rgb(34, 211, 238),
10
+ indigo: rgb(129, 140, 248),
11
+ ok: rgb(52, 211, 153),
12
+ warn: rgb(251, 191, 36),
13
+ err: rgb(248, 113, 113),
14
+ bold: (s) => TTY ? `\x1b[1m${s}\x1b[22m` : String(s),
15
+ dim: (s) => TTY ? `\x1b[2m${s}\x1b[22m` : String(s),
16
+ };
17
+
18
+ // Gradiente cyan→indigo caractere a caractere (a assinatura visual do ts)
19
+ function gradient(text) {
20
+ if (!TTY) return text;
21
+ const a = [34, 211, 238], b = [129, 140, 248];
22
+ const chars = [...text];
23
+ const n = Math.max(1, chars.length - 1);
24
+ return chars.map((ch, i) => {
25
+ const k = i / n;
26
+ const [r, g, bl] = a.map((v, j) => Math.round(v + (b[j] - v) * k));
27
+ return `\x1b[38;2;${r};${g};${bl}m${ch}`;
28
+ }).join('') + '\x1b[39m';
29
+ }
30
+
31
+ function banner(version, tagline) {
32
+ const title = 'T E R M I N A L S M A R T';
33
+ return `\n ${C.cyan('⌁')} ${C.bold(gradient(title))}\n ${gradient('━'.repeat(title.length + 2))}\n ${C.dim(tagline)} ${C.dim('· CLI v' + version)}\n`;
34
+ }
35
+
36
+ const _width = () => Math.min(process.stdout.columns || 80, 96);
37
+
38
+ // Caixa arredondada com título opcional. lines = strings JÁ coloridas.
39
+ function box(lines, { title = '', color = C.cyan } = {}) {
40
+ const strip = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, '');
41
+ const w = Math.min(_width() - 4, Math.max(...lines.map(l => strip(l).length), strip(title).length + 2, 20));
42
+ const top = title
43
+ ? color('╭─ ') + C.bold(title) + ' ' + color('─'.repeat(Math.max(0, w - strip(title).length - 1)) + '╮')
44
+ : color('╭' + '─'.repeat(w + 2) + '╮');
45
+ const body = lines.map(l => color('│ ') + l + ' '.repeat(Math.max(0, w - strip(l).length)) + color(' │'));
46
+ return [top, ...body, color('╰' + '─'.repeat(w + 2) + '╯')].join('\n');
47
+ }
48
+
49
+ // Spinner braille — start/text/stop; em não-TTY vira no-op silencioso.
50
+ function spinner(initial) {
51
+ const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
52
+ let i = 0, text = initial || '', timer = null;
53
+ const paint = () => process.stderr.write(`\r\x1b[2K ${C.cyan(frames[i = (i + 1) % frames.length])} ${C.dim(text)}`);
54
+ return {
55
+ start() { if (TTY && !timer) { process.stderr.write(esc('\x1b[?25l')); timer = setInterval(paint, 90); } return this; },
56
+ text(t) { text = t; return this; },
57
+ stop(finalLine) {
58
+ if (timer) { clearInterval(timer); timer = null; process.stderr.write('\r\x1b[2K'); process.stderr.write(esc('\x1b[?25h')); }
59
+ if (finalLine) process.stderr.write(' ' + finalLine + '\n');
60
+ return this;
61
+ },
62
+ };
63
+ }
64
+
65
+ // Markdown-lite: negrito, código inline, blocos de código com borda, títulos, listas.
66
+ function md(text) {
67
+ if (!TTY) return String(text);
68
+ const out = [];
69
+ const lines = String(text).split('\n');
70
+ let inCode = false;
71
+ for (const line of lines) {
72
+ if (/^\s*```/.test(line)) { inCode = !inCode; out.push(C.dim(' ' + (inCode ? '╭──' : '╰──'))); continue; }
73
+ if (inCode) { out.push(C.dim(' │ ') + C.cyan(line)); continue; }
74
+ let l = line;
75
+ // [CMD]…[/CMD] é a marcação de comando do chat web (/ia) — aqui vira código destacado
76
+ l = l.replace(/\[CMD\]\s*([\s\S]*?)\s*\[\/CMD\]/g, (_, c) => C.cyan('$ ' + c));
77
+ l = l.replace(/\[\/?CMD\]/g, '');
78
+ l = l.replace(/^#{1,3}\s+(.+)$/, (_, h) => C.bold(C.cyan(h)));
79
+ l = l.replace(/\*\*([^*]+)\*\*/g, (_, b) => C.bold(b));
80
+ l = l.replace(/`([^`]+)`/g, (_, c) => C.cyan(c));
81
+ l = l.replace(/^(\s*)[-*]\s+/, (_, sp) => sp + C.cyan('•') + ' ');
82
+ out.push(l);
83
+ }
84
+ return out.join('\n');
85
+ }
86
+
87
+ // Barra de progresso ▰▰▰▱▱ (uso de créditos)
88
+ function bar(frac, width = 22) {
89
+ const n = Math.round(Math.max(0, Math.min(1, frac)) * width);
90
+ const color = frac >= 0.9 ? C.err : frac >= 0.75 ? C.warn : C.cyan;
91
+ return color('▰'.repeat(n)) + C.dim('▱'.repeat(width - n));
92
+ }
93
+
94
+ // Pergunta sim/não no terminal
95
+ function ask(question) {
96
+ return new Promise((resolve) => {
97
+ const rl = require('readline').createInterface({ input: process.stdin, output: process.stderr });
98
+ rl.question(' ' + question, (ans) => { rl.close(); resolve(String(ans || '').trim().toLowerCase()); });
99
+ });
100
+ }
101
+
102
+ const okLine = (s) => ' ' + C.ok('✔') + ' ' + s;
103
+ const errLine = (s) => ' ' + C.err('✖') + ' ' + s;
104
+ const infoLine = (s) => ' ' + C.cyan('⌁') + ' ' + s;
105
+
106
+ module.exports = { C, TTY, gradient, banner, box, spinner, md, bar, ask, okLine, errLine, infoLine };
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "terminal-smart-cli",
3
+ "version": "0.32.0",
4
+ "description": "Terminal Smart no seu terminal — pergunte, analise logs por pipe e orquestre agentes de IA. Comando: ts",
5
+ "bin": {
6
+ "ts": "bin/ts.js"
7
+ },
8
+ "files": [
9
+ "bin",
10
+ "lib",
11
+ "README.md"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18"
15
+ },
16
+ "keywords": [
17
+ "devops",
18
+ "ai",
19
+ "cli",
20
+ "terminal",
21
+ "ssh",
22
+ "agente"
23
+ ],
24
+ "author": "Terminal Smart <contato@terminalsmart.com.br>",
25
+ "homepage": "https://terminalsmart.com.br/cli",
26
+ "bugs": {
27
+ "url": "https://terminalsmart.com.br/cli"
28
+ },
29
+ "license": "UNLICENSED",
30
+ "dependencies": {
31
+ "puppeteer-core": "^23.11.1",
32
+ "qrcode-terminal": "^0.12.0",
33
+ "ssh2": "^1.17.0"
34
+ }
35
+ }