liberty-code-cli 1.0.0 → 2.0.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 +212 -0
- package/bin/liberty.js +101 -171
- package/package.json +21 -4
- package/src/api.js +45 -0
- package/src/configStore.js +14 -0
- package/src/constants.js +77 -0
- package/src/engine.js +64 -0
- package/src/hooks.js +105 -0
- package/src/ignoreEngine.js +46 -0
- package/src/mcp.js +139 -0
- package/src/memory.js +89 -0
- package/src/permissions.js +77 -0
- package/src/repl.js +285 -0
- package/src/session.js +82 -0
- package/src/skills.js +94 -0
- package/src/subagents.js +70 -0
- package/src/tools/exec-tools.js +73 -0
- package/src/tools/fs-tools.js +242 -0
- package/src/tools/index.js +192 -0
- package/src/tools/todo-tool.js +38 -0
- package/src/ui.js +71 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Ferramentas de arquivo. Comparado à v1 (list_directory, read_file,
|
|
4
|
+
* write_file só), o que muda:
|
|
5
|
+
*
|
|
6
|
+
* - list_directory: agora respeita .gitignore de verdade (ignoreEngine),
|
|
7
|
+
* aceita recursive+max_depth, e tem um teto de entradas pra não afogar o
|
|
8
|
+
* contexto num repositório grande.
|
|
9
|
+
* - read_file: arquivo grande não é mais um "não dá" seco — aceita
|
|
10
|
+
* offset/limit (linha inicial/quantidade) igual ao Read do Claude Code,
|
|
11
|
+
* então o modelo consegue pedir só o trecho que interessa.
|
|
12
|
+
* - write_file: continua criando/sobrescrevendo o arquivo INTEIRO, mas
|
|
13
|
+
* passa pela camada de permissão (permissions.js) em vez de um y/N fixo.
|
|
14
|
+
* - edit_file (NOVA): troca old_string → new_string com casamento exato,
|
|
15
|
+
* tipo find&replace cirúrgico — pra mudanças pequenas isso é mais seguro
|
|
16
|
+
* e mais barato que reescrever o arquivo inteiro (menos chance do modelo
|
|
17
|
+
* "esquecer" um trecho no meio ao reescrever tudo).
|
|
18
|
+
* - glob_files (NOVA): encontra arquivos por padrão (**\/*.test.js etc).
|
|
19
|
+
* - grep_search (NOVA): busca por regex dentro do conteúdo dos arquivos.
|
|
20
|
+
*
|
|
21
|
+
* safeResolve ganhou um teste de prefixo mais rigoroso: a versão original
|
|
22
|
+
* comparava com resolved.startsWith(cwd), que TEM UM BUG DE SEGURANÇA sutil
|
|
23
|
+
* — se o projeto for "/home/user/foo", o caminho "/home/user/foobar/x"
|
|
24
|
+
* passava no startsWith() mesmo sendo um diretório IRMÃO, não um filho. Corrigido
|
|
25
|
+
* exigindo o separador de caminho depois do prefixo.
|
|
26
|
+
*/
|
|
27
|
+
const fs = require('fs');
|
|
28
|
+
const path = require('path');
|
|
29
|
+
const { MAX_FILE_READ_BYTES } = require('../constants');
|
|
30
|
+
const { paint, renderEditPreview } = require('../ui');
|
|
31
|
+
|
|
32
|
+
function safeResolve(cwd, relPath) {
|
|
33
|
+
const base = path.resolve(cwd);
|
|
34
|
+
const resolved = path.resolve(base, relPath || '.');
|
|
35
|
+
if (resolved !== base && !resolved.startsWith(base + path.sep)) {
|
|
36
|
+
throw new Error('Caminho fora do diretório do projeto não é permitido.');
|
|
37
|
+
}
|
|
38
|
+
return resolved;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isProbablyBinary(buf) {
|
|
42
|
+
const len = Math.min(buf.length, 8000);
|
|
43
|
+
for (let i = 0; i < len; i++) if (buf[i] === 0) return true;
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── list_directory ──────────────────────────────────────────────────────
|
|
48
|
+
function toolListDirectory(args, ctx) {
|
|
49
|
+
const dir = safeResolve(ctx.cwd, args.path || '.');
|
|
50
|
+
const recursive = !!args.recursive;
|
|
51
|
+
const maxDepth = Number.isFinite(args.max_depth) ? args.max_depth : (recursive ? 4 : 1);
|
|
52
|
+
const MAX_ENTRIES = 400;
|
|
53
|
+
const out = [];
|
|
54
|
+
|
|
55
|
+
function walk(current, depth) {
|
|
56
|
+
if (out.length >= MAX_ENTRIES || depth > maxDepth) return;
|
|
57
|
+
let entries;
|
|
58
|
+
try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { return; }
|
|
59
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
60
|
+
for (const e of entries) {
|
|
61
|
+
if (out.length >= MAX_ENTRIES) { out.push('… (limite de entradas atingido, refine o path)'); return; }
|
|
62
|
+
const full = path.join(current, e.name);
|
|
63
|
+
const rel = path.relative(ctx.cwd, full);
|
|
64
|
+
if (ctx.ignoreEngine.isIgnored(rel)) continue;
|
|
65
|
+
out.push(`${' '.repeat(depth - 1)}${rel}${e.isDirectory() ? '/' : ''}`);
|
|
66
|
+
if (e.isDirectory() && recursive) walk(full, depth + 1);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
walk(dir, 1);
|
|
70
|
+
return out.join('\n') || '(diretório vazio ou tudo ignorado por .gitignore)';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── read_file ────────────────────────────────────────────────────────────
|
|
74
|
+
function toolReadFile(args, ctx) {
|
|
75
|
+
const filePath = safeResolve(ctx.cwd, args.path);
|
|
76
|
+
const stat = fs.statSync(filePath);
|
|
77
|
+
if (stat.isDirectory()) throw new Error('Esse caminho é um diretório, use list_directory.');
|
|
78
|
+
|
|
79
|
+
const hasRange = Number.isFinite(args.offset) || Number.isFinite(args.limit);
|
|
80
|
+
if (stat.size > MAX_FILE_READ_BYTES && !hasRange) {
|
|
81
|
+
return `[Arquivo grande: ${(stat.size / 1024).toFixed(0)}KB, acima do limite de ${MAX_FILE_READ_BYTES / 1024}KB por leitura. ` +
|
|
82
|
+
`Use offset/limit pra ler em partes, ex: {"path":"${args.path}","offset":0,"limit":200}.]`;
|
|
83
|
+
}
|
|
84
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
85
|
+
if (!hasRange) return content;
|
|
86
|
+
|
|
87
|
+
const lines = content.split('\n');
|
|
88
|
+
const offset = Math.max(0, args.offset || 0);
|
|
89
|
+
const limit = Number.isFinite(args.limit) ? args.limit : 500;
|
|
90
|
+
const slice = lines.slice(offset, offset + limit);
|
|
91
|
+
const numbered = slice.map((l, i) => `${offset + i + 1}\t${l}`).join('\n');
|
|
92
|
+
const footer = offset + limit < lines.length ? `\n… (${lines.length - offset - limit} linhas restantes — aumente offset pra continuar)` : '';
|
|
93
|
+
return numbered + footer;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── write_file ───────────────────────────────────────────────────────────
|
|
97
|
+
async function toolWriteFile(args, ctx) {
|
|
98
|
+
const filePath = safeResolve(ctx.cwd, args.path);
|
|
99
|
+
const rel = path.relative(ctx.cwd, filePath);
|
|
100
|
+
const exists = fs.existsSync(filePath);
|
|
101
|
+
console.log(paint('yellow', `\n${exists ? '✏️ Sobrescrever' : '📄 Criar'} arquivo: ${rel}`));
|
|
102
|
+
console.log(paint('gray', ` (${(args.content || '').length} caracteres)`));
|
|
103
|
+
|
|
104
|
+
const hookRes = ctx.hooks.runHooks('PreToolUse', { toolName: 'write_file', args, filePath: rel, cwd: ctx.cwd }, ctx.cwd);
|
|
105
|
+
hookRes.infoLines.forEach(l => console.log(l));
|
|
106
|
+
if (hookRes.blocked) return `[Bloqueado por hook: ${hookRes.reason}]`;
|
|
107
|
+
|
|
108
|
+
const ok = await ctx.permissions.check('write', rel, ' Permitir?', ctx.rl);
|
|
109
|
+
if (!ok) return '[Usuário negou a escrita deste arquivo.]';
|
|
110
|
+
|
|
111
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
112
|
+
fs.writeFileSync(filePath, args.content, 'utf8');
|
|
113
|
+
ctx.hooks.runHooks('PostToolUse', { toolName: 'write_file', args: { path: args.path }, filePath: rel, cwd: ctx.cwd }, ctx.cwd)
|
|
114
|
+
.infoLines.forEach(l => console.log(l));
|
|
115
|
+
return `Arquivo salvo com sucesso: ${rel}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── edit_file (NOVA) ─────────────────────────────────────────────────────
|
|
119
|
+
async function toolEditFile(args, ctx) {
|
|
120
|
+
const { path: relPath, old_string, new_string, replace_all } = args;
|
|
121
|
+
if (typeof old_string !== 'string' || !old_string.length) throw new Error('old_string é obrigatório e não pode ser vazio.');
|
|
122
|
+
const filePath = safeResolve(ctx.cwd, relPath);
|
|
123
|
+
const rel = path.relative(ctx.cwd, filePath);
|
|
124
|
+
const current = fs.readFileSync(filePath, 'utf8');
|
|
125
|
+
|
|
126
|
+
const occurrences = current.split(old_string).length - 1;
|
|
127
|
+
if (occurrences === 0) {
|
|
128
|
+
return `[old_string não encontrado em ${rel}. Confira se o texto é EXATAMENTE igual (espaços/indentação incluídos) ao que está no arquivo — releia o arquivo se necessário.]`;
|
|
129
|
+
}
|
|
130
|
+
if (occurrences > 1 && !replace_all) {
|
|
131
|
+
return `[old_string aparece ${occurrences} vezes em ${rel} — inclua mais contexto ao redor pra torná-lo único, ou passe replace_all:true se a intenção é trocar todas.]`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const updated = replace_all ? current.split(old_string).join(new_string) : current.replace(old_string, new_string);
|
|
135
|
+
|
|
136
|
+
console.log(paint('yellow', `\n✏️ Editar arquivo: ${rel}`) + paint('gray', replace_all ? ` (${occurrences} ocorrências)` : ''));
|
|
137
|
+
console.log(renderEditPreview(old_string, new_string));
|
|
138
|
+
|
|
139
|
+
const hookRes = ctx.hooks.runHooks('PreToolUse', { toolName: 'edit_file', args: { path: relPath }, filePath: rel, cwd: ctx.cwd }, ctx.cwd);
|
|
140
|
+
hookRes.infoLines.forEach(l => console.log(l));
|
|
141
|
+
if (hookRes.blocked) return `[Bloqueado por hook: ${hookRes.reason}]`;
|
|
142
|
+
|
|
143
|
+
const ok = await ctx.permissions.check('edit', rel, ' Permitir?', ctx.rl);
|
|
144
|
+
if (!ok) return '[Usuário negou esta edição.]';
|
|
145
|
+
|
|
146
|
+
fs.writeFileSync(filePath, updated, 'utf8');
|
|
147
|
+
ctx.hooks.runHooks('PostToolUse', { toolName: 'edit_file', args: { path: relPath }, filePath: rel, cwd: ctx.cwd }, ctx.cwd)
|
|
148
|
+
.infoLines.forEach(l => console.log(l));
|
|
149
|
+
return `Edição aplicada com sucesso em ${rel} (${occurrences} ocorrência${occurrences > 1 ? 's' : ''} trocada${occurrences > 1 ? 's' : ''}).`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── glob_files (NOVA) ────────────────────────────────────────────────────
|
|
153
|
+
function globToRegExp(pattern) {
|
|
154
|
+
let re = '';
|
|
155
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
156
|
+
const ch = pattern[i];
|
|
157
|
+
if (ch === '*' && pattern[i + 1] === '*') {
|
|
158
|
+
i++;
|
|
159
|
+
if (pattern[i + 1] === '/') { re += '(?:.*/)?'; i++; }
|
|
160
|
+
else re += '.*';
|
|
161
|
+
} else if (ch === '*') re += '[^/]*';
|
|
162
|
+
else if (ch === '?') re += '[^/]';
|
|
163
|
+
else if ('.+^$()[]{}|\\'.includes(ch)) re += '\\' + ch;
|
|
164
|
+
else re += ch;
|
|
165
|
+
}
|
|
166
|
+
return new RegExp('^' + re + '$');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function toolGlobFiles(args, ctx) {
|
|
170
|
+
if (!args.pattern) throw new Error('pattern é obrigatório, ex: "**/*.test.js"');
|
|
171
|
+
const startDir = safeResolve(ctx.cwd, args.path || '.');
|
|
172
|
+
const regex = globToRegExp(args.pattern);
|
|
173
|
+
const MAX_RESULTS = 200;
|
|
174
|
+
const results = [];
|
|
175
|
+
|
|
176
|
+
function walk(dir) {
|
|
177
|
+
if (results.length >= MAX_RESULTS) return;
|
|
178
|
+
let entries;
|
|
179
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
180
|
+
for (const e of entries) {
|
|
181
|
+
const full = path.join(dir, e.name);
|
|
182
|
+
const rel = path.relative(ctx.cwd, full);
|
|
183
|
+
if (ctx.ignoreEngine.isIgnored(rel)) continue;
|
|
184
|
+
if (e.isDirectory()) { walk(full); continue; }
|
|
185
|
+
if (regex.test(rel.split(path.sep).join('/'))) results.push(rel);
|
|
186
|
+
if (results.length >= MAX_RESULTS) return;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
walk(startDir);
|
|
190
|
+
if (!results.length) return `Nenhum arquivo encontrado para o padrão "${args.pattern}".`;
|
|
191
|
+
return results.join('\n') + (results.length >= MAX_RESULTS ? '\n… (limite de resultados atingido, refine o padrão)' : '');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── grep_search (NOVA) ───────────────────────────────────────────────────
|
|
195
|
+
function toolGrepSearch(args, ctx) {
|
|
196
|
+
if (!args.pattern) throw new Error('pattern é obrigatório (regex).');
|
|
197
|
+
const startDir = safeResolve(ctx.cwd, args.path || '.');
|
|
198
|
+
let regex;
|
|
199
|
+
try { regex = new RegExp(args.pattern, args.case_insensitive ? 'i' : ''); }
|
|
200
|
+
catch (e) { throw new Error(`Regex inválida: ${e.message}`); }
|
|
201
|
+
const globRegex = args.glob ? globToRegExp(args.glob) : null;
|
|
202
|
+
const MAX_MATCHES = 200, MAX_FILES_SCANNED = 800, MAX_FILE_SIZE = 1.5 * 1024 * 1024;
|
|
203
|
+
|
|
204
|
+
const matches = [];
|
|
205
|
+
let filesScanned = 0;
|
|
206
|
+
|
|
207
|
+
function walk(dir) {
|
|
208
|
+
if (matches.length >= MAX_MATCHES || filesScanned >= MAX_FILES_SCANNED) return;
|
|
209
|
+
let entries;
|
|
210
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
211
|
+
for (const e of entries) {
|
|
212
|
+
if (matches.length >= MAX_MATCHES || filesScanned >= MAX_FILES_SCANNED) return;
|
|
213
|
+
const full = path.join(dir, e.name);
|
|
214
|
+
const rel = path.relative(ctx.cwd, full);
|
|
215
|
+
if (ctx.ignoreEngine.isIgnored(rel)) continue;
|
|
216
|
+
if (e.isDirectory()) { walk(full); continue; }
|
|
217
|
+
const relSlash = rel.split(path.sep).join('/');
|
|
218
|
+
if (globRegex && !globRegex.test(relSlash)) continue;
|
|
219
|
+
let stat;
|
|
220
|
+
try { stat = fs.statSync(full); } catch { continue; }
|
|
221
|
+
if (stat.size > MAX_FILE_SIZE) continue;
|
|
222
|
+
filesScanned++;
|
|
223
|
+
let buf;
|
|
224
|
+
try { buf = fs.readFileSync(full); } catch { continue; }
|
|
225
|
+
if (isProbablyBinary(buf)) continue;
|
|
226
|
+
const lines = buf.toString('utf8').split('\n');
|
|
227
|
+
for (let i = 0; i < lines.length; i++) {
|
|
228
|
+
if (regex.test(lines[i])) {
|
|
229
|
+
matches.push(`${rel}:${i + 1}: ${lines[i].trim().slice(0, 200)}`);
|
|
230
|
+
if (matches.length >= MAX_MATCHES) break;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
walk(startDir);
|
|
236
|
+
if (!matches.length) return `Nenhuma ocorrência de "${args.pattern}" encontrada (${filesScanned} arquivos verificados).`;
|
|
237
|
+
return matches.join('\n') + (matches.length >= MAX_MATCHES ? '\n… (limite de resultados atingido, refine a busca)' : '');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
module.exports = {
|
|
241
|
+
safeResolve, toolListDirectory, toolReadFile, toolWriteFile, toolEditFile, toolGlobFiles, toolGrepSearch,
|
|
242
|
+
};
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Registro central de ferramentas.
|
|
4
|
+
*
|
|
5
|
+
* Duas responsabilidades que ANTES estavam espalhadas (schema vivia no
|
|
6
|
+
* server.js/services.js, dispatch vivia no bin/liberty.js, sem ligação
|
|
7
|
+
* explícita entre os dois — fácil os dois lados desalinharem):
|
|
8
|
+
*
|
|
9
|
+
* 1. buildToolSchemas(mode, extra) — decide QUAIS ferramentas o modelo
|
|
10
|
+
* recebe, de acordo com o modo atual. Em 'plan', as ferramentas de
|
|
11
|
+
* escrita simplesmente não entram na lista — o modelo não só "não
|
|
12
|
+
* deveria usar", ele NÃO TEM a ferramenta disponível pra chamar.
|
|
13
|
+
* 2. executeLocalTool(call, ctx) — dispatch único de execução, incluindo
|
|
14
|
+
* ferramentas nativas, a ferramenta task (subagente) e ferramentas MCP
|
|
15
|
+
* (reconhecidas pelo prefixo mcp__).
|
|
16
|
+
*
|
|
17
|
+
* O array de tools construído aqui é enviado pro servidor a cada requisição
|
|
18
|
+
* (ver api.js) — o server.js precisa aceitar esse override (ver mudança em
|
|
19
|
+
* server.js) em vez de sempre usar o buildLibertyCodeTools() fixo dele.
|
|
20
|
+
*/
|
|
21
|
+
const { TOOL_RESULT_CHAR_CAP, PERMISSION_MODES } = require('../constants');
|
|
22
|
+
const { safeResolve, toolListDirectory, toolReadFile, toolWriteFile, toolEditFile, toolGlobFiles, toolGrepSearch } = require('./fs-tools');
|
|
23
|
+
const { toolRunCommand } = require('./exec-tools');
|
|
24
|
+
const { toolUpdateTodos } = require('./todo-tool');
|
|
25
|
+
|
|
26
|
+
const READ_ONLY_TOOL_NAMES = ['list_directory', 'read_file', 'glob_files', 'grep_search', 'update_todos'];
|
|
27
|
+
const WRITE_TOOL_NAMES = ['write_file', 'edit_file', 'run_command'];
|
|
28
|
+
|
|
29
|
+
const SCHEMAS = {
|
|
30
|
+
list_directory: {
|
|
31
|
+
type: 'function',
|
|
32
|
+
function: {
|
|
33
|
+
name: 'list_directory',
|
|
34
|
+
description: 'Lista arquivos e pastas de um diretório do projeto (respeita .gitignore). Use recursive:true pra ver a árvore inteira de uma vez.',
|
|
35
|
+
parameters: { type: 'object', properties: {
|
|
36
|
+
path: { type: 'string', description: 'Caminho relativo ao projeto. Padrão: "."' },
|
|
37
|
+
recursive: { type: 'boolean', description: 'Descer em subpastas. Padrão: false.' },
|
|
38
|
+
max_depth: { type: 'number', description: 'Profundidade máxima quando recursive:true. Padrão: 4.' },
|
|
39
|
+
} },
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
read_file: {
|
|
43
|
+
type: 'function',
|
|
44
|
+
function: {
|
|
45
|
+
name: 'read_file',
|
|
46
|
+
description: 'Lê o conteúdo de um arquivo do projeto. Para arquivos grandes, use offset (linha inicial, 0-based) e limit (quantidade de linhas).',
|
|
47
|
+
parameters: { type: 'object', properties: {
|
|
48
|
+
path: { type: 'string', description: 'Caminho relativo ao projeto.' },
|
|
49
|
+
offset: { type: 'number', description: 'Linha inicial (0-based). Opcional.' },
|
|
50
|
+
limit: { type: 'number', description: 'Quantidade de linhas a partir de offset. Opcional, padrão 500.' },
|
|
51
|
+
}, required: ['path'] },
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
write_file: {
|
|
55
|
+
type: 'function',
|
|
56
|
+
function: {
|
|
57
|
+
name: 'write_file',
|
|
58
|
+
description: 'Cria um arquivo novo OU sobrescreve um arquivo inteiro com novo conteúdo. Para mudanças pequenas em arquivo existente, prefira edit_file — é mais seguro e mais barato.',
|
|
59
|
+
parameters: { type: 'object', properties: {
|
|
60
|
+
path: { type: 'string', description: 'Caminho relativo ao projeto.' },
|
|
61
|
+
content: { type: 'string', description: 'Conteúdo COMPLETO do arquivo.' },
|
|
62
|
+
}, required: ['path', 'content'] },
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
edit_file: {
|
|
66
|
+
type: 'function',
|
|
67
|
+
function: {
|
|
68
|
+
name: 'edit_file',
|
|
69
|
+
description: 'Troca um trecho exato (old_string) por outro (new_string) num arquivo existente. old_string deve casar EXATAMENTE (incluindo espaços/indentação) e ser único no arquivo — inclua linhas de contexto ao redor se necessário. Use replace_all:true pra trocar todas as ocorrências de uma vez.',
|
|
70
|
+
parameters: { type: 'object', properties: {
|
|
71
|
+
path: { type: 'string', description: 'Caminho relativo ao projeto.' },
|
|
72
|
+
old_string: { type: 'string', description: 'Trecho exato a ser substituído.' },
|
|
73
|
+
new_string: { type: 'string', description: 'Novo trecho.' },
|
|
74
|
+
replace_all: { type: 'boolean', description: 'Trocar todas as ocorrências em vez de exigir que seja única. Padrão: false.' },
|
|
75
|
+
}, required: ['path', 'old_string', 'new_string'] },
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
glob_files: {
|
|
79
|
+
type: 'function',
|
|
80
|
+
function: {
|
|
81
|
+
name: 'glob_files',
|
|
82
|
+
description: 'Encontra arquivos por padrão glob, ex: "**/*.test.js", "src/**/*.tsx". Suporta * (dentro de uma pasta), ** (várias pastas) e ?.',
|
|
83
|
+
parameters: { type: 'object', properties: {
|
|
84
|
+
pattern: { type: 'string', description: 'Padrão glob.' },
|
|
85
|
+
path: { type: 'string', description: 'Pasta base pra busca. Padrão: raiz do projeto.' },
|
|
86
|
+
}, required: ['pattern'] },
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
grep_search: {
|
|
90
|
+
type: 'function',
|
|
91
|
+
function: {
|
|
92
|
+
name: 'grep_search',
|
|
93
|
+
description: 'Busca um padrão (regex) dentro do conteúdo dos arquivos do projeto. Devolve arquivo:linha e o trecho que casou.',
|
|
94
|
+
parameters: { type: 'object', properties: {
|
|
95
|
+
pattern: { type: 'string', description: 'Expressão regular (sintaxe JavaScript).' },
|
|
96
|
+
path: { type: 'string', description: 'Pasta base pra busca. Padrão: raiz do projeto.' },
|
|
97
|
+
glob: { type: 'string', description: 'Filtrar arquivos por padrão glob antes de buscar, ex: "*.js".' },
|
|
98
|
+
case_insensitive: { type: 'boolean' },
|
|
99
|
+
}, required: ['pattern'] },
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
run_command: {
|
|
103
|
+
type: 'function',
|
|
104
|
+
function: {
|
|
105
|
+
name: 'run_command',
|
|
106
|
+
description: 'Executa um comando de shell no diretório do projeto. Use run_in_background:true para comandos de longa duração (servidores, watchers) — nesse caso a saída vai pra um arquivo de log em vez de bloquear.',
|
|
107
|
+
parameters: { type: 'object', properties: {
|
|
108
|
+
command: { type: 'string', description: 'Comando de shell completo.' },
|
|
109
|
+
run_in_background: { type: 'boolean', description: 'Rodar em background e devolver controle imediatamente. Padrão: false.' },
|
|
110
|
+
}, required: ['command'] },
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
update_todos: {
|
|
114
|
+
type: 'function',
|
|
115
|
+
function: {
|
|
116
|
+
name: 'update_todos',
|
|
117
|
+
description: 'Cria/atualiza a lista de tarefas da sessão. Use em tarefas com 3+ passos pra planejar antes de agir e manter o progresso visível. Marque um item como in_progress só quando estiver de fato trabalhando nele (um por vez).',
|
|
118
|
+
parameters: { type: 'object', properties: {
|
|
119
|
+
todos: {
|
|
120
|
+
type: 'array',
|
|
121
|
+
items: { type: 'object', properties: {
|
|
122
|
+
content: { type: 'string' },
|
|
123
|
+
status: { type: 'string', enum: ['pending', 'in_progress', 'completed'] },
|
|
124
|
+
}, required: ['content', 'status'] },
|
|
125
|
+
},
|
|
126
|
+
}, required: ['todos'] },
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
task: {
|
|
130
|
+
type: 'function',
|
|
131
|
+
function: {
|
|
132
|
+
name: 'task',
|
|
133
|
+
description: 'Delega uma sub-tarefa AUTOCONTIDA a um subagente com contexto próprio (não vê o histórico desta conversa, só o prompt que você der). Use pra investigações que gerariam muito ruído no contexto principal (ex: "procure em todo o repo onde X é usado e resuma"). O subagente devolve só um resumo final, não os passos intermediários.',
|
|
134
|
+
parameters: { type: 'object', properties: {
|
|
135
|
+
description: { type: 'string', description: 'Resumo curto (3-6 palavras) da tarefa, pra exibir ao usuário.' },
|
|
136
|
+
prompt: { type: 'string', description: 'Instrução completa e autocontida pro subagente — ele não tem mais contexto além disso.' },
|
|
137
|
+
subagent_type: { type: 'string', enum: ['general', 'read-only-researcher'], description: '"read-only-researcher" só pode explorar/ler (mais seguro, mais rápido de aprovar); "general" também pode editar arquivos e rodar comandos.' },
|
|
138
|
+
}, required: ['description', 'prompt'] },
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
function buildToolSchemas({ mode, includeTask = false, mcpTools = [] } = {}) {
|
|
144
|
+
let names;
|
|
145
|
+
if (mode === PERMISSION_MODES.PLAN) names = [...READ_ONLY_TOOL_NAMES];
|
|
146
|
+
else names = [...READ_ONLY_TOOL_NAMES, ...WRITE_TOOL_NAMES];
|
|
147
|
+
const schemas = names.map(n => SCHEMAS[n]);
|
|
148
|
+
if (includeTask && mode !== PERMISSION_MODES.PLAN) schemas.push(SCHEMAS.task);
|
|
149
|
+
return [...schemas, ...mcpTools];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Ferramentas do subagente: nunca inclui "task" (profundidade máx. 1) e read-only restringe a leitura. */
|
|
153
|
+
function buildSubagentToolSchemas(subagentType, mcpTools = []) {
|
|
154
|
+
const names = subagentType === 'read-only-researcher'
|
|
155
|
+
? READ_ONLY_TOOL_NAMES
|
|
156
|
+
: [...READ_ONLY_TOOL_NAMES, ...WRITE_TOOL_NAMES];
|
|
157
|
+
return [...names.map(n => SCHEMAS[n]), ...mcpTools];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function executeLocalTool(call, ctx) {
|
|
161
|
+
try {
|
|
162
|
+
let result;
|
|
163
|
+
if (call.name.startsWith('mcp__')) {
|
|
164
|
+
if (!ctx.mcpManager) return `[MCP não está configurado nesta sessão.]`;
|
|
165
|
+
result = await ctx.mcpManager.callTool(call.name, call.arguments || {});
|
|
166
|
+
} else {
|
|
167
|
+
switch (call.name) {
|
|
168
|
+
case 'list_directory': result = toolListDirectory(call.arguments || {}, ctx); break;
|
|
169
|
+
case 'read_file': result = toolReadFile(call.arguments || {}, ctx); break;
|
|
170
|
+
case 'write_file': result = await toolWriteFile(call.arguments || {}, ctx); break;
|
|
171
|
+
case 'edit_file': result = await toolEditFile(call.arguments || {}, ctx); break;
|
|
172
|
+
case 'glob_files': result = toolGlobFiles(call.arguments || {}, ctx); break;
|
|
173
|
+
case 'grep_search': result = toolGrepSearch(call.arguments || {}, ctx); break;
|
|
174
|
+
case 'run_command': result = await toolRunCommand(call.arguments || {}, ctx); break;
|
|
175
|
+
case 'update_todos': result = toolUpdateTodos(call.arguments || {}, ctx); break;
|
|
176
|
+
case 'task':
|
|
177
|
+
if (!ctx.allowSubagents) { result = '[task indisponível: subagentes não podem lançar outros subagentes.]'; break; }
|
|
178
|
+
result = await ctx.runSubagent(call.arguments || {}, ctx);
|
|
179
|
+
break;
|
|
180
|
+
default: result = `Ferramenta desconhecida: ${call.name}`;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const str = String(result ?? '');
|
|
184
|
+
return str.length > TOOL_RESULT_CHAR_CAP
|
|
185
|
+
? str.slice(0, TOOL_RESULT_CHAR_CAP) + `\n… (resultado cortado em ${TOOL_RESULT_CHAR_CAP} caracteres)`
|
|
186
|
+
: str;
|
|
187
|
+
} catch (e) {
|
|
188
|
+
return `Erro ao executar ${call.name}: ${e.message}`;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
module.exports = { buildToolSchemas, buildSubagentToolSchemas, executeLocalTool, safeResolve, SCHEMAS };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* update_todos — equivalente ao TodoWrite do Claude Code.
|
|
4
|
+
*
|
|
5
|
+
* Não mexe no disco, é só estado da sessão (por isso entra na sessão salva
|
|
6
|
+
* em session.js, pra sobreviver a um /resume). Serve dois propósitos: (1)
|
|
7
|
+
* força o modelo a quebrar tarefas grandes em passos explícitos em vez de
|
|
8
|
+
* tentar tudo de uma vez, (2) dá pro usuário visibilidade do progresso sem
|
|
9
|
+
* precisar ler cada mensagem.
|
|
10
|
+
*/
|
|
11
|
+
const { paint } = require('../ui');
|
|
12
|
+
|
|
13
|
+
const ICONS = { pending: '☐', in_progress: '◐', completed: '☑' };
|
|
14
|
+
|
|
15
|
+
function toolUpdateTodos(args, ctx) {
|
|
16
|
+
const todos = Array.isArray(args.todos) ? args.todos : [];
|
|
17
|
+
for (const t of todos) {
|
|
18
|
+
if (!t.content || !['pending', 'in_progress', 'completed'].includes(t.status)) {
|
|
19
|
+
throw new Error('Cada todo precisa de {content, status: pending|in_progress|completed}.');
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
ctx.todos.length = 0;
|
|
23
|
+
ctx.todos.push(...todos);
|
|
24
|
+
renderTodos(ctx.todos);
|
|
25
|
+
const done = todos.filter(t => t.status === 'completed').length;
|
|
26
|
+
return `Lista de tarefas atualizada (${done}/${todos.length} concluídas).`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function renderTodos(todos) {
|
|
30
|
+
if (!todos.length) return;
|
|
31
|
+
console.log(paint('cyan', '\n📋 Tarefas:'));
|
|
32
|
+
todos.forEach(t => {
|
|
33
|
+
const color = t.status === 'completed' ? 'green' : t.status === 'in_progress' ? 'yellow' : 'gray';
|
|
34
|
+
console.log(paint(color, ` ${ICONS[t.status]} ${t.content}`));
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { toolUpdateTodos, renderTodos };
|
package/src/ui.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* UI de terminal — sem dependências externas, de propósito (mesma filosofia
|
|
4
|
+
* do liberty.js original: um CLI de agente não precisa de chalk/ora só pra
|
|
5
|
+
* pintar texto e mostrar "carregando").
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const c = {
|
|
9
|
+
reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m', italic: '\x1b[3m',
|
|
10
|
+
red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m',
|
|
11
|
+
blue: '\x1b[34m', magenta: '\x1b[35m', cyan: '\x1b[36m', gray: '\x1b[90m',
|
|
12
|
+
bgRed: '\x1b[41m', bgGreen: '\x1b[42m',
|
|
13
|
+
};
|
|
14
|
+
const paint = (color, text) => `${c[color]}${text}${c.reset}`;
|
|
15
|
+
|
|
16
|
+
// ── Spinner simples (sem setInterval "vazando" — sempre limpo no fim) ──────
|
|
17
|
+
function spinner(label) {
|
|
18
|
+
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
19
|
+
let i = 0;
|
|
20
|
+
const isTTY = process.stdout.isTTY;
|
|
21
|
+
if (!isTTY) {
|
|
22
|
+
process.stdout.write(paint('gray', `${label}...\n`));
|
|
23
|
+
return { stop() {} };
|
|
24
|
+
}
|
|
25
|
+
const timer = setInterval(() => {
|
|
26
|
+
process.stdout.write(`\r${paint('cyan', frames[i = (i + 1) % frames.length])} ${paint('gray', label)} `);
|
|
27
|
+
}, 80);
|
|
28
|
+
return {
|
|
29
|
+
stop(clearLine = true) {
|
|
30
|
+
clearInterval(timer);
|
|
31
|
+
if (clearLine) process.stdout.write(`\r${' '.repeat(label.length + 4)}\r`);
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── Diff colorido simples (linha a linha, sem lib de diff — old vs new) ────
|
|
37
|
+
// Não é um diff mínimo tipo Myers; é propositalmente simples: mostra o
|
|
38
|
+
// old_string todo em vermelho e o new_string todo em verde, prefixados com
|
|
39
|
+
// - / +. Suficiente pro caso de uso (confirmar uma edição antes de aplicar)
|
|
40
|
+
// sem puxar uma dependência de diff só pra isso.
|
|
41
|
+
function renderEditPreview(oldStr, newStr, contextLabel) {
|
|
42
|
+
const lines = [];
|
|
43
|
+
if (contextLabel) lines.push(paint('gray', contextLabel));
|
|
44
|
+
const oldLines = oldStr.split('\n');
|
|
45
|
+
const newLines = newStr.split('\n');
|
|
46
|
+
const MAX_LINES = 40;
|
|
47
|
+
oldLines.slice(0, MAX_LINES).forEach(l => lines.push(paint('red', ` - ${l}`)));
|
|
48
|
+
if (oldLines.length > MAX_LINES) lines.push(paint('gray', ` … (+${oldLines.length - MAX_LINES} linhas removidas)`));
|
|
49
|
+
newLines.slice(0, MAX_LINES).forEach(l => lines.push(paint('green', ` + ${l}`)));
|
|
50
|
+
if (newLines.length > MAX_LINES) lines.push(paint('gray', ` … (+${newLines.length - MAX_LINES} linhas adicionadas)`));
|
|
51
|
+
return lines.join('\n');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function truncateForDisplay(str, max = 240) {
|
|
55
|
+
const s = String(str ?? '');
|
|
56
|
+
return s.length > max ? s.slice(0, max) + paint('gray', `… (+${s.length - max} chars)`) : s;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function banner(version, cwd, extras) {
|
|
60
|
+
const lines = [];
|
|
61
|
+
lines.push(paint('bold', '\n🦅 Liberty Code') + paint('gray', ` v${version}`));
|
|
62
|
+
lines.push(paint('gray', ` ${cwd}`));
|
|
63
|
+
(extras || []).forEach(e => lines.push(paint('gray', ` ${e}`)));
|
|
64
|
+
return lines.join('\n');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function toolLine(icon, label) {
|
|
68
|
+
return paint('yellow', `\n${icon} ${label}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { c, paint, spinner, renderEditPreview, truncateForDisplay, banner, toolLine };
|