@danieltmn/tamnora 0.2.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 CHANGED
@@ -5,6 +5,31 @@ 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.0] - 2026-10-02
9
+
10
+ ### Agregado
11
+
12
+ - Tool local `fetch_url` (http/https) para que el agente pueda leer docs, APIs y
13
+ archivos remotos; bloquea hosts privados/loopback y tiene timeout y tope de
14
+ tamano. Disponible en CLI/TUI/consola/Telegram y en modo plan.
15
+ - Importar **skills desde skills.sh**: `POST /api/skills/import` y la tool
16
+ `install_skill` aceptan una URL de skills.sh o un id `owner/repo/skill`, bajan
17
+ el `SKILL.md` (y archivos de apoyo) desde GitHub y lo instalan en la casa.
18
+ (La API de skills.sh pide token OIDC de Vercel; resolvemos por GitHub.)
19
+ - Importar **servidores MCP**: `POST /api/mcp/import` y la tool `install_mcp`
20
+ aceptan el nombre del registro oficial de MCP, un snippet JSON (bloque
21
+ `mcpServers`) o una URL del registro. `mcpservers.org` esta detras de
22
+ Cloudflare y no se puede leer desde el server; se puede pegar su JSON.
23
+ - Consola (ajustes): barras para importar skills y MCP, con estado y aviso de
24
+ variables de entorno requeridas.
25
+
26
+ ### Pendiente
27
+
28
+ - Confirmar el proveedor Xiaomi (`mimo-v2.6-flash`): respondio `429 Too Many
29
+ Requests` (cuota/rate limit del endpoint), no del harness.
30
+ - Telegram: audio y voz (transcribir notas de voz y responder con audio).
31
+ - Plugins: cargador de plugins locales (fase 2 del apartado ajustes).
32
+
8
33
  ## [0.2.0] - 2026-10-02
9
34
 
10
35
  ### Agregado
@@ -43,15 +68,6 @@ y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
43
68
  `No se pudo conectar con <host>...` en vez del crudo `fetch failed` (el error
44
69
  conserva el `code` para seguir siendo reintentable).
45
70
 
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
71
  ## [0.1.0] - 2026-10-01
56
72
 
57
73
  ### 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 = {
@@ -2124,8 +2124,52 @@ async function submitSkill(event) {
2124
2124
  refreshSkills();
2125
2125
  }
2126
2126
 
2127
+ async function importMcp() {
2128
+ const input = $('#mcp-import');
2129
+ const value = input.value.trim();
2130
+ if (!value) return;
2131
+ setStatus('#mcp-import-status', 'importando…');
2132
+ const payload = value.startsWith('{')
2133
+ ? { json: value }
2134
+ : /^https?:\/\//i.test(value)
2135
+ ? { url: value }
2136
+ : { name: value };
2137
+ const data = await api('/api/mcp/import', { method: 'POST', body: payload }).catch((e) => ({ error: e.message }));
2138
+ if (data?.error) return setStatus('#mcp-import-status', data.error, 'err');
2139
+ input.value = '';
2140
+ const required = (data.requiredEnv ?? []).length ? ` · requiere env: ${data.requiredEnv.join(', ')}` : '';
2141
+ setStatus('#mcp-import-status', `Importado ${data.imported}${required}`, 'ok');
2142
+ refreshMcp();
2143
+ }
2144
+
2145
+ async function importSkill() {
2146
+ const input = $('#skill-import');
2147
+ const ref = input.value.trim();
2148
+ if (!ref) return;
2149
+ setStatus('#skill-import-status', 'importando desde GitHub…');
2150
+ const data = await api('/api/skills/import', { method: 'POST', body: { ref } }).catch((e) => ({ error: e.message }));
2151
+ if (data?.error) return setStatus('#skill-import-status', data.error, 'err');
2152
+ input.value = '';
2153
+ setStatus('#skill-import-status', `Importada ${data.installed} (${data.source})`, 'ok');
2154
+ refreshSkills();
2155
+ }
2156
+
2127
2157
  if (mcpListEl) {
2128
2158
  $('#mcp-new').addEventListener('click', () => openMcpForm());
2159
+ $('#mcp-import-btn')?.addEventListener('click', importMcp);
2160
+ $('#mcp-import')?.addEventListener('keydown', (event) => {
2161
+ if (event.key === 'Enter') {
2162
+ event.preventDefault();
2163
+ importMcp();
2164
+ }
2165
+ });
2166
+ $('#skill-import-btn')?.addEventListener('click', importSkill);
2167
+ $('#skill-import')?.addEventListener('keydown', (event) => {
2168
+ if (event.key === 'Enter') {
2169
+ event.preventDefault();
2170
+ importSkill();
2171
+ }
2172
+ });
2129
2173
  $('#mcp-reload').addEventListener('click', () => refreshMcp({ connect: true }));
2130
2174
  $('#mcp-close').addEventListener('click', closeMcpForm);
2131
2175
  $('#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>
@@ -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.0",
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",