@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.
Files changed (58) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/harness/packages/cli/src/cli.js +2 -0
  3. package/harness/packages/cli/src/commands/eval.js +3 -0
  4. package/harness/packages/cli/src/commands/repl.js +2 -0
  5. package/harness/packages/cli/src/commands/run.js +2 -1
  6. package/harness/packages/cli/src/commands/tui.js +18 -11
  7. package/harness/packages/cli/src/lib/runtime.js +3 -1
  8. package/harness/packages/cli/src/lib/tunnel.js +2 -113
  9. package/harness/packages/cli/src/lib/util.js +2 -0
  10. package/harness/packages/cli/src/tui/app.js +45 -4
  11. package/harness/packages/cli/src/tui/keys.js +3 -0
  12. package/harness/packages/cli/test/tui.test.js +5 -0
  13. package/harness/packages/core/src/agent/modes.js +2 -2
  14. package/harness/packages/core/src/config/index.js +3 -0
  15. package/harness/packages/core/src/console.js +18 -0
  16. package/harness/packages/core/src/eval/judge.js +3 -3
  17. package/harness/packages/core/src/eval/runner.js +3 -0
  18. package/harness/packages/core/src/index.js +11 -0
  19. package/harness/packages/core/src/mcp/registry.js +117 -0
  20. package/harness/packages/core/src/procs/detect.js +69 -0
  21. package/harness/packages/core/src/providers/ollama.js +3 -3
  22. package/harness/packages/core/src/providers/openaiCompatible.js +3 -3
  23. package/harness/packages/core/src/providers/openrouter.js +2 -2
  24. package/harness/packages/core/src/providers/resilience.js +1 -1
  25. package/harness/packages/core/src/providers/utils.js +43 -0
  26. package/harness/packages/core/src/skills/index.js +90 -0
  27. package/harness/packages/core/src/skills/remote.js +80 -0
  28. package/harness/packages/core/src/storage/sqlite.js +21 -0
  29. package/harness/packages/core/src/tools/executors.js +55 -0
  30. package/harness/packages/core/src/tools/permissions.js +2 -0
  31. package/harness/packages/core/src/tools/schema.js +16 -0
  32. package/harness/packages/core/src/tunnel.js +113 -0
  33. package/harness/packages/core/test/config.test.js +15 -0
  34. package/harness/packages/core/test/mcp-registry.test.js +49 -0
  35. package/harness/packages/core/test/procs.test.js +34 -0
  36. package/harness/packages/core/test/providers.test.js +20 -0
  37. package/harness/packages/core/test/resilience.test.js +1 -0
  38. package/harness/packages/core/test/skills.test.js +69 -0
  39. package/harness/packages/core/test/tools.test.js +10 -0
  40. package/harness/packages/server/public/app.js +484 -6
  41. package/harness/packages/server/public/index.html +134 -6
  42. package/harness/packages/server/public/styles.css +45 -1
  43. package/harness/packages/server/src/cli.js +2 -0
  44. package/harness/packages/server/src/lib/agentTools.js +238 -0
  45. package/harness/packages/server/src/lib/procs.js +125 -0
  46. package/harness/packages/server/src/lib/prompt.js +6 -3
  47. package/harness/packages/server/src/lib/skills.js +69 -0
  48. package/harness/packages/server/src/lib/telegram.js +267 -0
  49. package/harness/packages/server/src/routes/chat.js +9 -1
  50. package/harness/packages/server/src/routes/mcp.js +128 -9
  51. package/harness/packages/server/src/routes/preview.js +58 -0
  52. package/harness/packages/server/src/routes/procs.js +47 -0
  53. package/harness/packages/server/src/routes/sessions.js +10 -0
  54. package/harness/packages/server/src/routes/skills.js +94 -0
  55. package/harness/packages/server/src/server.js +79 -6
  56. package/harness/packages/server/test/server.test.js +268 -1
  57. package/harness/tamnora.example.json +7 -1
  58. package/package.json +1 -1
@@ -37,6 +37,7 @@
37
37
  <nav class="tabs">
38
38
  <button class="tab active" data-tab="chat">chat</button>
39
39
  <button class="tab" data-tab="providers">proveedores</button>
40
+ <button class="tab" data-tab="ajustes">ajustes</button>
40
41
  <div class="menu">
41
42
  <button id="more-tab" class="tab" title="más" aria-haspopup="true" aria-expanded="false" aria-label="más">
42
43
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
@@ -150,7 +151,11 @@
150
151
 
151
152
  <aside id="explorer" class="explorer" hidden>
152
153
  <div class="explorer-head">
153
- <span class="explorer-title">estructura</span>
154
+ <div class="xtabs">
155
+ <button type="button" class="xtab is-active" data-xp="files">estructura</button>
156
+ <button type="button" class="xtab" data-xp="preview">preview</button>
157
+ <button type="button" class="xtab" data-xp="procs">procs</button>
158
+ </div>
154
159
  <span class="spacer"></span>
155
160
  <button id="explorer-refresh" type="button" class="btn icon" title="refrescar" aria-label="refrescar">
156
161
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
@@ -166,12 +171,35 @@
166
171
  </svg>
167
172
  </button>
168
173
  </div>
169
- <div id="explorer-root" class="explorer-cwd" title=""></div>
170
- <div class="explorer-filter">
171
- <input id="explorer-filter" placeholder="filtrar por nombre…" spellcheck="false" />
174
+
175
+ <div id="xp-files" class="xp-pane">
176
+ <div id="explorer-root" class="explorer-cwd" title=""></div>
177
+ <div class="explorer-filter">
178
+ <input id="explorer-filter" placeholder="filtrar por nombre…" spellcheck="false" />
179
+ </div>
180
+ <ul id="explorer-tree" class="tree"></ul>
181
+ <div id="explorer-status" class="explorer-status"></div>
182
+ </div>
183
+
184
+ <div id="xp-preview" class="xp-pane" hidden>
185
+ <div class="xp-bar">
186
+ <input id="preview-url" placeholder="http://127.0.0.1:5173" spellcheck="false" />
187
+ <button id="preview-open" class="btn" type="button">abrir</button>
188
+ <button id="preview-tunnel" class="btn" type="button" title="Abrir un túnel público al puerto (para el celular)">túnel</button>
189
+ </div>
190
+ <div id="preview-status" class="explorer-status"></div>
191
+ <iframe id="preview-frame" class="preview-frame" title="vista previa" referrerpolicy="no-referrer"></iframe>
192
+ </div>
193
+
194
+ <div id="xp-procs" class="xp-pane" hidden>
195
+ <div class="xp-bar">
196
+ <button id="procs-detect" class="btn" type="button">detectar</button>
197
+ <span id="procs-cwd" class="muted grow"></span>
198
+ </div>
199
+ <div id="procs-chips" class="chips"></div>
200
+ <ul id="procs-list" class="providers"></ul>
201
+ <pre id="procs-log" class="procs-log"></pre>
172
202
  </div>
173
- <ul id="explorer-tree" class="tree"></ul>
174
- <div id="explorer-status" class="explorer-status"></div>
175
203
  </aside>
176
204
 
177
205
  <dialog id="file-modal" class="modal file-modal">
@@ -266,6 +294,43 @@
266
294
  </div>
267
295
  </section>
268
296
 
297
+ <section id="view-ajustes" class="view">
298
+ <div class="panel">
299
+ <div class="row between">
300
+ <h2>Servidores MCP</h2>
301
+ <div class="row">
302
+ <button id="mcp-reload" class="btn">recargar</button>
303
+ <button id="mcp-new" class="btn">+ nuevo</button>
304
+ </div>
305
+ </div>
306
+ <p class="muted verify-line">
307
+ Se combinan con <code>mcp[]</code> de <code>tamnora.json</code>; los guardados acá viven en la base.
308
+ </p>
309
+ <div class="import-bar">
310
+ <input id="mcp-import" placeholder="nombre del registro oficial, URL o JSON (bloque mcpServers)" spellcheck="false" />
311
+ <button id="mcp-import-btn" class="btn" type="button">importar</button>
312
+ </div>
313
+ <div id="mcp-import-status" class="summary"></div>
314
+ <ul id="mcp-list" class="providers"></ul>
315
+ </div>
316
+
317
+ <div class="panel">
318
+ <div class="row between">
319
+ <h2>Skills</h2>
320
+ <button id="skill-new" class="btn">+ nueva</button>
321
+ </div>
322
+ <p class="muted verify-line">
323
+ Instrucciones especializadas en la casa de Tamnora (<code>skills/</code>). Las habilitadas se inyectan al system prompt.
324
+ </p>
325
+ <div class="import-bar">
326
+ <input id="skill-import" placeholder="https://skills.sh/owner/repo/skill" spellcheck="false" />
327
+ <button id="skill-import-btn" class="btn" type="button">importar</button>
328
+ </div>
329
+ <div id="skill-import-status" class="summary"></div>
330
+ <ul id="skill-list" class="providers"></ul>
331
+ </div>
332
+ </section>
333
+
269
334
  <dialog id="folder-modal" class="modal folder-modal">
270
335
  <div class="modal-card">
271
336
  <div class="row between">
@@ -349,6 +414,69 @@
349
414
  </div>
350
415
  </dialog>
351
416
 
417
+ <dialog id="mcp-modal" class="modal">
418
+ <form id="mcp-form" class="modal-card">
419
+ <div class="row between">
420
+ <h3 id="mcp-title">nuevo servidor MCP</h3>
421
+ <button type="button" id="mcp-close" class="btn icon" title="cerrar" aria-label="cerrar">
422
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
423
+ <path d="M18 6 6 18M6 6l12 12" />
424
+ </svg>
425
+ </button>
426
+ </div>
427
+ <div class="row">
428
+ <label class="field"><span>id</span><input id="mf-id" spellcheck="false" placeholder="echo" /></label>
429
+ <label class="field grow"><span>nombre</span><input id="mf-name" spellcheck="false" placeholder="Echo" /></label>
430
+ </div>
431
+ <div class="row">
432
+ <label class="field"><span>transporte</span>
433
+ <select id="mf-transport">
434
+ <option value="stdio">stdio</option>
435
+ <option value="http">http</option>
436
+ </select>
437
+ </label>
438
+ <label class="field grow" id="mf-command-field"><span>comando</span><input id="mf-command" spellcheck="false" placeholder="node" /></label>
439
+ <label class="field grow" id="mf-url-field" hidden><span>url</span><input id="mf-url" spellcheck="false" placeholder="https://…/mcp" /></label>
440
+ </div>
441
+ <div class="row">
442
+ <label class="field grow" id="mf-args-field"><span>args (separados por espacio)</span><input id="mf-args" spellcheck="false" placeholder="harness/scripts/mcp-echo-server.mjs" /></label>
443
+ </div>
444
+ <label class="check"><input id="mf-enabled" type="checkbox" checked /> habilitado</label>
445
+ <div class="row between settings-actions">
446
+ <button type="button" id="mf-cancel" class="btn">cancelar</button>
447
+ <button type="submit" class="btn primary">guardar</button>
448
+ </div>
449
+ <div id="mf-status" class="summary"></div>
450
+ </form>
451
+ </dialog>
452
+
453
+ <dialog id="skill-modal" class="modal">
454
+ <form id="skill-form" class="modal-card">
455
+ <div class="row between">
456
+ <h3 id="skill-title">nueva skill</h3>
457
+ <button type="button" id="skill-close" class="btn icon" title="cerrar" aria-label="cerrar">
458
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
459
+ <path d="M18 6 6 18M6 6l12 12" />
460
+ </svg>
461
+ </button>
462
+ </div>
463
+ <div class="row">
464
+ <label class="field"><span>id</span><input id="sf-id" spellcheck="false" placeholder="revisar-pr" /></label>
465
+ <label class="field grow"><span>nombre</span><input id="sf-name" spellcheck="false" placeholder="Revisar PR" /></label>
466
+ </div>
467
+ <label class="field"><span>descripción</span><input id="sf-description" spellcheck="false" placeholder="Cuándo conviene usarla" /></label>
468
+ <label class="field"><span>cuándo usarla (opcional)</span><input id="sf-when" spellcheck="false" placeholder="al revisar un pull request" /></label>
469
+ <label class="field"><span>cuerpo (markdown)</span>
470
+ <textarea id="sf-body" rows="10" spellcheck="false" placeholder="Pasos e instrucciones de la skill…"></textarea>
471
+ </label>
472
+ <div class="row between settings-actions">
473
+ <button type="button" id="sf-cancel" class="btn">cancelar</button>
474
+ <button type="submit" class="btn primary">guardar</button>
475
+ </div>
476
+ <div id="sf-status" class="summary"></div>
477
+ </form>
478
+ </dialog>
479
+
352
480
  <dialog id="palette-modal" class="modal palette-modal">
353
481
  <div class="palette">
354
482
  <div class="palette-row">
@@ -234,18 +234,45 @@ main { flex: 1; min-height: 0; min-width: 0; display: flex; }
234
234
  .group-head .gcount { font: 500 10px var(--mono); opacity: 0.65; }
235
235
  .group-sessions { list-style: none; margin: 0; padding: 3px 0 0 15px; display: flex; flex-direction: column; gap: 2px; }
236
236
  .session-item {
237
+ display: flex;
238
+ align-items: center;
239
+ gap: 6px;
237
240
  padding: 6px 9px;
238
241
  border-radius: 6px;
239
242
  cursor: pointer;
240
243
  color: var(--muted);
241
244
  white-space: nowrap;
242
245
  overflow: hidden;
243
- text-overflow: ellipsis;
244
246
  border: 1px solid transparent;
245
247
  }
246
248
  .session-item:hover { background: var(--panel); color: var(--text); }
247
249
  .session-item.active { background: var(--theirs); color: var(--text); border-color: var(--border); }
248
250
  .session-item .sub { color: var(--muted); font-size: 11px; overflow: hidden; text-overflow: ellipsis; }
251
+ .session-item .session-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
252
+ .session-del,
253
+ .group-del {
254
+ flex: 0 0 auto;
255
+ display: grid;
256
+ place-items: center;
257
+ padding: 2px;
258
+ border: 0;
259
+ border-radius: 5px;
260
+ background: none;
261
+ color: var(--muted);
262
+ cursor: pointer;
263
+ opacity: 0;
264
+ transition: opacity 0.12s ease, color 0.12s ease, background 0.12s ease;
265
+ }
266
+ .session-item:hover .session-del,
267
+ .session-item:focus-within .session-del,
268
+ .group-head:hover .group-del,
269
+ .group-head:focus-within .group-del { opacity: 1; }
270
+ .session-del:hover,
271
+ .group-del:hover { color: var(--danger); background: var(--panel); }
272
+ @media (hover: none) {
273
+ .session-del,
274
+ .group-del { opacity: 0.7; }
275
+ }
249
276
  .rail-footer { border-top: 1px solid var(--border); padding-top: 8px; color: var(--muted); font-size: 12px; }
250
277
  .rail-footer b { color: var(--text); display: block; word-break: break-all; font-weight: 500; }
251
278
  .rail-exit {
@@ -717,6 +744,23 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 12.5px; }
717
744
  .explorer-status { padding: 6px 12px; border-top: 1px solid var(--border); color: var(--muted); font: 500 11px var(--mono); min-height: 27px; }
718
745
  .explorer-status.err { color: var(--danger); }
719
746
 
747
+ .xtabs { display: flex; gap: 4px; }
748
+ .xtab { border: 0; background: none; color: var(--muted); cursor: pointer; font: 600 12px var(--sans); padding: 4px 7px; border-radius: 7px; }
749
+ .xtab:hover { background: var(--theirs); color: var(--text); }
750
+ .xtab.is-active { background: var(--theirs); color: var(--text); }
751
+ .xp-pane { display: flex; flex-direction: column; flex: 1; min-height: 0; }
752
+ .xp-bar { display: flex; gap: 6px; align-items: center; padding: 9px 12px; }
753
+ .xp-bar input { flex: 1; min-width: 0; }
754
+ .xp-bar .grow { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
755
+ .chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 12px 8px; }
756
+ .xchip { border: 1px solid var(--border); background: var(--panel); color: var(--text); border-radius: 999px; padding: 4px 10px; font: 500 11px var(--mono); cursor: pointer; }
757
+ .xchip:hover { border-color: var(--accent); color: var(--accent); }
758
+ .preview-frame { flex: 1; width: 100%; border: 0; background: #fff; min-height: 0; }
759
+ #procs-list { flex: 1; min-height: 0; overflow-y: auto; }
760
+ .procs-log { margin: 0; padding: 8px 12px; height: 150px; overflow: auto; background: var(--theirs); border-top: 1px solid var(--border); font: 500 11px var(--mono); white-space: pre-wrap; word-break: break-word; }
761
+ .import-bar { display: flex; gap: 6px; margin: 4px 0 10px; }
762
+ .import-bar input { flex: 1; min-width: 0; }
763
+
720
764
  .file-actions { display: flex; align-items: center; gap: 10px; }
721
765
  .file-modal { width: min(880px, calc(100vw - 40px)); }
722
766
  .file-preview {
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { parseArgs } from 'node:util';
3
+ import { ensureUtf8Console } from './lib/core.js';
3
4
  import { createTamnoraServer } from './server.js';
4
5
 
5
6
  const OPTIONS = {
@@ -13,6 +14,7 @@ const OPTIONS = {
13
14
 
14
15
  /** Entry point shared by `tamnora serve` and the standalone server bin. */
15
16
  export async function serveCommand(argv) {
17
+ ensureUtf8Console();
16
18
  const { values } = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true, strict: false });
17
19
  if (values.help) {
18
20
  printHelp();
@@ -0,0 +1,238 @@
1
+ // Tools de administracion que el agente puede usar desde el chat: registrar
2
+ // MCP, instalar skills (contenido o URL) y listarlos. Pasarlas por permisos
3
+ // porque instalar MCP/skills puede ejecutar codigo.
4
+
5
+ import path from 'node:path';
6
+ import {
7
+ parseSkillMarkdown,
8
+ parseSkillRef,
9
+ fetchSkillFromGithub,
10
+ writeSkillFiles,
11
+ getMcpRegistryServer,
12
+ registryServerToConfig,
13
+ normalizeMcpConfig,
14
+ homeDir,
15
+ } from './core.js';
16
+ import { SKILL_ID_PATTERN, skillsView, writeSkill, setSkillEnabled } from './skills.js';
17
+
18
+ function sanitizeId(value) {
19
+ return String(value ?? '')
20
+ .toLowerCase()
21
+ .replace(/[^a-z0-9_-]+/g, '-')
22
+ .replace(/^-+|-+$/g, '')
23
+ .slice(0, 40);
24
+ }
25
+
26
+ const MCP_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,39}$/i;
27
+
28
+ function definition(name, description, parameters, required = []) {
29
+ return { type: 'function', function: { name, description, parameters: { type: 'object', properties: parameters, required } } };
30
+ }
31
+
32
+ const DEFINITIONS = [
33
+ definition('list_mcp', 'Lista los servidores MCP configurados (de tamnora.json y de la base).', {}),
34
+ definition(
35
+ 'install_mcp',
36
+ 'Registra un servidor MCP. stdio: pasá command (y args). http: pasá url.',
37
+ {
38
+ id: { type: 'string', description: 'Id corto, ej. "echo"' },
39
+ name: { type: 'string' },
40
+ registry: { type: 'string', description: 'Nombre en el registro oficial de MCP, ej. "io.github.foo/bar"' },
41
+ json: { type: 'string', description: 'Snippet JSON (bloque mcpServers o {command,args}/{url})' },
42
+ transport: { type: 'string', enum: ['stdio', 'http'] },
43
+ command: { type: 'string', description: 'Para stdio, ej. "node" o "npx"' },
44
+ args: { type: 'array', items: { type: 'string' } },
45
+ url: { type: 'string', description: 'Para http' },
46
+ enabled: { type: 'boolean' },
47
+ },
48
+ []
49
+ ),
50
+ definition('remove_mcp', 'Elimina un servidor MCP guardado desde la consola.', { id: { type: 'string' } }, ['id']),
51
+ definition('list_skills', 'Lista las skills instaladas y si están habilitadas.', {}),
52
+ definition(
53
+ 'install_skill',
54
+ 'Crea o actualiza una skill en la casa. Pasá "content" (markdown, opcionalmente con frontmatter) o "url" para descargarla.',
55
+ {
56
+ id: { type: 'string', description: 'Slug de la skill, ej. "revisar-pr"' },
57
+ name: { type: 'string' },
58
+ description: { type: 'string' },
59
+ content: { type: 'string', description: 'Contenido markdown de la skill' },
60
+ url: { type: 'string', description: 'URL para descargar el contenido' },
61
+ ref: { type: 'string', description: 'URL/id de skills.sh (https://skills.sh/owner/repo/skill)' },
62
+ enabled: { type: 'boolean' },
63
+ },
64
+ []
65
+ ),
66
+ ];
67
+
68
+ async function fetchText(url) {
69
+ const controller = new AbortController();
70
+ const timer = setTimeout(() => controller.abort(), 15000);
71
+ timer.unref?.();
72
+ try {
73
+ const res = await fetch(url, { signal: controller.signal, redirect: 'follow' });
74
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
75
+ const type = res.headers.get('content-type') || '';
76
+ if (type && !/text|json|markdown|xml|plain/i.test(type)) {
77
+ throw new Error(`tipo de contenido no textual: ${type}`);
78
+ }
79
+ return await res.text();
80
+ } finally {
81
+ clearTimeout(timer);
82
+ }
83
+ }
84
+
85
+ function decisionFor(ctx, name) {
86
+ const perms = ctx.config.permissions ?? {};
87
+ const value = perms[name];
88
+ if (value === 'allow' || value === 'ask' || value === 'deny') return value;
89
+ return name.startsWith('list_') ? 'allow' : 'ask';
90
+ }
91
+
92
+ /**
93
+ * Crea las tools de administracion. `execute(name, args, { approve })`, donde
94
+ * `approve(name, args)` resuelve true/false para los permisos "ask".
95
+ */
96
+ export function createAgentTools(ctx) {
97
+ function handles(name) {
98
+ return DEFINITIONS.some((d) => d.function.name === name);
99
+ }
100
+
101
+ async function execute(name, args = {}, { approve } = {}) {
102
+ const decision = decisionFor(ctx, name);
103
+ if (decision === 'deny') return `Permiso denegado para "${name}".`;
104
+ if (decision === 'ask') {
105
+ const ok = approve ? await approve(name, args) : false;
106
+ if (!ok) return `Permiso denegado para "${name}".`;
107
+ }
108
+
109
+ switch (name) {
110
+ case 'list_mcp':
111
+ return JSON.stringify(ctx.mergedMcpServers().map((s) => ({
112
+ id: s.id,
113
+ name: s.name,
114
+ transport: s.url ? 'http' : 'stdio',
115
+ enabled: s.enabled !== false,
116
+ source: s.source ?? 'config',
117
+ })), null, 2);
118
+
119
+ case 'install_mcp': {
120
+ if (args.registry || args.name) {
121
+ let server;
122
+ try {
123
+ server = await getMcpRegistryServer(String(args.registry || args.name), { strict: true });
124
+ } catch (error) {
125
+ return `No se pudo consultar el registro MCP: ${error.message}`;
126
+ }
127
+ if (!server) return 'No encontré ese servidor en el registro oficial.';
128
+ const cfg = registryServerToConfig(server);
129
+ ctx.store.saveMcpServer({
130
+ id: cfg.id,
131
+ name: cfg.name,
132
+ transport: cfg.type === 'http' ? 'http' : 'stdio',
133
+ command: cfg.command ?? null,
134
+ url: cfg.url ?? null,
135
+ enabled: true,
136
+ config: { args: cfg.args ?? [], env: cfg.env ?? null },
137
+ });
138
+ await ctx.reloadMcp();
139
+ const required = (cfg.requiredEnv ?? []).length ? ` Requiere env: ${cfg.requiredEnv.join(', ')}.` : '';
140
+ return `MCP "${cfg.id}" importado del registro oficial.${required}`;
141
+ }
142
+ if (args.json || args.config) {
143
+ let cfg;
144
+ try {
145
+ cfg = normalizeMcpConfig(args.json ?? args.config, args.id);
146
+ } catch (error) {
147
+ return `Config MCP inválida: ${error.message}`;
148
+ }
149
+ if (!MCP_ID_PATTERN.test(cfg.id)) return 'Id de MCP inválido.';
150
+ ctx.store.saveMcpServer({
151
+ id: cfg.id,
152
+ name: cfg.name,
153
+ transport: cfg.type === 'http' ? 'http' : 'stdio',
154
+ command: cfg.command ?? null,
155
+ url: cfg.url ?? null,
156
+ enabled: cfg.enabled !== false,
157
+ config: { args: cfg.args ?? [], env: cfg.env ?? null },
158
+ });
159
+ await ctx.reloadMcp();
160
+ return `MCP "${cfg.id}" importado.`;
161
+ }
162
+
163
+ const id = String(args.id ?? '').trim();
164
+ if (!MCP_ID_PATTERN.test(id)) return 'Id de MCP inválido.';
165
+ const transport = args.transport === 'http' || (!args.command && args.url) ? 'http' : 'stdio';
166
+ const command = String(args.command ?? '').trim();
167
+ const url = String(args.url ?? '').trim();
168
+ if (transport === 'stdio' && !command) return 'Un MCP stdio necesita "command".';
169
+ if (transport === 'http' && !url) return 'Un MCP http necesita "url".';
170
+ ctx.store.saveMcpServer({
171
+ id,
172
+ name: String(args.name ?? id).trim() || id,
173
+ transport,
174
+ command: transport === 'stdio' ? command : null,
175
+ url: transport === 'http' ? url : null,
176
+ enabled: args.enabled !== false,
177
+ config: { args: Array.isArray(args.args) ? args.args : [], env: args.env ?? null },
178
+ });
179
+ await ctx.reloadMcp();
180
+ return `MCP "${id}" registrado (${transport}).`;
181
+ }
182
+
183
+ case 'remove_mcp': {
184
+ const id = String(args.id ?? '').trim();
185
+ if (!ctx.store.listMcpServers().some((s) => s.id === id)) return `No hay un MCP guardado con id "${id}".`;
186
+ ctx.store.deleteMcpServer(id);
187
+ await ctx.reloadMcp();
188
+ return `MCP "${id}" eliminado.`;
189
+ }
190
+
191
+ case 'list_skills':
192
+ return JSON.stringify(skillsView(ctx).map(({ id, name, enabled, description }) => ({ id, name, enabled, description })), null, 2);
193
+
194
+ case 'install_skill': {
195
+ const ref = parseSkillRef(args.ref ?? args.url ?? '');
196
+ if (ref && (args.ref || (args.url && !/\.md(\?|$)/i.test(String(args.url))))) {
197
+ let remote;
198
+ try {
199
+ remote = await fetchSkillFromGithub(ref);
200
+ } catch (error) {
201
+ return `No se pudo importar desde GitHub: ${error.message}`;
202
+ }
203
+ const imported = sanitizeId(remote.slug || ref.slug || ref.repo);
204
+ if (!SKILL_ID_PATTERN.test(imported)) return 'No se pudo derivar un id de skill válido.';
205
+ await writeSkillFiles(path.join(homeDir(), 'skills', imported), remote.files);
206
+ setSkillEnabled(ctx.store, imported, true);
207
+ return `Skill "${imported}" importada desde ${remote.source}.`;
208
+ }
209
+
210
+ const id = String(args.id ?? '').trim();
211
+ if (!SKILL_ID_PATTERN.test(id)) return 'Id de skill inválido (a-z, 0-9, - y _).';
212
+ let content = String(args.content ?? '');
213
+ if (!content.trim() && args.url) {
214
+ try {
215
+ content = await fetchText(String(args.url));
216
+ } catch (error) {
217
+ return `No se pudo descargar la skill: ${error.message}`;
218
+ }
219
+ }
220
+ if (!content.trim()) return 'Pasá "content" (markdown) o "url".';
221
+ const parsed = parseSkillMarkdown(content, id);
222
+ await writeSkill(id, {
223
+ name: args.name || parsed.name || id,
224
+ description: args.description || parsed.description,
225
+ when: parsed.when,
226
+ body: parsed.body || content,
227
+ });
228
+ if (args.enabled === false) setSkillEnabled(ctx.store, id, false);
229
+ return `Skill "${id}" instalada.`;
230
+ }
231
+
232
+ default:
233
+ return `Herramienta desconocida: ${name}`;
234
+ }
235
+ }
236
+
237
+ return { definitions: DEFINITIONS, handles, execute };
238
+ }
@@ -0,0 +1,125 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import path from 'node:path';
4
+ import { DEFAULT_PROCESS_ALLOW } from './core.js';
5
+
6
+ const MAX_LOG_LINES = 400;
7
+
8
+ function killTree(child) {
9
+ if (!child || child.killed) return;
10
+ const pid = child.pid;
11
+ try {
12
+ if (process.platform === 'win32' && pid) {
13
+ spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true });
14
+ } else {
15
+ if (pid) {
16
+ try {
17
+ process.kill(-pid, 'SIGTERM');
18
+ } catch {
19
+ /* sin grupo */
20
+ }
21
+ }
22
+ try {
23
+ child.kill('SIGTERM');
24
+ } catch {
25
+ /* ya murio */
26
+ }
27
+ }
28
+ } catch {
29
+ /* ya murio */
30
+ }
31
+ }
32
+
33
+ function view(record) {
34
+ return {
35
+ id: record.id,
36
+ command: record.command,
37
+ args: record.args,
38
+ cwd: record.cwd,
39
+ port: record.port,
40
+ pid: record.pid,
41
+ startedAt: record.startedAt,
42
+ status: record.status,
43
+ running: Boolean(record.child),
44
+ };
45
+ }
46
+
47
+ /** Gestiona dev servers (procesos) con lista blanca de binarios. */
48
+ export function createProcManager({ allow } = {}) {
49
+ const allowed = new Set(
50
+ (Array.isArray(allow) && allow.length ? allow : DEFAULT_PROCESS_ALLOW).map((b) => String(b).toLowerCase())
51
+ );
52
+ const procs = new Map();
53
+
54
+ function start({ cwd, command, args = [], port } = {}) {
55
+ const bin = String(command ?? '').trim();
56
+ if (!bin) throw new Error('Falta el comando.');
57
+ const base = path.basename(bin).replace(/\.(exe|cmd|bat)$/i, '').toLowerCase();
58
+ if (!allowed.has(base) && !allowed.has(bin.toLowerCase())) {
59
+ throw new Error(`Comando no permitido: ${bin}. Agregalo a processes.allow en tamnora.json.`);
60
+ }
61
+ const child = spawn(bin, args.map(String), {
62
+ cwd,
63
+ stdio: ['ignore', 'pipe', 'pipe'],
64
+ windowsHide: true,
65
+ shell: process.platform === 'win32' && !path.isAbsolute(bin),
66
+ detached: process.platform !== 'win32',
67
+ });
68
+ const id = randomUUID().slice(0, 8);
69
+ const record = {
70
+ id,
71
+ command: bin,
72
+ args: args.map(String),
73
+ cwd,
74
+ port: port ? Number(port) : null,
75
+ pid: child.pid,
76
+ startedAt: Date.now(),
77
+ logs: [],
78
+ status: 'running',
79
+ child,
80
+ };
81
+ const push = (stream, data) => {
82
+ record.logs.push({ t: Date.now(), stream, text: String(data) });
83
+ while (record.logs.length > MAX_LOG_LINES) record.logs.shift();
84
+ };
85
+ child.stdout.on('data', (d) => push('out', d));
86
+ child.stderr.on('data', (d) => push('err', d));
87
+ child.on('error', (err) => {
88
+ record.status = 'error';
89
+ push('err', err.message);
90
+ record.child = null;
91
+ });
92
+ child.on('exit', (code) => {
93
+ record.status = code === 0 ? 'exited' : `exit ${code}`;
94
+ record.child = null;
95
+ });
96
+ procs.set(id, record);
97
+ return view(record);
98
+ }
99
+
100
+ function stop(id) {
101
+ const record = procs.get(id);
102
+ if (!record) return false;
103
+ if (record.child) killTree(record.child);
104
+ record.status = 'stopped';
105
+ record.child = null;
106
+ return true;
107
+ }
108
+
109
+ function list() {
110
+ return [...procs.values()].map(view);
111
+ }
112
+
113
+ function logs(id, since = 0) {
114
+ const record = procs.get(id);
115
+ if (!record) return null;
116
+ return { status: record.status, running: Boolean(record.child), logs: record.logs.slice(since) };
117
+ }
118
+
119
+ function close() {
120
+ for (const record of procs.values()) if (record.child) killTree(record.child);
121
+ procs.clear();
122
+ }
123
+
124
+ return { start, stop, list, logs, close, allowed: [...allowed] };
125
+ }
@@ -30,8 +30,9 @@ function workspacePreamble(cwd) {
30
30
  * @param {object} opts
31
31
  * @param {string} opts.cwd - server default workspace
32
32
  * @param {string[]} opts.files - instruction file names (e.g. ['AGENTS.md'])
33
+ * @param {() => string} [opts.skillsText] - skills block, re-read on every call
33
34
  */
34
- export function createPromptBuilder({ cwd, files }) {
35
+ export function createPromptBuilder({ cwd, files, skillsText }) {
35
36
  const cache = new Map();
36
37
 
37
38
  async function buildFor(workspace) {
@@ -40,9 +41,11 @@ export function createPromptBuilder({ cwd, files }) {
40
41
  return instructions ? `${base}\n\n---\n\n${instructions}` : base;
41
42
  }
42
43
 
43
- return function systemPromptFor(workspace = cwd) {
44
+ return async function systemPromptFor(workspace = cwd) {
44
45
  const key = workspace || cwd;
45
46
  if (!cache.has(key)) cache.set(key, buildFor(key));
46
- return cache.get(key);
47
+ const base = await cache.get(key);
48
+ const extra = typeof skillsText === 'function' ? String(skillsText() ?? '').trim() : '';
49
+ return extra ? `${base}\n\n---\n\n${extra}` : base;
47
50
  };
48
51
  }