@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,94 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { homeDir, parseSkillRef, fetchSkillFromGithub, writeSkillFiles } from '../lib/core.js';
|
|
3
|
+
import { readJson, sendJson, sendError } from '../lib/http.js';
|
|
4
|
+
import {
|
|
5
|
+
SKILL_ID_PATTERN,
|
|
6
|
+
skillsView,
|
|
7
|
+
findSkill,
|
|
8
|
+
writeSkill,
|
|
9
|
+
removeSkill,
|
|
10
|
+
setSkillEnabled,
|
|
11
|
+
} from '../lib/skills.js';
|
|
12
|
+
|
|
13
|
+
function sanitizeId(value) {
|
|
14
|
+
return String(value ?? '')
|
|
15
|
+
.toLowerCase()
|
|
16
|
+
.replace(/[^a-z0-9_-]+/g, '-')
|
|
17
|
+
.replace(/^-+|-+$/g, '')
|
|
18
|
+
.slice(0, 40);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function registerSkills(route, ctx) {
|
|
22
|
+
route('POST', '/api/skills/import', async ({ req, res }) => {
|
|
23
|
+
const body = await readJson(req);
|
|
24
|
+
const ref = parseSkillRef(body.ref ?? body.url ?? body.id);
|
|
25
|
+
if (!ref) {
|
|
26
|
+
return sendError(res, 400, 'Pegá una URL de skills.sh (https://skills.sh/owner/repo/skill) o un id owner/repo/skill.');
|
|
27
|
+
}
|
|
28
|
+
let remote;
|
|
29
|
+
try {
|
|
30
|
+
remote = await fetchSkillFromGithub(ref);
|
|
31
|
+
} catch (error) {
|
|
32
|
+
return sendError(res, 502, `No se pudo importar desde GitHub: ${error.message}`);
|
|
33
|
+
}
|
|
34
|
+
const id = sanitizeId(remote.slug || ref.slug || ref.repo);
|
|
35
|
+
if (!SKILL_ID_PATTERN.test(id)) return sendError(res, 502, 'No se pudo derivar un id de skill válido.');
|
|
36
|
+
try {
|
|
37
|
+
await writeSkillFiles(path.join(homeDir(), 'skills', id), remote.files);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
return sendError(res, 500, `No se pudo escribir la skill: ${error.message}`);
|
|
40
|
+
}
|
|
41
|
+
setSkillEnabled(ctx.store, id, true);
|
|
42
|
+
sendJson(res, 200, { installed: id, source: remote.source, skills: skillsView(ctx) });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
route('GET', '/api/skills', ({ res }) => {
|
|
46
|
+
sendJson(res, 200, { skills: skillsView(ctx) });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
route('GET', '/api/skills/:id', ({ res, params }) => {
|
|
50
|
+
const skill = findSkill(ctx, params.id);
|
|
51
|
+
if (!skill) return sendError(res, 404, 'Skill no encontrada.');
|
|
52
|
+
sendJson(res, 200, {
|
|
53
|
+
skill: {
|
|
54
|
+
id: skill.id,
|
|
55
|
+
name: skill.name || skill.id,
|
|
56
|
+
description: skill.description || '',
|
|
57
|
+
when: skill.when || '',
|
|
58
|
+
body: skill.body,
|
|
59
|
+
removable: Boolean(skill.home),
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
route('PUT', '/api/skills/:id', async ({ req, res, params }) => {
|
|
65
|
+
const id = String(params.id);
|
|
66
|
+
if (!SKILL_ID_PATTERN.test(id)) return sendError(res, 400, 'Id de skill inválido (a-z, 0-9, - y _).');
|
|
67
|
+
const body = await readJson(req);
|
|
68
|
+
if (!String(body.body ?? '').trim()) return sendError(res, 400, 'La skill necesita un cuerpo.');
|
|
69
|
+
await writeSkill(id, {
|
|
70
|
+
name: body.name,
|
|
71
|
+
description: body.description,
|
|
72
|
+
when: body.when,
|
|
73
|
+
body: body.body,
|
|
74
|
+
});
|
|
75
|
+
sendJson(res, 200, { skills: skillsView(ctx) });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
route('DELETE', '/api/skills/:id', async ({ res, params }) => {
|
|
79
|
+
const skill = findSkill(ctx, params.id);
|
|
80
|
+
if (!skill) return sendError(res, 404, 'Skill no encontrada.');
|
|
81
|
+
if (!skill.home) return sendError(res, 400, 'Esa skill vive en el workspace y no se borra desde acá.');
|
|
82
|
+
await removeSkill(skill.id);
|
|
83
|
+
setSkillEnabled(ctx.store, skill.id, true);
|
|
84
|
+
sendJson(res, 200, { skills: skillsView(ctx) });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
route('POST', '/api/skills/:id/toggle', async ({ res, params }) => {
|
|
88
|
+
const skill = findSkill(ctx, params.id);
|
|
89
|
+
if (!skill) return sendError(res, 404, 'Skill no encontrada.');
|
|
90
|
+
const enabled = !skillsView(ctx).find((s) => s.id === skill.id)?.enabled;
|
|
91
|
+
setSkillEnabled(ctx.store, skill.id, enabled);
|
|
92
|
+
sendJson(res, 200, { id: skill.id, enabled, skills: skillsView(ctx) });
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -16,7 +16,11 @@ import {
|
|
|
16
16
|
} from './lib/core.js';
|
|
17
17
|
import { sendJson, sendError } from './lib/http.js';
|
|
18
18
|
import { createPromptBuilder, DEFAULT_SYSTEM_PROMPT } from './lib/prompt.js';
|
|
19
|
+
import { skillsPrompt } from './lib/skills.js';
|
|
19
20
|
import { createApprovalBroker } from './lib/approvals.js';
|
|
21
|
+
import { createProcManager } from './lib/procs.js';
|
|
22
|
+
import { startTelegramBot } from './lib/telegram.js';
|
|
23
|
+
import { createAgentTools } from './lib/agentTools.js';
|
|
20
24
|
import { createStaticHandler } from './static.js';
|
|
21
25
|
import { requireLogin, requireCsrf } from './auth.js';
|
|
22
26
|
import { registerAuth } from './routes/auth.js';
|
|
@@ -26,6 +30,9 @@ import { registerFiles } from './routes/files.js';
|
|
|
26
30
|
import { registerModels } from './routes/models.js';
|
|
27
31
|
import { registerProviders } from './routes/providers.js';
|
|
28
32
|
import { registerMcp } from './routes/mcp.js';
|
|
33
|
+
import { registerSkills } from './routes/skills.js';
|
|
34
|
+
import { registerProcs } from './routes/procs.js';
|
|
35
|
+
import { registerPreview } from './routes/preview.js';
|
|
29
36
|
import { registerChat } from './routes/chat.js';
|
|
30
37
|
import { registerEval } from './routes/eval.js';
|
|
31
38
|
|
|
@@ -67,26 +74,60 @@ export async function createTamnoraServer({
|
|
|
67
74
|
resolveProvider,
|
|
68
75
|
yes = false,
|
|
69
76
|
loadMcp = true,
|
|
77
|
+
telegram = true,
|
|
70
78
|
dbPath,
|
|
71
79
|
} = {}) {
|
|
72
80
|
const config = await loadConfig(cwd);
|
|
73
81
|
const app = readApp();
|
|
74
82
|
const authEnabled = Boolean(app.user && app.user.password);
|
|
75
83
|
const store = new Store(dbPath ?? databasePath(cwd));
|
|
76
|
-
const systemPromptFor = createPromptBuilder({ cwd, files: config.instructions });
|
|
77
|
-
const systemPrompt = (await systemPromptFor(cwd)) || DEFAULT_SYSTEM_PROMPT;
|
|
78
84
|
const approvals = createApprovalBroker({ timeoutMs: config.approvalTimeoutMs });
|
|
85
|
+
const procs = createProcManager({ allow: config.processes?.allow });
|
|
86
|
+
const procsEnabled = config.processes?.enabled !== false;
|
|
79
87
|
const autoApproved = new Set();
|
|
80
88
|
const isAutoApprove = (sessionId) => autoApproved.has(sessionId);
|
|
81
89
|
const setAutoApprove = (sessionId, value) => (value ? autoApproved.add(sessionId) : autoApproved.delete(sessionId));
|
|
82
90
|
|
|
91
|
+
// MCP servers come from tamnora.json plus the ones saved from the console;
|
|
92
|
+
// the store wins on id collisions.
|
|
93
|
+
const mergedMcpServers = () => {
|
|
94
|
+
const fromConfig = Array.isArray(config.mcp) ? config.mcp : [];
|
|
95
|
+
const fromStore = store.listMcpServers().map((row) => ({
|
|
96
|
+
id: row.id,
|
|
97
|
+
name: row.name ?? row.id,
|
|
98
|
+
type: row.transport ?? 'stdio',
|
|
99
|
+
command: row.command ?? undefined,
|
|
100
|
+
args: Array.isArray(row.config?.args) ? row.config.args : [],
|
|
101
|
+
url: row.url ?? undefined,
|
|
102
|
+
env: row.config?.env ?? undefined,
|
|
103
|
+
enabled: row.enabled !== false,
|
|
104
|
+
source: 'store',
|
|
105
|
+
}));
|
|
106
|
+
const byId = new Map();
|
|
107
|
+
for (const server of fromConfig) if (server?.id) byId.set(server.id, { ...server, source: 'config' });
|
|
108
|
+
for (const server of fromStore) byId.set(server.id, server);
|
|
109
|
+
return [...byId.values()];
|
|
110
|
+
};
|
|
111
|
+
|
|
83
112
|
let mcpPromise = null;
|
|
84
113
|
const getMcp = async () => {
|
|
85
|
-
if (!loadMcp
|
|
86
|
-
|
|
114
|
+
if (!loadMcp) return null;
|
|
115
|
+
const servers = mergedMcpServers().filter((s) => s.enabled !== false);
|
|
116
|
+
if (!servers.length) return null;
|
|
117
|
+
if (!mcpPromise) mcpPromise = buildMcpRegistry(servers);
|
|
87
118
|
return mcpPromise;
|
|
88
119
|
};
|
|
89
120
|
|
|
121
|
+
const reloadMcp = async () => {
|
|
122
|
+
const registry = mcpPromise ? await mcpPromise.catch(() => null) : null;
|
|
123
|
+
try {
|
|
124
|
+
registry?.close?.();
|
|
125
|
+
} catch {
|
|
126
|
+
// ignore MCP shutdown errors
|
|
127
|
+
}
|
|
128
|
+
mcpPromise = null;
|
|
129
|
+
};
|
|
130
|
+
|
|
90
131
|
const customProviderDefs = () =>
|
|
91
132
|
store
|
|
92
133
|
.listProviders()
|
|
@@ -111,8 +152,6 @@ export async function createTamnoraServer({
|
|
|
111
152
|
app,
|
|
112
153
|
authEnabled,
|
|
113
154
|
store,
|
|
114
|
-
systemPrompt,
|
|
115
|
-
systemPromptFor,
|
|
116
155
|
approvals,
|
|
117
156
|
isAutoApprove,
|
|
118
157
|
setAutoApprove,
|
|
@@ -125,8 +164,35 @@ export async function createTamnoraServer({
|
|
|
125
164
|
apiKeyFor: (id) => resolveApiKey(providerSettings(id).apiKey, process.env),
|
|
126
165
|
resolveProvider: resolveProvider ?? ((settings) => resolveProviderRuntime(settings)),
|
|
127
166
|
getMcp,
|
|
167
|
+
reloadMcp,
|
|
168
|
+
mergedMcpServers,
|
|
169
|
+
procs,
|
|
170
|
+
procsEnabled,
|
|
128
171
|
};
|
|
129
172
|
|
|
173
|
+
ctx.systemPromptFor = createPromptBuilder({
|
|
174
|
+
cwd,
|
|
175
|
+
files: config.instructions,
|
|
176
|
+
skillsText: () => skillsPrompt(ctx),
|
|
177
|
+
});
|
|
178
|
+
ctx.systemPrompt = (await ctx.systemPromptFor(cwd)) || DEFAULT_SYSTEM_PROMPT;
|
|
179
|
+
ctx.agentTools = createAgentTools(ctx);
|
|
180
|
+
|
|
181
|
+
// Bot de Telegram (long polling) si hay token: config, app.json o entorno.
|
|
182
|
+
const tgConfig = { ...(app.telegram ?? {}), ...(config.telegram ?? {}) };
|
|
183
|
+
const tgToken = tgConfig.token || process.env.TAMNORA_TELEGRAM_TOKEN || '';
|
|
184
|
+
const rawAllow = tgConfig.allowedChatIds ?? process.env.TAMNORA_TELEGRAM_CHAT_IDS ?? [];
|
|
185
|
+
const tgAllow = Array.isArray(rawAllow) ? rawAllow : String(rawAllow).split(/[,\s]+/).filter(Boolean);
|
|
186
|
+
const telegramBot =
|
|
187
|
+
telegram && tgToken
|
|
188
|
+
? startTelegramBot(ctx, {
|
|
189
|
+
token: tgToken,
|
|
190
|
+
allowedChatIds: tgAllow,
|
|
191
|
+
log: (message) => process.stderr.write(`${message}\n`),
|
|
192
|
+
})
|
|
193
|
+
: null;
|
|
194
|
+
ctx.telegram = Boolean(telegramBot);
|
|
195
|
+
|
|
130
196
|
const routes = [];
|
|
131
197
|
const route = (method, pattern, handler) =>
|
|
132
198
|
routes.push({ method, segments: pattern.split('/').filter(Boolean), handler });
|
|
@@ -139,6 +205,7 @@ export async function createTamnoraServer({
|
|
|
139
205
|
home: os.homedir(),
|
|
140
206
|
tamnoraHome: homeDir(),
|
|
141
207
|
version: VERSION,
|
|
208
|
+
telegram: ctx.telegram,
|
|
142
209
|
});
|
|
143
210
|
});
|
|
144
211
|
|
|
@@ -149,6 +216,9 @@ export async function createTamnoraServer({
|
|
|
149
216
|
registerModels(route, ctx);
|
|
150
217
|
registerProviders(route, ctx);
|
|
151
218
|
registerMcp(route, ctx);
|
|
219
|
+
registerSkills(route, ctx);
|
|
220
|
+
registerProcs(route, ctx);
|
|
221
|
+
const preview = registerPreview(route, ctx);
|
|
152
222
|
registerChat(route, ctx);
|
|
153
223
|
registerEval(route, ctx);
|
|
154
224
|
|
|
@@ -231,6 +301,9 @@ export async function createTamnoraServer({
|
|
|
231
301
|
|
|
232
302
|
const close = async () => {
|
|
233
303
|
approvals.cancelAll();
|
|
304
|
+
telegramBot?.stop();
|
|
305
|
+
preview.close();
|
|
306
|
+
procs.close();
|
|
234
307
|
try {
|
|
235
308
|
const registry = mcpPromise ? await mcpPromise : null;
|
|
236
309
|
registry?.close?.();
|
|
@@ -78,6 +78,44 @@ test('creates, lists, reads and deletes sessions', async () => {
|
|
|
78
78
|
}
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
+
test('lists and deletes sessions grouped by project (cwd)', async () => {
|
|
82
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-proj-'));
|
|
83
|
+
const dirA = path.join(cwd, 'a');
|
|
84
|
+
const dirB = path.join(cwd, 'b');
|
|
85
|
+
await mkdir(dirA);
|
|
86
|
+
await mkdir(dirB);
|
|
87
|
+
const { instance, base } = await startServer({ cwd });
|
|
88
|
+
const create = (body) =>
|
|
89
|
+
fetch(`${base}/api/sessions`, {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: { 'Content-Type': 'application/json' },
|
|
92
|
+
body: JSON.stringify(body),
|
|
93
|
+
});
|
|
94
|
+
try {
|
|
95
|
+
await create({ title: 'a1', model: 'm', cwd: dirA });
|
|
96
|
+
await create({ title: 'a2', model: 'm', cwd: dirA });
|
|
97
|
+
await create({ title: 'b1', model: 'm', cwd: dirB });
|
|
98
|
+
|
|
99
|
+
const projects = await (await fetch(`${base}/api/projects`)).json();
|
|
100
|
+
assert.equal(projects.projects.find((p) => p.cwd === dirA).sessions, 2);
|
|
101
|
+
assert.equal(projects.projects.find((p) => p.cwd === dirB).sessions, 1);
|
|
102
|
+
|
|
103
|
+
const removed = await (
|
|
104
|
+
await fetch(`${base}/api/projects?cwd=${encodeURIComponent(dirA)}`, { method: 'DELETE' })
|
|
105
|
+
).json();
|
|
106
|
+
assert.equal(removed.sessions, 2);
|
|
107
|
+
|
|
108
|
+
const list = await (await fetch(`${base}/api/sessions`)).json();
|
|
109
|
+
assert.equal(list.sessions.length, 1);
|
|
110
|
+
assert.equal(list.sessions[0].cwd, dirB);
|
|
111
|
+
|
|
112
|
+
const missing = await fetch(`${base}/api/projects`, { method: 'DELETE' });
|
|
113
|
+
assert.equal(missing.status, 400);
|
|
114
|
+
} finally {
|
|
115
|
+
await instance.close();
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
81
119
|
test('streams a chat turn over SSE and persists the messages', async () => {
|
|
82
120
|
const { instance, base } = await startServer();
|
|
83
121
|
try {
|
|
@@ -103,6 +141,235 @@ test('streams a chat turn over SSE and persists the messages', async () => {
|
|
|
103
141
|
}
|
|
104
142
|
});
|
|
105
143
|
|
|
144
|
+
test('manages MCP servers from the console', async () => {
|
|
145
|
+
const { instance, base } = await startServer();
|
|
146
|
+
const json = { 'Content-Type': 'application/json' };
|
|
147
|
+
try {
|
|
148
|
+
let data = await (await fetch(`${base}/api/mcp`)).json();
|
|
149
|
+
assert.deepEqual(data.servers, []);
|
|
150
|
+
|
|
151
|
+
let res = await fetch(`${base}/api/mcp/servers/echo`, {
|
|
152
|
+
method: 'PUT',
|
|
153
|
+
headers: json,
|
|
154
|
+
body: JSON.stringify({ name: 'Echo', transport: 'stdio', command: 'node', args: ['a.mjs'], enabled: true }),
|
|
155
|
+
});
|
|
156
|
+
assert.equal(res.status, 200);
|
|
157
|
+
data = await res.json();
|
|
158
|
+
const echo = data.servers.find((s) => s.id === 'echo');
|
|
159
|
+
assert.equal(echo.source, 'store');
|
|
160
|
+
assert.deepEqual(echo.args, ['a.mjs']);
|
|
161
|
+
|
|
162
|
+
const bad = await fetch(`${base}/api/mcp/servers/bad`, {
|
|
163
|
+
method: 'PUT',
|
|
164
|
+
headers: json,
|
|
165
|
+
body: JSON.stringify({ transport: 'http' }),
|
|
166
|
+
});
|
|
167
|
+
assert.equal(bad.status, 400);
|
|
168
|
+
|
|
169
|
+
res = await fetch(`${base}/api/mcp/servers/echo`, { method: 'DELETE' });
|
|
170
|
+
assert.equal(res.status, 200);
|
|
171
|
+
data = await res.json();
|
|
172
|
+
assert.equal(data.servers.length, 0);
|
|
173
|
+
} finally {
|
|
174
|
+
await instance.close();
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('imports an MCP server from a JSON snippet', async () => {
|
|
179
|
+
const { instance, base } = await startServer();
|
|
180
|
+
const json = { 'Content-Type': 'application/json' };
|
|
181
|
+
try {
|
|
182
|
+
const res = await fetch(`${base}/api/mcp/import`, {
|
|
183
|
+
method: 'POST',
|
|
184
|
+
headers: json,
|
|
185
|
+
body: JSON.stringify({ json: '{"mcpServers":{"demo":{"command":"npx","args":["-y","pkg"]}}}' }),
|
|
186
|
+
});
|
|
187
|
+
assert.equal(res.status, 200);
|
|
188
|
+
const data = await res.json();
|
|
189
|
+
assert.equal(data.imported, 'demo');
|
|
190
|
+
assert.ok(data.servers.some((s) => s.id === 'demo' && s.transport === 'stdio'));
|
|
191
|
+
} finally {
|
|
192
|
+
await instance.close();
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('rejects unknown import refs (skills and mcpservers.org)', async () => {
|
|
197
|
+
const { instance, base } = await startServer();
|
|
198
|
+
const json = { 'Content-Type': 'application/json' };
|
|
199
|
+
try {
|
|
200
|
+
const badSkill = await fetch(`${base}/api/skills/import`, {
|
|
201
|
+
method: 'POST',
|
|
202
|
+
headers: json,
|
|
203
|
+
body: JSON.stringify({ ref: 'nope' }),
|
|
204
|
+
});
|
|
205
|
+
assert.equal(badSkill.status, 400);
|
|
206
|
+
|
|
207
|
+
const blocked = await fetch(`${base}/api/mcp/import`, {
|
|
208
|
+
method: 'POST',
|
|
209
|
+
headers: json,
|
|
210
|
+
body: JSON.stringify({ url: 'https://mcpservers.org/es/official' }),
|
|
211
|
+
});
|
|
212
|
+
assert.equal(blocked.status, 400);
|
|
213
|
+
} finally {
|
|
214
|
+
await instance.close();
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('manages skills from the console', async () => {
|
|
219
|
+
const { instance, base } = await startServer();
|
|
220
|
+
const json = { 'Content-Type': 'application/json' };
|
|
221
|
+
const put = (id, body) => fetch(`${base}/api/skills/${id}`, { method: 'PUT', headers: json, body: JSON.stringify(body) });
|
|
222
|
+
try {
|
|
223
|
+
let res = await put('demo', { name: 'Demo', description: 'para pruebas', body: 'Hacé X.' });
|
|
224
|
+
assert.equal(res.status, 200);
|
|
225
|
+
let data = await res.json();
|
|
226
|
+
const demo = data.skills.find((s) => s.id === 'demo');
|
|
227
|
+
assert.equal(demo.enabled, true);
|
|
228
|
+
assert.equal(demo.removable, true);
|
|
229
|
+
|
|
230
|
+
const detail = await (await fetch(`${base}/api/skills/demo`)).json();
|
|
231
|
+
assert.match(detail.skill.body, /Hacé X/);
|
|
232
|
+
|
|
233
|
+
await fetch(`${base}/api/skills/demo/toggle`, { method: 'POST' });
|
|
234
|
+
data = await (await fetch(`${base}/api/skills`)).json();
|
|
235
|
+
assert.equal(data.skills.find((s) => s.id === 'demo').enabled, false);
|
|
236
|
+
|
|
237
|
+
res = await fetch(`${base}/api/skills/demo`, { method: 'DELETE' });
|
|
238
|
+
assert.equal(res.status, 200);
|
|
239
|
+
data = await (await fetch(`${base}/api/skills`)).json();
|
|
240
|
+
assert.equal(data.skills.length, 0);
|
|
241
|
+
|
|
242
|
+
res = await put('vacia', { body: ' ' });
|
|
243
|
+
assert.equal(res.status, 400);
|
|
244
|
+
} finally {
|
|
245
|
+
await instance.close();
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('injects only enabled skills into the system prompt', async () => {
|
|
250
|
+
let captured = '';
|
|
251
|
+
const captureProvider = {
|
|
252
|
+
id: 'capture',
|
|
253
|
+
async streamChat(_config, { messages }) {
|
|
254
|
+
captured = messages?.[0]?.content ?? '';
|
|
255
|
+
return { content: 'ok', reasoning: '', toolCalls: [] };
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
const { instance, base } = await startServer({ resolveProvider: () => ({ provider: captureProvider, config: {} }) });
|
|
259
|
+
const json = { 'Content-Type': 'application/json' };
|
|
260
|
+
const chat = (sessionId) =>
|
|
261
|
+
fetch(`${base}/api/chat`, { method: 'POST', headers: json, body: JSON.stringify({ sessionId, model: 'm', input: 'hola' }) });
|
|
262
|
+
const newSession = async () => {
|
|
263
|
+
const created = await (
|
|
264
|
+
await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 't', model: 'm' }) })
|
|
265
|
+
).json();
|
|
266
|
+
return created.session.id;
|
|
267
|
+
};
|
|
268
|
+
try {
|
|
269
|
+
await fetch(`${base}/api/skills/demo`, {
|
|
270
|
+
method: 'PUT',
|
|
271
|
+
headers: json,
|
|
272
|
+
body: JSON.stringify({ name: 'Demo', body: 'Regla secreta de la skill.' }),
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
await (await chat(await newSession())).text();
|
|
276
|
+
assert.match(captured, /Regla secreta de la skill/);
|
|
277
|
+
|
|
278
|
+
await fetch(`${base}/api/skills/demo/toggle`, { method: 'POST' });
|
|
279
|
+
await (await chat(await newSession())).text();
|
|
280
|
+
assert.doesNotMatch(captured, /Regla secreta de la skill/);
|
|
281
|
+
} finally {
|
|
282
|
+
await instance.close();
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('detects, runs and stops dev processes', async () => {
|
|
287
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-proc-'));
|
|
288
|
+
await writeFile(path.join(cwd, 'package.json'), JSON.stringify({ scripts: { dev: 'node server.js' } }));
|
|
289
|
+
const { instance, base } = await startServer({ cwd });
|
|
290
|
+
const json = { 'Content-Type': 'application/json' };
|
|
291
|
+
try {
|
|
292
|
+
const detected = await (await fetch(`${base}/api/procs/detect?cwd=${encodeURIComponent(cwd)}`)).json();
|
|
293
|
+
assert.ok(detected.chips.some((c) => c.command === 'npm' && c.args.includes('dev')));
|
|
294
|
+
|
|
295
|
+
const blocked = await fetch(`${base}/api/procs/start`, {
|
|
296
|
+
method: 'POST',
|
|
297
|
+
headers: json,
|
|
298
|
+
body: JSON.stringify({ cwd, command: 'curl' }),
|
|
299
|
+
});
|
|
300
|
+
assert.equal(blocked.status, 400);
|
|
301
|
+
|
|
302
|
+
const started = await (
|
|
303
|
+
await fetch(`${base}/api/procs/start`, {
|
|
304
|
+
method: 'POST',
|
|
305
|
+
headers: json,
|
|
306
|
+
body: JSON.stringify({ cwd, command: 'node', args: ['-e', 'console.log(42)'] }),
|
|
307
|
+
})
|
|
308
|
+
).json();
|
|
309
|
+
assert.ok(started.proc.id);
|
|
310
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
311
|
+
|
|
312
|
+
const logs = await (await fetch(`${base}/api/procs/${started.proc.id}/logs`)).json();
|
|
313
|
+
assert.match(logs.logs.map((l) => l.text).join(''), /42/);
|
|
314
|
+
|
|
315
|
+
const stopped = await fetch(`${base}/api/procs/${started.proc.id}/stop`, { method: 'POST' });
|
|
316
|
+
assert.equal(stopped.status, 200);
|
|
317
|
+
|
|
318
|
+
const badTunnel = await fetch(`${base}/api/preview/tunnel`, {
|
|
319
|
+
method: 'POST',
|
|
320
|
+
headers: json,
|
|
321
|
+
body: JSON.stringify({ port: 0 }),
|
|
322
|
+
});
|
|
323
|
+
assert.equal(badTunnel.status, 400);
|
|
324
|
+
} finally {
|
|
325
|
+
await instance.close();
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test('the agent can install a skill from chat (with approval)', async () => {
|
|
330
|
+
const calls = { n: 0 };
|
|
331
|
+
const toolProvider = {
|
|
332
|
+
id: 'tooly',
|
|
333
|
+
async streamChat() {
|
|
334
|
+
calls.n += 1;
|
|
335
|
+
if (calls.n === 1) {
|
|
336
|
+
return {
|
|
337
|
+
content: '',
|
|
338
|
+
reasoning: '',
|
|
339
|
+
toolCalls: [
|
|
340
|
+
{
|
|
341
|
+
id: 't1',
|
|
342
|
+
function: {
|
|
343
|
+
name: 'install_skill',
|
|
344
|
+
arguments: JSON.stringify({ id: 'demo-skill', content: '---\nname: Demo\n---\nCuerpo de prueba.' }),
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
],
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
return { content: 'listo', reasoning: '', toolCalls: [] };
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
const { instance, base } = await startServer({ resolveProvider: () => ({ provider: toolProvider, config: {} }) });
|
|
354
|
+
const json = { 'Content-Type': 'application/json' };
|
|
355
|
+
try {
|
|
356
|
+
const created = await (
|
|
357
|
+
await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 't', model: 'm' }) })
|
|
358
|
+
).json();
|
|
359
|
+
const res = await fetch(`${base}/api/chat`, {
|
|
360
|
+
method: 'POST',
|
|
361
|
+
headers: json,
|
|
362
|
+
body: JSON.stringify({ sessionId: created.session.id, model: 'm', input: 'instalá una skill', yes: true }),
|
|
363
|
+
});
|
|
364
|
+
await res.text();
|
|
365
|
+
|
|
366
|
+
const skills = await (await fetch(`${base}/api/skills`)).json();
|
|
367
|
+
assert.ok(skills.skills.some((s) => s.id === 'demo-skill'));
|
|
368
|
+
} finally {
|
|
369
|
+
await instance.close();
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
|
|
106
373
|
test('rejects chat without input or model', async () => {
|
|
107
374
|
const { instance, base } = await startServer();
|
|
108
375
|
try {
|
|
@@ -605,7 +872,7 @@ test('plan mode exposes only read-only tools and adds guidance', async () => {
|
|
|
605
872
|
body: JSON.stringify({ input: 'planificá', model: 'm', mode: 'plan' }),
|
|
606
873
|
})
|
|
607
874
|
).text();
|
|
608
|
-
assert.deepEqual(captured.tools, ['git_diff', 'git_log', 'git_status', 'glob', 'grep', 'list', 'read']);
|
|
875
|
+
assert.deepEqual(captured.tools, ['fetch_url', 'git_diff', 'git_log', 'git_status', 'glob', 'grep', 'list', 'read']);
|
|
609
876
|
assert.match(captured.system, /modo PLAN/);
|
|
610
877
|
} finally {
|
|
611
878
|
await instance.close();
|
|
@@ -14,5 +14,11 @@
|
|
|
14
14
|
"args": ["harness/scripts/mcp-echo-server.mjs"],
|
|
15
15
|
"enabled": true
|
|
16
16
|
}
|
|
17
|
-
]
|
|
17
|
+
],
|
|
18
|
+
"processes": {
|
|
19
|
+
"enabled": true,
|
|
20
|
+
"allow": ["npm", "node", "npx", "pnpm", "yarn", "bun", "php", "python", "composer"]
|
|
21
|
+
},
|
|
22
|
+
"preview": { "provider": "tunnelmole" },
|
|
23
|
+
"telegram": { "token": "", "allowedChatIds": [] }
|
|
18
24
|
}
|
package/package.json
CHANGED