@danieltmn/tamnora 0.1.0 → 0.3.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/CHANGELOG.md +63 -0
- package/harness/packages/cli/src/cli.js +2 -0
- package/harness/packages/cli/src/commands/eval.js +3 -0
- package/harness/packages/cli/src/commands/repl.js +2 -0
- package/harness/packages/cli/src/commands/run.js +2 -1
- package/harness/packages/cli/src/commands/tui.js +18 -11
- package/harness/packages/cli/src/lib/runtime.js +3 -1
- package/harness/packages/cli/src/lib/tunnel.js +2 -113
- package/harness/packages/cli/src/lib/util.js +2 -0
- package/harness/packages/cli/src/tui/app.js +45 -4
- package/harness/packages/cli/src/tui/keys.js +3 -0
- package/harness/packages/cli/test/tui.test.js +5 -0
- package/harness/packages/core/src/agent/modes.js +2 -2
- package/harness/packages/core/src/config/index.js +3 -0
- package/harness/packages/core/src/console.js +18 -0
- package/harness/packages/core/src/eval/judge.js +3 -3
- package/harness/packages/core/src/eval/runner.js +3 -0
- package/harness/packages/core/src/index.js +11 -0
- package/harness/packages/core/src/mcp/registry.js +117 -0
- package/harness/packages/core/src/procs/detect.js +69 -0
- package/harness/packages/core/src/providers/ollama.js +3 -3
- package/harness/packages/core/src/providers/openaiCompatible.js +3 -3
- package/harness/packages/core/src/providers/openrouter.js +2 -2
- package/harness/packages/core/src/providers/resilience.js +1 -1
- package/harness/packages/core/src/providers/utils.js +43 -0
- package/harness/packages/core/src/skills/index.js +90 -0
- package/harness/packages/core/src/skills/remote.js +80 -0
- package/harness/packages/core/src/storage/sqlite.js +21 -0
- package/harness/packages/core/src/tools/executors.js +55 -0
- package/harness/packages/core/src/tools/permissions.js +2 -0
- package/harness/packages/core/src/tools/schema.js +16 -0
- package/harness/packages/core/src/tunnel.js +113 -0
- package/harness/packages/core/test/config.test.js +15 -0
- package/harness/packages/core/test/mcp-registry.test.js +49 -0
- package/harness/packages/core/test/procs.test.js +34 -0
- package/harness/packages/core/test/providers.test.js +20 -0
- package/harness/packages/core/test/resilience.test.js +1 -0
- package/harness/packages/core/test/skills.test.js +69 -0
- package/harness/packages/core/test/tools.test.js +10 -0
- package/harness/packages/server/public/app.js +484 -6
- package/harness/packages/server/public/index.html +134 -6
- package/harness/packages/server/public/styles.css +45 -1
- package/harness/packages/server/src/cli.js +2 -0
- package/harness/packages/server/src/lib/agentTools.js +238 -0
- package/harness/packages/server/src/lib/procs.js +125 -0
- package/harness/packages/server/src/lib/prompt.js +6 -3
- package/harness/packages/server/src/lib/skills.js +69 -0
- package/harness/packages/server/src/lib/telegram.js +267 -0
- package/harness/packages/server/src/routes/chat.js +9 -1
- package/harness/packages/server/src/routes/mcp.js +128 -9
- package/harness/packages/server/src/routes/preview.js +58 -0
- package/harness/packages/server/src/routes/procs.js +47 -0
- package/harness/packages/server/src/routes/sessions.js +10 -0
- package/harness/packages/server/src/routes/skills.js +94 -0
- package/harness/packages/server/src/server.js +79 -6
- package/harness/packages/server/test/server.test.js +268 -1
- package/harness/tamnora.example.json +7 -1
- package/package.json +1 -1
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { homeDir, loadSkills, mergeSkills, buildSkillsPrompt } from './core.js';
|
|
4
|
+
|
|
5
|
+
export const SKILL_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,39}$/i;
|
|
6
|
+
|
|
7
|
+
/** Skill roots, lowest priority first: casa skills, then the workspace's. */
|
|
8
|
+
export function skillRoots(cwd) {
|
|
9
|
+
return [path.join(homeDir(), 'skills'), path.join(cwd, '.tamnora', 'skills')];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function allSkills(cwd) {
|
|
13
|
+
const home = path.join(homeDir(), 'skills');
|
|
14
|
+
return mergeSkills(skillRoots(cwd).map((root) => loadSkills(root).map((s) => ({ ...s, root, home: root === home }))));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function disabledIds(store) {
|
|
18
|
+
const raw = store.getSetting('disabledSkills');
|
|
19
|
+
return new Set(Array.isArray(raw) ? raw.map(String) : []);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function skillsView(ctx) {
|
|
23
|
+
const disabled = disabledIds(ctx.store);
|
|
24
|
+
return allSkills(ctx.cwd).map((s) => ({
|
|
25
|
+
id: s.id,
|
|
26
|
+
name: s.name || s.id,
|
|
27
|
+
description: s.description || '',
|
|
28
|
+
when: s.when || '',
|
|
29
|
+
enabled: !disabled.has(s.id),
|
|
30
|
+
removable: Boolean(s.home),
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function findSkill(ctx, id) {
|
|
35
|
+
return allSkills(ctx.cwd).find((s) => s.id === id) ?? null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function enabledSkills(ctx) {
|
|
39
|
+
const disabled = disabledIds(ctx.store);
|
|
40
|
+
return allSkills(ctx.cwd)
|
|
41
|
+
.filter((s) => !disabled.has(s.id))
|
|
42
|
+
.map((s) => ({ id: s.id, name: s.name || s.id, description: s.description, when: s.when, body: s.body }));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function skillsPrompt(ctx) {
|
|
46
|
+
return buildSkillsPrompt(enabledSkills(ctx));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function writeSkill(id, { name, description, when, body }) {
|
|
50
|
+
const dir = path.join(homeDir(), 'skills', id);
|
|
51
|
+
await mkdir(dir, { recursive: true });
|
|
52
|
+
const lines = ['---', `name: ${name || id}`];
|
|
53
|
+
if (description) lines.push(`description: ${description}`);
|
|
54
|
+
if (when) lines.push(`when: ${when}`);
|
|
55
|
+
lines.push('---', '', String(body ?? '').trim(), '');
|
|
56
|
+
await writeFile(path.join(dir, 'SKILL.md'), lines.join('\n'), 'utf8');
|
|
57
|
+
return dir;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function removeSkill(id) {
|
|
61
|
+
await rm(path.join(homeDir(), 'skills', id), { recursive: true, force: true });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function setSkillEnabled(store, id, enabled) {
|
|
65
|
+
const current = new Set(Array.isArray(store.getSetting('disabledSkills')) ? store.getSetting('disabledSkills') : []);
|
|
66
|
+
if (enabled) current.delete(id);
|
|
67
|
+
else current.add(id);
|
|
68
|
+
store.setSetting('disabledSkills', [...current]);
|
|
69
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { runAgent, createTools, resolveMode } from './core.js';
|
|
2
|
+
import { resolveExistingDir } from './workspace.js';
|
|
3
|
+
|
|
4
|
+
const API = 'https://api.telegram.org';
|
|
5
|
+
|
|
6
|
+
const HELP = [
|
|
7
|
+
'Tamnora Harness — bot',
|
|
8
|
+
'',
|
|
9
|
+
'/new — nueva sesión',
|
|
10
|
+
'/status — proveedor, modelo, modo y sesión',
|
|
11
|
+
'/model <id> — cambia el modelo',
|
|
12
|
+
'/provider <id> — cambia el proveedor',
|
|
13
|
+
'/mode plan|build — cambia el modo',
|
|
14
|
+
'/stop — cancela la respuesta en curso',
|
|
15
|
+
'/help — esta ayuda',
|
|
16
|
+
'',
|
|
17
|
+
'Cualquier otro texto se envía al agente.',
|
|
18
|
+
].join('\n');
|
|
19
|
+
|
|
20
|
+
async function tg(token, method, payload) {
|
|
21
|
+
const res = await fetch(`${API}/bot${token}/${method}`, {
|
|
22
|
+
method: 'POST',
|
|
23
|
+
headers: { 'Content-Type': 'application/json' },
|
|
24
|
+
body: JSON.stringify(payload ?? {}),
|
|
25
|
+
});
|
|
26
|
+
let data = null;
|
|
27
|
+
try {
|
|
28
|
+
data = await res.json();
|
|
29
|
+
} catch {
|
|
30
|
+
data = null;
|
|
31
|
+
}
|
|
32
|
+
if (!data?.ok) {
|
|
33
|
+
const error = new Error(data?.description || `HTTP ${res.status}`);
|
|
34
|
+
error.status = res.status;
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
return data.result;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function splitMessage(text, max = 3900) {
|
|
41
|
+
const value = String(text ?? '').trim() || '(sin texto)';
|
|
42
|
+
const parts = [];
|
|
43
|
+
for (let i = 0; i < value.length; i += max) parts.push(value.slice(i, i + max));
|
|
44
|
+
return parts;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Bot de Telegram por long polling (no necesita URL publica). Los chats
|
|
51
|
+
* autorizados conversan con el mismo engine del harness; cada chat mapea a una
|
|
52
|
+
* sesion. Devuelve `{ stop }` o `null` si no hay token.
|
|
53
|
+
*/
|
|
54
|
+
export function startTelegramBot(ctx, { token, allowedChatIds = [], log = () => {} } = {}) {
|
|
55
|
+
if (!token) return null;
|
|
56
|
+
const allowed = new Set(allowedChatIds.map(String));
|
|
57
|
+
const controllers = new Map();
|
|
58
|
+
let running = true;
|
|
59
|
+
let offset = 0;
|
|
60
|
+
|
|
61
|
+
const authorized = (chatId) => allowed.size > 0 && allowed.has(String(chatId));
|
|
62
|
+
const sessionKey = (chatId) => `telegram:chat:${chatId}`;
|
|
63
|
+
|
|
64
|
+
async function send(chatId, text) {
|
|
65
|
+
let first = null;
|
|
66
|
+
for (const chunk of splitMessage(text)) {
|
|
67
|
+
const result = await tg(token, 'sendMessage', { chat_id: chatId, text: chunk, disable_web_page_preview: true });
|
|
68
|
+
if (!first) first = result;
|
|
69
|
+
}
|
|
70
|
+
return first;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function edit(chatId, messageId, text) {
|
|
74
|
+
const parts = splitMessage(text);
|
|
75
|
+
await tg(token, 'editMessageText', { chat_id: chatId, message_id: messageId, text: parts[0], disable_web_page_preview: true }).catch(() => null);
|
|
76
|
+
for (let i = 1; i < parts.length; i++) {
|
|
77
|
+
await tg(token, 'sendMessage', { chat_id: chatId, text: parts[i], disable_web_page_preview: true }).catch(() => null);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function ensureSession(chatId) {
|
|
82
|
+
const key = sessionKey(chatId);
|
|
83
|
+
let sessionId = ctx.store.getSetting(key);
|
|
84
|
+
if (!sessionId || !ctx.store.getSession(sessionId)) {
|
|
85
|
+
sessionId = ctx.store.createSession({
|
|
86
|
+
title: `Telegram ${chatId}`,
|
|
87
|
+
provider: ctx.effectiveProviderId(),
|
|
88
|
+
model: ctx.effectiveModel(),
|
|
89
|
+
cwd: ctx.cwd,
|
|
90
|
+
meta: { telegramChatId: String(chatId) },
|
|
91
|
+
});
|
|
92
|
+
ctx.store.setSetting(key, sessionId);
|
|
93
|
+
}
|
|
94
|
+
return sessionId;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function run(chatId, input) {
|
|
98
|
+
const sessionId = ensureSession(chatId);
|
|
99
|
+
const session = ctx.store.getSession(sessionId);
|
|
100
|
+
const providerId = session.provider || ctx.effectiveProviderId();
|
|
101
|
+
const model = session.model || ctx.effectiveModel();
|
|
102
|
+
if (!model) {
|
|
103
|
+
await send(chatId, 'Falta el modelo. Usá /model <id> o configurá uno en Ajustes.');
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const mode = resolveMode(ctx.effectiveMode());
|
|
107
|
+
const cwd = resolveExistingDir(session.cwd || ctx.cwd);
|
|
108
|
+
const history = ctx.store.getMessages(sessionId);
|
|
109
|
+
|
|
110
|
+
let mcp = null;
|
|
111
|
+
try {
|
|
112
|
+
mcp = await ctx.getMcp();
|
|
113
|
+
} catch {
|
|
114
|
+
mcp = null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const local = createTools({
|
|
118
|
+
root: cwd,
|
|
119
|
+
enabled: mode.tools ?? undefined,
|
|
120
|
+
permissions: ctx.config.permissions,
|
|
121
|
+
yes: false,
|
|
122
|
+
mcp,
|
|
123
|
+
});
|
|
124
|
+
const exposeAdmin = !mode.tools;
|
|
125
|
+
const admin = ctx.agentTools;
|
|
126
|
+
const definitions = exposeAdmin ? [...local.definitions, ...admin.definitions] : local.definitions;
|
|
127
|
+
const execute = (name, args, context) =>
|
|
128
|
+
exposeAdmin && admin.handles(name)
|
|
129
|
+
? admin.execute(name, args, { approve: async () => false })
|
|
130
|
+
: local.execute(name, args, context);
|
|
131
|
+
const { provider, config } = ctx.resolveProvider({
|
|
132
|
+
...ctx.providerSettings(providerId),
|
|
133
|
+
apiKey: ctx.apiKeyFor(providerId),
|
|
134
|
+
});
|
|
135
|
+
const basePrompt = await ctx.systemPromptFor(cwd);
|
|
136
|
+
const messages = [
|
|
137
|
+
{ role: 'system', content: mode.systemPrompt ? `${basePrompt}\n\n${mode.systemPrompt}` : basePrompt },
|
|
138
|
+
...history,
|
|
139
|
+
{ role: 'user', content: input },
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
const controller = new AbortController();
|
|
143
|
+
controllers.set(String(chatId), controller);
|
|
144
|
+
const message = await send(chatId, '…');
|
|
145
|
+
let full = '';
|
|
146
|
+
let lastEdit = 0;
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const result = await runAgent({
|
|
150
|
+
provider,
|
|
151
|
+
config,
|
|
152
|
+
model,
|
|
153
|
+
messages,
|
|
154
|
+
tools: definitions,
|
|
155
|
+
execute,
|
|
156
|
+
signal: controller.signal,
|
|
157
|
+
maxRounds: ctx.config.maxRounds,
|
|
158
|
+
onEvent(event) {
|
|
159
|
+
if (event.type !== 'text') return;
|
|
160
|
+
full = event.full;
|
|
161
|
+
const now = Date.now();
|
|
162
|
+
if (now - lastEdit > 1200) {
|
|
163
|
+
lastEdit = now;
|
|
164
|
+
edit(chatId, message.message_id, full || '…');
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
await edit(chatId, message.message_id, result.content?.trim() || full || '(sin texto)');
|
|
169
|
+
const newMessages = result.messages.slice(1 + history.length);
|
|
170
|
+
ctx.store.addMessages(sessionId, newMessages);
|
|
171
|
+
ctx.store.touchSession(sessionId, { provider: providerId, model });
|
|
172
|
+
} catch (error) {
|
|
173
|
+
await edit(chatId, message.message_id, error?.name === 'AbortError' ? 'Cancelado.' : `Error: ${error?.message ?? error}`);
|
|
174
|
+
} finally {
|
|
175
|
+
controllers.delete(String(chatId));
|
|
176
|
+
mcp?.close?.();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function handleCommand(chatId, text) {
|
|
181
|
+
const [raw, ...rest] = text.slice(1).split(/\s+/);
|
|
182
|
+
const cmd = raw.toLowerCase();
|
|
183
|
+
const arg = rest.join(' ').trim();
|
|
184
|
+
const sessionId = ctx.store.getSetting(sessionKey(chatId));
|
|
185
|
+
if (cmd === 'start' || cmd === 'help' || cmd === 'ayuda') return send(chatId, HELP);
|
|
186
|
+
if (cmd === 'new' || cmd === 'nueva') {
|
|
187
|
+
ctx.store.setSetting(sessionKey(chatId), null);
|
|
188
|
+
return send(chatId, 'Nueva sesión. Mandá tu mensaje.');
|
|
189
|
+
}
|
|
190
|
+
if (cmd === 'status') {
|
|
191
|
+
const session = sessionId ? ctx.store.getSession(sessionId) : null;
|
|
192
|
+
return send(
|
|
193
|
+
chatId,
|
|
194
|
+
`proveedor: ${session?.provider || ctx.effectiveProviderId()}\nmodelo: ${session?.model || ctx.effectiveModel() || '—'}\nmodo: ${ctx.effectiveMode()}\nsesión: ${session?.title || '—'}`
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
if (cmd === 'model') {
|
|
198
|
+
if (!arg) return send(chatId, `modelo actual: ${ctx.effectiveModel() || '—'}`);
|
|
199
|
+
ctx.store.setSetting('activeModel', arg);
|
|
200
|
+
if (sessionId) ctx.store.touchSession(sessionId, { model: arg });
|
|
201
|
+
return send(chatId, `modelo: ${arg}`);
|
|
202
|
+
}
|
|
203
|
+
if (cmd === 'provider') {
|
|
204
|
+
if (!arg) return send(chatId, `proveedor actual: ${ctx.effectiveProviderId()}`);
|
|
205
|
+
ctx.store.setSetting('activeProvider', arg);
|
|
206
|
+
if (sessionId) ctx.store.touchSession(sessionId, { provider: arg });
|
|
207
|
+
return send(chatId, `proveedor: ${arg}`);
|
|
208
|
+
}
|
|
209
|
+
if (cmd === 'mode' || cmd === 'modo') {
|
|
210
|
+
if (!arg) return send(chatId, `modo actual: ${ctx.effectiveMode()}`);
|
|
211
|
+
const id = arg.toLowerCase() === 'build' ? 'agent' : arg.toLowerCase();
|
|
212
|
+
ctx.store.setSetting('activeMode', resolveMode(id).id);
|
|
213
|
+
return send(chatId, `modo: ${resolveMode(id).label}`);
|
|
214
|
+
}
|
|
215
|
+
if (cmd === 'stop' || cmd === 'parar') {
|
|
216
|
+
controllers.get(String(chatId))?.abort();
|
|
217
|
+
return send(chatId, 'Cancelando…');
|
|
218
|
+
}
|
|
219
|
+
return send(chatId, `Comando desconocido: /${cmd}.\n\n${HELP}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function handleMessage(message) {
|
|
223
|
+
const chatId = String(message.chat.id);
|
|
224
|
+
if (!authorized(chatId)) {
|
|
225
|
+
await send(chatId, `No autorizado. Tu chat id es ${chatId}; agregalo a telegram.allowedChatIds para habilitarlo.`);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const text = String(message.text ?? '').trim();
|
|
229
|
+
if (!text) return;
|
|
230
|
+
if (text.startsWith('/')) return handleCommand(chatId, text);
|
|
231
|
+
return run(chatId, text);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function loop() {
|
|
235
|
+
while (running) {
|
|
236
|
+
let updates = [];
|
|
237
|
+
try {
|
|
238
|
+
updates = await tg(token, 'getUpdates', { offset, timeout: 25, allowed_updates: ['message'] });
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (error.status === 401 || error.status === 404) {
|
|
241
|
+
log(`telegram: token inválido (${error.message}); se detiene el bot.`);
|
|
242
|
+
running = false;
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
await sleep(3000);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
for (const update of updates) {
|
|
249
|
+
offset = update.update_id + 1;
|
|
250
|
+
if (update.message?.text) {
|
|
251
|
+
handleMessage(update.message).catch((error) => log(`telegram: ${error?.message ?? error}`));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Arranca en segundo plano; no bloquea el server.
|
|
258
|
+
loop().catch((error) => log(`telegram: ${error?.message ?? error}`));
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
stop() {
|
|
262
|
+
running = false;
|
|
263
|
+
for (const controller of controllers.values()) controller.abort();
|
|
264
|
+
controllers.clear();
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
}
|
|
@@ -123,7 +123,7 @@ export function registerChat(route, ctx) {
|
|
|
123
123
|
sse.event('permission_resolved', { askId, allow });
|
|
124
124
|
return allow;
|
|
125
125
|
};
|
|
126
|
-
const
|
|
126
|
+
const local = createTools({
|
|
127
127
|
root: sessionCwd,
|
|
128
128
|
enabled,
|
|
129
129
|
permissions: ctx.config.permissions,
|
|
@@ -131,6 +131,14 @@ export function registerChat(route, ctx) {
|
|
|
131
131
|
onAsk,
|
|
132
132
|
mcp,
|
|
133
133
|
});
|
|
134
|
+
// En modo plan (solo lectura) no exponemos las tools de administración.
|
|
135
|
+
const exposeAdmin = !mode.tools;
|
|
136
|
+
const admin = ctx.agentTools;
|
|
137
|
+
const definitions = exposeAdmin ? [...local.definitions, ...admin.definitions] : local.definitions;
|
|
138
|
+
const execute = (name, args, context) =>
|
|
139
|
+
exposeAdmin && admin.handles(name)
|
|
140
|
+
? admin.execute(name, args, { approve: autoApprove ? async () => true : onAsk })
|
|
141
|
+
: local.execute(name, args, context);
|
|
134
142
|
|
|
135
143
|
const { provider, config } = ctx.resolveProvider({
|
|
136
144
|
...ctx.providerSettings(providerId),
|
|
@@ -1,5 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
buildMcpRegistry,
|
|
3
|
+
mcpTransport,
|
|
4
|
+
getMcpRegistryServer,
|
|
5
|
+
registryServerToConfig,
|
|
6
|
+
normalizeMcpConfig,
|
|
7
|
+
} from '../lib/core.js';
|
|
8
|
+
import { readJson, sendJson, sendError } from '../lib/http.js';
|
|
9
|
+
|
|
10
|
+
const ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,39}$/i;
|
|
3
11
|
|
|
4
12
|
function safeTransport(server) {
|
|
5
13
|
try {
|
|
@@ -9,17 +17,49 @@ function safeTransport(server) {
|
|
|
9
17
|
}
|
|
10
18
|
}
|
|
11
19
|
|
|
20
|
+
function listServers(ctx) {
|
|
21
|
+
return ctx.mergedMcpServers().map((server) => ({
|
|
22
|
+
id: server.id,
|
|
23
|
+
name: server.name ?? server.id,
|
|
24
|
+
transport: safeTransport(server),
|
|
25
|
+
enabled: server.enabled !== false,
|
|
26
|
+
source: server.source ?? 'config',
|
|
27
|
+
command: server.command ?? null,
|
|
28
|
+
args: Array.isArray(server.args) ? server.args : [],
|
|
29
|
+
url: server.url ?? null,
|
|
30
|
+
env: server.env ?? null,
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseServer(body, id) {
|
|
35
|
+
const transport = body.transport === 'http' ? 'http' : 'stdio';
|
|
36
|
+
const command = String(body.command ?? '').trim();
|
|
37
|
+
const url = String(body.url ?? '').trim();
|
|
38
|
+
if (transport === 'stdio' && !command) throw new Error('Un servidor stdio necesita "command".');
|
|
39
|
+
if (transport === 'http' && !url) throw new Error('Un servidor http necesita "url".');
|
|
40
|
+
const args = Array.isArray(body.args)
|
|
41
|
+
? body.args.map((a) => String(a))
|
|
42
|
+
: String(body.args ?? '')
|
|
43
|
+
.split(/\s+/)
|
|
44
|
+
.filter(Boolean);
|
|
45
|
+
return {
|
|
46
|
+
id,
|
|
47
|
+
name: String(body.name ?? id).trim() || id,
|
|
48
|
+
transport,
|
|
49
|
+
command: transport === 'stdio' ? command : null,
|
|
50
|
+
url: transport === 'http' ? url : null,
|
|
51
|
+
enabled: body.enabled !== false,
|
|
52
|
+
config: { args, env: body.env ?? null },
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
12
56
|
export function registerMcp(route, ctx) {
|
|
13
57
|
route('GET', '/api/mcp', async ({ res, query }) => {
|
|
14
|
-
const servers = (ctx
|
|
15
|
-
id: server.id,
|
|
16
|
-
name: server.name,
|
|
17
|
-
transport: safeTransport(server),
|
|
18
|
-
enabled: server.enabled !== false,
|
|
19
|
-
}));
|
|
58
|
+
const servers = listServers(ctx);
|
|
20
59
|
|
|
21
60
|
if (query.get('connect')) {
|
|
22
|
-
const
|
|
61
|
+
const connectable = ctx.mergedMcpServers().filter((s) => s.enabled !== false);
|
|
62
|
+
const registry = await buildMcpRegistry(connectable);
|
|
23
63
|
try {
|
|
24
64
|
for (const server of servers) {
|
|
25
65
|
const session = registry.sessions.find((s) => s.server.id === server.id);
|
|
@@ -47,4 +87,83 @@ export function registerMcp(route, ctx) {
|
|
|
47
87
|
|
|
48
88
|
sendJson(res, 200, { servers });
|
|
49
89
|
});
|
|
90
|
+
|
|
91
|
+
route('POST', '/api/mcp/import', async ({ req, res }) => {
|
|
92
|
+
const body = await readJson(req);
|
|
93
|
+
let record;
|
|
94
|
+
try {
|
|
95
|
+
if (body.json !== undefined || body.config !== undefined) {
|
|
96
|
+
record = normalizeMcpConfig(body.json ?? body.config, body.id);
|
|
97
|
+
} else if (body.name) {
|
|
98
|
+
const server = await getMcpRegistryServer(String(body.name), { strict: true });
|
|
99
|
+
if (!server) return sendError(res, 404, 'No encontré ese servidor en el registro oficial.');
|
|
100
|
+
record = registryServerToConfig(server);
|
|
101
|
+
} else if (body.url) {
|
|
102
|
+
const value = String(body.url);
|
|
103
|
+
if (/registry\.modelcontextprotocol\.io/i.test(value)) {
|
|
104
|
+
const name = new URL(value).searchParams.get('search') || value.split('/').filter(Boolean).pop();
|
|
105
|
+
const server = await getMcpRegistryServer(name, { strict: true });
|
|
106
|
+
if (!server) return sendError(res, 404, 'No encontré ese servidor en el registro oficial.');
|
|
107
|
+
record = registryServerToConfig(server);
|
|
108
|
+
} else if (/mcpservers\.org/i.test(value)) {
|
|
109
|
+
return sendError(
|
|
110
|
+
res,
|
|
111
|
+
400,
|
|
112
|
+
'mcpservers.org está detrás de Cloudflare y no se puede leer desde el server. Pegá el JSON del servidor (bloque "mcpServers") o usá el registro oficial (registry.modelcontextprotocol.io).'
|
|
113
|
+
);
|
|
114
|
+
} else {
|
|
115
|
+
return sendError(res, 400, 'URL no reconocida. Usá el registro oficial o pegá el JSON.');
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
return sendError(res, 400, 'Pasá "name" (registro), "json" (config) o "url".');
|
|
119
|
+
}
|
|
120
|
+
} catch (error) {
|
|
121
|
+
return sendError(res, 400, error.message);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
ctx.store.saveMcpServer({
|
|
125
|
+
id: record.id,
|
|
126
|
+
name: record.name,
|
|
127
|
+
transport: record.type === 'http' ? 'http' : 'stdio',
|
|
128
|
+
command: record.command ?? null,
|
|
129
|
+
url: record.url ?? null,
|
|
130
|
+
enabled: record.enabled !== false,
|
|
131
|
+
config: { args: record.args ?? [], env: record.env ?? null },
|
|
132
|
+
});
|
|
133
|
+
await ctx.reloadMcp();
|
|
134
|
+
sendJson(res, 200, {
|
|
135
|
+
imported: record.id,
|
|
136
|
+
requiredEnv: record.requiredEnv ?? [],
|
|
137
|
+
servers: listServers(ctx),
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
route('PUT', '/api/mcp/servers/:id', async ({ req, res, params }) => {
|
|
142
|
+
const id = String(params.id);
|
|
143
|
+
if (!ID_PATTERN.test(id)) return sendError(res, 400, 'Id de servidor inválido.');
|
|
144
|
+
const body = await readJson(req);
|
|
145
|
+
let record;
|
|
146
|
+
try {
|
|
147
|
+
record = parseServer(body, id);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
return sendError(res, 400, error.message);
|
|
150
|
+
}
|
|
151
|
+
ctx.store.saveMcpServer(record);
|
|
152
|
+
await ctx.reloadMcp();
|
|
153
|
+
sendJson(res, 200, { servers: listServers(ctx) });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
route('DELETE', '/api/mcp/servers/:id', async ({ res, params }) => {
|
|
157
|
+
const id = String(params.id);
|
|
158
|
+
const stored = ctx.store.listMcpServers().some((s) => s.id === id);
|
|
159
|
+
if (!stored) return sendError(res, 404, 'Ese servidor está definido en tamnora.json y no se borra desde acá.');
|
|
160
|
+
ctx.store.deleteMcpServer(id);
|
|
161
|
+
await ctx.reloadMcp();
|
|
162
|
+
sendJson(res, 200, { servers: listServers(ctx) });
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
route('POST', '/api/mcp/reload', async ({ res }) => {
|
|
166
|
+
await ctx.reloadMcp();
|
|
167
|
+
sendJson(res, 200, { ok: true, servers: listServers(ctx) });
|
|
168
|
+
});
|
|
50
169
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { startTunnel } from '../lib/core.js';
|
|
2
|
+
import { readJson, sendJson, sendError } from '../lib/http.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Preview de un dev server para acceso remoto: abre un tunel aparte al puerto
|
|
6
|
+
* del proceso (asi el celular puede embeber la app aunque este detras del
|
|
7
|
+
* tunel del harness). Devuelve `{ close }` para liberar el tunel al apagar.
|
|
8
|
+
*/
|
|
9
|
+
export function registerPreview(route, ctx) {
|
|
10
|
+
let tunnel = null;
|
|
11
|
+
|
|
12
|
+
route('GET', '/api/preview', ({ res }) => {
|
|
13
|
+
sendJson(res, 200, { tunnel: tunnel ? { url: tunnel.url, port: tunnel.port } : null });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
route('POST', '/api/preview/tunnel', async ({ req, res }) => {
|
|
17
|
+
const body = await readJson(req);
|
|
18
|
+
const port = Number(body.port);
|
|
19
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) return sendError(res, 400, 'Puerto inválido.');
|
|
20
|
+
if (tunnel) {
|
|
21
|
+
try {
|
|
22
|
+
tunnel.stop?.();
|
|
23
|
+
} catch {
|
|
24
|
+
// ignore
|
|
25
|
+
}
|
|
26
|
+
tunnel = null;
|
|
27
|
+
}
|
|
28
|
+
const provider = ctx.config.preview?.provider || 'tunnelmole';
|
|
29
|
+
try {
|
|
30
|
+
const started = await startTunnel(port, provider);
|
|
31
|
+
tunnel = { url: started.url, port, stop: started.stop };
|
|
32
|
+
sendJson(res, 200, { tunnel: { url: tunnel.url, port } });
|
|
33
|
+
} catch (error) {
|
|
34
|
+
sendError(res, 502, `No se pudo abrir el túnel: ${error.message}`);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
route('DELETE', '/api/preview/tunnel', ({ res }) => {
|
|
39
|
+
try {
|
|
40
|
+
tunnel?.stop?.();
|
|
41
|
+
} catch {
|
|
42
|
+
// ignore
|
|
43
|
+
}
|
|
44
|
+
tunnel = null;
|
|
45
|
+
sendJson(res, 200, { ok: true });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
close() {
|
|
50
|
+
try {
|
|
51
|
+
tunnel?.stop?.();
|
|
52
|
+
} catch {
|
|
53
|
+
// ignore
|
|
54
|
+
}
|
|
55
|
+
tunnel = null;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { detectProcs } from '../lib/core.js';
|
|
2
|
+
import { readJson, sendJson, sendError } from '../lib/http.js';
|
|
3
|
+
import { resolveExistingDir } from '../lib/workspace.js';
|
|
4
|
+
|
|
5
|
+
export function registerProcs(route, ctx) {
|
|
6
|
+
route('GET', '/api/procs', ({ res }) => {
|
|
7
|
+
sendJson(res, 200, { enabled: ctx.procsEnabled, allow: ctx.procs.allowed, procs: ctx.procs.list() });
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
route('GET', '/api/procs/detect', ({ res, query }) => {
|
|
11
|
+
let dir;
|
|
12
|
+
try {
|
|
13
|
+
dir = resolveExistingDir(query.get('cwd') || ctx.cwd);
|
|
14
|
+
} catch (error) {
|
|
15
|
+
return sendError(res, 400, error.message);
|
|
16
|
+
}
|
|
17
|
+
sendJson(res, 200, { cwd: dir, chips: detectProcs(dir) });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
route('POST', '/api/procs/start', async ({ req, res }) => {
|
|
21
|
+
if (!ctx.procsEnabled) return sendError(res, 403, 'Los procesos están deshabilitados (processes.enabled = false).');
|
|
22
|
+
const body = await readJson(req);
|
|
23
|
+
let dir;
|
|
24
|
+
try {
|
|
25
|
+
dir = resolveExistingDir(body.cwd || ctx.cwd);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
return sendError(res, 400, error.message);
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
const proc = ctx.procs.start({ cwd: dir, command: body.command, args: body.args, port: body.port });
|
|
31
|
+
sendJson(res, 201, { proc });
|
|
32
|
+
} catch (error) {
|
|
33
|
+
sendError(res, 400, error.message);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
route('GET', '/api/procs/:id/logs', ({ res, params, query }) => {
|
|
38
|
+
const data = ctx.procs.logs(params.id, Number(query.get('since')) || 0);
|
|
39
|
+
if (!data) return sendError(res, 404, 'Proceso no encontrado.');
|
|
40
|
+
sendJson(res, 200, data);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
route('POST', '/api/procs/:id/stop', ({ res, params }) => {
|
|
44
|
+
if (!ctx.procs.stop(params.id)) return sendError(res, 404, 'Proceso no encontrado.');
|
|
45
|
+
sendJson(res, 200, { ok: true });
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -154,4 +154,14 @@ export function registerSessions(route, ctx) {
|
|
|
154
154
|
ctx.store.deleteSession(params.id);
|
|
155
155
|
sendJson(res, 200, { ok: true });
|
|
156
156
|
});
|
|
157
|
+
|
|
158
|
+
route('GET', '/api/projects', ({ res }) => {
|
|
159
|
+
sendJson(res, 200, { projects: ctx.store.listProjects() });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
route('DELETE', '/api/projects', ({ res, query }) => {
|
|
163
|
+
if (!query.has('cwd')) return sendError(res, 400, 'Falta "cwd".');
|
|
164
|
+
const removed = ctx.store.deleteSessionsByCwd(query.get('cwd'));
|
|
165
|
+
sendJson(res, 200, { ok: true, ...removed });
|
|
166
|
+
});
|
|
157
167
|
}
|