@danieltmn/tamnora 0.2.0 → 0.3.1

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 CHANGED
@@ -5,6 +5,38 @@ Todos los cambios relevantes de este proyecto se documentan en este archivo.
5
5
  El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/)
6
6
  y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
7
7
 
8
+ ## [0.3.1] - 2026-10-02
9
+
10
+ ### Agregado
11
+
12
+ - Consola (ajustes): campo `env` (lineas `KEY=valor`) en el modal de MCP, para
13
+ cargar las variables de los servidores importados del registro.
14
+
15
+ ### Pendiente
16
+
17
+ - Confirmar el proveedor Xiaomi (`mimo-v2.6-flash`): respondio `429 Too Many
18
+ Requests` (cuota/rate limit del endpoint), no del harness.
19
+ - Telegram: audio y voz (transcribir notas de voz y responder con audio).
20
+ - Plugins: cargador de plugins locales (fase 2 del apartado ajustes).
21
+
22
+ ## [0.3.0] - 2026-10-02
23
+
24
+ ### Agregado
25
+
26
+ - Tool local `fetch_url` (http/https) para que el agente pueda leer docs, APIs y
27
+ archivos remotos; bloquea hosts privados/loopback y tiene timeout y tope de
28
+ tamano. Disponible en CLI/TUI/consola/Telegram y en modo plan.
29
+ - Importar **skills desde skills.sh**: `POST /api/skills/import` y la tool
30
+ `install_skill` aceptan una URL de skills.sh o un id `owner/repo/skill`, bajan
31
+ el `SKILL.md` (y archivos de apoyo) desde GitHub y lo instalan en la casa.
32
+ (La API de skills.sh pide token OIDC de Vercel; resolvemos por GitHub.)
33
+ - Importar **servidores MCP**: `POST /api/mcp/import` y la tool `install_mcp`
34
+ aceptan el nombre del registro oficial de MCP, un snippet JSON (bloque
35
+ `mcpServers`) o una URL del registro. `mcpservers.org` esta detras de
36
+ Cloudflare y no se puede leer desde el server; se puede pegar su JSON.
37
+ - Consola (ajustes): barras para importar skills y MCP, con estado y aviso de
38
+ variables de entorno requeridas.
39
+
8
40
  ## [0.2.0] - 2026-10-02
9
41
 
10
42
  ### Agregado
@@ -43,15 +75,6 @@ y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
43
75
  `No se pudo conectar con <host>...` en vez del crudo `fetch failed` (el error
44
76
  conserva el `code` para seguir siendo reintentable).
45
77
 
46
- ### Pendiente
47
-
48
- - Publicar el fix de proveedores custom como patch (`npm run release -- patch`)
49
- para que `npm i -g @danieltmn/tamnora` lo incluya; la 0.1.0 publicada todavia
50
- tiene el bug.
51
- - Confirmar el proveedor Xiaomi (`mimo-v2.6-flash`): en la prueba respondio
52
- `429 Too Many Requests` (cuota/rate limit del endpoint), no del harness.
53
- - Cerrar la revision del sitio en `website/` (contenido ya alineado al harness).
54
-
55
78
  ## [0.1.0] - 2026-10-01
56
79
 
57
80
  ### Agregado
@@ -13,7 +13,7 @@ export const MODES = {
13
13
  id: 'plan',
14
14
  label: 'Plan',
15
15
  description: 'Solo lectura: investiga y propone un plan, sin modificar nada.',
16
- tools: ['read', 'list', 'glob', 'grep', 'git_status', 'git_diff', 'git_log'],
16
+ tools: ['read', 'list', 'glob', 'grep', 'fetch_url', 'git_status', 'git_diff', 'git_log'],
17
17
  systemPrompt:
18
18
  'Estás en modo PLAN: no modifiques archivos ni ejecutes comandos. Investigá el proyecto con las herramientas de solo lectura y respondé con un plan concreto y accionable.',
19
19
  },
@@ -71,6 +71,13 @@ export { summarize } from './eval/report.js';
71
71
  export { runScorers, validateSchema, extractJson, SCORERS } from './eval/scorers.js';
72
72
  export { createJudge } from './eval/judge.js';
73
73
  export { createTaskWorkspace, normalizeExpect } from './eval/task.js';
74
- export { parseSkillMarkdown, loadSkills, mergeSkills, buildSkillsPrompt } from './skills/index.js';
74
+ export { parseSkillMarkdown, loadSkills, mergeSkills, writeSkillFiles, buildSkillsPrompt } from './skills/index.js';
75
+ export { parseSkillRef, fetchSkillFromGithub } from './skills/remote.js';
76
+ export {
77
+ searchMcpRegistry,
78
+ getMcpRegistryServer,
79
+ registryServerToConfig,
80
+ normalizeMcpConfig,
81
+ } from './mcp/registry.js';
75
82
  export { parseTunnelmoleUrls, startTunnelmole, startTunnel } from './tunnel.js';
76
83
  export { detectProcs, packageManager, DEFAULT_PROCESS_ALLOW } from './procs/detect.js';
@@ -0,0 +1,117 @@
1
+ // Cliente del registro oficial de MCP (registry.modelcontextprotocol.io) y
2
+ // normalizador de configs para importar servidores desde JSON.
3
+
4
+ import { providerFetch } from '../providers/utils.js';
5
+
6
+ const REGISTRY = 'https://registry.modelcontextprotocol.io';
7
+
8
+ function slugifyId(name) {
9
+ return String(name ?? '')
10
+ .split('/')
11
+ .pop()
12
+ .replace(/[^a-z0-9_-]+/gi, '-')
13
+ .replace(/^-+|-+$/g, '')
14
+ .toLowerCase()
15
+ .slice(0, 40);
16
+ }
17
+
18
+ /** Busca servidores en el registro oficial. Devuelve la lista de `server`. */
19
+ export async function searchMcpRegistry(query, { limit = 10 } = {}) {
20
+ const url = new URL(`${REGISTRY}/v0/servers`);
21
+ if (query) url.searchParams.set('search', String(query));
22
+ url.searchParams.set('limit', String(limit));
23
+ const res = await providerFetch(url.toString(), {
24
+ headers: { 'User-Agent': 'tamnora-harness', Accept: 'application/json' },
25
+ });
26
+ if (!res.ok) throw new Error(`El registro MCP respondio HTTP ${res.status}`);
27
+ const data = await res.json();
28
+ return (data.servers ?? []).map((entry) => entry.server ?? entry);
29
+ }
30
+
31
+ /** Busca por nombre exacto; con `strict` no cae en el primer resultado difuso. */
32
+ export async function getMcpRegistryServer(query, { strict = false } = {}) {
33
+ const servers = await searchMcpRegistry(query, { limit: 10 });
34
+ const exact = servers.find(
35
+ (s) => s.name === query || s.title === query || slugifyId(s.name) === slugifyId(query)
36
+ );
37
+ if (exact) return exact;
38
+ return strict ? null : servers[0] ?? null;
39
+ }
40
+
41
+ /** Mapea un server del registro oficial a nuestra config de `mcp[]`. */
42
+ export function registryServerToConfig(server) {
43
+ if (!server?.name) throw new Error('El servidor no tiene nombre.');
44
+ const id = slugifyId(server.name) || 'mcp';
45
+ const remote = (server.remotes ?? [])[0];
46
+ if (remote?.url) {
47
+ return { id, name: server.title || server.name, type: 'http', url: remote.url, enabled: true };
48
+ }
49
+ const pkg = (server.packages ?? [])[0];
50
+ if (pkg) {
51
+ const runtime = pkg.runtimeHint || (pkg.registryType === 'npm' ? 'npx' : 'uvx');
52
+ const args = [];
53
+ for (const arg of pkg.runtimeArguments ?? []) if (arg.type === 'positional' && arg.value) args.push(arg.value);
54
+ if (runtime === 'npx' && !args.includes('-y')) args.unshift('-y');
55
+ args.push(`${pkg.identifier}${pkg.version ? `@${pkg.version}` : ''}`);
56
+ const env = {};
57
+ for (const variable of pkg.environmentVariables ?? []) env[variable.name] = '';
58
+ const requiredEnv = (pkg.environmentVariables ?? []).filter((v) => v.isRequired).map((v) => v.name);
59
+ return {
60
+ id,
61
+ name: server.title || server.name,
62
+ type: 'stdio',
63
+ command: runtime,
64
+ args,
65
+ env,
66
+ requiredEnv,
67
+ enabled: true,
68
+ };
69
+ }
70
+ throw new Error('El servidor del registro no tiene remotes ni packages compatibles.');
71
+ }
72
+
73
+ /**
74
+ * Normaliza un snippet JSON a nuestra config. Acepta:
75
+ * - `{ command, args, env }` o `{ url }`
76
+ * - `{ mcpServers: { nombre: {...} } }` (formato Claude Desktop / mcpservers.org)
77
+ */
78
+ export function normalizeMcpConfig(input, fallbackId = 'mcp') {
79
+ let value = input;
80
+ if (typeof value === 'string') {
81
+ try {
82
+ value = JSON.parse(value);
83
+ } catch {
84
+ throw new Error('El JSON no es valido.');
85
+ }
86
+ }
87
+ if (!value || typeof value !== 'object') throw new Error('Falta la config del servidor.');
88
+
89
+ let id = fallbackId;
90
+ let def = value;
91
+ const servers = value.mcpServers ?? value.mcp_servers;
92
+ if (servers && typeof servers === 'object') {
93
+ const key = Object.keys(servers)[0];
94
+ if (!key) throw new Error('El bloque "mcpServers" esta vacio.');
95
+ id = key;
96
+ def = servers[key];
97
+ }
98
+ if (value.id) id = String(value.id);
99
+ if (!def || typeof def !== 'object') throw new Error('Config invalida.');
100
+
101
+ const url = def.url ?? def.serverUrl;
102
+ const command = def.command;
103
+ if (!url && !command) throw new Error('La config necesita "command" (stdio) o "url" (http).');
104
+
105
+ const rawArgs = def.args ?? def.arguments ?? [];
106
+ const args = Array.isArray(rawArgs) ? rawArgs.map(String) : String(rawArgs).split(/\s+/).filter(Boolean);
107
+ return {
108
+ id: slugifyId(id) || fallbackId,
109
+ name: def.name || id,
110
+ type: url ? 'http' : 'stdio',
111
+ command: url ? undefined : String(command),
112
+ args: url ? [] : args,
113
+ url: url ? String(url) : undefined,
114
+ env: def.env && typeof def.env === 'object' ? def.env : undefined,
115
+ enabled: def.enabled !== false,
116
+ };
117
+ }
@@ -3,6 +3,7 @@
3
3
  // las que esten habilitadas.
4
4
 
5
5
  import { readdirSync, readFileSync, statSync } from 'node:fs';
6
+ import { mkdir, writeFile } from 'node:fs/promises';
6
7
  import path from 'node:path';
7
8
 
8
9
  /** Parse a SKILL.md: frontmatter `--- key: value ---` plus body. */
@@ -59,6 +60,23 @@ export function mergeSkills(rootLists = []) {
59
60
  return [...byId.values()];
60
61
  }
61
62
 
63
+ /** Escribe los archivos de una skill en `dir` (con guarda de traversal). */
64
+ export async function writeSkillFiles(dir, files = []) {
65
+ const root = path.resolve(dir);
66
+ for (const file of files) {
67
+ const rel = String(file.path ?? '')
68
+ .replace(/\\/g, '/')
69
+ .replace(/^\/+/, '');
70
+ if (!rel) continue;
71
+ const target = path.resolve(root, rel);
72
+ if (target !== root && !target.startsWith(root + path.sep)) {
73
+ throw new Error(`Ruta de skill inválida: ${file.path}`);
74
+ }
75
+ await mkdir(path.dirname(target), { recursive: true });
76
+ await writeFile(target, String(file.contents ?? ''), 'utf8');
77
+ }
78
+ }
79
+
62
80
  /** Text block appended to the system prompt with the enabled skills. */
63
81
  export function buildSkillsPrompt(skills = []) {
64
82
  if (!skills.length) return '';
@@ -0,0 +1,80 @@
1
+ // Importa skills desde skills.sh resolviendolas por GitHub (la API de skills.sh
2
+ // requiere un token OIDC de Vercel, pero los skills salen de repos de GitHub).
3
+
4
+ const GITHUB_API = 'https://api.github.com';
5
+ const RAW = 'https://raw.githubusercontent.com';
6
+
7
+ /**
8
+ * Acepta una URL de skills.sh (`https://skills.sh/<owner>/<repo>/<slug>`), una
9
+ * de GitHub, o un id `owner/repo[/slug]`. Devuelve `{ owner, repo, slug }`.
10
+ */
11
+ export function parseSkillRef(input) {
12
+ const raw = String(input ?? '').trim();
13
+ if (!raw) return null;
14
+ let url = null;
15
+ try {
16
+ url = new URL(raw);
17
+ } catch {
18
+ url = null;
19
+ }
20
+ if (url) {
21
+ const parts = url.pathname.split('/').filter(Boolean);
22
+ if (/(^|\.)skills\.sh$/i.test(url.hostname) && parts.length >= 3) {
23
+ return { owner: parts[0], repo: parts[1], slug: parts[2] };
24
+ }
25
+ if (url.hostname === 'github.com' && parts.length >= 2) {
26
+ return { owner: parts[0], repo: parts[1], slug: parts[2] ?? null };
27
+ }
28
+ return null;
29
+ }
30
+ const parts = raw.replace(/^\/+/, '').split('/').filter(Boolean);
31
+ if (parts.length === 3) return { owner: parts[0], repo: parts[1], slug: parts[2] };
32
+ if (parts.length === 2) return { owner: parts[0], repo: parts[1], slug: null };
33
+ return null;
34
+ }
35
+
36
+ async function ghJson(url) {
37
+ const res = await fetch(url, {
38
+ headers: { 'User-Agent': 'tamnora-harness', Accept: 'application/vnd.github+json' },
39
+ });
40
+ if (!res.ok) throw new Error(`GitHub respondio HTTP ${res.status}`);
41
+ return res.json();
42
+ }
43
+
44
+ /**
45
+ * Baja los archivos de una skill (`SKILL.md` y los del mismo directorio) desde
46
+ * el repositorio de GitHub. Sin `slug`, toma el `SKILL.md` de la raiz.
47
+ */
48
+ export async function fetchSkillFromGithub({ owner, repo, slug }) {
49
+ const meta = await ghJson(`${GITHUB_API}/repos/${owner}/${repo}`);
50
+ const ref = meta.default_branch || 'main';
51
+ const tree = await ghJson(`${GITHUB_API}/repos/${owner}/${repo}/git/trees/${encodeURIComponent(ref)}?recursive=1`);
52
+ const entries = (tree.tree ?? []).filter((e) => e.type === 'blob');
53
+
54
+ let dir = null;
55
+ if (slug) {
56
+ const matches = entries
57
+ .filter((e) => e.path.endsWith(`/${slug}/SKILL.md`) || e.path === `${slug}/SKILL.md`)
58
+ .sort((a, b) => a.path.length - b.path.length);
59
+ if (matches.length) dir = matches[0].path.slice(0, -'SKILL.md'.length).replace(/\/$/, '');
60
+ }
61
+ if (dir === null && !slug) {
62
+ const root = entries.find((e) => e.path === 'SKILL.md');
63
+ if (root) dir = '';
64
+ }
65
+ if (dir === null) throw new Error(`No encontre la skill "${slug ?? repo}" en ${owner}/${repo}.`);
66
+
67
+ const prefix = dir ? `${dir}/` : '';
68
+ const paths = entries
69
+ .filter((e) => e.path.startsWith(prefix) && e.path !== prefix)
70
+ .map((e) => e.path);
71
+ if (!paths.some((p) => p === `${prefix}SKILL.md`)) throw new Error('La skill no tiene SKILL.md.');
72
+
73
+ const files = [];
74
+ for (const path of paths) {
75
+ const res = await fetch(`${RAW}/${owner}/${repo}/${encodeURIComponent(ref)}/${path}`);
76
+ if (!res.ok) continue;
77
+ files.push({ path: path.slice(prefix.length), contents: await res.text() });
78
+ }
79
+ return { id: slug || repo, source: `${owner}/${repo}`, slug: slug || repo, files };
80
+ }
@@ -11,6 +11,60 @@ const MAX_GLOB = 500;
11
11
  const MAX_GREP = 300;
12
12
  const MAX_GREP_FILE_BYTES = 2_000_000;
13
13
  const MAX_BASH_OUTPUT = 100_000;
14
+ const FETCH_TIMEOUT_MS = 20000;
15
+ const MAX_FETCH_CHARS = 200_000;
16
+
17
+ function isBlockedHost(hostname) {
18
+ const h = String(hostname ?? '')
19
+ .toLowerCase()
20
+ .replace(/^\[|\]$/g, '');
21
+ if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local')) return true;
22
+ if (h === '::1' || h === '0.0.0.0') return true;
23
+ const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
24
+ if (!m) return false;
25
+ const a = Number(m[1]);
26
+ const b = Number(m[2]);
27
+ if (a === 127 || a === 10 || a === 0) return true;
28
+ if (a === 169 && b === 254) return true;
29
+ if (a === 192 && b === 168) return true;
30
+ if (a === 172 && b >= 16 && b <= 31) return true;
31
+ return false;
32
+ }
33
+
34
+ export async function fetchUrlTool(_root, { url, maxLength } = {}) {
35
+ let target;
36
+ try {
37
+ target = new URL(String(url ?? ''));
38
+ } catch {
39
+ return 'Error: URL inválida.';
40
+ }
41
+ if (!/^https?:$/.test(target.protocol)) return 'Error: solo se permite http/https.';
42
+ if (isBlockedHost(target.hostname)) return `Error: host bloqueado (${target.hostname}).`;
43
+
44
+ const controller = new AbortController();
45
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
46
+ try {
47
+ const res = await fetch(target.toString(), {
48
+ redirect: 'follow',
49
+ signal: controller.signal,
50
+ headers: {
51
+ 'User-Agent': 'tamnora-harness (+https://github.com/tamnora/tamnora-harness)',
52
+ Accept: '*/*',
53
+ },
54
+ });
55
+ const type = res.headers.get('content-type') || '';
56
+ const limit = Math.min(Math.max(Number(maxLength) || 20000, 500), MAX_FETCH_CHARS);
57
+ const text = await res.text();
58
+ const body = text.slice(0, limit);
59
+ const truncated = text.length > limit ? `\n[... truncado ${text.length - limit} caracteres]` : '';
60
+ return `${res.status} ${res.statusText || ''} · ${type || 'sin content-type'} · ${target.toString()}\n\n${body}${truncated}`;
61
+ } catch (err) {
62
+ if (err?.name === 'AbortError') return `Error: timeout (${FETCH_TIMEOUT_MS / 1000}s) al pedir ${target}.`;
63
+ return `Error de red al pedir ${target}: ${err?.message ?? err}`;
64
+ } finally {
65
+ clearTimeout(timer);
66
+ }
67
+ }
14
68
 
15
69
  export async function readTool(root, { path: p, offset, limit } = {}) {
16
70
  const abs = resolveInRoot(root, p);
@@ -270,6 +324,7 @@ export const EXECUTORS = {
270
324
  glob: globTool,
271
325
  grep: grepTool,
272
326
  bash: bashTool,
327
+ fetch_url: fetchUrlTool,
273
328
  git_status: gitStatusTool,
274
329
  git_diff: gitDiffTool,
275
330
  git_log: gitLogTool,
@@ -12,6 +12,7 @@ export const DEFAULT_PERMISSIONS = {
12
12
  git_status: 'allow',
13
13
  git_diff: 'allow',
14
14
  git_log: 'allow',
15
+ fetch_url: 'allow',
15
16
  write: 'ask',
16
17
  edit: 'ask',
17
18
  bash: 'ask',
@@ -40,6 +41,7 @@ function matchPattern(pattern, subject) {
40
41
  function subjectFor(name, args) {
41
42
  if (name === 'bash') return args?.command ?? '';
42
43
  if (name === 'git_commit') return args?.message ?? '';
44
+ if (name === 'fetch_url') return args?.url ?? '';
43
45
  return args?.path ?? '';
44
46
  }
45
47
 
@@ -116,6 +116,22 @@ export const TOOL_DEFINITIONS = [
116
116
  },
117
117
  },
118
118
  },
119
+ {
120
+ type: 'function',
121
+ function: {
122
+ name: 'fetch_url',
123
+ description:
124
+ 'Fetch a URL over http(s) and return its text content (status, content-type and body). Use it to read docs, APIs or raw files.',
125
+ parameters: {
126
+ type: 'object',
127
+ properties: {
128
+ url: { type: 'string', description: 'Absolute http(s) URL' },
129
+ maxLength: { type: 'integer', description: 'Max characters of the body (default 20000)' },
130
+ },
131
+ required: ['url'],
132
+ },
133
+ },
134
+ },
119
135
  {
120
136
  type: 'function',
121
137
  function: {
@@ -0,0 +1,49 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { registryServerToConfig, normalizeMcpConfig } from '../src/mcp/registry.js';
4
+
5
+ test('registryServerToConfig maps a remote server to http', () => {
6
+ const cfg = registryServerToConfig({
7
+ name: 'ac.inference.sh/mcp',
8
+ title: 'inference.sh',
9
+ remotes: [{ type: 'streamable-http', url: 'https://sh.inference.ac' }],
10
+ });
11
+ assert.equal(cfg.type, 'http');
12
+ assert.equal(cfg.url, 'https://sh.inference.ac');
13
+ assert.equal(cfg.id, 'mcp');
14
+ });
15
+
16
+ test('registryServerToConfig maps an npm package to stdio and lists required env', () => {
17
+ const cfg = registryServerToConfig({
18
+ name: 'com.pulsemcp/remote-filesystem',
19
+ title: 'Remote Filesystem',
20
+ packages: [
21
+ {
22
+ registryType: 'npm',
23
+ identifier: 'remote-filesystem-mcp-server',
24
+ version: '0.1.2',
25
+ runtimeHint: 'npx',
26
+ runtimeArguments: [{ value: '-y', type: 'positional' }],
27
+ environmentVariables: [{ name: 'GCS_BUCKET', isRequired: true }, { name: 'GCS_PROJECT_ID' }],
28
+ },
29
+ ],
30
+ });
31
+ assert.equal(cfg.type, 'stdio');
32
+ assert.equal(cfg.command, 'npx');
33
+ assert.deepEqual(cfg.args, ['-y', 'remote-filesystem-mcp-server@0.1.2']);
34
+ assert.deepEqual(cfg.requiredEnv, ['GCS_BUCKET']);
35
+ });
36
+
37
+ test('normalizeMcpConfig handles mcpServers snippets and plain configs', () => {
38
+ const wrapped = normalizeMcpConfig('{"mcpServers":{"files":{"command":"npx","args":["-y","pkg"]}}}', 'fallback');
39
+ assert.equal(wrapped.id, 'files');
40
+ assert.equal(wrapped.type, 'stdio');
41
+ assert.deepEqual(wrapped.args, ['-y', 'pkg']);
42
+
43
+ const http = normalizeMcpConfig({ url: 'https://x/mcp' }, 'web');
44
+ assert.equal(http.type, 'http');
45
+ assert.equal(http.url, 'https://x/mcp');
46
+
47
+ assert.throws(() => normalizeMcpConfig('{bad json}', 'x'), /no es valido/);
48
+ assert.throws(() => normalizeMcpConfig({}, 'x'), /command|url/);
49
+ });
@@ -4,6 +4,7 @@ import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { parseSkillMarkdown, loadSkills, mergeSkills, buildSkillsPrompt } from '../src/skills/index.js';
7
+ import { parseSkillRef } from '../src/skills/remote.js';
7
8
 
8
9
  test('parseSkillMarkdown reads frontmatter and body', () => {
9
10
  const parsed = parseSkillMarkdown(
@@ -41,6 +42,25 @@ test('loadSkills scans a root and mergeSkills lets later roots win', async () =>
41
42
  assert.equal(merged.find((s) => s.id === 'demo').name, 'Demo WS');
42
43
  });
43
44
 
45
+ test('parseSkillRef accepts skills.sh URLs, GitHub URLs and ids', () => {
46
+ assert.deepEqual(parseSkillRef('https://skills.sh/vercel-labs/skills/find-skills'), {
47
+ owner: 'vercel-labs',
48
+ repo: 'skills',
49
+ slug: 'find-skills',
50
+ });
51
+ assert.deepEqual(parseSkillRef('anthropics/skills/frontend-design'), {
52
+ owner: 'anthropics',
53
+ repo: 'skills',
54
+ slug: 'frontend-design',
55
+ });
56
+ assert.deepEqual(parseSkillRef('https://github.com/vercel-labs/skills'), {
57
+ owner: 'vercel-labs',
58
+ repo: 'skills',
59
+ slug: null,
60
+ });
61
+ assert.equal(parseSkillRef('solo-un-slug'), null);
62
+ });
63
+
44
64
  test('buildSkillsPrompt lists enabled skills and is empty without skills', () => {
45
65
  assert.equal(buildSkillsPrompt([]), '');
46
66
  const text = buildSkillsPrompt([{ id: 'demo', name: 'Demo', description: 'desc', body: 'Hacé X.' }]);
@@ -6,6 +6,7 @@ import path from 'node:path';
6
6
  import { resolveInRoot, toRel } from '../src/tools/paths.js';
7
7
  import { evaluatePermission } from '../src/tools/permissions.js';
8
8
  import { createTools, createToolRegistry } from '../src/tools/index.js';
9
+ import { fetchUrlTool } from '../src/tools/executors.js';
9
10
 
10
11
  async function makeWorkspace() {
11
12
  const root = await mkdtemp(path.join(tmpdir(), 'tamnora-ws-'));
@@ -74,6 +75,15 @@ test('permission "ask" is denied by default and allowed with yes', async () => {
74
75
  assert.match(await allowed.execute('write', { path: 'z.txt', content: 'z' }), /Creado|Escrito/);
75
76
  });
76
77
 
78
+ test('fetchUrlTool rejects invalid URLs and private hosts', async () => {
79
+ const root = await makeWorkspace();
80
+ assert.match(await fetchUrlTool(root, { url: 'not-a-url' }), /URL inválida/);
81
+ assert.match(await fetchUrlTool(root, { url: 'ftp://example.com' }), /http\/https/);
82
+ assert.match(await fetchUrlTool(root, { url: 'http://127.0.0.1:9/' }), /host bloqueado/);
83
+ assert.match(await fetchUrlTool(root, { url: 'http://192.168.1.10/' }), /host bloqueado/);
84
+ assert.match(await fetchUrlTool(root, { url: 'http://169.254.169.254/' }), /host bloqueado/);
85
+ });
86
+
77
87
  test('createTools combines local and MCP tool routing', async () => {
78
88
  const root = await makeWorkspace();
79
89
  const mcp = {
@@ -1981,6 +1981,9 @@ function openMcpForm(server = null) {
1981
1981
  $('#mf-command').value = server?.command ?? '';
1982
1982
  $('#mf-args').value = (server?.args ?? []).join(' ');
1983
1983
  $('#mf-url').value = server?.url ?? '';
1984
+ $('#mf-env').value = Object.entries(server?.env ?? {})
1985
+ .map(([key, value]) => `${key}=${value ?? ''}`)
1986
+ .join('\n');
1984
1987
  $('#mf-enabled').checked = server ? server.enabled !== false : true;
1985
1988
  syncMcpFormFields();
1986
1989
  setStatus('#mf-status', '');
@@ -2001,6 +2004,7 @@ async function toggleMcp(server) {
2001
2004
  command: server.command,
2002
2005
  args: server.args,
2003
2006
  url: server.url,
2007
+ env: server.env,
2004
2008
  enabled: !server.enabled,
2005
2009
  },
2006
2010
  }).catch((error) => notify(`No se pudo actualizar MCP: ${error.message}`, 'notice err'));
@@ -2016,6 +2020,18 @@ async function removeMcp(id) {
2016
2020
  refreshMcp();
2017
2021
  }
2018
2022
 
2023
+ function parseEnv(text) {
2024
+ const out = {};
2025
+ for (const line of String(text ?? '').split('\n')) {
2026
+ const trimmed = line.trim();
2027
+ if (!trimmed || trimmed.startsWith('#')) continue;
2028
+ const eq = trimmed.indexOf('=');
2029
+ if (eq === -1) continue;
2030
+ out[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1);
2031
+ }
2032
+ return out;
2033
+ }
2034
+
2019
2035
  async function submitMcp(event) {
2020
2036
  event.preventDefault();
2021
2037
  const id = $('#mf-id').value.trim();
@@ -2026,6 +2042,7 @@ async function submitMcp(event) {
2026
2042
  command: $('#mf-command').value.trim(),
2027
2043
  args: $('#mf-args').value.trim(),
2028
2044
  url: $('#mf-url').value.trim(),
2045
+ env: parseEnv($('#mf-env').value),
2029
2046
  enabled: $('#mf-enabled').checked,
2030
2047
  };
2031
2048
  try {
@@ -2124,8 +2141,52 @@ async function submitSkill(event) {
2124
2141
  refreshSkills();
2125
2142
  }
2126
2143
 
2144
+ async function importMcp() {
2145
+ const input = $('#mcp-import');
2146
+ const value = input.value.trim();
2147
+ if (!value) return;
2148
+ setStatus('#mcp-import-status', 'importando…');
2149
+ const payload = value.startsWith('{')
2150
+ ? { json: value }
2151
+ : /^https?:\/\//i.test(value)
2152
+ ? { url: value }
2153
+ : { name: value };
2154
+ const data = await api('/api/mcp/import', { method: 'POST', body: payload }).catch((e) => ({ error: e.message }));
2155
+ if (data?.error) return setStatus('#mcp-import-status', data.error, 'err');
2156
+ input.value = '';
2157
+ const required = (data.requiredEnv ?? []).length ? ` · requiere env: ${data.requiredEnv.join(', ')}` : '';
2158
+ setStatus('#mcp-import-status', `Importado ${data.imported}${required}`, 'ok');
2159
+ refreshMcp();
2160
+ }
2161
+
2162
+ async function importSkill() {
2163
+ const input = $('#skill-import');
2164
+ const ref = input.value.trim();
2165
+ if (!ref) return;
2166
+ setStatus('#skill-import-status', 'importando desde GitHub…');
2167
+ const data = await api('/api/skills/import', { method: 'POST', body: { ref } }).catch((e) => ({ error: e.message }));
2168
+ if (data?.error) return setStatus('#skill-import-status', data.error, 'err');
2169
+ input.value = '';
2170
+ setStatus('#skill-import-status', `Importada ${data.installed} (${data.source})`, 'ok');
2171
+ refreshSkills();
2172
+ }
2173
+
2127
2174
  if (mcpListEl) {
2128
2175
  $('#mcp-new').addEventListener('click', () => openMcpForm());
2176
+ $('#mcp-import-btn')?.addEventListener('click', importMcp);
2177
+ $('#mcp-import')?.addEventListener('keydown', (event) => {
2178
+ if (event.key === 'Enter') {
2179
+ event.preventDefault();
2180
+ importMcp();
2181
+ }
2182
+ });
2183
+ $('#skill-import-btn')?.addEventListener('click', importSkill);
2184
+ $('#skill-import')?.addEventListener('keydown', (event) => {
2185
+ if (event.key === 'Enter') {
2186
+ event.preventDefault();
2187
+ importSkill();
2188
+ }
2189
+ });
2129
2190
  $('#mcp-reload').addEventListener('click', () => refreshMcp({ connect: true }));
2130
2191
  $('#mcp-close').addEventListener('click', closeMcpForm);
2131
2192
  $('#mf-cancel').addEventListener('click', closeMcpForm);
@@ -306,6 +306,11 @@
306
306
  <p class="muted verify-line">
307
307
  Se combinan con <code>mcp[]</code> de <code>tamnora.json</code>; los guardados acá viven en la base.
308
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>
309
314
  <ul id="mcp-list" class="providers"></ul>
310
315
  </div>
311
316
 
@@ -317,6 +322,11 @@
317
322
  <p class="muted verify-line">
318
323
  Instrucciones especializadas en la casa de Tamnora (<code>skills/</code>). Las habilitadas se inyectan al system prompt.
319
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>
320
330
  <ul id="skill-list" class="providers"></ul>
321
331
  </div>
322
332
  </section>
@@ -431,6 +441,9 @@
431
441
  <div class="row">
432
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>
433
443
  </div>
444
+ <label class="field"><span>env (KEY=valor por línea)</span>
445
+ <textarea id="mf-env" rows="3" spellcheck="false" placeholder="API_KEY=..."></textarea>
446
+ </label>
434
447
  <label class="check"><input id="mf-enabled" type="checkbox" checked /> habilitado</label>
435
448
  <div class="row between settings-actions">
436
449
  <button type="button" id="mf-cancel" class="btn">cancelar</button>
@@ -758,6 +758,8 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 12.5px; }
758
758
  .preview-frame { flex: 1; width: 100%; border: 0; background: #fff; min-height: 0; }
759
759
  #procs-list { flex: 1; min-height: 0; overflow-y: auto; }
760
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; }
761
763
 
762
764
  .file-actions { display: flex; align-items: center; gap: 10px; }
763
765
  .file-modal { width: min(880px, calc(100vw - 40px)); }
@@ -2,9 +2,27 @@
2
2
  // MCP, instalar skills (contenido o URL) y listarlos. Pasarlas por permisos
3
3
  // porque instalar MCP/skills puede ejecutar codigo.
4
4
 
5
- import { parseSkillMarkdown } from './core.js';
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';
6
16
  import { SKILL_ID_PATTERN, skillsView, writeSkill, setSkillEnabled } from './skills.js';
7
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
+
8
26
  const MCP_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,39}$/i;
9
27
 
10
28
  function definition(name, description, parameters, required = []) {
@@ -19,13 +37,15 @@ const DEFINITIONS = [
19
37
  {
20
38
  id: { type: 'string', description: 'Id corto, ej. "echo"' },
21
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})' },
22
42
  transport: { type: 'string', enum: ['stdio', 'http'] },
23
43
  command: { type: 'string', description: 'Para stdio, ej. "node" o "npx"' },
24
44
  args: { type: 'array', items: { type: 'string' } },
25
45
  url: { type: 'string', description: 'Para http' },
26
46
  enabled: { type: 'boolean' },
27
47
  },
28
- ['id', 'transport']
48
+ []
29
49
  ),
30
50
  definition('remove_mcp', 'Elimina un servidor MCP guardado desde la consola.', { id: { type: 'string' } }, ['id']),
31
51
  definition('list_skills', 'Lista las skills instaladas y si están habilitadas.', {}),
@@ -38,9 +58,10 @@ const DEFINITIONS = [
38
58
  description: { type: 'string' },
39
59
  content: { type: 'string', description: 'Contenido markdown de la skill' },
40
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)' },
41
62
  enabled: { type: 'boolean' },
42
63
  },
43
- ['id']
64
+ []
44
65
  ),
45
66
  ];
46
67
 
@@ -96,6 +117,49 @@ export function createAgentTools(ctx) {
96
117
  })), null, 2);
97
118
 
98
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
+
99
163
  const id = String(args.id ?? '').trim();
100
164
  if (!MCP_ID_PATTERN.test(id)) return 'Id de MCP inválido.';
101
165
  const transport = args.transport === 'http' || (!args.command && args.url) ? 'http' : 'stdio';
@@ -128,6 +192,21 @@ export function createAgentTools(ctx) {
128
192
  return JSON.stringify(skillsView(ctx).map(({ id, name, enabled, description }) => ({ id, name, enabled, description })), null, 2);
129
193
 
130
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
+
131
210
  const id = String(args.id ?? '').trim();
132
211
  if (!SKILL_ID_PATTERN.test(id)) return 'Id de skill inválido (a-z, 0-9, - y _).';
133
212
  let content = String(args.content ?? '');
@@ -1,4 +1,10 @@
1
- import { buildMcpRegistry, mcpTransport } from '../lib/core.js';
1
+ import {
2
+ buildMcpRegistry,
3
+ mcpTransport,
4
+ getMcpRegistryServer,
5
+ registryServerToConfig,
6
+ normalizeMcpConfig,
7
+ } from '../lib/core.js';
2
8
  import { readJson, sendJson, sendError } from '../lib/http.js';
3
9
 
4
10
  const ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,39}$/i;
@@ -82,6 +88,56 @@ export function registerMcp(route, ctx) {
82
88
  sendJson(res, 200, { servers });
83
89
  });
84
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
+
85
141
  route('PUT', '/api/mcp/servers/:id', async ({ req, res, params }) => {
86
142
  const id = String(params.id);
87
143
  if (!ID_PATTERN.test(id)) return sendError(res, 400, 'Id de servidor inválido.');
@@ -1,3 +1,5 @@
1
+ import path from 'node:path';
2
+ import { homeDir, parseSkillRef, fetchSkillFromGithub, writeSkillFiles } from '../lib/core.js';
1
3
  import { readJson, sendJson, sendError } from '../lib/http.js';
2
4
  import {
3
5
  SKILL_ID_PATTERN,
@@ -8,7 +10,38 @@ import {
8
10
  setSkillEnabled,
9
11
  } from '../lib/skills.js';
10
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
+
11
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
+
12
45
  route('GET', '/api/skills', ({ res }) => {
13
46
  sendJson(res, 200, { skills: skillsView(ctx) });
14
47
  });
@@ -175,6 +175,46 @@ test('manages MCP servers from the console', async () => {
175
175
  }
176
176
  });
177
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
+
178
218
  test('manages skills from the console', async () => {
179
219
  const { instance, base } = await startServer();
180
220
  const json = { 'Content-Type': 'application/json' };
@@ -832,7 +872,7 @@ test('plan mode exposes only read-only tools and adds guidance', async () => {
832
872
  body: JSON.stringify({ input: 'planificá', model: 'm', mode: 'plan' }),
833
873
  })
834
874
  ).text();
835
- 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']);
836
876
  assert.match(captured.system, /modo PLAN/);
837
877
  } finally {
838
878
  await instance.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieltmn/tamnora",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Harness local para IAs: agente CLI-first con TUI, consola web y evaluacion. Node puro, sin dependencias.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.25.0",