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/README.md +55 -0
- package/bin/ts.js +1267 -0
- package/lib/acp.js +191 -0
- package/lib/agent.js +470 -0
- package/lib/api.js +77 -0
- package/lib/compactor.js +152 -0
- package/lib/config.js +20 -0
- package/lib/eval.js +243 -0
- package/lib/hooks.js +72 -0
- package/lib/i18n.js +294 -0
- package/lib/memoria.js +38 -0
- package/lib/meta.js +1199 -0
- package/lib/router.js +82 -0
- package/lib/skills.js +81 -0
- package/lib/ssh.js +93 -0
- package/lib/tools.js +397 -0
- package/lib/ui.js +106 -0
- package/package.json +35 -0
package/bin/ts.js
ADDED
|
@@ -0,0 +1,1267 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ⌁ Terminal Smart CLI — agente DevOps no seu terminal.
|
|
3
|
+
// Fase 1: cliente do backend (chat, orquestração, uso). Docs: terminalsmart.com.br/cli
|
|
4
|
+
'use strict';
|
|
5
|
+
const pkg = require('../package.json');
|
|
6
|
+
const config = require('../lib/config');
|
|
7
|
+
const { t } = require('../lib/i18n');
|
|
8
|
+
const ui = require('../lib/ui');
|
|
9
|
+
const router = require('../lib/router');
|
|
10
|
+
const { api, sse, base, ApiError } = require('../lib/api');
|
|
11
|
+
|
|
12
|
+
// Windows legado (cmd.exe): força UTF-8 pro desenho das caixas não virar "?".
|
|
13
|
+
if (process.platform === 'win32' && process.stdout.isTTY) {
|
|
14
|
+
try { require('child_process').execSync('chcp 65001', { stdio: 'ignore' }); } catch (_) {}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const rawArgs = process.argv.slice(2);
|
|
18
|
+
const FLAGS = new Set(rawArgs.filter(a => a.startsWith('-')));
|
|
19
|
+
const POS = rawArgs.filter(a => !a.startsWith('-'));
|
|
20
|
+
const JSON_OUT = FLAGS.has('--json');
|
|
21
|
+
const YES = FLAGS.has('--yes') || FLAGS.has('-y');
|
|
22
|
+
|
|
23
|
+
let cfg = config.load();
|
|
24
|
+
let T = t(cfg.lang || 'pt');
|
|
25
|
+
const { C } = ui;
|
|
26
|
+
|
|
27
|
+
function fmtK(n) { return n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n); }
|
|
28
|
+
function needToken() {
|
|
29
|
+
if (cfg.token) return cfg.token;
|
|
30
|
+
console.error(ui.errLine(T.need_login));
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
async function readStdin() {
|
|
34
|
+
if (process.stdin.isTTY) return '';
|
|
35
|
+
let d = '';
|
|
36
|
+
process.stdin.setEncoding('utf8');
|
|
37
|
+
for await (const c of process.stdin) { d += c; if (d.length > 120000) break; }
|
|
38
|
+
return d;
|
|
39
|
+
}
|
|
40
|
+
function fail(e) {
|
|
41
|
+
if (JSON_OUT) { console.log(JSON.stringify({ ok: false, error: (e && e.message) || 'erro', code: e && e.code })); process.exit(1); }
|
|
42
|
+
if (e instanceof ApiError) {
|
|
43
|
+
if (e.code === 'auth') console.error(ui.errLine(T.session_expired));
|
|
44
|
+
else if (e.code === 'conn') console.error(ui.errLine(T.err_conn));
|
|
45
|
+
else if (e.code === 'no_credits') console.error(ui.errLine(T.no_credits));
|
|
46
|
+
else if (e.code === 'plan_limit') console.error(ui.errLine(e.message)); // mensagem de upgrade vinda do servidor
|
|
47
|
+
else console.error(ui.errLine(`${T.err_generic}: ${e.message}`));
|
|
48
|
+
} else console.error(ui.errLine(`${T.err_generic}: ${(e && e.message) || e}`));
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Ajuda ────────────────────────────────────────────────────────────────────
|
|
53
|
+
function help() {
|
|
54
|
+
const out = [ui.banner(pkg.version, T.tagline)];
|
|
55
|
+
for (const g of T.help_groups) {
|
|
56
|
+
out.push(' ' + C.bold(C.indigo(g.title)));
|
|
57
|
+
const w = Math.max(...g.items.map(i => i[0].length)) + 3;
|
|
58
|
+
for (const [cmd, desc] of g.items) out.push(' ' + C.cyan(cmd.padEnd(w)) + C.dim(desc));
|
|
59
|
+
out.push('');
|
|
60
|
+
}
|
|
61
|
+
out.push(' ' + C.bold(C.indigo(T.help_examples)));
|
|
62
|
+
out.push(...[T.ex1, T.ex2, T.ex3].map(e => ' ' + C.dim('$ ') + e));
|
|
63
|
+
out.push('', ' ' + C.dim(T.help_footer), '');
|
|
64
|
+
console.log(out.join('\n'));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Login por código (device-code) ───────────────────────────────────────────
|
|
68
|
+
async function login() {
|
|
69
|
+
if (cfg.token) {
|
|
70
|
+
try {
|
|
71
|
+
const chk = await api('/api/auth/check', { token: cfg.token });
|
|
72
|
+
if (chk && (chk.success || chk.authenticated)) { console.log(ui.infoLine(T.login_already(cfg.username || '?'))); return; }
|
|
73
|
+
} catch (_) { /* sessão morta → segue pro pareamento */ }
|
|
74
|
+
}
|
|
75
|
+
const r = await api('/api/cli/pair/start', { method: 'POST' });
|
|
76
|
+
console.log(ui.banner(pkg.version, T.tagline));
|
|
77
|
+
console.log(ui.box([
|
|
78
|
+
C.bold(T.login_step1) + ' ' + C.cyan(r.url),
|
|
79
|
+
C.bold(T.login_step2),
|
|
80
|
+
'',
|
|
81
|
+
' ' + ui.gradient([...r.code].join(' ')),
|
|
82
|
+
'',
|
|
83
|
+
], { title: T.login_title }));
|
|
84
|
+
// conveniência: tenta abrir o navegador (best-effort; TS_NO_BROWSER=1 desliga p/ cron/ssh)
|
|
85
|
+
if (process.env.TS_NO_BROWSER !== '1') try {
|
|
86
|
+
const { exec } = require('child_process');
|
|
87
|
+
const cmd = process.platform === 'win32' ? `start "" "${r.url}"` : process.platform === 'darwin' ? `open "${r.url}"` : `xdg-open "${r.url}"`;
|
|
88
|
+
exec(cmd, () => {});
|
|
89
|
+
} catch (_) {}
|
|
90
|
+
const sp = ui.spinner(T.login_waiting).start();
|
|
91
|
+
const t0 = Date.now();
|
|
92
|
+
for (;;) {
|
|
93
|
+
await new Promise(res => setTimeout(res, 2500));
|
|
94
|
+
if (Date.now() - t0 > 10.5 * 60000) { sp.stop(ui.errLine(T.login_expired)); process.exit(1); }
|
|
95
|
+
let p;
|
|
96
|
+
try { p = await api('/api/cli/pair/poll', { method: 'POST', body: { poll: r.poll } }); } catch (_) { continue; }
|
|
97
|
+
if (p && p.status === 'ok') {
|
|
98
|
+
cfg = config.save({ token: p.token, username: p.username, plan: p.plan, convId: undefined });
|
|
99
|
+
sp.stop(ui.okLine(C.bold(T.login_ok(p.username))));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (p && (p.status === 'expired' || p.status === 'not_found')) { sp.stop(ui.errLine(T.login_expired)); process.exit(1); }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function logout() {
|
|
107
|
+
if (!cfg.token) { console.log(ui.infoLine(T.logout_none)); return; }
|
|
108
|
+
try { await api('/api/logout', { method: 'POST', token: cfg.token }); } catch (_) {}
|
|
109
|
+
config.save({ token: undefined, username: undefined, plan: undefined, convId: undefined });
|
|
110
|
+
console.log(ui.okLine(T.logout_ok));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Chat ─────────────────────────────────────────────────────────────────────
|
|
114
|
+
async function ensureConv(token) {
|
|
115
|
+
if (cfg.convId) return cfg.convId;
|
|
116
|
+
const r = await api('/api/conversations', { method: 'POST', token, body: { title: 'CLI' } });
|
|
117
|
+
cfg = config.save({ convId: r.id });
|
|
118
|
+
return r.id;
|
|
119
|
+
}
|
|
120
|
+
// Envia uma mensagem na conversa ativa e devolve o texto (recria a conversa 1x se apagada na web).
|
|
121
|
+
async function sendMessage(token, content) {
|
|
122
|
+
const doStream = async (convId) => {
|
|
123
|
+
const sp = ui.spinner(T.thinking).start();
|
|
124
|
+
let buf = '', errMsg = null;
|
|
125
|
+
try {
|
|
126
|
+
await sse('/api/ia/chat', {
|
|
127
|
+
token, body: { conversationId: convId, content },
|
|
128
|
+
onEvent: (ev) => {
|
|
129
|
+
if (ev.delta) { buf += ev.delta; sp.text(`${T.receiving} ${fmtK(buf.length)}`); }
|
|
130
|
+
if (ev.error) errMsg = ev.error;
|
|
131
|
+
if (ev.tool) sp.text(String(ev.tool).slice(0, 64));
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
} catch (e) { sp.stop(); throw e; }
|
|
135
|
+
sp.stop();
|
|
136
|
+
if (errMsg && !buf) throw new ApiError(errMsg, {});
|
|
137
|
+
return buf;
|
|
138
|
+
};
|
|
139
|
+
let convId = await ensureConv(token);
|
|
140
|
+
try { return await doStream(convId); }
|
|
141
|
+
catch (e) {
|
|
142
|
+
if (e instanceof ApiError && e.status === 404) { cfg = config.save({ convId: undefined }); convId = await ensureConv(token); return doStream(convId); }
|
|
143
|
+
throw e;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function printAnswer(text, ms) {
|
|
147
|
+
console.log('');
|
|
148
|
+
console.log(' ' + ui.gradient('⌁ TS'));
|
|
149
|
+
console.log(ui.md(text).split('\n').map(l => ' ' + l).join('\n'));
|
|
150
|
+
if (ms) console.log(' ' + C.dim('· ' + (ms / 1000).toFixed(1).replace('.', ',') + 's'));
|
|
151
|
+
console.log('');
|
|
152
|
+
}
|
|
153
|
+
// ── ts video <url> [pergunta]: dá "olhos" pro ts em vídeo (ideia da skill Cloud Video).
|
|
154
|
+
// Minimalista de propósito: pega a TRANSCRIÇÃO (legenda auto do YouTube via yt-dlp — grátis,
|
|
155
|
+
// sem baixar o vídeo inteiro) e joga no chat, que é exatamente o fluxo manual "colar transcript".
|
|
156
|
+
function _cmd(bin, args, timeout = 120000) {
|
|
157
|
+
try { return require('child_process').execFileSync(bin, args, { encoding: 'utf8', timeout, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }); }
|
|
158
|
+
catch (e) { return { _err: e }; }
|
|
159
|
+
}
|
|
160
|
+
// Acha como invocar o yt-dlp: no PATH OU via `python -m yt_dlp` (pip instala assim, e o Scripts
|
|
161
|
+
// costuma NÃO estar no PATH). Retorna {bin, pre} ou null. pre = args fixos antes dos nossos.
|
|
162
|
+
function _ytDlp() {
|
|
163
|
+
for (const c of [{ bin: 'yt-dlp', pre: [] }, { bin: 'python', pre: ['-m', 'yt_dlp'] }, { bin: 'py', pre: ['-m', 'yt_dlp'] }, { bin: 'python3', pre: ['-m', 'yt_dlp'] }]) {
|
|
164
|
+
const r = _cmd(c.bin, [...c.pre, '--version'], 15000);
|
|
165
|
+
if (!(r && r._err)) return c;
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
function _vttToText(vtt) {
|
|
170
|
+
const seen = new Set(); const out = [];
|
|
171
|
+
for (let line of String(vtt).split(/\r?\n/)) {
|
|
172
|
+
line = line.replace(/<[^>]+>/g, '').trim(); // tira tags de timing inline
|
|
173
|
+
if (!line || line === 'WEBVTT' || line.includes('-->') || /^\d+$/.test(line) || /^(Kind|Language):/.test(line)) continue;
|
|
174
|
+
if (seen.has(line)) continue; seen.add(line); out.push(line);
|
|
175
|
+
}
|
|
176
|
+
return out.join(' ');
|
|
177
|
+
}
|
|
178
|
+
async function videoCmd(words) {
|
|
179
|
+
const token = needToken();
|
|
180
|
+
const url = (words[0] || '').trim();
|
|
181
|
+
const pergunta = words.slice(1).join(' ').trim();
|
|
182
|
+
if (!/^https?:\/\//.test(url)) { console.error(ui.infoLine(cfg.lang === 'en' ? 'Usage: ts video <url> [question]' : 'Uso: ts video <url> [pergunta]')); process.exit(2); }
|
|
183
|
+
const yt = _ytDlp();
|
|
184
|
+
if (!yt) {
|
|
185
|
+
console.error(ui.errLine(cfg.lang === 'en'
|
|
186
|
+
? 'yt-dlp not found. Install it: `winget install yt-dlp` (Windows) or `pip install yt-dlp`, then run again.'
|
|
187
|
+
: 'yt-dlp não encontrado. Instale: `winget install yt-dlp` (Windows) ou `pip install yt-dlp`, e rode de novo.'));
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
const os = require('os'), fs = require('fs'), path = require('path');
|
|
191
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'tsvid-'));
|
|
192
|
+
const sp = ui.spinner(cfg.lang === 'en' ? 'Fetching transcript…' : 'Buscando a transcrição…').start();
|
|
193
|
+
const out = path.join(tmp, 'v');
|
|
194
|
+
const r = _cmd(yt.bin, [...yt.pre, '--skip-download', '--write-auto-subs', '--write-subs',
|
|
195
|
+
'--sub-langs', 'pt.*,pt-BR,en.*,en', '--sub-format', 'vtt', '--convert-subs', 'vtt',
|
|
196
|
+
'-o', out + '.%(ext)s', url], 180000);
|
|
197
|
+
sp.stop();
|
|
198
|
+
let vtt = null;
|
|
199
|
+
try { const f = fs.readdirSync(tmp).find(n => n.endsWith('.vtt')); if (f) vtt = fs.readFileSync(path.join(tmp, f), 'utf8'); } catch (_) {}
|
|
200
|
+
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {}
|
|
201
|
+
if (!vtt) {
|
|
202
|
+
console.error(ui.errLine(cfg.lang === 'en'
|
|
203
|
+
? 'No transcript/captions found for this video (yt-dlp returned nothing). It may have no captions.'
|
|
204
|
+
: 'Sem transcrição/legendas pra esse vídeo (o yt-dlp não retornou nada). Pode ser um vídeo sem legendas.'));
|
|
205
|
+
if (r && r._err) console.error(ui.infoLine(String(r._err.stderr || r._err.message || '').split('\n').slice(-3).join(' ').slice(0, 200)));
|
|
206
|
+
process.exit(1);
|
|
207
|
+
}
|
|
208
|
+
const texto = _vttToText(vtt).slice(0, 30000);
|
|
209
|
+
console.log(' ' + C.cyan('▶') + ' ' + C.dim((cfg.lang === 'en' ? 'transcript: ' : 'transcrição: ') + texto.length + ' chars'));
|
|
210
|
+
const base = pergunta || (cfg.lang === 'en' ? 'Summarize this video transcript and highlight what matters.' : 'Resuma a transcrição deste vídeo e destaque o que importa.');
|
|
211
|
+
const content = base + `\n\n${cfg.lang === 'en' ? 'VIDEO TRANSCRIPT' : 'TRANSCRIÇÃO DO VÍDEO'} (${url}):\n\`\`\`\n${texto}\n\`\`\``;
|
|
212
|
+
const t0 = Date.now();
|
|
213
|
+
const text = await sendMessage(token, content);
|
|
214
|
+
if (JSON_OUT) { console.log(JSON.stringify({ ok: true, chars: texto.length, answer: text })); return; }
|
|
215
|
+
printAnswer(text, Date.now() - t0);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── ts arquivar <url> [saida.html]: salva uma página web como UM HTML autocontido (offline
|
|
219
|
+
// fiel — CSS/imagens embutidos) usando o monolith (Rust). Utilitário; monolith NÃO roda JS,
|
|
220
|
+
// então página muito dinâmica pode vir incompleta. Degrada com instrução se não instalado.
|
|
221
|
+
function _monolith() { const r = _cmd('monolith', ['--version'], 12000); return !(r && r._err) ? 'monolith' : null; }
|
|
222
|
+
// ts desfazer <arquivo> — restaura o backup automático mais recente (sem gastar IA).
|
|
223
|
+
async function desfazerCmd(words) {
|
|
224
|
+
const _path = require('path');
|
|
225
|
+
const alvo = (words.join(' ') || '').trim();
|
|
226
|
+
if (!alvo) { console.error(ui.infoLine(cfg.lang === 'en' ? 'Usage: ts desfazer <file> — restores the last auto-backup' : 'Uso: ts desfazer <arquivo> — restaura o último backup automático')); process.exit(2); }
|
|
227
|
+
const abs = _path.resolve(process.cwd(), alvo);
|
|
228
|
+
const r = await require('../lib/tools').execute('restaurar_arquivo', { caminho: abs }, { baseDir: process.cwd() });
|
|
229
|
+
if (r && r.erro) { console.error(ui.errLine(r.erro)); process.exit(1); }
|
|
230
|
+
console.log(' ' + C.ok('✔ ') + (cfg.lang === 'en' ? 'Restored ' : 'Restaurado ') + C.bold(r.caminho) + C.dim(' (' + (r.de_backup || 'backup') + ')'));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function arquivarCmd(words) {
|
|
234
|
+
const fs = require('fs'), path = require('path');
|
|
235
|
+
const url = (words[0] || '').trim();
|
|
236
|
+
if (!/^https?:\/\//.test(url)) { console.error(ui.infoLine(cfg.lang === 'en' ? 'Usage: ts arquivar <url> [output.html]' : 'Uso: ts arquivar <url> [saida.html]')); process.exit(2); }
|
|
237
|
+
if (!_monolith()) {
|
|
238
|
+
console.error(ui.errLine(cfg.lang === 'en'
|
|
239
|
+
? 'monolith not found. Install it: `winget install monolith` / `scoop install monolith` / `cargo install monolith`, then run again.'
|
|
240
|
+
: 'monolith não encontrado. Instale: `winget install monolith` / `scoop install monolith` / `cargo install monolith`, e rode de novo.'));
|
|
241
|
+
process.exit(1);
|
|
242
|
+
}
|
|
243
|
+
let out = (words[1] || '').trim();
|
|
244
|
+
if (!out) { let h = 'pagina'; try { h = new URL(url).hostname.replace(/^www\./, '').replace(/[^a-z0-9.-]/gi, '_'); } catch (_) {} out = h + '.html'; }
|
|
245
|
+
out = path.resolve(out);
|
|
246
|
+
const sp = ui.spinner(cfg.lang === 'en' ? 'Archiving page…' : 'Arquivando a página…').start();
|
|
247
|
+
// monolith escreve em STDOUT (`-o -`) e NÓS gravamos o arquivo: o `-o <arquivo>` do monolith
|
|
248
|
+
// tem bug de caminho no Windows; capturar o stdout é mais robusto (controlamos o path via fs).
|
|
249
|
+
// maxBuffer alto: a página vem com assets embutidos em base64 (pode passar de vários MB).
|
|
250
|
+
let html = null, err = null;
|
|
251
|
+
try { html = require('child_process').execFileSync('monolith', [url, '-o', '-'], { encoding: 'utf8', timeout: 180000, maxBuffer: 256 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }); }
|
|
252
|
+
catch (e) { err = e; }
|
|
253
|
+
sp.stop();
|
|
254
|
+
if (err || !html) {
|
|
255
|
+
console.error(ui.errLine((cfg.lang === 'en' ? 'monolith failed: ' : 'monolith falhou: ') + String((err && (err.stderr || err.message)) || 'sem saída').split('\n').filter(Boolean).slice(-2).join(' ').slice(0, 200)));
|
|
256
|
+
process.exit(1);
|
|
257
|
+
}
|
|
258
|
+
try { fs.writeFileSync(out, html, 'utf8'); } catch (e) { console.error(ui.errLine((cfg.lang === 'en' ? 'could not write file: ' : 'não consegui gravar o arquivo: ') + e.message)); process.exit(1); }
|
|
259
|
+
let size = 0; try { size = fs.statSync(out).size; } catch (_) {}
|
|
260
|
+
if (JSON_OUT) { console.log(JSON.stringify({ ok: true, arquivo: out, bytes: size })); return; }
|
|
261
|
+
console.log(' ' + ui.gradient('⌁') + ' ' + (cfg.lang === 'en' ? 'saved' : 'salvo') + ': ' + C.cyan(out) + C.dim(' (' + (size / 1024).toFixed(0) + ' KB)'));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── ts qr [url]: mostra um QR no terminal pra CONTINUAR no celular (ideia collab do OMYP).
|
|
265
|
+
// Sem url, aponta pra conversa do ts na web (/ia). Abre no navegador do celular via câmera.
|
|
266
|
+
async function qrCmd(words) {
|
|
267
|
+
const url = (words[0] || '').trim() || (base().replace(/\/+$/, '') + '/ia');
|
|
268
|
+
let qrgen; try { qrgen = require('qrcode-terminal'); } catch (_) {
|
|
269
|
+
console.log(' ' + C.cyan('▸') + ' ' + (cfg.lang === 'en' ? 'Open on your phone: ' : 'Abra no celular: ') + C.bold(url));
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
console.log('');
|
|
273
|
+
qrgen.generate(url, { small: true }, q => console.log(q.split('\n').map(l => ' ' + l).join('\n')));
|
|
274
|
+
console.log(' ' + C.dim(cfg.lang === 'en' ? 'Scan to continue on your phone → ' : 'Aponte a câmera pra continuar no celular → ') + C.cyan(url));
|
|
275
|
+
console.log('');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── ts skills: galeria da comunidade (buscar/ver/instalar/publicar) ──────────
|
|
279
|
+
async function skillsCmd(words) {
|
|
280
|
+
const token = needToken();
|
|
281
|
+
const skills = require('../lib/skills');
|
|
282
|
+
const sub = (words[0] || '').toLowerCase();
|
|
283
|
+
const arg = words.slice(1).join(' ').trim();
|
|
284
|
+
|
|
285
|
+
// export [pasta] — exporta as skills INSTALADAS (~/.ts/skills) pro formato aberto Agent Skills
|
|
286
|
+
// (cada skill vira <slug>/SKILL.md) + README, pronto pra virar um repo `npx skills add owner/repo`.
|
|
287
|
+
if (sub === 'export' || sub === 'exportar' || sub === 'publish-git') {
|
|
288
|
+
const _fs = require('fs'), _p = require('path'), _os = require('os');
|
|
289
|
+
const en = cfg.lang === 'en';
|
|
290
|
+
const srcDir = _p.join(_os.homedir(), '.ts', 'skills');
|
|
291
|
+
let slugs = []; try { slugs = _fs.readdirSync(srcDir).filter(s => _fs.existsSync(_p.join(srcDir, s, 'SKILL.md'))); } catch (_) {}
|
|
292
|
+
if (!slugs.length) { console.error(ui.infoLine(en ? 'No installed skills to export (~/.ts/skills is empty). Install with: ts skills add <slug>' : 'Nenhuma skill instalada pra exportar (~/.ts/skills vazio). Instale com: ts skills add <slug>')); return; }
|
|
293
|
+
const outDir = _p.resolve(arg || 'ts-skills');
|
|
294
|
+
_fs.mkdirSync(outDir, { recursive: true });
|
|
295
|
+
const rows = [];
|
|
296
|
+
for (const slug of slugs) {
|
|
297
|
+
const raw = _fs.readFileSync(_p.join(srcDir, slug, 'SKILL.md'), 'utf8');
|
|
298
|
+
_fs.mkdirSync(_p.join(outDir, slug), { recursive: true });
|
|
299
|
+
_fs.writeFileSync(_p.join(outDir, slug, 'SKILL.md'), raw);
|
|
300
|
+
const nm = (raw.match(/^name\s*:\s*(.+)$/mi) || [, slug])[1].trim();
|
|
301
|
+
const ds = (raw.match(/^description\s*:\s*(.+)$/mi) || [, ''])[1].trim();
|
|
302
|
+
rows.push(`- **${nm}** (\`${slug}\`) — ${ds}`);
|
|
303
|
+
}
|
|
304
|
+
const readme = `# ts-skills\n\n${en ? 'Skills for AI coding agents, in the open **Agent Skills** format (each skill is a `<slug>/SKILL.md`). Works with Terminal Smart (`ts`), Claude Code, Cursor, Codex and any agent that reads Agent Skills.' : 'Skills para agentes de código de IA, no formato aberto **Agent Skills** (cada skill é um `<slug>/SKILL.md`). Funciona com o Terminal Smart (`ts`), Claude Code, Cursor, Codex e qualquer agente que leia Agent Skills.'}\n\n## ${en ? 'Install' : 'Instalar'}\n\n\`\`\`bash\nnpx skills add GabbrielGG/ts-skills\n\`\`\`\n\n## Skills\n\n${rows.join('\n')}\n`;
|
|
305
|
+
_fs.writeFileSync(_p.join(outDir, 'README.md'), readme);
|
|
306
|
+
console.log(' ' + C.ok('✔ ') + (en ? 'Exported ' : 'Exportei ') + C.bold(slugs.length + (en ? ' skill(s)' : ' skill(s)')) + (en ? ' to ' : ' pra ') + C.cyan(outDir));
|
|
307
|
+
console.log(' ' + C.dim(en ? 'Next — turn it into an installable repo:' : 'Próximo — vire um repo instalável:'));
|
|
308
|
+
console.log(C.dim(` cd ${outDir}\n git init && git add . && git commit -m "skills"\n git branch -M main\n git remote add origin https://github.com/GabbrielGG/ts-skills.git\n git push -u origin main`));
|
|
309
|
+
console.log(' ' + C.dim(en ? 'Then anyone runs: ' : 'Depois qualquer um roda: ') + C.cyan('npx skills add GabbrielGG/ts-skills') + '\n');
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// publicar <caminho>
|
|
314
|
+
if (sub === 'publicar' || sub === 'publish') {
|
|
315
|
+
const dir = arg || '.';
|
|
316
|
+
let s; try { s = skills.readLocal(require('path').resolve(dir)); }
|
|
317
|
+
catch (e) { console.error(ui.errLine((cfg.lang === 'en' ? 'could not read skill: ' : 'não consegui ler a skill: ') + e.message)); process.exit(1); }
|
|
318
|
+
console.log(' ' + C.bold(cfg.lang === 'en' ? 'Publishing:' : 'Publicando:') + ' ' + C.cyan(s.name) + C.dim(' · ' + s.category));
|
|
319
|
+
console.log(' ' + C.dim(s.description || '(sem descrição)'));
|
|
320
|
+
console.log(' ' + C.dim((cfg.lang === 'en' ? 'instructions: ' : 'instruções: ') + (s.content.instructions || '').length + ' chars' + (s.content.script ? ' · +script ' + s.content.script.nome : '') + (s.content.references.length ? ' · +' + s.content.references.length + ' refs' : '')));
|
|
321
|
+
if (process.stdin.isTTY && !YES) { const a = String(await ui.ask(' ' + (cfg.lang === 'en' ? 'Publish to the public gallery? (y/N) ' : 'Publicar na galeria pública? (s/N) '))).trim().toLowerCase(); if (!['s', 'sim', 'y', 'yes'].includes(a)) { console.log(' ' + C.dim(cfg.lang === 'en' ? 'cancelled.' : 'cancelado.')); return; } }
|
|
322
|
+
const r = await skills.publish(token, s);
|
|
323
|
+
if (r && r.slug) console.log(' ' + ui.gradient('⌁') + ' ' + (cfg.lang === 'en' ? 'published: ' : 'publicada: ') + C.cyan(r.slug) + C.dim(' (ts skills add ' + r.slug + ')'));
|
|
324
|
+
else console.error(ui.errLine((r && r.error) || 'falha ao publicar'));
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ver <slug> (transparência: mostra o conteúdo completo)
|
|
329
|
+
if (sub === 'ver' || sub === 'show') {
|
|
330
|
+
const s = await skills.get(token, arg);
|
|
331
|
+
if (!s) { console.error(ui.infoLine(cfg.lang === 'en' ? 'skill not found.' : 'skill não encontrada.')); process.exit(1); }
|
|
332
|
+
console.log('');
|
|
333
|
+
console.log(' ' + C.bold(C.cyan(s.name)) + C.dim(' · ' + s.category + ' · por ' + s.author + ' · ' + s.installs + ' instalações'));
|
|
334
|
+
if (s.description) console.log(' ' + C.dim(s.description));
|
|
335
|
+
console.log('');
|
|
336
|
+
console.log(ui.md(String(s.content.instructions || '')).split('\n').map(l => ' ' + l).join('\n'));
|
|
337
|
+
if (s.content.script) console.log('\n ' + C.warn('⚠ inclui script: ' + s.content.script.nome) + C.dim(' (roda com os gates normais do agente, nunca sozinho)'));
|
|
338
|
+
console.log('');
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// add <slug> (instala, com transparência + confirmação)
|
|
343
|
+
if (sub === 'add' || sub === 'instalar' || sub === 'install') {
|
|
344
|
+
const s = await skills.get(token, arg);
|
|
345
|
+
if (!s) { console.error(ui.infoLine(cfg.lang === 'en' ? 'skill not found.' : 'skill não encontrada.')); process.exit(1); }
|
|
346
|
+
console.log(' ' + C.bold(C.cyan(s.name)) + C.dim(' · por ' + s.author + ' · ' + s.installs + ' instalações'));
|
|
347
|
+
if (s.description) console.log(' ' + C.dim(s.description));
|
|
348
|
+
console.log(' ' + C.dim((cfg.lang === 'en' ? 'content: ' : 'conteúdo: ') + (s.content.instructions || '').length + ' chars de instrução' + (s.content.script ? C.warn(' · +script ' + s.content.script.nome) : '')));
|
|
349
|
+
console.log(' ' + C.dim(cfg.lang === 'en' ? 'tip: `ts skills ver ' + s.slug + '` to read it all first.' : 'dica: `ts skills ver ' + s.slug + '` pra ler tudo antes.'));
|
|
350
|
+
if (process.stdin.isTTY && !YES) { const a = String(await ui.ask(' ' + (cfg.lang === 'en' ? 'Install to ~/.ts/skills? (y/N) ' : 'Instalar em ~/.ts/skills? (s/N) '))).trim().toLowerCase(); if (!['s', 'sim', 'y', 'yes'].includes(a)) { console.log(' ' + C.dim(cfg.lang === 'en' ? 'cancelled.' : 'cancelado.')); return; } }
|
|
351
|
+
const dir = skills.writeLocal(s); await skills.markInstalled(token, s.slug);
|
|
352
|
+
console.log(' ' + ui.gradient('⌁') + ' ' + (cfg.lang === 'en' ? 'installed at ' : 'instalada em ') + C.cyan(dir));
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// (sem sub) ou "buscar <q>" → lista
|
|
357
|
+
const q = (sub === 'buscar' || sub === 'search') ? arg : words.join(' ').trim();
|
|
358
|
+
const list = await skills.list(token, q);
|
|
359
|
+
console.log('');
|
|
360
|
+
console.log(' ' + ui.gradient('⌁ ' + (cfg.lang === 'en' ? 'Skills gallery' : 'Galeria de skills')) + (q ? C.dim(' · "' + q + '"') : ''));
|
|
361
|
+
if (!list.length) { console.log(' ' + C.dim(cfg.lang === 'en' ? 'nothing found. Publish yours: ts skills publicar <folder>' : 'nada encontrado. Publique a sua: ts skills publicar <pasta>')); console.log(''); return; }
|
|
362
|
+
const w = Math.max(...list.map(s => s.slug.length)) + 2;
|
|
363
|
+
for (const s of list) console.log(' ' + C.cyan(s.slug.padEnd(w)) + C.dim('↓' + s.installs + ' ') + s.name + (s.description ? C.dim(' — ' + s.description.slice(0, 50)) : ''));
|
|
364
|
+
console.log('\n ' + C.dim(cfg.lang === 'en' ? 'install: ts skills add <slug> · read: ts skills ver <slug>' : 'instalar: ts skills add <slug> · ler: ts skills ver <slug>'));
|
|
365
|
+
console.log('');
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ── ts memoria: fatos persistentes que o agente sempre carrega (memória de projeto/global) ──
|
|
369
|
+
async function memoriaCmd(words) {
|
|
370
|
+
const memoria = require('../lib/memoria');
|
|
371
|
+
const sub = (words[0] || '').toLowerCase();
|
|
372
|
+
if (sub === 'add' || sub === 'lembrar') {
|
|
373
|
+
const glob = process.argv.includes('-g') || process.argv.includes('--global');
|
|
374
|
+
const fato = words.slice(1).join(' ').trim(); // POS já removeu o -g/--global
|
|
375
|
+
if (!fato) { console.error(ui.infoLine(cfg.lang === 'en' ? 'Usage: ts memoria add "fact" [-g]' : 'Uso: ts memoria add "fato" [-g]')); process.exit(2); }
|
|
376
|
+
const f = memoria.append(process.cwd(), fato, glob);
|
|
377
|
+
console.log(' ' + ui.gradient('⌁') + ' ' + (cfg.lang === 'en' ? 'saved to ' : 'salvo em ') + C.cyan(f) + C.dim(glob ? ' (global)' : ' (projeto)'));
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
// ver
|
|
381
|
+
const fs = require('fs');
|
|
382
|
+
const proj = memoria.projFile(process.cwd());
|
|
383
|
+
console.log('');
|
|
384
|
+
console.log(' ' + ui.gradient('⌁ ' + (cfg.lang === 'en' ? 'Memory' : 'Memória')));
|
|
385
|
+
const show = (label, file) => {
|
|
386
|
+
let txt = ''; try { txt = fs.existsSync(file) ? fs.readFileSync(file, 'utf8').trim() : ''; } catch (_) {}
|
|
387
|
+
console.log(' ' + C.bold(label) + C.dim(' ' + file));
|
|
388
|
+
console.log(txt ? txt.split('\n').map(l => ' ' + l).join('\n') : ' ' + C.dim(cfg.lang === 'en' ? '(empty)' : '(vazia)'));
|
|
389
|
+
console.log('');
|
|
390
|
+
};
|
|
391
|
+
show(cfg.lang === 'en' ? 'This project:' : 'Deste projeto:', proj);
|
|
392
|
+
show('Global:', memoria.GLOBAL);
|
|
393
|
+
console.log(' ' + C.dim(cfg.lang === 'en' ? 'add: ts memoria add "fact" [-g for global]' : 'adicionar: ts memoria add "fato" [-g p/ global]'));
|
|
394
|
+
console.log('');
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function chat(question) {
|
|
398
|
+
const token = needToken();
|
|
399
|
+
const piped = await readStdin();
|
|
400
|
+
if (!question && !piped) { console.error(ui.infoLine(T.ask_empty)); process.exit(2); }
|
|
401
|
+
let content = question || (cfg.lang === 'en' ? 'Analyze the input below and summarize what matters.' : 'Analise a entrada abaixo e resuma o que importa.');
|
|
402
|
+
if (piped) content += `\n\n${T.pipe_ctx}:\n\`\`\`\n${piped.slice(0, 30000)}\n\`\`\``;
|
|
403
|
+
const t0 = Date.now();
|
|
404
|
+
const text = await sendMessage(token, content);
|
|
405
|
+
if (JSON_OUT) { console.log(JSON.stringify({ ok: true, answer: text })); return; }
|
|
406
|
+
printAnswer(text, Date.now() - t0);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ── Modo conversa (REPL) — o modo PADRÃO do ts: digite naturalmente ──────────
|
|
410
|
+
async function chatRepl() {
|
|
411
|
+
const token = needToken();
|
|
412
|
+
// Cabeçalho: banner + conta + créditos (best-effort, não trava a entrada)
|
|
413
|
+
console.log(ui.banner(pkg.version, T.tagline));
|
|
414
|
+
try {
|
|
415
|
+
const cr = await api('/api/credits', { token, timeoutMs: 6000 });
|
|
416
|
+
const cred = (cr.unlimited || cr.granted < 0) ? T.uso_unlimited : T.repl_credits((cr.remaining ?? 0).toLocaleString('pt-BR'));
|
|
417
|
+
console.log(' ' + C.dim(T.repl_account(cfg.username || '?', cfg.plan || '?') + ' · ') + C.cyan(cred));
|
|
418
|
+
} catch (_) {}
|
|
419
|
+
console.log(' ' + C.dim(T.repl_hello) + '\n');
|
|
420
|
+
|
|
421
|
+
// Onboarding: só na PRIMEIRA vez — depois a linha de dica do cabeçalho basta
|
|
422
|
+
if (!cfg.onboarded) {
|
|
423
|
+
const w = Math.max(...T.onboard_lines.map(l => l[0].length)) + 3;
|
|
424
|
+
console.log(ui.box([
|
|
425
|
+
...T.onboard_lines.map(([k, v]) => C.cyan(k.padEnd(w)) + C.dim(v)),
|
|
426
|
+
'',
|
|
427
|
+
C.dim(T.onboard_footer),
|
|
428
|
+
], { title: T.onboard_title }) + '\n');
|
|
429
|
+
cfg = config.save({ onboarded: true });
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const rl = require('readline').createInterface({ input: process.stdin, output: process.stdout });
|
|
433
|
+
// 'close' (Ctrl+C/Ctrl+D/EOF de pipe) resolve a pergunta pendente com null —
|
|
434
|
+
// assim cada linha já lida é processada e o loop encerra limpo depois da última.
|
|
435
|
+
let closed = false, pending = null;
|
|
436
|
+
rl.on('close', () => { closed = true; if (pending) { const p = pending; pending = null; p(null); } });
|
|
437
|
+
const ask1 = (q) => new Promise((res) => { pending = res; rl.question(q, (a) => { pending = null; res(a); }); });
|
|
438
|
+
const prompt = ui.gradient('⌁') + ' ' + C.bold(T.chat_prompt);
|
|
439
|
+
// cwd da SESSÃO do agente: persiste entre mensagens (o problema 1). "cd"/"pwd"
|
|
440
|
+
// no REPL alteram/mostram; cada msg roteada pro agente resolve caminhos daqui.
|
|
441
|
+
const _pp = require('path'), _oo = require('os'), _ff = require('fs');
|
|
442
|
+
let agentCwd = process.cwd();
|
|
443
|
+
console.log(' ' + C.dim((cfg.lang === 'en' ? 'working folder: ' : 'pasta de trabalho: ') + agentCwd) + C.dim(cfg.lang === 'en' ? ' · cd <folder> to change' : ' · cd <pasta> pra trocar') + '\n');
|
|
444
|
+
|
|
445
|
+
for (;;) {
|
|
446
|
+
const raw = await ask1(prompt);
|
|
447
|
+
if (raw === null) break; // entrada terminou
|
|
448
|
+
const msg = String(raw || '').trim();
|
|
449
|
+
if (!msg) { if (closed) break; continue; }
|
|
450
|
+
const low = msg.toLowerCase();
|
|
451
|
+
if (['sair', 'exit', 'quit', '/sair', '/exit'].includes(low)) break;
|
|
452
|
+
try {
|
|
453
|
+
if (['/nova', '/new'].includes(low)) {
|
|
454
|
+
const r = await api('/api/conversations', { method: 'POST', token, body: { title: 'CLI' } });
|
|
455
|
+
cfg = config.save({ convId: r.id });
|
|
456
|
+
console.log(ui.okLine(T.new_conv) + '\n');
|
|
457
|
+
} else if (['/ajuda', '/help', '/?'].includes(low)) {
|
|
458
|
+
const w = Math.max(...T.repl_help.map(i => i[0].length)) + 3;
|
|
459
|
+
console.log('');
|
|
460
|
+
for (const [k, d] of T.repl_help) console.log(' ' + C.cyan(k.padEnd(w)) + C.dim(d));
|
|
461
|
+
console.log('');
|
|
462
|
+
} else if (low === '/uso') {
|
|
463
|
+
await uso();
|
|
464
|
+
} else if (low === '/runs') {
|
|
465
|
+
await runsCmd();
|
|
466
|
+
} else if (low.startsWith('/run')) {
|
|
467
|
+
const goal = msg.slice(4).trim().replace(/^["']|["']$/g, '');
|
|
468
|
+
if (goal.length < 10) { console.log(ui.infoLine(T.goal_short)); continue; }
|
|
469
|
+
await runFlow(goal, { askFn: (q) => ask1(' ' + q) });
|
|
470
|
+
} else if (low.startsWith('/agente') || low.startsWith('/agent')) {
|
|
471
|
+
const task = msg.replace(/^\/\w+\s*/, '').replace(/^["']|["']$/g, '');
|
|
472
|
+
if (task.length < 8) { console.log(ui.infoLine(T.agent_need)); continue; }
|
|
473
|
+
await agentCmd([task], { askFn: (q) => ask1(' ' + q), cwd: agentCwd, onCwd: (c) => { agentCwd = c; } });
|
|
474
|
+
} else if (low.startsWith('/chat ')) {
|
|
475
|
+
// força conversa (escape do roteador)
|
|
476
|
+
const t0 = Date.now();
|
|
477
|
+
printAnswer(await sendMessage(token, msg.slice(6)), Date.now() - t0);
|
|
478
|
+
} else if (low === 'pwd' || low === '/pwd') {
|
|
479
|
+
console.log(' ' + C.dim(agentCwd));
|
|
480
|
+
} else if (low === 'cd' || low.startsWith('cd ')) {
|
|
481
|
+
// muda a pasta de trabalho da SESSÃO (base dos caminhos relativos do agente)
|
|
482
|
+
let raw = msg.slice(2).trim().replace(/^["']|["']$/g, '') || _oo.homedir();
|
|
483
|
+
if (raw === '~' || raw.startsWith('~/') || raw.startsWith('~\\')) raw = _pp.join(_oo.homedir(), raw.slice(1));
|
|
484
|
+
const target = _pp.resolve(agentCwd, raw);
|
|
485
|
+
try {
|
|
486
|
+
if (!_ff.statSync(target).isDirectory()) throw new Error('não é pasta');
|
|
487
|
+
agentCwd = target;
|
|
488
|
+
console.log(' ' + C.dim((cfg.lang === 'en' ? 'working folder: ' : 'pasta de trabalho: ') + agentCwd));
|
|
489
|
+
} catch (_) { console.error(ui.errLine((cfg.lang === 'en' ? 'no such folder: ' : 'pasta inexistente: ') + target)); }
|
|
490
|
+
} else {
|
|
491
|
+
// ROTEADOR: linguagem natural pura — o sistema decide o caminho e AVISA
|
|
492
|
+
const r = await router.route(msg, token);
|
|
493
|
+
if (r.dest === 'agente') {
|
|
494
|
+
console.log(' ' + C.cyan('⚙') + ' ' + C.dim(T.route_agent));
|
|
495
|
+
await agentCmd([msg], { askFn: (q) => ask1(' ' + q), cwd: agentCwd, onCwd: (c) => { agentCwd = c; } });
|
|
496
|
+
} else if (r.dest === 'run') {
|
|
497
|
+
console.log(' ' + C.indigo('◆') + ' ' + C.dim(T.route_run));
|
|
498
|
+
await runFlow(msg, { askFn: (q) => ask1(' ' + q) });
|
|
499
|
+
} else {
|
|
500
|
+
const t0 = Date.now();
|
|
501
|
+
printAnswer(await sendMessage(token, msg), Date.now() - t0);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
} catch (e) {
|
|
505
|
+
if (e instanceof ApiError && e.code === 'conn') console.error(ui.errLine(T.err_conn));
|
|
506
|
+
else if (e instanceof ApiError && e.code === 'auth') { console.error(ui.errLine(T.session_expired)); break; }
|
|
507
|
+
else if (e instanceof ApiError && e.code === 'no_credits') console.error(ui.errLine(T.no_credits));
|
|
508
|
+
else if (e instanceof ApiError && e.code === 'plan_limit') console.error(ui.errLine(e.message));
|
|
509
|
+
else console.error(ui.errLine(`${T.err_generic}: ${(e && e.message) || e}`));
|
|
510
|
+
}
|
|
511
|
+
if (closed) break; // pipe: processa a última linha lida e sai
|
|
512
|
+
}
|
|
513
|
+
try { rl.close(); } catch (_) {}
|
|
514
|
+
console.log(ui.infoLine(T.chat_bye));
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// ── Orquestração ─────────────────────────────────────────────────────────────
|
|
518
|
+
const RUN_LBL = {
|
|
519
|
+
pt: { planning: 'planejando', awaiting_approval: 'aguardando aprovação', queued: 'na fila', running: 'executando', reviewing: 'revisando', paused: 'pausada', waiting_credits: 'sem créditos', done: 'concluída', failed: 'falhou', cancelled: 'cancelada' },
|
|
520
|
+
en: { planning: 'planning', awaiting_approval: 'awaiting approval', queued: 'queued', running: 'running', reviewing: 'reviewing', paused: 'paused', waiting_credits: 'out of credits', done: 'done', failed: 'failed', cancelled: 'cancelled' },
|
|
521
|
+
};
|
|
522
|
+
const runLbl = (st) => (RUN_LBL[cfg.lang || 'pt'] || RUN_LBL.pt)[st] || st;
|
|
523
|
+
const stepTitle = (s) => String(s.title || s.task || s.descricao || s.description || s.agent || '?').slice(0, 70);
|
|
524
|
+
|
|
525
|
+
function planBox(run) {
|
|
526
|
+
const steps = run.steps || [];
|
|
527
|
+
const est = run.estimated_credits ?? run.estimatedCredits ?? null;
|
|
528
|
+
const lines = [
|
|
529
|
+
C.bold(String(run.goal || '').slice(0, 84)),
|
|
530
|
+
C.dim(`${steps.length} ${T.plan_steps}` + (est != null ? ` · ${T.plan_est(est)}` : '') + (run.complexity ? ` · ${run.complexity}` : '')),
|
|
531
|
+
'',
|
|
532
|
+
...steps.map((s, i) => C.cyan(String(i + 1).padStart(2)) + ' ' + stepTitle(s) + (s.agent ? C.dim(' ·' + s.agent) : '')),
|
|
533
|
+
];
|
|
534
|
+
return ui.box(lines, { title: T.plan_title(run.id) });
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
async function pollRun(token, runId, sp) {
|
|
538
|
+
for (;;) {
|
|
539
|
+
await new Promise(res => setTimeout(res, 2500));
|
|
540
|
+
let r;
|
|
541
|
+
try { r = await api(`/api/ia/run/${runId}`, { token }); } catch (_) { continue; }
|
|
542
|
+
const run = r.run || r;
|
|
543
|
+
const steps = run.steps || [];
|
|
544
|
+
const done = steps.filter(s => s.status === 'done').length;
|
|
545
|
+
if (sp) sp.text(T.run_running(runLbl(run.status), done, steps.length));
|
|
546
|
+
if (['done', 'failed', 'cancelled', 'paused', 'waiting_credits'].includes(run.status)) return run;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Fluxo completo de orquestração (usado pelo `ts run` e pelo /run do modo conversa).
|
|
551
|
+
// askFn permite aprovar com o readline do REPL sem conflito de stdin; nunca chama process.exit.
|
|
552
|
+
async function runFlow(goal, { askFn = ui.ask, json = false, yes = false } = {}) {
|
|
553
|
+
const token = needToken();
|
|
554
|
+
const sp0 = ui.spinner(runLbl('planning')).start();
|
|
555
|
+
let r;
|
|
556
|
+
try { r = await api('/api/ia/orchestrate', { method: 'POST', token, body: { goal }, timeoutMs: 120000 }); }
|
|
557
|
+
catch (e) { sp0.stop(); throw e; }
|
|
558
|
+
sp0.stop();
|
|
559
|
+
let run = r.run;
|
|
560
|
+
if (!json) console.log('\n' + planBox(run) + '\n');
|
|
561
|
+
|
|
562
|
+
if (!yes) {
|
|
563
|
+
const ans = String(await askFn(C.bold(T.plan_approve))).trim().toLowerCase();
|
|
564
|
+
if (!['s', 'sim', 'y', 'yes'].includes(ans)) {
|
|
565
|
+
try { await api(`/api/ia/run/${run.id}/cancel`, { method: 'POST', token }); } catch (_) {}
|
|
566
|
+
console.log(ui.infoLine(T.plan_cancelled));
|
|
567
|
+
return { status: 'cancelled', run };
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
await api(`/api/ia/run/${run.id}/approve-plan`, { method: 'POST', token });
|
|
571
|
+
const sp = ui.spinner(T.run_running(runLbl('running'), 0, (run.steps || []).length)).start();
|
|
572
|
+
run = await pollRun(token, run.id, sp);
|
|
573
|
+
sp.stop();
|
|
574
|
+
|
|
575
|
+
if (json) { console.log(JSON.stringify({ ok: run.status === 'done', run })); return { status: run.status, run }; }
|
|
576
|
+
if (run.status === 'done') {
|
|
577
|
+
console.log(ui.okLine(C.bold(T.run_done) + C.dim(` · #${run.id}` + (run.actual_credits ? ` · ${run.actual_credits} ${T.uso_credits}` : ''))));
|
|
578
|
+
const result = run.final_result || run.result || '';
|
|
579
|
+
if (result) { console.log(''); console.log(ui.md(String(result)).split('\n').map(l => ' ' + l).join('\n')); console.log(''); }
|
|
580
|
+
} else if (run.status === 'failed') {
|
|
581
|
+
console.error(ui.errLine(C.bold(T.run_failed) + (run.error ? C.dim(' · ' + String(run.error).slice(0, 140)) : '')));
|
|
582
|
+
} else {
|
|
583
|
+
console.log(ui.infoLine(T.run_paused(runLbl(run.status))));
|
|
584
|
+
}
|
|
585
|
+
return { status: run.status, run };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async function runCmd(goalWords) {
|
|
589
|
+
const goal = goalWords.join(' ').trim();
|
|
590
|
+
if (goal.length < 10) { console.error(ui.infoLine(T.goal_short)); process.exit(2); }
|
|
591
|
+
const out = await runFlow(goal, { json: JSON_OUT, yes: YES });
|
|
592
|
+
if (out && out.status === 'failed') process.exit(1);
|
|
593
|
+
if (JSON_OUT && out && out.status !== 'done') process.exit(1);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// @arquivo → injeta o conteúdo do arquivo no texto. Só chars de CAMINHO (para não engolir
|
|
597
|
+
// pontuação nem @menção). Só expande se EXISTE. Não injeta arquivos de SEGREDO (evita mandar
|
|
598
|
+
// .env/chave pro gateway). Limita tamanho.
|
|
599
|
+
const _SECRET_RE = /(^|[\\/.])(\.env|\.pem|\.key|id_rsa|id_ed25519|\.p12|\.pfx|credentials|secrets?)($|[\\/.])/i;
|
|
600
|
+
const _BIN_RE = /\.(png|jpe?g|gif|webp|ico|bmp|pdf|zip|tar|gz|7z|rar|exe|dll|so|dylib|bin|mp[34]|wav|mov|avi|mkv|ttf|otf|woff2?|sqlite|db|class|o|a|lib|wasm)$/i;
|
|
601
|
+
function _expandFileRefs(text, baseCwd) {
|
|
602
|
+
const _fs = require('fs'), _p = require('path');
|
|
603
|
+
// aceita @"caminho com espaço" (aspas) OU @caminho-sem-espaco
|
|
604
|
+
return String(text || '').replace(/(^|\s)@(?:"([^"]+)"|([\w.\-~/\\]+))/g, (m, pre, quoted, bare) => {
|
|
605
|
+
let clean = quoted != null ? quoted : String(bare || '').replace(/[.,;:!?)\]]+$/, ''); // tira pontuação final grudada
|
|
606
|
+
if (!clean) return m;
|
|
607
|
+
// ref BARE só expande se for path-like (tem barra ou extensão) — evita expandir @menção que
|
|
608
|
+
// por acaso bata com um arquivo (ex: @todo). Ref entre aspas é intenção explícita → sempre.
|
|
609
|
+
if (quoted == null && !/[\\/]/.test(clean) && !/\.[a-z0-9]+$/i.test(clean)) return m;
|
|
610
|
+
if (_SECRET_RE.test(clean)) return `${pre}[@${clean} — não injetado (arquivo sensível; leia sob demanda se precisar)]`;
|
|
611
|
+
try {
|
|
612
|
+
const abs = _p.resolve(baseCwd || process.cwd(), clean);
|
|
613
|
+
const st = _fs.statSync(abs);
|
|
614
|
+
if (st.isDirectory()) {
|
|
615
|
+
const list = _fs.readdirSync(abs).slice(0, 100).join('\n');
|
|
616
|
+
return `${pre}[@${clean} (pasta)]:\n\`\`\`\n${list}\n\`\`\``;
|
|
617
|
+
}
|
|
618
|
+
if (st.isFile()) {
|
|
619
|
+
if (_BIN_RE.test(clean)) return `${pre}[@${clean} — binário, não injetado (use ler_arquivo se precisar)]`;
|
|
620
|
+
if (st.size <= 200 * 1024) return `${pre}[@${clean}]:\n\`\`\`\n${_fs.readFileSync(abs, 'utf8')}\n\`\`\``;
|
|
621
|
+
}
|
|
622
|
+
} catch (_) {}
|
|
623
|
+
return m; // não existe / grande demais → mantém o @ literal
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// ── ts hooks: mostra onde ficam os hooks + o que está configurado (descoberta) ──
|
|
628
|
+
async function hooksCmd(words) {
|
|
629
|
+
const _fs = require('fs');
|
|
630
|
+
const en = cfg.lang === 'en';
|
|
631
|
+
const hm = require('../lib/hooks');
|
|
632
|
+
const exists = _fs.existsSync(hm.FILE);
|
|
633
|
+
console.log('\n ' + C.bold(en ? 'Hooks (deterministic, run around each tool call)' : 'Hooks (determinísticos, rodam em volta de cada ferramenta)'));
|
|
634
|
+
console.log(' ' + C.dim(en ? 'File (global, trusted): ' : 'Arquivo (global, confiável): ') + hm.FILE + (exists ? '' : C.dim(en ? ' (not created yet)' : ' (ainda não existe)')));
|
|
635
|
+
if (exists) {
|
|
636
|
+
const h = hm.load();
|
|
637
|
+
for (const ev of ['PreToolUse', 'PostToolUse', 'SessionStart', 'Stop']) {
|
|
638
|
+
const n = (h[ev] || []).length; if (n) console.log(' ' + C.cyan(ev) + C.dim(': ' + n + (en ? ' hook(s)' : ' hook(s)')) + (h[ev].map(x => x.match ? ' [' + x.match + ']' : '').join('')));
|
|
639
|
+
}
|
|
640
|
+
if (!h._any) console.log(' ' + C.dim(en ? '(file present but no hooks defined)' : '(arquivo presente mas sem hooks definidos)'));
|
|
641
|
+
} else {
|
|
642
|
+
console.log(' ' + C.dim(en ? 'Example — save as the file above:' : 'Exemplo — salve no arquivo acima:'));
|
|
643
|
+
console.log(C.dim(' {\n "PreToolUse": [{ "match": "escrever_arquivo|editar_arquivo", "command": "meu-check.sh" }],\n "PostToolUse": [{ "match": "editar_arquivo", "command": "prettier --write \\"$TS_FILE\\" || true" }]\n }'));
|
|
644
|
+
console.log(' ' + C.dim(en ? 'PreToolUse: exit≠0 blocks the tool. Payload arrives as JSON on stdin; env TS_TOOL/TS_FILE set.' : 'PreToolUse: exit≠0 bloqueia a ferramenta. Payload chega como JSON no stdin; env TS_TOOL/TS_FILE definidos.'));
|
|
645
|
+
}
|
|
646
|
+
console.log('');
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// ── ts init: gera um .ts-memoria.md inicial escaneando o projeto (determinístico, sem IA) ──
|
|
650
|
+
async function initCmd() {
|
|
651
|
+
const _fs = require('fs'), _p = require('path');
|
|
652
|
+
const cwd = process.cwd();
|
|
653
|
+
const dest = _p.join(cwd, '.ts-memoria.md');
|
|
654
|
+
const has = (f) => _fs.existsSync(_p.join(cwd, f));
|
|
655
|
+
const readJson = (f) => { try { return JSON.parse(_fs.readFileSync(_p.join(cwd, f), 'utf8')); } catch (_) { return null; } };
|
|
656
|
+
const en = cfg.lang === 'en';
|
|
657
|
+
let tipo = en ? 'unknown' : 'desconhecido', build = [], run = [], test = [];
|
|
658
|
+
const pkg = has('package.json') && readJson('package.json');
|
|
659
|
+
if (pkg) {
|
|
660
|
+
tipo = 'Node.js' + (pkg.dependencies && (pkg.dependencies.react || pkg.dependencies.next) ? ' (React/Next)' : '');
|
|
661
|
+
const s = pkg.scripts || {};
|
|
662
|
+
if (s.build) build.push('npm run build'); if (s.dev || s.start) run.push('npm run ' + (s.dev ? 'dev' : 'start')); if (s.test) test.push('npm test');
|
|
663
|
+
} else if (has('pubspec.yaml')) { tipo = 'Flutter/Dart'; build = ['flutter build']; run = ['flutter run']; test = ['flutter test']; }
|
|
664
|
+
else if (has('requirements.txt') || has('pyproject.toml')) { tipo = 'Python'; run = ['python main.py']; test = ['pytest']; }
|
|
665
|
+
else if (has('go.mod')) { tipo = 'Go'; build = ['go build ./...']; run = ['go run .']; test = ['go test ./...']; }
|
|
666
|
+
else if (has('Cargo.toml')) { tipo = 'Rust'; build = ['cargo build']; run = ['cargo run']; test = ['cargo test']; }
|
|
667
|
+
else if (has('index.html')) { tipo = en ? 'Static web (HTML/JS)' : 'Web estático (HTML/JS)'; run = ['python -m http.server']; }
|
|
668
|
+
const top = _fs.readdirSync(cwd).filter(n => !n.startsWith('.') && n !== 'node_modules').slice(0, 25).join(', ');
|
|
669
|
+
const nl = (a) => a.length ? a.map(x => '`' + x + '`').join(' · ') : (en ? '(fill in)' : '(preencher)');
|
|
670
|
+
const body = (en
|
|
671
|
+
? `# Project memory (ts)\n\n> Auto-generated by \`ts init\`. Edit freely — the ts agent reads this every session.\n\n- **Type:** ${tipo}\n- **Build:** ${nl(build)}\n- **Run:** ${nl(run)}\n- **Test:** ${nl(test)}\n- **Top-level:** ${top}\n\n## Conventions / gotchas\n- (add what the agent should always know: architecture, pitfalls, how to deploy)\n`
|
|
672
|
+
: `# Memória do projeto (ts)\n\n> Gerado por \`ts init\`. Edite à vontade — o agente ts lê isto toda sessão.\n\n- **Tipo:** ${tipo}\n- **Build:** ${nl(build)}\n- **Rodar:** ${nl(run)}\n- **Testar:** ${nl(test)}\n- **Raiz:** ${top}\n\n## Convenções / armadilhas\n- (anote o que o agente sempre deve saber: arquitetura, pegadinhas, como fazer deploy)\n`);
|
|
673
|
+
if (_fs.existsSync(dest)) {
|
|
674
|
+
const ok = await ui.ask(C.warn('▲ ') + (en ? '.ts-memoria.md already exists. Overwrite? (backup kept) [s/N] ' : '.ts-memoria.md já existe. Sobrescrever? (com backup) [s/N] '));
|
|
675
|
+
if (!['s', 'sim', 'y', 'yes'].includes(String(ok).trim().toLowerCase())) { console.log(' ' + C.dim(en ? 'cancelled.' : 'cancelado.')); return; }
|
|
676
|
+
let r; try { r = await require('../lib/tools').execute('escrever_arquivo', { caminho: dest, conteudo: body }, { baseDir: cwd }); } catch (_) {}
|
|
677
|
+
if (!r || r.erro) { try { _fs.writeFileSync(dest, body); } catch (e) { console.error(ui.errLine((en ? 'Failed to write .ts-memoria.md: ' : 'Falha ao escrever .ts-memoria.md: ') + e.message)); return; } }
|
|
678
|
+
} else { _fs.writeFileSync(dest, body); }
|
|
679
|
+
console.log(' ' + C.ok('✔ ') + (en ? 'Created ' : 'Criado ') + C.bold('.ts-memoria.md') + C.dim(' — ' + tipo) + '\n ' + C.dim(en ? 'Edit it to teach the agent your project. It loads automatically.' : 'Edite pra ensinar o projeto ao agente. Ele carrega sozinho.'));
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// ── git worktrees: isola uma run numa cópia descartável do repo ──
|
|
683
|
+
// execFileSync (SEM shell): args como array → zero injeção/fragilidade de aspas (Win/POSIX).
|
|
684
|
+
function _git(cwd, args) { return require('child_process').execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }); }
|
|
685
|
+
function _isGitRepo(dir) { try { _git(dir, ['rev-parse', '--is-inside-work-tree']); return true; } catch (_) { return false; } }
|
|
686
|
+
function _wtRoot() { return require('path').join(require('os').homedir(), '.ts', 'worktrees'); }
|
|
687
|
+
function _worktreeCreate(baseCwd) {
|
|
688
|
+
const _p = require('path'), _fs = require('fs');
|
|
689
|
+
const stamp = Date.now().toString(36);
|
|
690
|
+
const name = (_p.basename(baseCwd).replace(/[^\w.-]/g, '_') || 'repo');
|
|
691
|
+
const branch = 'ts/' + stamp;
|
|
692
|
+
_fs.mkdirSync(_wtRoot(), { recursive: true });
|
|
693
|
+
const wtPath = _p.join(_wtRoot(), name + '-' + stamp);
|
|
694
|
+
_git(baseCwd, ['worktree', 'add', wtPath, '-b', branch]);
|
|
695
|
+
return { path: wtPath, branch, base: baseCwd };
|
|
696
|
+
}
|
|
697
|
+
// Depois da run: commita o que o agente mudou. Rastreia se o commit DEU CERTO (em CI/cron sem
|
|
698
|
+
// git user.email o commit falha) — senão a instrução de merge enganaria e o clean apagaria o trabalho.
|
|
699
|
+
function _worktreeFinish(wt) {
|
|
700
|
+
let stat = ''; try { stat = _git(wt.path, ['status', '--porcelain']).trim(); } catch (_) {}
|
|
701
|
+
const changed = stat ? stat.split('\n').filter(Boolean).length : 0;
|
|
702
|
+
let committed = false;
|
|
703
|
+
if (stat) { try { _git(wt.path, ['add', '-A']); _git(wt.path, ['commit', '-m', 'ts: run isolada em worktree']); committed = true; } catch (_) {} }
|
|
704
|
+
return { changed, committed };
|
|
705
|
+
}
|
|
706
|
+
async function worktreesCmd(words) {
|
|
707
|
+
const en = cfg.lang === 'en';
|
|
708
|
+
// ANCORADO em ~/.ts/worktrees, normalizando barras (git devolve "/" mesmo no Windows, path.join dá "\").
|
|
709
|
+
const _norm = (s) => String(s || '').replace(/\\/g, '/').replace(/\/+$/, '');
|
|
710
|
+
const rootN = _norm(_wtRoot()) + '/';
|
|
711
|
+
const isTs = (p) => !!p && (_norm(p) + '/').startsWith(rootN);
|
|
712
|
+
const sub = (words[0] || '').toLowerCase();
|
|
713
|
+
if (!_isGitRepo(process.cwd())) { console.error(ui.infoLine(en ? 'Not a git repo here.' : 'Aqui não é um repositório git.')); return; }
|
|
714
|
+
let list = ''; try { list = _git(process.cwd(), ['worktree', 'list', '--porcelain']); } catch (_) {}
|
|
715
|
+
const entries = [...list.matchAll(/^worktree (.+)$/gm)].map(m => m[1].trim()).filter(isTs);
|
|
716
|
+
if (sub === 'clean' || sub === 'limpar') {
|
|
717
|
+
if (!entries.length) { console.log(' ' + C.dim(en ? 'no ts worktrees to clean.' : 'nenhum worktree do ts pra limpar.')); return; }
|
|
718
|
+
for (const p of entries) {
|
|
719
|
+
let dirty = ''; try { dirty = _git(p, ['status', '--porcelain']).trim(); } catch (_) {}
|
|
720
|
+
if (dirty) { console.log(' ' + C.warn('▲ ') + (en ? 'uncommitted changes in ' : 'há mudanças NÃO-commitadas em ') + C.dim(p) + (en ? ' — skipped (commit or remove by hand).' : ' — pulado (commite ou remova à mão pra não perder).')); continue; }
|
|
721
|
+
try { _git(process.cwd(), ['worktree', 'remove', p, '--force']); console.log(' ' + C.ok('✔ ') + (en ? 'removed ' : 'removido ') + C.dim(p)); } catch (_) {}
|
|
722
|
+
}
|
|
723
|
+
try { _git(process.cwd(), ['worktree', 'prune']); } catch (_) {}
|
|
724
|
+
// apaga só as branches ts/* JÁ MERGEADAS (-d recusa não-mergeadas → nunca perde trabalho)
|
|
725
|
+
try { const br = _git(process.cwd(), ['branch', '--list', 'ts/*']).split('\n').map(s => s.replace(/^[*+]?\s*/, '').trim()).filter(Boolean); for (const b of br) { try { _git(process.cwd(), ['branch', '-d', b]); } catch (_) {} } } catch (_) {}
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
console.log('\n ' + C.bold(en ? 'ts worktrees (isolated runs)' : 'worktrees do ts (runs isoladas)'));
|
|
729
|
+
if (!entries.length) console.log(' ' + C.dim(en ? 'none. Create one: ts agente --worktree "..."' : 'nenhum. Crie um: ts agente --worktree "..."'));
|
|
730
|
+
else entries.forEach(p => console.log(' ' + C.dim(p)));
|
|
731
|
+
console.log(' ' + C.dim(en ? 'remove all: ts worktrees clean' : 'remover todos: ts worktrees limpar') + '\n');
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// ── Agente LOCAL (Fase 2): executa a tarefa NESTA máquina com ferramentas reais ──
|
|
735
|
+
async function agentCmd(words, { askFn, cwd: cwdIn = null, onCwd = null } = {}) {
|
|
736
|
+
const token = needToken();
|
|
737
|
+
// flags que levam VALOR: o filtro remove só a flag (começa com "-"), deixando o VALOR nos
|
|
738
|
+
// words → tira o valor daqui pra ele não virar parte do texto da tarefa.
|
|
739
|
+
for (const flag of ['--modelo', '--model', '--output-format']) {
|
|
740
|
+
const _i = process.argv.findIndex(a => a === flag); const _v = _i >= 0 ? process.argv[_i + 1] : null;
|
|
741
|
+
if (_v) { const j = words.indexOf(_v); if (j >= 0) words = words.slice(0, j).concat(words.slice(j + 1)); }
|
|
742
|
+
}
|
|
743
|
+
let task = words.join(' ').trim();
|
|
744
|
+
const piped = await readStdin();
|
|
745
|
+
if (piped) task = (task || (cfg.lang === 'en' ? 'Analyze the input below and act on it.' : 'Analise a entrada abaixo e aja sobre ela.'))
|
|
746
|
+
+ `\n\n${T.pipe_ctx}:\n\`\`\`\n${piped.slice(0, 30000)}\n\`\`\``;
|
|
747
|
+
if (task.length < 8) { console.error(ui.infoLine(T.agent_need)); process.exit(2); }
|
|
748
|
+
|
|
749
|
+
const agent = require('../lib/agent');
|
|
750
|
+
// --modelo/--model força o executor do agente (bake-off de modelos, ex: --modelo glm-5.2)
|
|
751
|
+
const _mi = process.argv.findIndex(a => a === '--modelo' || a === '--model');
|
|
752
|
+
const model = _mi >= 0 ? (process.argv[_mi + 1] || null) : null;
|
|
753
|
+
// --ler = modo Ask (só leitura, nunca escreve/roda) · --plano = propõe um plano sem agir
|
|
754
|
+
const readOnly = process.argv.includes('--ler') || process.argv.includes('--read-only');
|
|
755
|
+
const plan = process.argv.includes('--plano') || process.argv.includes('--plan');
|
|
756
|
+
// --stream-json / --output-format stream-json: modo HEADLESS pra scripts/CI — SÓ NDJSON no stdout
|
|
757
|
+
// (eventos tipados system/tool/assistant/result), sem UI bonita. Padrão Cursor/Claude Code.
|
|
758
|
+
const _ofi = process.argv.indexOf('--output-format');
|
|
759
|
+
const streamJson = process.argv.includes('--stream-json') || (_ofi >= 0 && process.argv[_ofi + 1] === 'stream-json');
|
|
760
|
+
const _emit = (o) => { try { process.stdout.write(JSON.stringify(o) + '\n'); } catch (_) {} };
|
|
761
|
+
// --worktree/-w: roda numa cópia ISOLADA do repo (git worktree). Nada toca a árvore principal.
|
|
762
|
+
const useWorktree = process.argv.includes('--worktree') || process.argv.includes('-w');
|
|
763
|
+
// SESSÃO RESUMÍVEL: `ts agente --continuar "..."` retoma o trabalho anterior DESTA pasta.
|
|
764
|
+
const _p = require('path'), _fs = require('fs'), _os = require('os'), _cr = require('crypto');
|
|
765
|
+
const sessFile = _p.join(_os.homedir(), '.ts', 'agente', _cr.createHash('md5').update(process.cwd().toLowerCase()).digest('hex').slice(0, 12) + '.json');
|
|
766
|
+
let priorMessages = null;
|
|
767
|
+
// cwd da sessão: o do REPL (cwdIn) tem prioridade; senão a pasta de lançamento.
|
|
768
|
+
let startCwd = cwdIn || process.cwd();
|
|
769
|
+
if (process.argv.includes('--continuar') || process.argv.includes('-c')) {
|
|
770
|
+
try { const s = JSON.parse(_fs.readFileSync(sessFile, 'utf8')); priorMessages = s.messages; if (!cwdIn && s.cwd) startCwd = s.cwd; if (!streamJson) console.log(' ' + C.dim((cfg.lang === 'en' ? '↻ resuming this folder\'s session (' : '↻ continuando a sessão desta pasta (') + (s.messages ? s.messages.length : 0) + ' msgs)')); }
|
|
771
|
+
catch (_) { if (!streamJson) console.log(' ' + C.dim(cfg.lang === 'en' ? '(no prior session here — starting fresh)' : '(sem sessão anterior aqui — começando nova)')); }
|
|
772
|
+
}
|
|
773
|
+
// WORKTREE: cria a cópia isolada e passa a trabalhar nela (todas as edições ficam confinadas ali).
|
|
774
|
+
let _wt = null;
|
|
775
|
+
if (useWorktree) {
|
|
776
|
+
if (_isGitRepo(startCwd)) {
|
|
777
|
+
try { _wt = _worktreeCreate(startCwd); startCwd = _wt.path; if (!streamJson) console.log(' ' + C.dim('⎇ ' + (cfg.lang === 'en' ? 'isolated run in worktree ' : 'run isolada na worktree ') + C.cyan(_wt.branch))); }
|
|
778
|
+
catch (e) { if (!streamJson) console.log(' ' + C.warn('▲ ') + C.dim('worktree falhou (' + String(e.message || '').slice(0, 50) + ') — rodando na pasta normal')); }
|
|
779
|
+
} else if (!streamJson) console.log(' ' + C.dim('⎇ ' + (cfg.lang === 'en' ? '--worktree needs a git repo — running normally' : '--worktree precisa de um repo git — rodando normal')));
|
|
780
|
+
}
|
|
781
|
+
// @arquivo: expande referências "@caminho" no texto pro conteúdo do arquivo (padrão Claude Code/Cursor).
|
|
782
|
+
task = _expandFileRefs(task, startCwd);
|
|
783
|
+
const ask = askFn || ui.ask;
|
|
784
|
+
const sp = streamJson ? { text() {}, start() {}, stop() {} } : ui.spinner(T.agent_thinking).start();
|
|
785
|
+
const t0 = Date.now();
|
|
786
|
+
if (streamJson) _emit({ type: 'system', subtype: 'init', model: model || 'auto', cwd: startCwd, mode: plan ? 'plan' : readOnly ? 'ask' : 'agent' });
|
|
787
|
+
let out;
|
|
788
|
+
try {
|
|
789
|
+
out = await agent.run(task, {
|
|
790
|
+
token, lang: cfg.lang || 'pt', yes: YES, model, priorMessages, cwd: startCwd, readOnly, plan,
|
|
791
|
+
onThinking: () => sp.text(T.agent_thinking),
|
|
792
|
+
onStep: ({ name, detail, blocked }) => {
|
|
793
|
+
if (streamJson) { _emit({ type: 'tool', subtype: blocked ? 'blocked' : 'started', tool: name, detail: detail || '' }); return; }
|
|
794
|
+
sp.stop();
|
|
795
|
+
const tag = blocked ? C.err('■ ' + T.agent_blocked) : C.cyan('⚙');
|
|
796
|
+
console.log(' ' + tag + ' ' + C.bold(name) + (detail ? C.dim(' · ' + detail) : ''));
|
|
797
|
+
sp.start();
|
|
798
|
+
},
|
|
799
|
+
askApprove: async (cmd) => {
|
|
800
|
+
// headless (stream-json): não dá pra perguntar → NEGA o destrutivo e sinaliza o evento.
|
|
801
|
+
if (streamJson) { _emit({ type: 'tool', subtype: 'approval_denied', reason: 'headless (--stream-json): destructive command not auto-approved', command: cmd }); return false; }
|
|
802
|
+
sp.stop();
|
|
803
|
+
const ans = String(await ask(C.err('▲ ') + T.agent_approve(C.bold(cmd)))).trim().toLowerCase();
|
|
804
|
+
sp.start();
|
|
805
|
+
return ['s', 'sim', 'y', 'yes'].includes(ans);
|
|
806
|
+
},
|
|
807
|
+
onRemote: ({ ttl }) => {
|
|
808
|
+
if (streamJson) { _emit({ type: 'tool', subtype: 'approval_remote', ttl: ttl || 120 }); return; }
|
|
809
|
+
sp.stop();
|
|
810
|
+
console.log(' ' + C.warn('▲') + ' ' + C.dim(T.agent_remote_wait(ttl || 120)));
|
|
811
|
+
sp.start();
|
|
812
|
+
},
|
|
813
|
+
});
|
|
814
|
+
} catch (e) { sp.stop(); throw e; }
|
|
815
|
+
sp.stop();
|
|
816
|
+
// cwd EFETIVO (o agente pode ter feito cd/mudar_diretorio) → devolve ao REPL e persiste.
|
|
817
|
+
const effCwd = out.cwd || startCwd;
|
|
818
|
+
// em worktree NÃO propaga o cwd (o REPL/sessão devem ficar na base, não na cópia descartável —
|
|
819
|
+
// senão --continuar retomaria num diretório que o `ts worktrees clean` já apagou).
|
|
820
|
+
if (onCwd && !_wt && effCwd !== startCwd) onCwd(effCwd);
|
|
821
|
+
const persistCwd = _wt ? _wt.base : effCwd;
|
|
822
|
+
try { _fs.mkdirSync(_p.dirname(sessFile), { recursive: true }); _fs.writeFileSync(sessFile, JSON.stringify({ cwd: persistCwd, at: new Date().toISOString(), messages: (out.messages || []).slice(-60) })); } catch (_) {}
|
|
823
|
+
const secs = ((Date.now() - t0) / 1000).toFixed(1).replace('.', ',');
|
|
824
|
+
// WORKTREE: commita o que mudou e prepara o resumo (merge/descarte).
|
|
825
|
+
const _wtInfo = _wt ? _worktreeFinish(_wt) : null;
|
|
826
|
+
const _printWt = () => {
|
|
827
|
+
if (!_wt) return;
|
|
828
|
+
const en = cfg.lang === 'en';
|
|
829
|
+
if (_wtInfo && _wtInfo.changed && _wtInfo.committed) {
|
|
830
|
+
console.log(' ' + C.dim('⎇ ' + (en ? 'isolated in ' : 'isolado em ') + C.cyan(_wt.branch) + C.dim(' — ' + _wtInfo.changed + (en ? ' file(s) changed. Review then:' : ' arquivo(s) alterado(s). Revise e:'))));
|
|
831
|
+
console.log(C.dim(` git -C ${JSON.stringify(_wt.base)} merge ${_wt.branch} ${en ? '# apply' : '# aplicar'}\n ts worktrees clean ${en ? '# discard ts worktrees' : '# descartar as worktrees do ts'}`));
|
|
832
|
+
} else if (_wtInfo && _wtInfo.changed && !_wtInfo.committed) {
|
|
833
|
+
// commit falhou (ex: git user.email não configurado em CI/cron) → NÃO instrui merge nem clean
|
|
834
|
+
console.log(' ' + C.warn('▲ ') + (en ? _wtInfo.changed + ' change(s) in worktree but the auto-commit FAILED (set git user.email/user.name). Uncommitted changes are in:' : _wtInfo.changed + ' mudança(s) na worktree mas o commit automático FALHOU (configure git user.email/user.name). As mudanças NÃO-commitadas estão em:'));
|
|
835
|
+
console.log(' ' + C.cyan(_wt.path) + C.dim(en ? ' (do NOT run `ts worktrees clean` — it would discard them)' : ' (NÃO rode `ts worktrees clean` — perderia as mudanças)'));
|
|
836
|
+
} else console.log(' ' + C.dim('⎇ ' + C.cyan(_wt.branch) + C.dim(en ? ' — no changes. Clean: ts worktrees clean' : ' — sem mudanças. Limpar: ts worktrees clean')));
|
|
837
|
+
};
|
|
838
|
+
// HEADLESS stream-json: fecha com assistant (texto) + result (métricas) e sai.
|
|
839
|
+
if (streamJson) {
|
|
840
|
+
_emit({ type: 'assistant', text: out.text || '' });
|
|
841
|
+
_emit({ type: 'result', steps: out.steps, credits: out.credits, tokens: out.tokens, context: out.context || null, needHuman: out.needHuman || null, cwd: effCwd, duration_ms: Date.now() - t0, worktree: _wt ? { path: _wt.path, branch: _wt.branch, base: _wt.base, changed: (_wtInfo && _wtInfo.changed) || 0 } : null });
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
if (JSON_OUT) { console.log(JSON.stringify({ ok: true, result: out.text, steps: out.steps, credits: out.credits, tokens: out.tokens, context: out.context, needHuman: out.needHuman })); return; }
|
|
845
|
+
// BLOQUEIO HUMANO: o agente pediu uma ação que só o usuário faz — mostra o pedido
|
|
846
|
+
// (antes o retorno vinha com texto vazio e o usuário via só um cabeçalho em branco).
|
|
847
|
+
if (out.needHuman) {
|
|
848
|
+
console.log('\n ' + C.warn('🙋 ' + (cfg.lang === 'en' ? 'I need you: ' : 'Preciso de você: ')) + C.bold(out.needHuman.motivo || ''));
|
|
849
|
+
if (out.needHuman.o_que_fazer) console.log(' ' + (cfg.lang === 'en' ? 'What to do: ' : 'O que fazer: ') + out.needHuman.o_que_fazer);
|
|
850
|
+
console.log(' ' + C.dim(cfg.lang === 'en' ? 'Then run: ts agente --continuar "done, continue"' : 'Depois rode: ts agente --continuar "feito, continua"') + '\n');
|
|
851
|
+
if (out.text) printAnswer(out.text);
|
|
852
|
+
_printWt();
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
printAnswer(out.text);
|
|
856
|
+
// medidor de contexto: ocupação REAL da janela na última chamada (só aparece se >25%)
|
|
857
|
+
const _cx = out.context && out.context.window
|
|
858
|
+
? (out.context.used / out.context.window > 0.25
|
|
859
|
+
? ' · ' + (cfg.lang === 'en' ? 'context ' : 'contexto ') + Math.round(out.context.used / 1000) + 'k/' + Math.round(out.context.window / 1000) + 'k (' + Math.round(100 * out.context.used / out.context.window) + '%)'
|
|
860
|
+
: '')
|
|
861
|
+
: '';
|
|
862
|
+
console.log(' ' + C.dim(T.agent_footer(out.steps, out.credits, secs)) + C.dim(_cx) + C.dim(' · ts agente --continuar "..." pra seguir') + '\n');
|
|
863
|
+
_printWt();
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// ── ts acp: servidor Agent Client Protocol (JSON-RPC/stdio) pra editores (Zed etc.) ──
|
|
867
|
+
async function acpCmd() {
|
|
868
|
+
// token pode faltar — o servidor responde o handshake mesmo assim e só recusa no prompt
|
|
869
|
+
// (mensagem "rode ts login"). NÃO escreve NADA em stdout aqui (corromperia o JSON-RPC).
|
|
870
|
+
const { acpServer } = require('../lib/acp');
|
|
871
|
+
acpServer({ token: cfg.token || null, lang: cfg.lang || 'pt' });
|
|
872
|
+
// fica vivo lendo stdin até o editor fechar (rl 'close' → process.exit).
|
|
873
|
+
return new Promise(() => {});
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// ── ts eval: roda o agente sobre uma suíte de casos e um LLM-juiz pontua cada um ──
|
|
877
|
+
async function evalCmd(words) {
|
|
878
|
+
const en = cfg.lang === 'en';
|
|
879
|
+
const evalMod = require('../lib/eval');
|
|
880
|
+
// --exemplo: imprime uma suíte modelo e sai (nem precisa de login).
|
|
881
|
+
if (FLAGS.has('--exemplo') || FLAGS.has('--example')) { console.log(JSON.stringify(evalMod.EXAMPLE, null, 2)); return; }
|
|
882
|
+
const token = needToken();
|
|
883
|
+
const _val = (names) => { const i = process.argv.findIndex(a => names.includes(a)); return i >= 0 ? (process.argv[i + 1] || null) : null; };
|
|
884
|
+
const model = _val(['--modelo', '--model']); // executor do agente (bake-off)
|
|
885
|
+
const judgeModel = _val(['--juiz', '--judge']) || undefined; // modelo do juiz (default barato)
|
|
886
|
+
const smoke = FLAGS.has('--smoke');
|
|
887
|
+
// caminho da suíte = positional que não seja valor de --modelo/--juiz. Prefere um que
|
|
888
|
+
// termine em .json ou exista em disco (robusto quando o valor de --modelo bate com o nome).
|
|
889
|
+
const skip = new Set([model, judgeModel].filter(Boolean));
|
|
890
|
+
const cand = words.filter(w => !skip.has(w));
|
|
891
|
+
const _isFile = (w) => { try { return fs.statSync(w).isFile(); } catch (_) { return false; } };
|
|
892
|
+
const suitePath = cand.find(w => /\.json$/i.test(w)) || cand.find(_isFile) || cand[0];
|
|
893
|
+
const _usage = en
|
|
894
|
+
? 'Usage: ts eval <suite.json> [--json] [--modelo X] [--juiz Y] · ts eval --smoke · ts eval --exemplo'
|
|
895
|
+
: 'Uso: ts eval <suite.json> [--json] [--modelo X] [--juiz Y] · ts eval --smoke · ts eval --exemplo';
|
|
896
|
+
const _die = (msg, code) => { if (JSON_OUT) console.log(JSON.stringify({ error: msg })); else console.error(ui.errLine(msg)); process.exit(code); };
|
|
897
|
+
|
|
898
|
+
let suite;
|
|
899
|
+
if (smoke) suite = evalMod.smokeSuite();
|
|
900
|
+
else if (suitePath) {
|
|
901
|
+
try { suite = evalMod.loadSuite(suitePath); }
|
|
902
|
+
catch (e) { _die((en ? 'suite error: ' : 'erro na suíte: ') + e.message, 2); }
|
|
903
|
+
} else {
|
|
904
|
+
// dica: o usuário provavelmente passou o .json como VALOR de --modelo/--juiz por engano.
|
|
905
|
+
const misTaken = [model, judgeModel].find(v => v && /\.json$/i.test(v));
|
|
906
|
+
if (JSON_OUT) { console.log(JSON.stringify({ error: 'missing suite', hint: misTaken ? `"${misTaken}" foi consumido por --modelo/--juiz` : _usage })); process.exit(2); }
|
|
907
|
+
if (misTaken) console.error(ui.infoLine((en ? 'Did you pass the suite as a flag value? ' : 'Passou a suíte como valor de flag? ') + C.cyan('"' + misTaken + '"') + (en ? ' was consumed by --modelo/--juiz.' : ' foi consumido por --modelo/--juiz.')));
|
|
908
|
+
console.error(ui.infoLine(_usage));
|
|
909
|
+
process.exit(2);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
if (!JSON_OUT) {
|
|
913
|
+
console.log('\n ' + ui.gradient('⌁ ' + (en ? 'Eval' : 'Avaliação')) + ' ' + C.bold(suite.name) + C.dim(' · ' + suite.cases.length + (en ? ' case(s)' : ' caso(s)')));
|
|
914
|
+
console.log(' ' + C.dim(en ? 'runs the real agent per case (costs credits), an AI judge scores each' : 'roda o agente de verdade por caso (gasta créditos); um juiz de IA pontua cada um') + '\n');
|
|
915
|
+
}
|
|
916
|
+
const sp = JSON_OUT ? { start() {}, stop() {}, text() {} } : ui.spinner('…').start();
|
|
917
|
+
let res;
|
|
918
|
+
try {
|
|
919
|
+
res = await evalMod.runSuite(suite, {
|
|
920
|
+
token, lang: cfg.lang || 'pt', model, judgeModel,
|
|
921
|
+
onCase: ({ i, total, id, phase, pass, score }) => {
|
|
922
|
+
if (JSON_OUT) return;
|
|
923
|
+
if (phase === 'done') {
|
|
924
|
+
sp.stop();
|
|
925
|
+
console.log(' ' + (pass ? C.ok('✔') : C.err('✗')) + ' ' + C.bold(id) + C.dim(' ' + score + '/100'));
|
|
926
|
+
sp.start();
|
|
927
|
+
} else {
|
|
928
|
+
sp.text((en ? 'case ' : 'caso ') + (i + 1) + '/' + total + ' · ' + id + ' · ' + (phase === 'judge' ? (en ? 'judging' : 'julgando') : (en ? 'running' : 'rodando')));
|
|
929
|
+
}
|
|
930
|
+
},
|
|
931
|
+
});
|
|
932
|
+
} catch (e) {
|
|
933
|
+
sp.stop();
|
|
934
|
+
// teto de IA (free/plano) → CTA de upgrade limpo, nunca "HTTP 402" cru.
|
|
935
|
+
if (e && (e.code === 'no_credits' || e.status === 402)) { console.error('\n' + ui.errLine(en ? 'AI limit reached on this plan — top up or upgrade to run evals.' : 'Limite de IA do plano atingido — recarregue ou faça upgrade pra rodar evals.')); process.exit(1); }
|
|
936
|
+
throw e;
|
|
937
|
+
}
|
|
938
|
+
sp.stop();
|
|
939
|
+
|
|
940
|
+
const s = res.summary;
|
|
941
|
+
if (JSON_OUT) { console.log(JSON.stringify(res)); if (s.failed > 0) process.exitCode = 1; return; }
|
|
942
|
+
console.log('');
|
|
943
|
+
for (const r of res.results) {
|
|
944
|
+
console.log(' ' + (r.pass ? C.ok('✔') : C.err('✗')) + ' ' + C.bold(r.id.padEnd(22)) + ' ' + C.dim(String(r.score).padStart(3) + '/100') + ' ' + r.reason);
|
|
945
|
+
if (r.error) console.log(' ' + C.warn(en ? 'run error: ' : 'erro na run: ') + C.dim(r.error));
|
|
946
|
+
}
|
|
947
|
+
const secs = (s.ms / 1000).toFixed(1).replace('.', ',');
|
|
948
|
+
const tally = s.passed + '/' + s.total;
|
|
949
|
+
const col = s.passRate === 100 ? C.ok : s.passRate >= 50 ? C.warn : C.err;
|
|
950
|
+
const judgeTok = s.judgeTokens ? ' · ' + (en ? 'judge ~' : 'juiz ~') + (s.judgeTokens >= 1000 ? Math.round(s.judgeTokens / 1000) + 'k' : s.judgeTokens) + ' tok' : '';
|
|
951
|
+
console.log('\n ' + C.bold(en ? 'Result: ' : 'Resultado: ') + col(tally) + C.dim(' ' + (en ? 'passed' : 'passaram') + ' (' + s.passRate + '%) · ' + (en ? 'avg ' : 'média ') + s.avgScore + '/100 · ' + s.credits + (en ? ' credits' : ' créditos') + judgeTok + ' · ' + secs + 's') + '\n');
|
|
952
|
+
// exit code pra CI: qualquer caso reprovado → status 1
|
|
953
|
+
if (s.failed > 0) process.exitCode = 1;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
async function runsCmd() {
|
|
957
|
+
const token = needToken();
|
|
958
|
+
const r = await api('/api/ia/runs', { token });
|
|
959
|
+
const runs = (r.runs || r || []).slice(0, 10);
|
|
960
|
+
if (!runs.length) { console.log(ui.infoLine(T.runs_empty)); return; }
|
|
961
|
+
console.log('\n' + ui.box(runs.map(x =>
|
|
962
|
+
C.cyan('#' + String(x.id).padEnd(5)) + ' ' + String(runLbl(x.status)).padEnd(20) + ' ' + C.dim(String(x.goal || '').slice(0, 48))
|
|
963
|
+
), { title: T.runs_title }) + '\n');
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
async function statusCmd(id) {
|
|
967
|
+
const token = needToken();
|
|
968
|
+
if (!id) return runsCmd();
|
|
969
|
+
let r;
|
|
970
|
+
try { r = await api(`/api/ia/run/${parseInt(id)}`, { token }); }
|
|
971
|
+
catch (e) { if (e.status === 404) { console.error(ui.errLine(T.status_notfound)); process.exit(1); } throw e; }
|
|
972
|
+
let run = r.run || r;
|
|
973
|
+
if (JSON_OUT) { console.log(JSON.stringify(run)); return; }
|
|
974
|
+
console.log('\n' + planBox(run));
|
|
975
|
+
const steps = run.steps || [];
|
|
976
|
+
console.log(' ' + C.dim(runLbl(run.status)) + C.dim(` · ${steps.filter(s => s.status === 'done').length}/${steps.length}`));
|
|
977
|
+
if (['running', 'queued', 'reviewing', 'planning'].includes(run.status)) {
|
|
978
|
+
const sp = ui.spinner(runLbl(run.status)).start();
|
|
979
|
+
run = await pollRun(token, run.id, sp);
|
|
980
|
+
sp.stop(run.status === 'done' ? ui.okLine(T.run_done) : ui.errLine(runLbl(run.status)));
|
|
981
|
+
if (run.status === 'done' && run.final_result) { console.log(''); console.log(ui.md(String(run.final_result)).split('\n').map(l => ' ' + l).join('\n')); }
|
|
982
|
+
}
|
|
983
|
+
console.log('');
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// ── ts vps: conectar/gerenciar um servidor remoto (SSH) da máquina local ──
|
|
987
|
+
async function vpsCmd(words) {
|
|
988
|
+
const ssh = require('../lib/ssh');
|
|
989
|
+
const sub = (words[0] || '').toLowerCase();
|
|
990
|
+
const rest = words.slice(1);
|
|
991
|
+
const flag = (n) => { const i = rest.indexOf(n); return i >= 0 ? rest[i + 1] : null; };
|
|
992
|
+
|
|
993
|
+
// ts vps set --host IP --user ubuntu --chave caminho.pem (ou --login arquivo.txt / --senha ...)
|
|
994
|
+
if (sub === 'set' || sub === 'config' || sub === 'add') {
|
|
995
|
+
const prof = ssh.loadProfile() || {};
|
|
996
|
+
if (flag('--login')) { const l = ssh.parseLoginFile(flag('--login')); if (l.user) prof.user = l.user; if (l.pass) prof.pass = l.pass; if (l.host) prof.host = l.host; }
|
|
997
|
+
if (flag('--host')) prof.host = flag('--host');
|
|
998
|
+
if (flag('--porta') || flag('--port')) prof.port = parseInt(flag('--porta') || flag('--port')) || 22;
|
|
999
|
+
if (flag('--user') || flag('--usuario')) prof.user = flag('--user') || flag('--usuario');
|
|
1000
|
+
if (flag('--chave') || flag('--key')) { prof.keyPath = flag('--chave') || flag('--key'); delete prof.pass; }
|
|
1001
|
+
if (flag('--senha') || flag('--pass')) { prof.pass = flag('--senha') || flag('--pass'); delete prof.keyPath; }
|
|
1002
|
+
ssh.saveProfile(prof);
|
|
1003
|
+
console.log(ui.infoLine(`Perfil salvo: ${prof.user || 'root'}@${prof.host || '?'}:${prof.port || 22} (${prof.keyPath ? 'chave' : prof.pass ? 'senha' : 'sem credencial!'})`));
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// ts vps chave — mostra a chave pública do ts (pra você colar no authorized_keys via EC2 Instance Connect)
|
|
1008
|
+
if (sub === 'chave' || sub === 'pubkey') {
|
|
1009
|
+
const os = require('os'); const fsx = require('fs'); const pth = require('path');
|
|
1010
|
+
const kp = pth.join(os.homedir(), '.ssh', 'id_ts_vps');
|
|
1011
|
+
if (!fsx.existsSync(kp + '.pub')) { try { require('child_process').execSync(`ssh-keygen -t ed25519 -f "${kp}" -N "" -C "ts-vps"`, { stdio: 'ignore' }); } catch (_) {} }
|
|
1012
|
+
let pub = ''; try { pub = fsx.readFileSync(kp + '.pub', 'utf8').trim(); } catch (_) {}
|
|
1013
|
+
console.log('\n' + C.bold('Chave pública do ts (adicione ao servidor):') + '\n');
|
|
1014
|
+
console.log(' ' + pub + '\n');
|
|
1015
|
+
console.log(C.dim(' No servidor (ou via EC2 Instance Connect no navegador), rode:'));
|
|
1016
|
+
console.log(' ' + C.cyan(`echo "${pub}" >> ~/.ssh/authorized_keys`) + '\n');
|
|
1017
|
+
console.log(C.dim(' Depois: ') + C.cyan('ts vps set --chave ' + kp) + C.dim(' e ') + C.cyan('ts vps') + '\n');
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
const prof = ssh.loadProfile();
|
|
1022
|
+
if (!prof || !prof.host) { console.log(ui.infoLine('Nenhum servidor configurado. Use "ts vps set --host IP --user ubuntu --chave chave.pem".')); return; }
|
|
1023
|
+
|
|
1024
|
+
const sp = ui.spinner ? ui.spinner(`conectando em ${prof.user}@${prof.host}…`) : null; sp && sp.start && sp.start();
|
|
1025
|
+
try {
|
|
1026
|
+
const r = await ssh.connect();
|
|
1027
|
+
sp && sp.stop && sp.stop();
|
|
1028
|
+
console.log('\n ' + C.ok('●') + ' ' + C.bold(`Logado em ${r.user}@${r.host}:${r.port}`));
|
|
1029
|
+
// ts vps "comando" → roda e sai; ts vps → prova de vida
|
|
1030
|
+
const remoteCmd = (sub && !['ver', 'status', 'info', 'conectar', 'connect'].includes(sub)) ? words.join(' ') : null;
|
|
1031
|
+
const cmd = remoteCmd || 'echo " usuário: $(whoami) | host: $(hostname) | $(uptime -p)"; echo " SO: $(. /etc/os-release; echo $PRETTY_NAME)"; echo " projetos em ~:"; ls -1 ~ 2>/dev/null | head';
|
|
1032
|
+
const out = await ssh.exec(cmd);
|
|
1033
|
+
console.log(C.dim((out.stdout || out.stderr || '').replace(/\n$/, '')));
|
|
1034
|
+
ssh.disconnect();
|
|
1035
|
+
console.log('');
|
|
1036
|
+
} catch (e) { sp && sp.stop && sp.stop(); console.log('\n ' + C.err('✗') + ' ' + e.message + '\n'); }
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// ── ts meta: MISSÃO longa (checklist + rodadas até terminar — modo noturno) ──
|
|
1040
|
+
function _metaChecklistBox(itens) {
|
|
1041
|
+
return ui.box(itens.map(it =>
|
|
1042
|
+
(it.passes ? C.ok('✔') : it.blocked ? C.err('!') : C.dim('○')) + ' ' + (it.passes ? C.dim(it.desc.slice(0, 76)) : it.desc.slice(0, 76))
|
|
1043
|
+
), { title: T.meta_title });
|
|
1044
|
+
}
|
|
1045
|
+
async function metaCmd() {
|
|
1046
|
+
const token = needToken();
|
|
1047
|
+
const metaMod = require('../lib/meta');
|
|
1048
|
+
const dir = process.cwd();
|
|
1049
|
+
|
|
1050
|
+
// objetivo = args após "meta", pulando flags e seus valores (--budget 300 não entra no texto)
|
|
1051
|
+
const mi = rawArgs.findIndex(a => ['meta', 'missao', 'mission'].includes(String(a).toLowerCase()));
|
|
1052
|
+
const parts = [];
|
|
1053
|
+
for (let i = mi + 1; i < rawArgs.length; i++) {
|
|
1054
|
+
const a = rawArgs[i];
|
|
1055
|
+
if (a.startsWith('-')) { if (['--budget', '--rodadas', '--rounds', '--modelo', '--model', '--pensador', '--thinker', '--maxmin', '--designer', '--design', '--olho', '--eye', '--mockup', '--arquivo', '--file', '-f'].includes(a)) i++; continue; }
|
|
1056
|
+
parts.push(a);
|
|
1057
|
+
}
|
|
1058
|
+
// objetivo pode vir de um ARQUIVO (--arquivo/-f caminho.txt) — evita a dor de passar
|
|
1059
|
+
// um texto gigante entre aspas no PowerShell/canvas. O arquivo tem prioridade.
|
|
1060
|
+
const _goalFileArg = (() => { const i = rawArgs.findIndex(a => ['--arquivo', '--file', '-f'].includes(a)); return i >= 0 ? rawArgs[i + 1] : null; })();
|
|
1061
|
+
let goal = parts.join(' ').trim();
|
|
1062
|
+
if (_goalFileArg) { try { const t = require('fs').readFileSync(_goalFileArg, 'utf8').trim(); if (t) goal = t; } catch (e) { console.error(ui.infoLine('Não consegui ler o objetivo de ' + _goalFileArg + ': ' + e.message)); process.exit(2); } }
|
|
1063
|
+
const flagNum = (name, def) => { const i = rawArgs.indexOf(name); const v = i >= 0 ? parseInt(rawArgs[i + 1]) : 0; return v > 0 ? v : def; };
|
|
1064
|
+
const flagStr = (name) => { const i = rawArgs.indexOf(name); return i >= 0 ? (rawArgs[i + 1] || null) : null; };
|
|
1065
|
+
const budget = flagNum('--budget', 400);
|
|
1066
|
+
const maxRounds = flagNum('--rodadas', flagNum('--rounds', 20));
|
|
1067
|
+
const forcedModel = flagStr('--modelo') || flagStr('--model'); // executor fixo (bake-off de modelos)
|
|
1068
|
+
const thinker = flagStr('--pensador') || flagStr('--thinker') || 'grok-4.5'; // modelo caro que só PENSA quando trava
|
|
1069
|
+
const maxMinutes = flagNum('--maxmin', 0); // freio de relógio (0 = sem limite)
|
|
1070
|
+
const designer = flagStr('--designer') || 'grok-4.5'; // modelo forte que projeta o VISUAL antes de codar
|
|
1071
|
+
const design = FLAGS.has('--sem-design') ? false : (flagStr('--design') || 'auto'); // auto = liga em app visual
|
|
1072
|
+
const eye = FLAGS.has('--sem-olho') ? null : (flagStr('--olho') || flagStr('--eye') || 'grok-4.5'); // "olho" que critica o print do app
|
|
1073
|
+
const visualLadder = !FLAGS.has('--sem-escada') && !FLAGS.has('--barato'); // escada visual: fix visual escala pro modelo forte (caro). --sem-escada/--barato deixa no executor barato
|
|
1074
|
+
const mockup = flagStr('--mockup'); // imagem de referência (ex: mockup do Google AI Studio) — designer projeta a partir dela e o olho cobra fidelidade
|
|
1075
|
+
const arch = FLAGS.has('--sem-arch') ? false : (FLAGS.has('--arch') || FLAGS.has('--arquitetura') ? true : 'auto'); // contrato de arquitetura antes de codar (auto = liga em app complexo)
|
|
1076
|
+
|
|
1077
|
+
const existing = metaMod.load(dir);
|
|
1078
|
+
if (FLAGS.has('--status')) {
|
|
1079
|
+
if (!existing) { console.log(ui.infoLine(T.meta_none)); return; }
|
|
1080
|
+
const done = existing.checklist.filter(i => i.passes).length;
|
|
1081
|
+
console.log('\n' + _metaChecklistBox(existing.checklist));
|
|
1082
|
+
console.log(' ' + C.dim(T.meta_progress(done, existing.checklist.length, existing.creditsSpent, existing.budget)) + C.dim(' · ' + existing.status) + '\n');
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
if (FLAGS.has('--novo')) { try { require('fs').unlinkSync(metaMod.stateFile(dir)); } catch (_) {} }
|
|
1086
|
+
const st0 = FLAGS.has('--novo') ? null : existing;
|
|
1087
|
+
if (goal && st0 && st0.status !== 'done') { console.error(ui.infoLine(T.meta_exists(st0.goal))); process.exit(2); }
|
|
1088
|
+
if (!goal && (!st0 || st0.status === 'done')) { console.error(ui.infoLine(T.meta_need)); process.exit(2); }
|
|
1089
|
+
|
|
1090
|
+
const yes = YES || !process.stdin.isTTY; // noturno/cron = autônomo (Telegram cobre o perigoso)
|
|
1091
|
+
console.log(ui.banner(pkg.version, T.tagline));
|
|
1092
|
+
if (st0 && st0.status !== 'done') {
|
|
1093
|
+
const d = st0.checklist.filter(i => i.passes).length;
|
|
1094
|
+
console.log(' ' + C.dim(T.meta_resuming(d, st0.checklist.length)) + '\n');
|
|
1095
|
+
}
|
|
1096
|
+
const sp = ui.spinner(T.meta_planning).start();
|
|
1097
|
+
const t0 = Date.now();
|
|
1098
|
+
if (forcedModel) console.log(' ' + C.dim('executor fixo: ') + C.cyan(forcedModel) + '\n');
|
|
1099
|
+
console.log(' ' + C.dim('pensador (escala no erro): ') + C.indigo(thinker) + (maxMinutes ? C.dim(' · limite ' + maxMinutes + ' min') : '') + '\n');
|
|
1100
|
+
const st = await metaMod.run(goal || null, {
|
|
1101
|
+
token, lang: cfg.lang || 'pt', yes, budget, maxRounds, dir, model: forcedModel, thinker, maxMinutes, designer, design, eye, visualLadder, mockup, arch,
|
|
1102
|
+
onAlert: ({ type, text }) => {
|
|
1103
|
+
sp.stop();
|
|
1104
|
+
const ic = type === 'human' ? C.warn('🙋') : type === 'escalate' ? C.indigo('🧠') : type === 'stagnated' ? C.err('🛑') : type === 'design' ? C.cyan('🎨') : type === 'retry' || type === 'conn' ? C.warn('📡') : C.warn('⏱');
|
|
1105
|
+
console.log('\n ' + ic + ' ' + C.bold(String(text).split('\n')[0]));
|
|
1106
|
+
const rest = String(text).split('\n').slice(1).filter(Boolean);
|
|
1107
|
+
for (const l of rest) console.log(' ' + C.dim(l));
|
|
1108
|
+
console.log('');
|
|
1109
|
+
metaMod.notify(token, text).catch(() => {}); // avisa no Telegram também
|
|
1110
|
+
sp.start();
|
|
1111
|
+
},
|
|
1112
|
+
onChecklist: (itens) => { sp.stop(); console.log(_metaChecklistBox(itens) + '\n'); sp.start(); },
|
|
1113
|
+
onRound: ({ n, item, attempt }) => { sp.stop(); console.log(' ' + C.indigo('◆') + ' ' + C.bold(T.meta_round(n, item.slice(0, 70), attempt))); sp.start(); },
|
|
1114
|
+
onThinking: () => sp.text(T.agent_thinking),
|
|
1115
|
+
onStep: ({ name, detail, blocked }) => {
|
|
1116
|
+
sp.stop();
|
|
1117
|
+
console.log(' ' + (blocked ? C.err('■ ' + T.agent_blocked) : C.cyan('⚙')) + ' ' + name + (detail ? C.dim(' · ' + detail) : ''));
|
|
1118
|
+
sp.start();
|
|
1119
|
+
},
|
|
1120
|
+
askApprove: async (cmd) => { sp.stop(); const a = String(await ui.ask(C.err('▲ ') + T.agent_approve(C.bold(cmd)))).trim().toLowerCase(); sp.start(); return ['s', 'sim', 'y', 'yes'].includes(a); },
|
|
1121
|
+
onRemote: ({ ttl }) => { sp.stop(); console.log(' ' + C.warn('▲') + ' ' + C.dim(T.agent_remote_wait(ttl || 120))); sp.start(); },
|
|
1122
|
+
onRoundDone: ({ checklist, spent }) => {
|
|
1123
|
+
sp.stop();
|
|
1124
|
+
const d = checklist.filter(i => i.passes).length;
|
|
1125
|
+
console.log(' ' + C.dim(T.meta_progress(d, checklist.length, spent, budget)) + '\n');
|
|
1126
|
+
sp.text(T.meta_marking).start();
|
|
1127
|
+
},
|
|
1128
|
+
});
|
|
1129
|
+
sp.stop();
|
|
1130
|
+
if (!st) { console.error(ui.infoLine(T.meta_none)); process.exit(2); }
|
|
1131
|
+
|
|
1132
|
+
const done = st.checklist.filter(i => i.passes).length;
|
|
1133
|
+
const blocked = st.checklist.filter(i => i.blocked && !i.passes);
|
|
1134
|
+
const secs = ((Date.now() - t0) / 1000 / 60).toFixed(1).replace('.', ',');
|
|
1135
|
+
console.log('\n' + _metaChecklistBox(st.checklist));
|
|
1136
|
+
if (JSON_OUT) { console.log(JSON.stringify(st)); }
|
|
1137
|
+
if (st.status === 'done') {
|
|
1138
|
+
console.log(ui.okLine(C.bold(T.meta_done) + C.dim(` · ${st.rounds.length} rodada(s) · ${st.creditsSpent} créditos · ${secs} min` + (st.escalations ? ` · ${st.escalations} escalonamento(s)` : ''))));
|
|
1139
|
+
metaMod.notify(token, T.meta_notify_done(st.goal, done, st.creditsSpent));
|
|
1140
|
+
} else if (st.pause_reason === 'awaiting_human' && st.humanRequest) {
|
|
1141
|
+
console.log(ui.infoLine(C.bold('🙋 Preciso de você: ') + st.humanRequest.motivo));
|
|
1142
|
+
console.log(' ' + C.dim(st.humanRequest.o_que_fazer));
|
|
1143
|
+
console.log(' ' + C.dim('Quando terminar, rode "ts meta" pra continuar de onde parou.'));
|
|
1144
|
+
} else if (st.pause_reason === 'stagnated') {
|
|
1145
|
+
console.log(ui.errLine(C.bold('Missão travada sem progresso — parei pra não desperdiçar créditos.') + C.dim(` (${st.escalations || 0} escalonamento(s) tentado(s))`)));
|
|
1146
|
+
console.log(' ' + C.dim('Resolva o bloqueio manualmente e rode "ts meta", ou ajuste o objetivo com --novo.'));
|
|
1147
|
+
} else if (st.pause_reason === 'timeout') {
|
|
1148
|
+
console.log(ui.infoLine('Tempo limite atingido. Retome com "ts meta".'));
|
|
1149
|
+
} else {
|
|
1150
|
+
console.log(ui.infoLine(st.pause_reason === 'budget' ? T.meta_paused_budget : T.meta_paused_rounds));
|
|
1151
|
+
metaMod.notify(token, T.meta_notify_paused(st.goal, done, st.checklist.length, st.pause_reason));
|
|
1152
|
+
}
|
|
1153
|
+
if (blocked.length) {
|
|
1154
|
+
console.log(' ' + C.err(T.meta_blocked));
|
|
1155
|
+
for (const b of blocked) console.log(' ' + C.err('!') + ' ' + b.desc.slice(0, 80));
|
|
1156
|
+
}
|
|
1157
|
+
console.log('');
|
|
1158
|
+
await new Promise(r => setTimeout(r, 1200)); // dá tempo do notify sair antes do exit
|
|
1159
|
+
if (st.status !== 'done') process.exit(3);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// ── Uso / conta / idioma ─────────────────────────────────────────────────────
|
|
1163
|
+
async function uso() {
|
|
1164
|
+
const token = needToken();
|
|
1165
|
+
const r = await api('/api/credits', { token });
|
|
1166
|
+
if (JSON_OUT) { console.log(JSON.stringify(r)); return; }
|
|
1167
|
+
const unlimited = r.unlimited || (r.granted < 0);
|
|
1168
|
+
const lines = [
|
|
1169
|
+
C.dim(T.uso_plan + ': ') + C.bold(String(r.plan || '?')),
|
|
1170
|
+
'',
|
|
1171
|
+
unlimited
|
|
1172
|
+
? C.ok(T.uso_unlimited) + C.dim(` · ${r.used || 0} ${T.uso_credits}`)
|
|
1173
|
+
: ui.bar((r.used || 0) / Math.max(1, r.granted || 1)) + ` ${C.bold(r.remaining ?? '?')} ${C.dim(`/ ${r.granted} ${T.uso_remaining}`)}`,
|
|
1174
|
+
];
|
|
1175
|
+
const ledger = (r.ledger || []).slice(0, 5);
|
|
1176
|
+
if (ledger.length) {
|
|
1177
|
+
lines.push('', C.dim(T.uso_last + ':'));
|
|
1178
|
+
for (const l of ledger) lines.push(C.dim(` -${l.credits || 0}`.padEnd(8) + String(l.reason || '').padEnd(20) + ' ' + String(l.model || '').slice(0, 26)));
|
|
1179
|
+
}
|
|
1180
|
+
console.log('\n' + ui.box(lines, { title: T.uso_title }) + '\n');
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
async function quem() {
|
|
1184
|
+
const token = needToken();
|
|
1185
|
+
let ok = false;
|
|
1186
|
+
try { const chk = await api('/api/auth/check', { token }); ok = !!(chk && (chk.success || chk.authenticated)); } catch (_) {}
|
|
1187
|
+
console.log('\n' + ui.box([
|
|
1188
|
+
C.dim(T.quem_user + ': ') + C.bold(cfg.username || '?') + (ok ? ' ' + C.ok('•') : ' ' + C.err('• offline')),
|
|
1189
|
+
C.dim(T.quem_plan + ': ') + (cfg.plan || '?'),
|
|
1190
|
+
C.dim(T.quem_server + ': ') + base(),
|
|
1191
|
+
C.dim(T.quem_conv + ': ') + (cfg.convId ? '#' + cfg.convId : '—'),
|
|
1192
|
+
], { title: T.quem_title }) + '\n');
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
function idioma(l) {
|
|
1196
|
+
const lang = String(l || '').toLowerCase();
|
|
1197
|
+
if (!['pt', 'en'].includes(lang)) { console.error(ui.infoLine(T.lang_invalid)); process.exit(2); }
|
|
1198
|
+
cfg = config.save({ lang });
|
|
1199
|
+
T = t(lang);
|
|
1200
|
+
console.log(ui.okLine(T.lang_set(lang)));
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
async function nova() {
|
|
1204
|
+
const token = needToken();
|
|
1205
|
+
const r = await api('/api/conversations', { method: 'POST', token, body: { title: 'CLI' } });
|
|
1206
|
+
cfg = config.save({ convId: r.id });
|
|
1207
|
+
console.log(ui.okLine(T.new_conv));
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// ── Dispatcher ───────────────────────────────────────────────────────────────
|
|
1211
|
+
(async () => {
|
|
1212
|
+
const cmd = (POS[0] || '').toLowerCase();
|
|
1213
|
+
if (FLAGS.has('--version') || FLAGS.has('-v') || cmd === 'versao' || cmd === 'version') {
|
|
1214
|
+
console.log(`ts ${pkg.version}`); return;
|
|
1215
|
+
}
|
|
1216
|
+
if (FLAGS.has('--help') || FLAGS.has('-h') || cmd === 'ajuda' || cmd === 'help') return help();
|
|
1217
|
+
switch (cmd) {
|
|
1218
|
+
case 'login': return login();
|
|
1219
|
+
case 'logout': return logout();
|
|
1220
|
+
case 'chat': case 'conversa': return chatRepl();
|
|
1221
|
+
case 'quem': case 'whoami': return quem();
|
|
1222
|
+
case 'nova': case 'new': return nova();
|
|
1223
|
+
case 'run': return runCmd(POS.slice(1));
|
|
1224
|
+
case 'agente': case 'agent': return agentCmd(POS.slice(1));
|
|
1225
|
+
case 'video': case 'v': return videoCmd(POS.slice(1));
|
|
1226
|
+
case 'arquivar': case 'archive': return arquivarCmd(POS.slice(1));
|
|
1227
|
+
case 'qr': return qrCmd(POS.slice(1));
|
|
1228
|
+
case 'desfazer': case 'undo': return desfazerCmd(POS.slice(1));
|
|
1229
|
+
case 'init': case 'iniciar': return initCmd();
|
|
1230
|
+
case 'hooks': case 'ganchos': return hooksCmd(POS.slice(1));
|
|
1231
|
+
case 'worktrees': case 'worktree': case 'wt': return worktreesCmd(POS.slice(1));
|
|
1232
|
+
case 'eval': case 'avaliar': case 'evals': return evalCmd(POS.slice(1));
|
|
1233
|
+
case 'acp': return acpCmd();
|
|
1234
|
+
case 'skills': case 'skill': return skillsCmd(POS.slice(1));
|
|
1235
|
+
case 'memoria': case 'memória': case 'memory': return memoriaCmd(POS.slice(1));
|
|
1236
|
+
case 'vps': case 'servidor': return vpsCmd(POS.slice(1));
|
|
1237
|
+
case 'meta': case 'missao': case 'mission': return metaCmd();
|
|
1238
|
+
case 'runs': return runsCmd();
|
|
1239
|
+
case 'status': return statusCmd(POS[1]);
|
|
1240
|
+
case 'uso': case 'usage': return uso();
|
|
1241
|
+
case 'idioma': case 'lang': return idioma(POS[1]);
|
|
1242
|
+
case '': {
|
|
1243
|
+
// ts puro: pipe → analisa; terminal → MODO CONVERSA (linguagem natural é o padrão)
|
|
1244
|
+
if (!process.stdin.isTTY) return chat('');
|
|
1245
|
+
if (!cfg.token) return help(); // sem login ainda: apresenta o CLI primeiro
|
|
1246
|
+
return chatRepl();
|
|
1247
|
+
}
|
|
1248
|
+
default: {
|
|
1249
|
+
// one-shot em TTY também passa pelo roteador ("ts crie um arquivo..." age);
|
|
1250
|
+
// pipe/--json ficam determinísticos no chat (scripts não levam surpresa)
|
|
1251
|
+
const text = POS.join(' ');
|
|
1252
|
+
if (process.stdin.isTTY && !JSON_OUT && cfg.token) {
|
|
1253
|
+
const r = await router.route(text, cfg.token);
|
|
1254
|
+
if (r.dest === 'agente') { console.log(' ' + C.cyan('⚙') + ' ' + C.dim(T.route_agent)); return agentCmd([text]); }
|
|
1255
|
+
if (r.dest === 'run') { console.log(' ' + C.indigo('◆') + ' ' + C.dim(T.route_run)); return runCmd([text]); }
|
|
1256
|
+
}
|
|
1257
|
+
return chat(text);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
})().then(
|
|
1261
|
+
// Encerramento limpo: se o agente/meta abriu uma conexão SSH (conectar_vps),
|
|
1262
|
+
// ela mantém o event loop VIVO e o processo NÃO sai (o `ts` fica pendurado
|
|
1263
|
+
// depois de imprimir o resultado). Fechar o socket deixa o Node drenar e sair
|
|
1264
|
+
// sozinho — sem process.exit forçado, então o stdout termina de descarregar.
|
|
1265
|
+
() => { try { require('../lib/ssh').disconnect(); } catch (_) {} },
|
|
1266
|
+
(e) => { try { require('../lib/ssh').disconnect(); } catch (_) {} fail(e); }
|
|
1267
|
+
);
|