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
package/src/constants.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Constantes centrais do Liberty Code.
|
|
4
|
+
*
|
|
5
|
+
* NOVO (v2): antes essas constantes viviam soltas dentro de bin/liberty.js.
|
|
6
|
+
* Extraídas pra cá porque agora vários módulos (memory, session, hooks,
|
|
7
|
+
* skills, mcp) precisam dos mesmos caminhos — ter uma fonte única evita
|
|
8
|
+
* inconsistência (ex: session.js e memory.js apontando pra pastas diferentes
|
|
9
|
+
* por causa de um typo em um dos dois lugares).
|
|
10
|
+
*/
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const os = require('os');
|
|
14
|
+
const crypto = require('crypto');
|
|
15
|
+
|
|
16
|
+
// ── Diretórios globais (nível usuário — válidos em qualquer projeto) ───────
|
|
17
|
+
const GLOBAL_DIR = path.join(os.homedir(), '.libertycode');
|
|
18
|
+
const CONFIG_FILE = path.join(GLOBAL_DIR, 'config.json');
|
|
19
|
+
const GLOBAL_MEMORY_FILE = path.join(GLOBAL_DIR, 'LIBERTY.md');
|
|
20
|
+
const GLOBAL_SKILLS_DIR = path.join(GLOBAL_DIR, 'skills');
|
|
21
|
+
const SESSIONS_ROOT = path.join(GLOBAL_DIR, 'sessions');
|
|
22
|
+
|
|
23
|
+
// ── Diretórios de projeto (nível repositório — pensados pra ir pro git,
|
|
24
|
+
// igual .claude/ do Claude Code) ─────────────────────────────────────────
|
|
25
|
+
const PROJECT_DIR_NAME = '.liberty';
|
|
26
|
+
function projectDir(cwd) { return path.join(cwd, PROJECT_DIR_NAME); }
|
|
27
|
+
function projectSettingsFile(cwd) { return path.join(projectDir(cwd), 'settings.json'); }
|
|
28
|
+
function projectMcpFile(cwd) { return path.join(projectDir(cwd), 'mcp.json'); }
|
|
29
|
+
function projectSkillsDir(cwd) { return path.join(projectDir(cwd), 'skills'); }
|
|
30
|
+
function projectLogsDir(cwd) { return path.join(projectDir(cwd), 'logs'); }
|
|
31
|
+
function projectMemoryFile(cwd) { return path.join(cwd, 'LIBERTY.md'); }
|
|
32
|
+
|
|
33
|
+
// ── Servidor ─────────────────────────────────────────────────────────────
|
|
34
|
+
const SERVER_URL = process.env.LIBERTY_SERVER_URL || 'https://libertychat.onrender.com';
|
|
35
|
+
|
|
36
|
+
// ── Limites ──────────────────────────────────────────────────────────────
|
|
37
|
+
const MAX_FILE_READ_BYTES = 256 * 1024; // por leitura de trecho; arquivos maiores exigem offset/limit
|
|
38
|
+
const MAX_TOOL_ITERATIONS = 30; // era 12 — baixo demais pra tarefas reais de várias etapas
|
|
39
|
+
const MAX_SUBAGENT_ITERATIONS = 12; // subagente é pra tarefa focada, não precisa do mesmo teto
|
|
40
|
+
const MAX_SUBAGENT_DEPTH = 1; // subagente não pode disparar outro subagente (evita recursão/custo descontrolado)
|
|
41
|
+
const TOOL_RESULT_CHAR_CAP = 12000; // por resultado de ferramenta enviado de volta ao modelo
|
|
42
|
+
const RUN_COMMAND_TIMEOUT_MS = 120_000;
|
|
43
|
+
const RUN_COMMAND_MAX_BUFFER = 8 * 1024 * 1024;
|
|
44
|
+
const HOOK_TIMEOUT_MS = 15_000;
|
|
45
|
+
const HISTORY_MESSAGES_SENT = 60; // espelha o slice(-60) que o server.js já aplica
|
|
46
|
+
|
|
47
|
+
const PERMISSION_MODES = Object.freeze({
|
|
48
|
+
DEFAULT: 'default', // pergunta antes de write_file / edit_file / run_command
|
|
49
|
+
ACCEPT_EDITS: 'acceptEdits', // aprova edições de arquivo automaticamente, ainda pergunta pra comandos
|
|
50
|
+
BYPASS: 'bypassPermissions', // aprova tudo automaticamente — requer flag explícita pra ligar
|
|
51
|
+
PLAN: 'plan', // somente leitura — nem oferece as ferramentas de escrita ao modelo
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function slugifyProjectPath(cwd) {
|
|
55
|
+
// Usado pra isolar sessões/memória por projeto sem depender do nome da
|
|
56
|
+
// pasta (que pode repetir entre máquinas) — hash curto do caminho absoluto
|
|
57
|
+
// + nome legível da pasta, só pra facilitar achar visualmente em ~/.libertycode/sessions.
|
|
58
|
+
const abs = path.resolve(cwd);
|
|
59
|
+
const hash = crypto.createHash('sha1').update(abs).digest('hex').slice(0, 10);
|
|
60
|
+
const base = path.basename(abs).replace(/[^a-zA-Z0-9_-]/g, '_') || 'root';
|
|
61
|
+
return `${base}-${hash}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function ensureDir(dir) {
|
|
65
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
66
|
+
return dir;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = {
|
|
70
|
+
GLOBAL_DIR, CONFIG_FILE, GLOBAL_MEMORY_FILE, GLOBAL_SKILLS_DIR, SESSIONS_ROOT,
|
|
71
|
+
PROJECT_DIR_NAME, projectDir, projectSettingsFile, projectMcpFile, projectSkillsDir, projectLogsDir, projectMemoryFile,
|
|
72
|
+
SERVER_URL,
|
|
73
|
+
MAX_FILE_READ_BYTES, MAX_TOOL_ITERATIONS, MAX_SUBAGENT_ITERATIONS, MAX_SUBAGENT_DEPTH,
|
|
74
|
+
TOOL_RESULT_CHAR_CAP, RUN_COMMAND_TIMEOUT_MS, RUN_COMMAND_MAX_BUFFER, HOOK_TIMEOUT_MS,
|
|
75
|
+
HISTORY_MESSAGES_SENT, PERMISSION_MODES,
|
|
76
|
+
slugifyProjectPath, ensureDir,
|
|
77
|
+
};
|
package/src/engine.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Loop do agente — extraído de dentro do promptUser() do bin/liberty.js
|
|
4
|
+
* original pra virar uma função reutilizável.
|
|
5
|
+
*
|
|
6
|
+
* Por que isso importa mais do que parece: sem essa extração, subagentes
|
|
7
|
+
* (task) precisariam duplicar o while(iterations...) inteiro, ou o REPL
|
|
8
|
+
* principal teria que virar recursivo de um jeito estranho. Com o loop
|
|
9
|
+
* isolado aqui, tanto repl.js (sessão principal, MAX_TOOL_ITERATIONS,
|
|
10
|
+
* ferramentas completas) quanto subagents.js (MAX_SUBAGENT_ITERATIONS,
|
|
11
|
+
* ferramentas restritas, histórico isolado) chamam a MESMA função — é
|
|
12
|
+
* literalmente o mesmo motor com parâmetros diferentes, igual o Claude Code
|
|
13
|
+
* reaproveita seu loop principal pra rodar subagentes.
|
|
14
|
+
*/
|
|
15
|
+
const { apiChat } = require('./api');
|
|
16
|
+
const { executeLocalTool } = require('./tools');
|
|
17
|
+
const { paint, truncateForDisplay } = require('./ui');
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {object} opts
|
|
21
|
+
* @param {Array} opts.messages - histórico mutável (a função dá push nele)
|
|
22
|
+
* @param {string} opts.token
|
|
23
|
+
* @param {Array} opts.tools - schemas enviados ao modelo nesta chamada
|
|
24
|
+
* @param {string} [opts.systemPromptSuffix]
|
|
25
|
+
* @param {object} opts.ctx - contexto de execução de ferramentas (cwd, rl, permissions, hooks, ignoreEngine, todos, runSubagent, allowSubagents, mcpManager)
|
|
26
|
+
* @param {number} opts.maxIterations
|
|
27
|
+
* @param {boolean} [opts.verbose] - imprime "→ resultado" de cada ferramenta (true no loop principal, false em subagentes pra não poluir)
|
|
28
|
+
* @param {AbortSignal} [opts.signal]
|
|
29
|
+
*/
|
|
30
|
+
async function runAgentLoop({ messages, token, tools, systemPromptSuffix, ctx, maxIterations, verbose = true, signal }) {
|
|
31
|
+
let iterations = 0;
|
|
32
|
+
while (iterations++ < maxIterations) {
|
|
33
|
+
let response;
|
|
34
|
+
try {
|
|
35
|
+
response = await apiChat(messages, token, { tools, systemPromptSuffix, signal });
|
|
36
|
+
} catch (e) {
|
|
37
|
+
if (e.name === 'AbortError') return { finalText: null, cancelled: true };
|
|
38
|
+
throw e;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (response.type === 'tool_calls') {
|
|
42
|
+
messages.push({
|
|
43
|
+
role: 'assistant', content: response.content || null,
|
|
44
|
+
tool_calls: response.toolCalls.map(tc => ({ id: tc.id, type: 'function', function: { name: tc.name, arguments: JSON.stringify(tc.arguments) } })),
|
|
45
|
+
});
|
|
46
|
+
for (const call of response.toolCalls) {
|
|
47
|
+
if (verbose) console.log(paint('yellow', `\n🔧 ${call.name}`) + paint('gray', ` ${truncateForDisplay(JSON.stringify(call.arguments || {}), 120)}`));
|
|
48
|
+
const result = await executeLocalTool(call, ctx);
|
|
49
|
+
if (verbose) console.log(paint('gray', ` → ${truncateForDisplay(result, 200)}`));
|
|
50
|
+
messages.push({ role: 'tool', tool_call_id: call.id, content: result });
|
|
51
|
+
}
|
|
52
|
+
continue; // devolve o resultado da(s) ferramenta(s) e deixa o modelo decidir o próximo passo
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
messages.push({ role: 'assistant', content: response.content });
|
|
56
|
+
return { finalText: response.content, cancelled: false, stoppedByLimit: false };
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
finalText: null, cancelled: false, stoppedByLimit: true,
|
|
60
|
+
limitMessage: `[Atingido o limite de ${maxIterations} chamadas de ferramenta nesta resposta. Se a tarefa não terminou, peça pra continuar.]`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { runAgentLoop };
|
package/src/hooks.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Hooks — equivalente aos hooks do Claude Code (PreToolUse, PostToolUse etc).
|
|
4
|
+
*
|
|
5
|
+
* NOVO (v2): não existia nenhuma forma de interceptar o agente de fora. Isso
|
|
6
|
+
* significa que coisas como "rodar eslint --fix depois de toda edição" ou
|
|
7
|
+
* "bloquear qualquer `rm -rf`" tinham que ser pedidas em português no prompt
|
|
8
|
+
* e torcer pro modelo obedecer sempre — nada garantido.
|
|
9
|
+
*
|
|
10
|
+
* Configurado em .liberty/settings.json:
|
|
11
|
+
* {
|
|
12
|
+
* "hooks": {
|
|
13
|
+
* "PreToolUse": [{ "matcher": "run_command", "command": "node .liberty/hooks/block-rm-rf.js" }],
|
|
14
|
+
* "PostToolUse": [{ "matcher": "write_file|edit_file", "command": "npx eslint --fix \"$LIBERTY_FILE\"" }],
|
|
15
|
+
* "SessionStart":[{ "command": "git status --short" }],
|
|
16
|
+
* "Stop": [{ "command": "echo sessão encerrada >> .liberty/logs/audit.log" }]
|
|
17
|
+
* }
|
|
18
|
+
* }
|
|
19
|
+
*
|
|
20
|
+
* Convenção de saída do hook (igual Claude Code):
|
|
21
|
+
* exit 0 → ok. stdout (se houver) é só informativo, mostrado ao usuário.
|
|
22
|
+
* exit 2 → BLOQUEIA a ação (só faz sentido em PreToolUse). stderr vira o
|
|
23
|
+
* motivo da recusa, devolvido ao modelo como resultado da ferramenta.
|
|
24
|
+
* qualquer outro código → aviso (mostrado ao usuário), mas não bloqueia nada.
|
|
25
|
+
*
|
|
26
|
+
* O payload do evento é enviado por stdin como JSON E também como variáveis
|
|
27
|
+
* de ambiente (LIBERTY_EVENT, LIBERTY_TOOL, LIBERTY_FILE) pra hooks simples
|
|
28
|
+
* poderem ser um one-liner de shell sem precisar parsear JSON.
|
|
29
|
+
*/
|
|
30
|
+
const fs = require('fs');
|
|
31
|
+
const { spawnSync } = require('child_process');
|
|
32
|
+
const { projectSettingsFile, HOOK_TIMEOUT_MS } = require('./constants');
|
|
33
|
+
const { paint } = require('./ui');
|
|
34
|
+
|
|
35
|
+
function loadHooksConfig(cwd) {
|
|
36
|
+
try {
|
|
37
|
+
const raw = fs.readFileSync(projectSettingsFile(cwd), 'utf8');
|
|
38
|
+
const parsed = JSON.parse(raw);
|
|
39
|
+
return parsed.hooks || {};
|
|
40
|
+
} catch { return {}; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function matches(matcher, toolName) {
|
|
44
|
+
if (!matcher) return true; // sem matcher = roda pra qualquer evento/ferramenta
|
|
45
|
+
try { return new RegExp(`^(${matcher})$`).test(toolName || ''); }
|
|
46
|
+
catch { return matcher === toolName; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {'PreToolUse'|'PostToolUse'|'SessionStart'|'Stop'|'UserPromptSubmit'} event
|
|
51
|
+
* @param {{toolName?: string, args?: object, filePath?: string, cwd: string}} payload
|
|
52
|
+
* @returns {{blocked: boolean, reason?: string, infoLines: string[]}}
|
|
53
|
+
*/
|
|
54
|
+
function runHooks(event, payload, cwd) {
|
|
55
|
+
const config = loadHooksConfig(cwd);
|
|
56
|
+
const list = config[event];
|
|
57
|
+
const result = { blocked: false, reason: null, infoLines: [], rawOutputs: [] };
|
|
58
|
+
if (!Array.isArray(list) || !list.length) return result;
|
|
59
|
+
|
|
60
|
+
for (const hook of list) {
|
|
61
|
+
if (!hook || !hook.command) continue;
|
|
62
|
+
if (!matches(hook.matcher, payload.toolName)) continue;
|
|
63
|
+
|
|
64
|
+
const env = {
|
|
65
|
+
...process.env,
|
|
66
|
+
LIBERTY_EVENT: event,
|
|
67
|
+
LIBERTY_TOOL: payload.toolName || '',
|
|
68
|
+
LIBERTY_FILE: payload.filePath || '',
|
|
69
|
+
};
|
|
70
|
+
let proc;
|
|
71
|
+
try {
|
|
72
|
+
proc = spawnSync(hook.command, {
|
|
73
|
+
shell: true,
|
|
74
|
+
cwd,
|
|
75
|
+
env,
|
|
76
|
+
input: JSON.stringify(payload),
|
|
77
|
+
timeout: HOOK_TIMEOUT_MS,
|
|
78
|
+
encoding: 'utf8',
|
|
79
|
+
maxBuffer: 1024 * 1024,
|
|
80
|
+
});
|
|
81
|
+
} catch (e) {
|
|
82
|
+
result.infoLines.push(paint('yellow', `⚠ hook "${hook.command}" falhou ao iniciar: ${e.message}`));
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (proc.error) {
|
|
86
|
+
result.infoLines.push(paint('yellow', `⚠ hook "${hook.command}" falhou: ${proc.error.message}`));
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (proc.status === 2) {
|
|
90
|
+
result.blocked = true;
|
|
91
|
+
result.reason = (proc.stderr || proc.stdout || 'hook bloqueou a ação sem motivo informado').trim();
|
|
92
|
+
break; // primeiro bloqueio já é suficiente pra interromper
|
|
93
|
+
}
|
|
94
|
+
if (proc.status && proc.status !== 0) {
|
|
95
|
+
result.infoLines.push(paint('yellow', `⚠ hook "${hook.command}" saiu com código ${proc.status}`));
|
|
96
|
+
}
|
|
97
|
+
if (proc.stdout && proc.stdout.trim()) {
|
|
98
|
+
result.infoLines.push(paint('gray', ` [hook:${event}] ${proc.stdout.trim().slice(0, 300)}`));
|
|
99
|
+
result.rawOutputs.push(proc.stdout.trim());
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = { loadHooksConfig, runHooks };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Filtro de arquivos ignorados, compartilhado por list_directory / glob_files
|
|
4
|
+
* / grep_search.
|
|
5
|
+
*
|
|
6
|
+
* ANTES: list_directory tinha uma lista fixa de 5 nomes (node_modules, .git,
|
|
7
|
+
* .next, dist, build) direto no código — não lia .gitignore de verdade, então
|
|
8
|
+
* qualquer projeto com convenções diferentes (coverage/, .venv/, target/ no
|
|
9
|
+
* Rust, __pycache__/ no Python...) poluía a listagem e desperdiçava contexto.
|
|
10
|
+
*
|
|
11
|
+
* AGORA: usa a lib `ignore` (mesma base usada por ESLint/Prettier pra
|
|
12
|
+
* interpretar regras de .gitignore) combinando: (1) defaults sensatos, (2)
|
|
13
|
+
* .gitignore do projeto se existir, (3) .liberty/.libertyignore opcional pra
|
|
14
|
+
* regras específicas do agente (ex: ignorar uma pasta de dados grande que nem
|
|
15
|
+
* está no .gitignore mas não faz sentido o agente varrer).
|
|
16
|
+
*/
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const ignore = require('ignore');
|
|
20
|
+
|
|
21
|
+
const DEFAULT_IGNORES = [
|
|
22
|
+
'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out', 'coverage',
|
|
23
|
+
'.venv', 'venv', '__pycache__', '*.pyc', 'target', '.cache', '.parcel-cache',
|
|
24
|
+
'.turbo', '.vercel', '.DS_Store', '*.log', '.env', '.env.*', '!.env.example',
|
|
25
|
+
'.liberty/logs',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
function buildIgnoreEngine(cwd) {
|
|
29
|
+
const ig = ignore().add(DEFAULT_IGNORES);
|
|
30
|
+
for (const file of ['.gitignore', path.join('.liberty', '.libertyignore')]) {
|
|
31
|
+
try {
|
|
32
|
+
const content = fs.readFileSync(path.join(cwd, file), 'utf8');
|
|
33
|
+
ig.add(content);
|
|
34
|
+
} catch { /* arquivo não existe — tudo bem, é opcional */ }
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
isIgnored(relPath) {
|
|
38
|
+
// a lib `ignore` exige caminhos relativos com separador "/" (mesmo no Windows)
|
|
39
|
+
const normalized = relPath.split(path.sep).join('/');
|
|
40
|
+
if (!normalized || normalized === '.') return false;
|
|
41
|
+
try { return ig.ignores(normalized); } catch { return false; }
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { buildIgnoreEngine };
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Integração MCP — igual ao Claude Code, servidores MCP são processos locais
|
|
4
|
+
* conversados via stdio (JSON-RPC), configurados em .liberty/mcp.json:
|
|
5
|
+
*
|
|
6
|
+
* {
|
|
7
|
+
* "mcpServers": {
|
|
8
|
+
* "github": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"], "env": { "GITHUB_TOKEN": "..." } }
|
|
9
|
+
* }
|
|
10
|
+
* }
|
|
11
|
+
*
|
|
12
|
+
* (Mesmo formato do mcpServers do Claude Desktop/Claude Code — quem já tem
|
|
13
|
+
* servidores MCP configurados lá pode só copiar o bloco.)
|
|
14
|
+
*
|
|
15
|
+
* NOVO (v2): não existia NENHUMA forma de estender as ferramentas do agente
|
|
16
|
+
* sem editar o código do CLI. Isso é o item mais "custoso" da lista pedida —
|
|
17
|
+
* é um protocolo de verdade (JSON-RPC sobre stdio, handshake de
|
|
18
|
+
* initialize/initialized), então usamos o SDK oficial (@modelcontextprotocol/sdk)
|
|
19
|
+
* em vez de reimplementar o protocolo na mão.
|
|
20
|
+
*
|
|
21
|
+
* Ferramentas de cada servidor entram no array enviado ao modelo com o nome
|
|
22
|
+
* "mcp__<servidor>__<ferramenta>" (mesma convenção do Claude Code) — e como
|
|
23
|
+
* tools/index.js já reconhece esse prefixo no dispatch, nenhuma outra parte
|
|
24
|
+
* do agente precisa saber a diferença entre uma ferramenta nativa e uma MCP.
|
|
25
|
+
*
|
|
26
|
+
* Falha em UM servidor (comando não existe, crashou, timeout no handshake)
|
|
27
|
+
* não derruba os outros nem o CLI — só avisa e segue sem aquele servidor.
|
|
28
|
+
*/
|
|
29
|
+
const fs = require('fs');
|
|
30
|
+
const { Client } = require('@modelcontextprotocol/sdk/client/index.js');
|
|
31
|
+
const { StdioClientTransport } = require('@modelcontextprotocol/sdk/client/stdio.js');
|
|
32
|
+
const { projectMcpFile } = require('./constants');
|
|
33
|
+
const { paint } = require('./ui');
|
|
34
|
+
const pkg = require('../package.json');
|
|
35
|
+
|
|
36
|
+
const CONNECT_TIMEOUT_MS = 10_000;
|
|
37
|
+
|
|
38
|
+
function loadMcpConfig(cwd) {
|
|
39
|
+
try {
|
|
40
|
+
const raw = fs.readFileSync(projectMcpFile(cwd), 'utf8');
|
|
41
|
+
return JSON.parse(raw).mcpServers || {};
|
|
42
|
+
} catch { return {}; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function sanitizeName(name) {
|
|
46
|
+
return String(name).replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function withTimeout(promise, ms, label) {
|
|
50
|
+
let timer;
|
|
51
|
+
const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`timeout (${ms}ms) em ${label}`)), ms); });
|
|
52
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
class McpManager {
|
|
56
|
+
constructor() {
|
|
57
|
+
this.servers = new Map(); // nome -> { client, transport }
|
|
58
|
+
this.toolIndex = new Map(); // "mcp__nome__tool" -> { serverName, toolName }
|
|
59
|
+
this._descriptions = new Map();
|
|
60
|
+
this._schemas = new Map();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async connectAll(cwd) {
|
|
64
|
+
const config = loadMcpConfig(cwd);
|
|
65
|
+
const names = Object.keys(config);
|
|
66
|
+
if (!names.length) return { connected: [], failed: [] };
|
|
67
|
+
|
|
68
|
+
const connected = [], failed = [];
|
|
69
|
+
for (const rawName of names) {
|
|
70
|
+
const name = sanitizeName(rawName);
|
|
71
|
+
const def = config[rawName];
|
|
72
|
+
if (!def || !def.command) { failed.push({ name: rawName, error: 'sem "command" definido' }); continue; }
|
|
73
|
+
try {
|
|
74
|
+
const transport = new StdioClientTransport({
|
|
75
|
+
command: def.command, args: def.args || [], env: { ...process.env, ...(def.env || {}) },
|
|
76
|
+
});
|
|
77
|
+
const client = new Client({ name: 'liberty-code-cli', version: pkg.version }, { capabilities: {} });
|
|
78
|
+
await withTimeout(client.connect(transport), CONNECT_TIMEOUT_MS, `conectar em ${rawName}`);
|
|
79
|
+
const { tools } = await withTimeout(client.listTools(), CONNECT_TIMEOUT_MS, `listar ferramentas de ${rawName}`);
|
|
80
|
+
|
|
81
|
+
this.servers.set(name, { client, transport });
|
|
82
|
+
for (const tool of tools) {
|
|
83
|
+
const namespaced = `mcp__${name}__${sanitizeName(tool.name)}`;
|
|
84
|
+
this.toolIndex.set(namespaced, { serverName: name, toolName: tool.name });
|
|
85
|
+
this._descriptions.set(namespaced, tool.description || '');
|
|
86
|
+
this._schemas.set(namespaced, tool.inputSchema || { type: 'object', properties: {} });
|
|
87
|
+
}
|
|
88
|
+
connected.push({ name: rawName, toolCount: tools.length });
|
|
89
|
+
} catch (e) {
|
|
90
|
+
failed.push({ name: rawName, error: e.message });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { connected, failed };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
getToolSchemas() {
|
|
97
|
+
const schemas = [];
|
|
98
|
+
for (const [namespaced, { serverName }] of this.toolIndex.entries()) {
|
|
99
|
+
schemas.push({
|
|
100
|
+
type: 'function',
|
|
101
|
+
function: {
|
|
102
|
+
name: namespaced,
|
|
103
|
+
description: `[MCP:${serverName}] ${this._descriptions.get(namespaced) || namespaced}`,
|
|
104
|
+
parameters: this._schemas.get(namespaced) || { type: 'object', properties: {} },
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return schemas;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async callTool(namespacedName, args) {
|
|
112
|
+
const entry = this.toolIndex.get(namespacedName);
|
|
113
|
+
if (!entry) return `[Ferramenta MCP desconhecida: ${namespacedName}]`;
|
|
114
|
+
const { client } = this.servers.get(entry.serverName);
|
|
115
|
+
try {
|
|
116
|
+
const result = await client.callTool({ name: entry.toolName, arguments: args || {} });
|
|
117
|
+
const text = (result.content || [])
|
|
118
|
+
.map(block => block.type === 'text' ? block.text : `[bloco ${block.type} omitido]`)
|
|
119
|
+
.join('\n');
|
|
120
|
+
return result.isError ? `[Erro reportado pelo servidor MCP] ${text}` : (text || '(sem retorno)');
|
|
121
|
+
} catch (e) {
|
|
122
|
+
return `[Erro ao chamar ferramenta MCP ${namespacedName}: ${e.message}]`;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async disconnectAll() {
|
|
127
|
+
for (const { client, transport } of this.servers.values()) {
|
|
128
|
+
try { await client.close(); } catch { /* ignora erro no encerramento */ }
|
|
129
|
+
try { await transport.close(); } catch { /* idem */ }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
printSummary(connected, failed) {
|
|
134
|
+
connected.forEach(s => console.log(paint('green', ` ✓ MCP "${s.name}" conectado (${s.toolCount} ferramenta${s.toolCount === 1 ? '' : 's'})`)));
|
|
135
|
+
failed.forEach(s => console.log(paint('red', ` ✗ MCP "${s.name}" falhou: ${s.error}`)));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = { McpManager, loadMcpConfig };
|
package/src/memory.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Memória persistente de projeto — equivalente ao CLAUDE.md.
|
|
4
|
+
*
|
|
5
|
+
* ANTES: loadProjectMemory() lia só um LIBERTY.md na raiz do cwd, uma vez,
|
|
6
|
+
* sem hierarquia nenhuma.
|
|
7
|
+
*
|
|
8
|
+
* AGORA:
|
|
9
|
+
* 1. Global — ~/.libertycode/LIBERTY.md: preferências do USUÁRIO,
|
|
10
|
+
* valem em qualquer projeto (ex: "sempre responda em português",
|
|
11
|
+
* "prefiro pnpm a npm").
|
|
12
|
+
* 2. Projeto — <cwd>/LIBERTY.md: convenções do REPOSITÓRIO (como
|
|
13
|
+
* já existia), pensado pra ir pro git e ser compartilhado pelo time.
|
|
14
|
+
* 3. Imports — dentro de qualquer LIBERTY.md, uma linha
|
|
15
|
+
* "@caminho/relativo.md" importa o conteúdo daquele arquivo (só 1
|
|
16
|
+
* nível de profundidade, sem recursão — evita loop de import A→B→A).
|
|
17
|
+
*
|
|
18
|
+
* Os três são concatenados com um cabeçalho indicando a origem, pra o modelo
|
|
19
|
+
* saber diferenciar "preferência pessoal" de "regra do repositório" se um dia
|
|
20
|
+
* entrarem em conflito.
|
|
21
|
+
*/
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
const { GLOBAL_MEMORY_FILE, projectMemoryFile, ensureDir, GLOBAL_DIR } = require('./constants');
|
|
25
|
+
|
|
26
|
+
function resolveImports(content, baseDir, depth = 0) {
|
|
27
|
+
if (depth > 0) return content; // só 1 nível — @import dentro de um @import não é seguido
|
|
28
|
+
return content.replace(/^@([^\s]+\.md)\s*$/gm, (match, relPath) => {
|
|
29
|
+
try {
|
|
30
|
+
const full = path.resolve(baseDir, relPath);
|
|
31
|
+
const imported = fs.readFileSync(full, 'utf8').trim();
|
|
32
|
+
return `\n--- (importado de ${relPath}) ---\n${imported}\n--- (fim de ${relPath}) ---\n`;
|
|
33
|
+
} catch {
|
|
34
|
+
return `[LIBERTY.md: import "@${relPath}" não encontrado, ignorado]`;
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readMemoryFile(filePath) {
|
|
40
|
+
try {
|
|
41
|
+
const raw = fs.readFileSync(filePath, 'utf8').trim();
|
|
42
|
+
if (!raw) return null;
|
|
43
|
+
return resolveImports(raw, path.dirname(filePath));
|
|
44
|
+
} catch { return null; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** @returns {string|null} bloco de contexto pronto pra virar a 1ª mensagem da sessão, ou null se não houver nenhuma memória. */
|
|
48
|
+
function loadHierarchicalMemory(cwd) {
|
|
49
|
+
const blocks = [];
|
|
50
|
+
const global = readMemoryFile(GLOBAL_MEMORY_FILE);
|
|
51
|
+
if (global) blocks.push(`### Preferências globais do usuário (~/.libertycode/LIBERTY.md)\n${global}`);
|
|
52
|
+
|
|
53
|
+
const project = readMemoryFile(projectMemoryFile(cwd));
|
|
54
|
+
if (project) blocks.push(`### Convenções deste projeto (./LIBERTY.md)\n${project}`);
|
|
55
|
+
|
|
56
|
+
if (!blocks.length) return null;
|
|
57
|
+
return blocks.join('\n\n');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function hasProjectMemory(cwd) {
|
|
61
|
+
return fs.existsSync(projectMemoryFile(cwd));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function hasGlobalMemory() {
|
|
65
|
+
return fs.existsSync(GLOBAL_MEMORY_FILE);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Usado pelo atalho "#" do REPL: anexa uma nota rápida ao LIBERTY.md do projeto sem chamar o modelo. */
|
|
69
|
+
function appendQuickNote(cwd, note) {
|
|
70
|
+
const file = projectMemoryFile(cwd);
|
|
71
|
+
const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf8').replace(/\s+$/, '') : '# LIBERTY.md\n\nContexto e convenções deste projeto para o Liberty Code.';
|
|
72
|
+
const hasNotesSection = /^##\s+Notas rápidas/m.test(existing);
|
|
73
|
+
const stamp = new Date().toISOString().slice(0, 10);
|
|
74
|
+
const line = `- (${stamp}) ${note}`;
|
|
75
|
+
const updated = hasNotesSection
|
|
76
|
+
? existing.replace(/^##\s+Notas rápidas.*$/m, match => `${match}\n${line}`)
|
|
77
|
+
: `${existing}\n\n## Notas rápidas\n${line}\n`;
|
|
78
|
+
fs.writeFileSync(file, updated.trimEnd() + '\n', 'utf8');
|
|
79
|
+
return file;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function ensureGlobalDir() {
|
|
83
|
+
ensureDir(GLOBAL_DIR);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = {
|
|
87
|
+
loadHierarchicalMemory, hasProjectMemory, hasGlobalMemory, appendQuickNote, ensureGlobalDir,
|
|
88
|
+
projectMemoryFile, GLOBAL_MEMORY_FILE,
|
|
89
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Modos de permissão — equivalente ao permission mode do Claude Code.
|
|
4
|
+
*
|
|
5
|
+
* ANTES: toolWriteFile e toolRunCommand tinham um "Permitir? [y/N]" idêntico
|
|
6
|
+
* e hardcoded, sem opção de modo. Isso significa reconfirmar a MESMA operação
|
|
7
|
+
* repetidas vezes numa sessão longa (ex: rodar `npm test` 15 vezes seguidas
|
|
8
|
+
* enquanto corrige um bug) — cansativo o suficiente pra incentivar o usuário
|
|
9
|
+
* a copiar/colar o código manualmente em vez de deixar o agente iterar.
|
|
10
|
+
*
|
|
11
|
+
* AGORA: 4 modos, trocáveis em runtime via `/mode <nome>`:
|
|
12
|
+
* - default pergunta pra write_file / edit_file / run_command
|
|
13
|
+
* - acceptEdits aprova edição de arquivo sozinho, ainda pergunta comando
|
|
14
|
+
* - bypassPermissions aprova tudo (requer confirmação explícita ao ligar)
|
|
15
|
+
* - plan somente leitura — nem chega a esta camada, porque as
|
|
16
|
+
* ferramentas de escrita simplesmente não são
|
|
17
|
+
* oferecidas ao modelo nesse modo (ver tools/index.js)
|
|
18
|
+
*
|
|
19
|
+
* E dentro do modo `default`, a resposta 'a' ("always"/sempre) grava uma
|
|
20
|
+
* regra na allow-list da SESSÃO ATUAL (perdida ao fechar o CLI, de propósito
|
|
21
|
+
* — não é uma allow-list persistente tipo .liberty/settings.json, que o
|
|
22
|
+
* usuário precisaria editar à mão pra ser realmente segura).
|
|
23
|
+
*/
|
|
24
|
+
const readline = require('readline');
|
|
25
|
+
const { PERMISSION_MODES } = require('./constants');
|
|
26
|
+
const { paint } = require('./ui');
|
|
27
|
+
|
|
28
|
+
class PermissionEngine {
|
|
29
|
+
constructor(initialMode = PERMISSION_MODES.DEFAULT) {
|
|
30
|
+
this.mode = initialMode;
|
|
31
|
+
this.sessionAllow = new Set(); // chaves tipo "write:src/index.js" ou "cmd:npm test"
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
setMode(mode) {
|
|
35
|
+
if (!Object.values(PERMISSION_MODES).includes(mode)) {
|
|
36
|
+
throw new Error(`Modo desconhecido: ${mode}. Use: ${Object.values(PERMISSION_MODES).join(', ')}`);
|
|
37
|
+
}
|
|
38
|
+
this.mode = mode;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {'write'|'edit'|'run'} kind
|
|
43
|
+
* @param {string} key - identificador estável pra allow-list (path ou comando)
|
|
44
|
+
* @param {string} promptLabel - texto mostrado antes do "Permitir?"
|
|
45
|
+
* @param {readline.Interface} rl
|
|
46
|
+
* @returns {Promise<boolean>}
|
|
47
|
+
*/
|
|
48
|
+
async check(kind, key, promptLabel, rl) {
|
|
49
|
+
const allowKey = `${kind}:${key}`;
|
|
50
|
+
if (this.mode === PERMISSION_MODES.BYPASS) return true;
|
|
51
|
+
if (this.mode === PERMISSION_MODES.ACCEPT_EDITS && (kind === 'write' || kind === 'edit')) return true;
|
|
52
|
+
if (this.sessionAllow.has(allowKey)) return true;
|
|
53
|
+
|
|
54
|
+
const answer = await new Promise(resolve => {
|
|
55
|
+
rl.question(
|
|
56
|
+
`${promptLabel} ${paint('gray', '[y]es / [n]o / [a]lways nesta sessão ')}`,
|
|
57
|
+
a => resolve(a.trim().toLowerCase())
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
if (['a', 'always', 'sempre'].includes(answer)) {
|
|
61
|
+
this.sessionAllow.add(allowKey);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
return ['y', 'yes', 's', 'sim'].includes(answer);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describeMode() {
|
|
68
|
+
return {
|
|
69
|
+
[PERMISSION_MODES.DEFAULT]: 'default — pergunta antes de editar arquivos ou rodar comandos',
|
|
70
|
+
[PERMISSION_MODES.ACCEPT_EDITS]: 'acceptEdits — edita arquivos sem perguntar, mas ainda confirma comandos',
|
|
71
|
+
[PERMISSION_MODES.BYPASS]: 'bypassPermissions — roda tudo sem perguntar (cuidado)',
|
|
72
|
+
[PERMISSION_MODES.PLAN]: 'plan — somente leitura, o modelo só pode explorar e propor um plano',
|
|
73
|
+
}[this.mode];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { PermissionEngine };
|