primocode 7.0.3 → 7.2.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 +78 -76
- package/bin/primocode.js +270 -223
- package/lib/act.js +126 -116
- package/lib/api.js +110 -17
- package/lib/browser.js +492 -492
- package/lib/config.js +106 -52
- package/lib/desktop.js +142 -142
- package/lib/tools.js +147 -104
- package/lib/ui.js +172 -161
- package/package.json +5 -3
package/bin/primocode.js
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* PrimoCode CLI
|
|
4
|
-
* Interface estilo Claude Code — minimalista, poderosa, elegante.
|
|
5
|
-
* Auto-instalação de Node.js com confirmação Y/n e barra de progresso.
|
|
6
|
-
* Suporte Termux (pkg install nodejs).
|
|
3
|
+
* PrimoCode CLI · ConectaPrimoAI
|
|
7
4
|
* Modos: primocode (agente completo, PADRÃO) · chat (conversa pura)
|
|
5
|
+
* Cada sessão trabalha dentro de uma pasta de projeto em ~/primocode/.
|
|
8
6
|
*/
|
|
9
7
|
|
|
10
8
|
'use strict';
|
|
@@ -13,14 +11,12 @@ const readline = require('readline');
|
|
|
13
11
|
const path = require('path');
|
|
14
12
|
const fs = require('fs');
|
|
15
13
|
const os = require('os');
|
|
16
|
-
const { execSync, spawn } = require('child_process');
|
|
17
|
-
const pkg = require('../package.json');
|
|
18
14
|
|
|
19
|
-
const
|
|
15
|
+
const pkg = require('../package.json');
|
|
16
|
+
const { c, welcomeBox, Spinner } = require('../lib/ui');
|
|
20
17
|
const api = require('../lib/api');
|
|
21
18
|
const tools = require('../lib/tools');
|
|
22
19
|
const cfg = require('../lib/config');
|
|
23
|
-
const { createProjectDir, getProjectDir, listProjects } = require('../lib/config');
|
|
24
20
|
const { runAct } = require('../lib/act');
|
|
25
21
|
|
|
26
22
|
const VERSION = pkg.version;
|
|
@@ -32,59 +28,141 @@ const state = {
|
|
|
32
28
|
attachedFiles: [],
|
|
33
29
|
lastResponse: '',
|
|
34
30
|
currentProject: null,
|
|
31
|
+
workDir: null, // pasta onde o agente trabalha nesta sessão
|
|
35
32
|
};
|
|
36
33
|
|
|
37
34
|
const HELP = `
|
|
38
35
|
${c.bold('Comandos')}
|
|
39
36
|
${c.brand('/project')} lista projetos ou muda: /project <nome>
|
|
40
|
-
${c.brand('/mode')}
|
|
41
|
-
${c.brand('/attach')}
|
|
42
|
-
${c.brand('/
|
|
43
|
-
${c.brand('/
|
|
44
|
-
${c.brand('/
|
|
37
|
+
${c.brand('/mode')} alterna: /mode primocode|chat
|
|
38
|
+
${c.brand('/attach')} <arquivo> anexa arquivo ao próximo pedido
|
|
39
|
+
${c.brand('/files')} lista anexos ${c.brand('/detach')} remove
|
|
40
|
+
${c.brand('/tree')} [n] árvore do projeto
|
|
41
|
+
${c.brand('/read')} <arquivo> mostra um arquivo
|
|
42
|
+
${c.brand('/run')} <comando> executa no shell do projeto
|
|
43
|
+
${c.brand('/open')} <arquivo|url> abre no navegador
|
|
44
|
+
${c.brand('/status')} checa o servidor
|
|
45
|
+
${c.brand('/clear')} limpa a conversa ${c.brand('/cls')} limpa a tela
|
|
46
|
+
${c.brand('/uninstall')} remove o PrimoCode
|
|
45
47
|
${c.brand('/exit')} sair
|
|
46
48
|
|
|
47
49
|
${c.bold('Modos')}
|
|
48
|
-
${c.magenta('Primo Code')} ${c.muted('Agente completo —
|
|
50
|
+
${c.magenta('Primo Code')} ${c.muted('Agente completo — cria arquivos, roda comandos, abre o navegador')}
|
|
49
51
|
${c.brand('Chat')} ${c.muted('Conversa pura — sem tools, só diálogo')}
|
|
50
52
|
`;
|
|
51
53
|
|
|
52
54
|
let rl = null;
|
|
53
55
|
|
|
54
56
|
function makePrompt() {
|
|
55
|
-
const modeTag = state.mode === 'primocode'
|
|
56
|
-
|
|
57
|
-
: c.brand('Chat');
|
|
57
|
+
const modeTag = state.mode === 'primocode' ? c.magenta('Primo Code') : c.brand('Chat');
|
|
58
|
+
const proj = state.currentProject ? c.dim(` ${state.currentProject}`) : '';
|
|
58
59
|
const att = state.attachedFiles.length ? c.muted(` +${state.attachedFiles.length}`) : '';
|
|
59
|
-
return `\n${c.muted(modeTag
|
|
60
|
+
return `\n${c.muted(modeTag)}${proj}${att} ${c.brand('❯')} `;
|
|
60
61
|
}
|
|
61
62
|
function refreshPrompt() { if (rl) rl.setPrompt(makePrompt()); }
|
|
62
63
|
|
|
64
|
+
// Enquanto uma pergunta está no ar, a próxima linha digitada é a RESPOSTA dela
|
|
65
|
+
// — não um novo comando. Sem isso o "s" de confirmação virava prompt para a IA.
|
|
66
|
+
let pendingAsk = null;
|
|
67
|
+
|
|
68
|
+
function ask(question) {
|
|
69
|
+
return new Promise((resolve) => {
|
|
70
|
+
if (rl) { process.stdout.write(question); pendingAsk = resolve; return; }
|
|
71
|
+
const tmp = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
72
|
+
tmp.question(question, (a) => { tmp.close(); resolve(a); });
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function confirm(question) {
|
|
77
|
+
const raw = await ask(`${c.brand('?')} ${question} ${c.muted('(S/n)')} `);
|
|
78
|
+
if (!process.stdin.isTTY) process.stdout.write('\n'); // pipe não ecoa o Enter
|
|
79
|
+
const a = raw.trim().toLowerCase();
|
|
80
|
+
return a === '' || a === 's' || a === 'sim' || a === 'y' || a === 'yes';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Projetos ───────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
const STOPWORDS = new Set([
|
|
86
|
+
'crie', 'cria', 'criar', 'faca', 'faça', 'faz', 'fazer', 'monte', 'montar',
|
|
87
|
+
'desenvolva', 'desenvolver', 'construa', 'construir', 'gere', 'gerar',
|
|
88
|
+
'quero', 'queria', 'gostaria', 'preciso', 'me', 'meu', 'minha', 'favor',
|
|
89
|
+
'um', 'uma', 'uns', 'umas', 'o', 'a', 'os', 'as', 'por', 'pra', 'para',
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
// "crie um jogo de carro" -> "jogo de carro"
|
|
93
|
+
function deriveProjectName(text) {
|
|
94
|
+
const words = String(text).trim().split(/\s+/);
|
|
95
|
+
let i = 0;
|
|
96
|
+
while (i < words.length && STOPWORDS.has(words[i].toLowerCase().replace(/[^\wáéíóúâêôãõç]/gi, ''))) i++;
|
|
97
|
+
const picked = words.slice(i, i + 5).join(' ').replace(/[^\w\sáéíóúâêôãõçÁÉÍÓÚÂÊÔÃÕÇ-]/gi, '').trim();
|
|
98
|
+
return picked || 'projeto';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Como o Claude Code: se você abriu o PrimoCode dentro de uma pasta de
|
|
102
|
+
// trabalho, é ali que ele trabalha. Home e raiz de disco não contam — ninguém
|
|
103
|
+
// quer um agente soltando arquivo no meio de C:\Users\você.
|
|
104
|
+
function isUsableCwd(dir) {
|
|
105
|
+
const home = os.homedir();
|
|
106
|
+
const resolved = path.resolve(dir);
|
|
107
|
+
if (resolved === path.resolve(home)) return false;
|
|
108
|
+
if (resolved === path.parse(resolved).root) return false;
|
|
109
|
+
if (resolved.startsWith(path.join(home, 'primocode'))) return false;
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Escolhe onde trabalhar SEM criar nada. A pasta só é criada de verdade em
|
|
114
|
+
// createPendingProject(), quando o agente for mexer em arquivo.
|
|
115
|
+
function planWorkDir(userText) {
|
|
116
|
+
if (state.workDir) return { dir: state.workDir, pending: false };
|
|
117
|
+
const proposed = deriveProjectName(userText);
|
|
118
|
+
const similar = cfg.findSimilarProject(proposed);
|
|
119
|
+
const name = similar ? similar.name : proposed;
|
|
120
|
+
return { dir: cfg.getProjectDir(name), pending: true, name, similar: !!similar };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function createPendingProject(plan) {
|
|
124
|
+
const dir = cfg.createProjectDir(plan.name);
|
|
125
|
+
state.workDir = dir;
|
|
126
|
+
state.currentProject = path.basename(dir);
|
|
127
|
+
const rotulo = plan.similar ? 'Continuando em' : 'Projeto novo:';
|
|
128
|
+
console.log(c.ok(` ${rotulo} ${state.currentProject}`) + c.muted(` (${dir})`));
|
|
129
|
+
refreshPrompt();
|
|
130
|
+
return dir;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function projectDir() {
|
|
134
|
+
return state.workDir || process.cwd();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Envio ──────────────────────────────────────────────────────────────
|
|
138
|
+
|
|
63
139
|
async function sendPrompt(userText) {
|
|
64
|
-
let
|
|
140
|
+
let prompt = userText;
|
|
65
141
|
if (state.attachedFiles.length) {
|
|
66
|
-
const parts = state.attachedFiles.map(f => `Arquivo: ${f.path}\n\`\`\`\n${f.content}\n\`\`\``);
|
|
67
|
-
|
|
142
|
+
const parts = state.attachedFiles.map((f) => `Arquivo: ${f.path}\n\`\`\`\n${f.content}\n\`\`\``);
|
|
143
|
+
prompt = parts.join('\n\n') + '\n\n' + userText;
|
|
68
144
|
}
|
|
69
145
|
|
|
70
|
-
if (state.mode === '
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const projectDir = state.currentProject ? getProjectDir(state.currentProject) : process.cwd();
|
|
74
|
-
const r = await runAct(promptWithAttach, {
|
|
146
|
+
if (state.mode === 'primocode') {
|
|
147
|
+
const plan = planWorkDir(userText);
|
|
148
|
+
const r = await runAct(prompt, {
|
|
75
149
|
server: state.config.server,
|
|
76
150
|
model: state.config.model,
|
|
151
|
+
token: state.config.token,
|
|
77
152
|
history: state.history,
|
|
78
|
-
projectDir:
|
|
79
|
-
projectName: state.currentProject,
|
|
153
|
+
projectDir: plan.dir,
|
|
154
|
+
projectName: plan.pending ? plan.name : state.currentProject,
|
|
155
|
+
// Só chamado se o agente for mesmo mexer em arquivo.
|
|
156
|
+
ensureWorkDir: plan.pending ? () => createPendingProject(plan) : null,
|
|
80
157
|
});
|
|
81
158
|
state.history.push({ role: 'user', content: userText });
|
|
82
159
|
state.history.push({ role: 'assistant', content: r.assistantMessage.content });
|
|
83
160
|
state.lastResponse = r.assistantMessage.content;
|
|
84
|
-
cfg.appendHistory({ mode: 'primocode',
|
|
161
|
+
cfg.appendHistory({ mode: 'primocode', project: state.currentProject, prompt: userText });
|
|
85
162
|
} else {
|
|
86
|
-
await runChat(
|
|
163
|
+
await runChat(prompt, userText);
|
|
87
164
|
}
|
|
165
|
+
|
|
88
166
|
state.attachedFiles = [];
|
|
89
167
|
refreshPrompt();
|
|
90
168
|
}
|
|
@@ -94,282 +172,251 @@ async function runChat(promptForModel, userTextForMemory) {
|
|
|
94
172
|
let started = false;
|
|
95
173
|
let text = '';
|
|
96
174
|
try {
|
|
97
|
-
await api.
|
|
175
|
+
await api.requestChat(state.config.server, {
|
|
98
176
|
prompt: promptForModel,
|
|
99
177
|
history: state.history.slice(-20),
|
|
100
178
|
model: state.config.model,
|
|
179
|
+
token: state.config.token,
|
|
101
180
|
}, (evt) => {
|
|
102
|
-
if (evt.erro) {
|
|
103
|
-
if (!started) sp.stop();
|
|
104
|
-
console.log(c.err(`\nerro: ${evt.erro}`));
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
181
|
+
if (evt.erro) { if (!started) sp.stop(); console.log(c.err(`\nerro: ${evt.erro}`)); return; }
|
|
107
182
|
if (evt.delta) {
|
|
108
183
|
if (!started) { sp.stop(); process.stdout.write('\n'); started = true; }
|
|
109
184
|
text += evt.delta;
|
|
110
185
|
process.stdout.write(c.white(evt.delta));
|
|
111
186
|
}
|
|
112
|
-
if (evt.done
|
|
187
|
+
if (evt.done && !started) sp.stop();
|
|
113
188
|
});
|
|
114
189
|
} catch (e) {
|
|
115
190
|
sp.stop();
|
|
116
191
|
console.log(c.err(`\n${e.message || e}`));
|
|
192
|
+
return;
|
|
117
193
|
}
|
|
194
|
+
sp.stop();
|
|
118
195
|
if (text) {
|
|
196
|
+
process.stdout.write('\n');
|
|
119
197
|
state.history.push({ role: 'user', content: userTextForMemory });
|
|
120
198
|
state.history.push({ role: 'assistant', content: text });
|
|
121
199
|
state.lastResponse = text;
|
|
122
|
-
cfg.appendHistory({ mode: 'chat', prompt: userTextForMemory
|
|
200
|
+
cfg.appendHistory({ mode: 'chat', prompt: userTextForMemory });
|
|
123
201
|
}
|
|
124
202
|
}
|
|
125
203
|
|
|
126
|
-
|
|
127
|
-
const sp = new Spinner('agente pensando').start();
|
|
128
|
-
let step = '';
|
|
129
|
-
let text = '';
|
|
130
|
-
try {
|
|
131
|
-
await api.requestStream(state.config.server, '/api/agent/stream', {
|
|
132
|
-
prompt: promptForModel,
|
|
133
|
-
history: state.history.slice(-20),
|
|
134
|
-
}, (evt) => {
|
|
135
|
-
if (evt.erro) { sp.stop(); console.log(c.err(`\nerro: ${evt.erro}`)); return; }
|
|
136
|
-
if (evt.step && evt.event === 'start') {
|
|
137
|
-
if (!step) sp.stop();
|
|
138
|
-
step = evt.step;
|
|
139
|
-
const label = step === 'arquiteto' ? 'Arquiteto' : step === 'engenheiro' ? 'Engenheiro' : 'Revisor';
|
|
140
|
-
console.log(c.muted(`\n ${label}…`));
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
if (evt.step === 'revisor' && evt.delta) {
|
|
144
|
-
if (!text) process.stdout.write('\n');
|
|
145
|
-
text += evt.delta;
|
|
146
|
-
process.stdout.write(c.white(evt.delta));
|
|
147
|
-
}
|
|
148
|
-
if (evt.done) process.stdout.write('\n');
|
|
149
|
-
});
|
|
150
|
-
} catch (e) { sp.stop(); console.log(c.err(`\n${e.message || e}`)); }
|
|
151
|
-
if (text) {
|
|
152
|
-
state.history.push({ role: 'user', content: userTextForMemory });
|
|
153
|
-
state.history.push({ role: 'assistant', content: text });
|
|
154
|
-
state.lastResponse = text;
|
|
155
|
-
cfg.appendHistory({ mode: 'agent', prompt: userTextForMemory, response: text });
|
|
156
|
-
}
|
|
157
|
-
}
|
|
204
|
+
// ── Slash commands ─────────────────────────────────────────────────────
|
|
158
205
|
|
|
159
206
|
function isSlash(text) { return text.trim().startsWith('/'); }
|
|
160
207
|
|
|
161
208
|
async function handleSlash(text) {
|
|
162
|
-
const [cmd
|
|
209
|
+
const [cmd] = text.trim().slice(1).split(/\s+/);
|
|
163
210
|
const rest = text.trim().slice(1 + cmd.length).trim();
|
|
211
|
+
|
|
164
212
|
switch (cmd.toLowerCase()) {
|
|
165
213
|
case 'help': console.log(HELP); break;
|
|
214
|
+
|
|
215
|
+
case 'project': {
|
|
216
|
+
if (!rest) {
|
|
217
|
+
const projects = cfg.listProjects();
|
|
218
|
+
if (!projects.length) { console.log(c.muted(' Nenhum projeto ainda. Peça algo e eu crio o primeiro.')); break; }
|
|
219
|
+
console.log('');
|
|
220
|
+
for (const p of projects) {
|
|
221
|
+
const mark = p === state.currentProject ? c.brand(' ●') : ' ';
|
|
222
|
+
console.log(`${mark} ${p === state.currentProject ? c.brand(p) : c.white(p)}`);
|
|
223
|
+
}
|
|
224
|
+
console.log(c.muted(`\n ${cfg.ROOT_DIR}`));
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
const dir = cfg.createProjectDir(rest);
|
|
228
|
+
state.workDir = dir;
|
|
229
|
+
state.currentProject = path.basename(dir);
|
|
230
|
+
console.log(c.ok(`Projeto: ${state.currentProject}`) + c.muted(` (${dir})`));
|
|
231
|
+
refreshPrompt();
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
|
|
166
235
|
case 'mode': {
|
|
167
236
|
const m = rest.toLowerCase();
|
|
168
|
-
if (['
|
|
169
|
-
else console.log(c.err('Uso: /mode chat
|
|
237
|
+
if (['primocode', 'chat'].includes(m)) { state.mode = m; console.log(c.ok(`Modo: ${m}`)); refreshPrompt(); }
|
|
238
|
+
else console.log(c.err('Uso: /mode primocode|chat'));
|
|
170
239
|
break;
|
|
171
240
|
}
|
|
241
|
+
|
|
172
242
|
case 'model': {
|
|
173
243
|
const m = rest.toLowerCase();
|
|
174
|
-
if (['main','fast'].includes(m)) { state.config.model = m; cfg.saveConfig(
|
|
244
|
+
if (['main', 'fast'].includes(m)) { state.config.model = m; cfg.saveConfig({ model: m }); console.log(c.ok(`Modelo: ${m}`)); }
|
|
175
245
|
else console.log(c.err('Uso: /model main|fast'));
|
|
176
246
|
break;
|
|
177
247
|
}
|
|
248
|
+
|
|
178
249
|
case 'attach': {
|
|
179
250
|
if (!rest) { console.log(c.err('Uso: /attach <arquivo>')); break; }
|
|
180
|
-
try {
|
|
181
|
-
|
|
251
|
+
try {
|
|
252
|
+
const content = fs.readFileSync(path.resolve(rest), 'utf-8');
|
|
253
|
+
state.attachedFiles.push({ path: rest, content });
|
|
254
|
+
console.log(c.ok(`Anexado: ${rest} (${tools.fmtBytes(content.length)})`));
|
|
255
|
+
refreshPrompt();
|
|
256
|
+
} catch { console.log(c.err(`Não consegui ler: ${rest}`)); }
|
|
182
257
|
break;
|
|
183
258
|
}
|
|
184
|
-
|
|
259
|
+
|
|
260
|
+
case 'detach': state.attachedFiles = []; console.log(c.ok('Anexos removidos.')); refreshPrompt(); break;
|
|
261
|
+
|
|
185
262
|
case 'files':
|
|
186
263
|
if (!state.attachedFiles.length) console.log(c.muted('Nenhum arquivo anexado.'));
|
|
187
|
-
else state.attachedFiles.forEach(f => console.log(` ${c.brand(f.path)} ${c.muted(tools.fmtBytes(f.content.length))}`));
|
|
264
|
+
else state.attachedFiles.forEach((f) => console.log(` ${c.brand(f.path)} ${c.muted(tools.fmtBytes(f.content.length))}`));
|
|
188
265
|
break;
|
|
189
|
-
|
|
266
|
+
|
|
267
|
+
case 'tree': console.log(tools.treeDir(projectDir(), parseInt(rest, 10) || 2)); break;
|
|
268
|
+
|
|
190
269
|
case 'read': {
|
|
191
270
|
if (!rest) { console.log(c.err('Uso: /read <arquivo>')); break; }
|
|
192
|
-
try {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
if (!rest) { console.log(c.err('Uso: /write <arquivo>')); break; }
|
|
198
|
-
if (!state.lastResponse) { console.log(c.err('Nenhuma resposta para salvar.')); break; }
|
|
199
|
-
try { fs.writeFileSync(path.resolve(rest), state.lastResponse, 'utf-8'); console.log(c.ok(`Salvo em: ${rest}`)); }
|
|
200
|
-
catch (e) { console.log(c.err(`Erro: ${e.message}`)); }
|
|
201
|
-
break;
|
|
202
|
-
}
|
|
203
|
-
case 'apply': {
|
|
204
|
-
const applied = tools.applyCodeBlocks(state.lastResponse);
|
|
205
|
-
if (applied.length) applied.forEach(a => console.log(c.ok(`${a.created?'Criado':'Atualizado'}: ${a.path}`)));
|
|
206
|
-
else console.log(c.muted('Nenhum bloco com hint // filename: encontrado.'));
|
|
271
|
+
try {
|
|
272
|
+
const content = fs.readFileSync(path.resolve(projectDir(), rest), 'utf-8');
|
|
273
|
+
console.log(c.muted(`\n--- ${rest} ---\n`));
|
|
274
|
+
console.log(content.length > 2000 ? content.slice(0, 2000) + '\n…' : content);
|
|
275
|
+
} catch { console.log(c.err(`Não consegui ler: ${rest}`)); }
|
|
207
276
|
break;
|
|
208
277
|
}
|
|
278
|
+
|
|
209
279
|
case 'run': {
|
|
210
280
|
if (!rest) { console.log(c.err('Uso: /run <comando>')); break; }
|
|
211
281
|
console.log(c.muted(`$ ${rest}`));
|
|
212
|
-
const result = await tools.runShell(rest);
|
|
282
|
+
const result = await tools.runShell(rest, 60000, projectDir());
|
|
213
283
|
if (result.stdout) console.log(result.stdout);
|
|
214
284
|
if (result.stderr) console.log(c.err(result.stderr));
|
|
215
285
|
if (result.code !== 0) console.log(c.err(`exit ${result.code}`));
|
|
216
286
|
break;
|
|
217
287
|
}
|
|
288
|
+
|
|
218
289
|
case 'open': {
|
|
219
290
|
if (!rest) { console.log(c.err('Uso: /open <arquivo|url>')); break; }
|
|
220
|
-
|
|
291
|
+
const target = /^https?:\/\//i.test(rest) ? rest : path.resolve(projectDir(), rest);
|
|
292
|
+
await tools.openInBrowser(target);
|
|
221
293
|
console.log(c.ok(`Aberto: ${rest}`));
|
|
222
294
|
break;
|
|
223
295
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const old = state.mode; state.mode = 'act'; await sendPrompt(rest); state.mode = old;
|
|
227
|
-
break;
|
|
228
|
-
}
|
|
229
|
-
case 'clear': state.history = []; console.log(c.ok('Memória limpa.')); break;
|
|
296
|
+
|
|
297
|
+
case 'clear': state.history = []; console.log(c.ok('Conversa limpa.')); break;
|
|
230
298
|
case 'cls': process.stdout.write('\x1Bc'); break;
|
|
299
|
+
|
|
231
300
|
case 'status': {
|
|
232
|
-
const
|
|
233
|
-
try {
|
|
234
|
-
|
|
301
|
+
const sp = new Spinner('verificando').start();
|
|
302
|
+
try {
|
|
303
|
+
const h = await api.getHealth(state.config.server, state.config.token);
|
|
304
|
+
sp.stop();
|
|
305
|
+
console.log(c.ok(`Servidor: ${h.status || 'ok'}`));
|
|
306
|
+
if (h.version) console.log(c.muted(` Versão: ${h.version}`));
|
|
307
|
+
if (h.model) console.log(c.muted(` Modelo: ${h.model}`));
|
|
308
|
+
} catch (e) { sp.stop(); console.log(c.err(`Indisponível: ${e.message}`)); }
|
|
235
309
|
break;
|
|
236
310
|
}
|
|
311
|
+
|
|
312
|
+
case 'uninstall': {
|
|
313
|
+
if (await confirm('Remover o PrimoCode? Seus projetos em ~/primocode NÃO serão apagados.')) {
|
|
314
|
+
await tools.uninstall();
|
|
315
|
+
} else console.log(c.muted('Cancelado.'));
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
|
|
237
319
|
case 'version': console.log(c.brand(`PrimoCode v${VERSION}`)); break;
|
|
238
|
-
|
|
320
|
+
// rl.close() em vez de process.exit(): sair na marra descarta o que
|
|
321
|
+
// ainda estava no buffer de saída.
|
|
322
|
+
case 'exit': case 'quit': if (rl) rl.close(); break;
|
|
239
323
|
default: console.log(c.err(`Comando desconhecido: /${cmd}. Digite /help.`));
|
|
240
324
|
}
|
|
241
325
|
}
|
|
242
326
|
|
|
243
|
-
//
|
|
244
|
-
function isTermux() {
|
|
245
|
-
return !!process.env.TERMUX_VERSION || (process.env.PREFIX && process.env.PREFIX.includes('termux'));
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// ---- Auto-instalação Node.js com barra de progresso ----
|
|
249
|
-
async function checkAndInstallNode() {
|
|
250
|
-
const nodeExists = () => { try { execSync('node --version', {stdio:'ignore'}); return true; } catch { return false; } };
|
|
327
|
+
// ── Main ───────────────────────────────────────────────────────────────
|
|
251
328
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
// PERGUNTA antes de instalar — aguarda resposta do usuário
|
|
262
|
-
const answer = await askYesNo('Deseja instalar o Node.js automaticamente?');
|
|
263
|
-
if (!answer) {
|
|
264
|
-
console.log(c.err('Node.js é necessário para rodar o PrimoCode.'));
|
|
265
|
-
console.log(c.muted('Instale manualmente em: https://nodejs.org/'));
|
|
266
|
-
process.exit(1);
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
const platform = os.platform();
|
|
270
|
-
const pb = new ProgressBar('Instalando Node.js');
|
|
329
|
+
function shortPath(p) {
|
|
330
|
+
// Normaliza as barras antes de comparar — no Windows o cwd vem com "\"
|
|
331
|
+
// e a home pode vir com "/", e aí o prefixo nunca batia.
|
|
332
|
+
const norm = (s) => path.resolve(s).replace(/\\/g, '/');
|
|
333
|
+
const home = norm(os.homedir());
|
|
334
|
+
const full = norm(p);
|
|
335
|
+
return full.startsWith(home + '/') ? '~' + full.slice(home.length) : full;
|
|
336
|
+
}
|
|
271
337
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
// Termux — usa pkg
|
|
275
|
-
pb.start();
|
|
276
|
-
const child = spawn('pkg', ['install', '-y', 'nodejs'], { stdio: 'pipe' });
|
|
277
|
-
let stderr = '';
|
|
278
|
-
child.stderr.on('data', d => { stderr += d; pb.tick(); });
|
|
279
|
-
await new Promise((resolve, reject) => {
|
|
280
|
-
child.on('close', code => { pb.stop(); code === 0 ? resolve() : reject(new Error(stderr || 'pkg failed')); });
|
|
281
|
-
});
|
|
282
|
-
} else if (platform === 'darwin' || platform === 'linux') {
|
|
283
|
-
const hasNvm = () => { try { execSync('command -v nvm', {stdio:'ignore'}); return true; } catch { return false; } };
|
|
284
|
-
const hasFnm = () => { try { execSync('command -v fnm', {stdio:'ignore'}); return true; } catch { return false; } };
|
|
285
|
-
|
|
286
|
-
if (hasNvm() || fs.existsSync(path.join(os.homedir(), '.nvm'))) {
|
|
287
|
-
pb.start();
|
|
288
|
-
const nvmDir = path.join(os.homedir(), '.nvm');
|
|
289
|
-
const child = spawn('bash', ['-c', `export NVM_DIR="${nvmDir}" && [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" && nvm install 20 && nvm use 20 && nvm alias default 20`], { stdio: 'pipe' });
|
|
290
|
-
let stderr = '';
|
|
291
|
-
child.stderr.on('data', d => { stderr += d; pb.tick(); });
|
|
292
|
-
await new Promise((resolve, reject) => {
|
|
293
|
-
child.on('close', code => { pb.stop(); code === 0 ? resolve() : reject(new Error(stderr || 'nvm failed')); });
|
|
294
|
-
});
|
|
295
|
-
} else if (hasFnm()) {
|
|
296
|
-
pb.start();
|
|
297
|
-
const child = spawn('bash', ['-c', 'fnm install 20 && fnm use 20 && fnm default 20'], { stdio: 'pipe' });
|
|
298
|
-
let stderr = '';
|
|
299
|
-
child.stderr.on('data', d => { stderr += d; pb.tick(); });
|
|
300
|
-
await new Promise((resolve, reject) => {
|
|
301
|
-
child.on('close', code => { pb.stop(); code === 0 ? resolve() : reject(new Error(stderr || 'fnm failed')); });
|
|
302
|
-
});
|
|
303
|
-
} else {
|
|
304
|
-
pb.start();
|
|
305
|
-
// Instala nvm primeiro
|
|
306
|
-
const child1 = spawn('bash', ['-c', 'curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash'], { stdio: 'pipe' });
|
|
307
|
-
let stderr1 = '';
|
|
308
|
-
child1.stderr.on('data', d => { stderr1 += d; pb.tick(); });
|
|
309
|
-
await new Promise((resolve, reject) => {
|
|
310
|
-
child1.on('close', code => { code === 0 ? resolve() : reject(new Error(stderr1)); });
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
// Instala Node via nvm
|
|
314
|
-
const nvmDir = path.join(os.homedir(), '.nvm');
|
|
315
|
-
const child2 = spawn('bash', ['-c', `export NVM_DIR="${nvmDir}" && [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" && nvm install 20 && nvm use 20 && nvm alias default 20`], { stdio: 'pipe' });
|
|
316
|
-
let stderr2 = '';
|
|
317
|
-
child2.stderr.on('data', d => { stderr2 += d; pb.tick(); });
|
|
318
|
-
await new Promise((resolve, reject) => {
|
|
319
|
-
child2.on('close', code => { pb.stop(); code === 0 ? resolve() : reject(new Error(stderr2 || 'nvm install failed')); });
|
|
320
|
-
});
|
|
321
|
-
}
|
|
322
|
-
} else if (platform === 'win32') {
|
|
323
|
-
pb.start();
|
|
324
|
-
const child = spawn('cmd', ['/c', 'winget install OpenJS.NodeJS.LTS'], { stdio: 'pipe' });
|
|
325
|
-
let stderr = '';
|
|
326
|
-
child.stderr.on('data', d => { stderr += d; pb.tick(); });
|
|
327
|
-
await new Promise((resolve, reject) => {
|
|
328
|
-
child.on('close', code => { pb.stop(); code === 0 ? resolve() : reject(new Error(stderr || 'winget failed')); });
|
|
329
|
-
});
|
|
330
|
-
}
|
|
338
|
+
async function main() {
|
|
339
|
+
cfg.ensureStructure();
|
|
331
340
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
const v = execSync('node --version', {encoding:'utf-8'}).trim();
|
|
337
|
-
console.log(c.ok(`Node.js ${v} instalado com sucesso!`));
|
|
338
|
-
return true;
|
|
339
|
-
} catch (e) {
|
|
340
|
-
pb.stop();
|
|
341
|
-
console.log(c.err(`Falha ao instalar Node.js: ${e.message}`));
|
|
342
|
-
console.log(c.muted('Por favor, instale manualmente: https://nodejs.org/'));
|
|
343
|
-
process.exit(1);
|
|
341
|
+
// Abriu dentro de uma pasta de trabalho? Então é ali que ele atua.
|
|
342
|
+
if (isUsableCwd(process.cwd())) {
|
|
343
|
+
state.workDir = process.cwd();
|
|
344
|
+
state.currentProject = path.basename(state.workDir);
|
|
344
345
|
}
|
|
345
|
-
}
|
|
346
346
|
|
|
347
|
-
async function main() {
|
|
348
|
-
await checkAndInstallNode();
|
|
349
347
|
const args = process.argv.slice(2);
|
|
348
|
+
|
|
349
|
+
// Modo one-shot: primocode "crie um jogo de carro"
|
|
350
350
|
if (args.length && !args[0].startsWith('-')) {
|
|
351
|
-
const prompt = args.join(' ');
|
|
352
|
-
const isAgent = args.includes('--agent');
|
|
353
351
|
const fileFlag = args.indexOf('-f');
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
352
|
+
const words = fileFlag >= 0 ? args.slice(0, fileFlag) : args;
|
|
353
|
+
const promptText = words.join(' ');
|
|
354
|
+
let fullPrompt = promptText;
|
|
355
|
+
|
|
356
|
+
if (fileFlag >= 0 && args[fileFlag + 1]) {
|
|
357
|
+
try {
|
|
358
|
+
const fc = fs.readFileSync(path.resolve(args[fileFlag + 1]), 'utf-8');
|
|
359
|
+
fullPrompt = `Arquivo: ${args[fileFlag + 1]}\n\`\`\`\n${fc}\n\`\`\`\n\n${promptText}`;
|
|
360
|
+
} catch { console.log(c.err(`Não consegui ler: ${args[fileFlag + 1]}`)); process.exit(1); }
|
|
358
361
|
}
|
|
359
|
-
|
|
360
|
-
|
|
362
|
+
|
|
363
|
+
const plan = planWorkDir(promptText);
|
|
364
|
+
await runAct(fullPrompt, {
|
|
365
|
+
server: state.config.server,
|
|
366
|
+
model: state.config.model,
|
|
367
|
+
token: state.config.token,
|
|
368
|
+
history: [],
|
|
369
|
+
projectDir: plan.dir,
|
|
370
|
+
projectName: plan.pending ? plan.name : state.currentProject,
|
|
371
|
+
ensureWorkDir: plan.pending ? () => createPendingProject(plan) : null,
|
|
372
|
+
});
|
|
361
373
|
process.exit(0);
|
|
362
374
|
}
|
|
363
|
-
|
|
375
|
+
|
|
376
|
+
console.log(welcomeBox(VERSION, state.workDir ? shortPath(state.workDir) : null));
|
|
364
377
|
rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: makePrompt() });
|
|
365
378
|
rl.prompt();
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
379
|
+
|
|
380
|
+
// Fila: o readline continua emitindo 'line' enquanto um pedido está sendo
|
|
381
|
+
// processado. Sem enfileirar, dois pedidos rodariam sobrepostos.
|
|
382
|
+
const queue = [];
|
|
383
|
+
let busy = false;
|
|
384
|
+
let closed = false;
|
|
385
|
+
let saidBye = false;
|
|
386
|
+
|
|
387
|
+
function sayBye() {
|
|
388
|
+
if (saidBye) return;
|
|
389
|
+
saidBye = true;
|
|
390
|
+
console.log(c.muted('\nAté logo!'));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function drain() {
|
|
394
|
+
if (busy) return;
|
|
395
|
+
busy = true;
|
|
396
|
+
while (queue.length) {
|
|
397
|
+
const text = queue.shift().trim();
|
|
398
|
+
if (!text) continue;
|
|
399
|
+
try {
|
|
400
|
+
if (isSlash(text)) await handleSlash(text);
|
|
401
|
+
else await sendPrompt(text);
|
|
402
|
+
} catch (e) {
|
|
403
|
+
console.log(c.err(`\n${e.message || e}`));
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
busy = false;
|
|
407
|
+
// Se o stdin já fechou (pipe, Ctrl+D), a despedida espera a fila terminar
|
|
408
|
+
// — senão ela aparece antes do resultado do último pedido.
|
|
409
|
+
if (closed) sayBye();
|
|
410
|
+
else rl.prompt();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
rl.on('line', (line) => {
|
|
414
|
+
if (pendingAsk) { const resolve = pendingAsk; pendingAsk = null; resolve(line); return; }
|
|
415
|
+
queue.push(line);
|
|
416
|
+
drain();
|
|
371
417
|
});
|
|
372
|
-
|
|
418
|
+
|
|
419
|
+
rl.on('close', () => { closed = true; if (!busy) sayBye(); });
|
|
373
420
|
}
|
|
374
421
|
|
|
375
|
-
main().catch(e => { console.error(c.err(e.message||e)); process.exit(1); });
|
|
422
|
+
main().catch((e) => { console.error(c.err(e.message || e)); process.exit(1); });
|