zyn-ai 1.2.1 → 1.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.
@@ -7,6 +7,16 @@ const fsp = fs.promises;
7
7
  const {
8
8
  MAX_FILE_LINES,
9
9
  } = require('../config');
10
+ const {
11
+ buildApiHeaders,
12
+ buildCloneUrl,
13
+ getApiBaseUrl,
14
+ listGitSecrets,
15
+ normalizeProfileName,
16
+ removeGitSecret,
17
+ resolveGitProfile,
18
+ upsertGitSecret,
19
+ } = require('../utils/secretStorage');
10
20
  const { resolveInputPath } = require('../utils/pathUtils');
11
21
  const {
12
22
  formatLineRange,
@@ -26,9 +36,15 @@ const TOOL_DEFINITIONS = [
26
36
  { name: 'append_file', usage: '{ path, content }' },
27
37
  { name: 'replace_in_file', usage: '{ path, search, replace, all? }' },
28
38
  { name: 'fetch_url', usage: '{ url, selector?, attribute?, limit?, headers? }' },
29
- { name: 'web_search', usage: '{ query }' },
39
+ { name: 'fetch', usage: '{ url, method?, headers?, query?, json?, data?, form?, files?, timeoutMs? }' },
40
+ { name: 'fetch_http', usage: '{ url, method?, headers?, query?, json?, data?, form?, files?, timeoutMs? }' },
41
+ { name: 'webfetch', usage: '{ url, headers?, timeoutMs? }' },
42
+ { name: 'scrape_site', usage: '{ url, selectors, limit?, headers? }' },
43
+ { name: 'web_search', usage: '{ query, lang?, limit? }' },
30
44
  { name: 'web_read', usage: '{ url }' },
45
+ { name: 'create_canvas_image', usage: '{ width, height, background?, elements?, format?, outputPath? }' },
31
46
  ];
47
+ const REGISTERED_TOOLS = new Set(TOOL_DEFINITIONS.map(tool => tool.name));
32
48
 
33
49
  function getToolPromptText() {
34
50
  return [
@@ -85,7 +101,19 @@ function getToolPromptText() {
85
101
  ' Con selector + attribute (ej: "href", "src"): extrae atributo.',
86
102
  ' limit: max elementos a extraer (default: 20, max: 50).',
87
103
  '',
88
- 'web_search { query }',
104
+ 'fetch_http { url, method?, headers?, query?, json?, data?, form?, files?, timeoutMs? }',
105
+ ' Cliente HTTP avanzado: soporta headers custom, query params, body JSON/texto, form-data y adjuntar archivos.',
106
+ '',
107
+ 'fetch { url, method?, headers?, query?, json?, data?, form?, files?, timeoutMs? }',
108
+ ' Alias profesional recomendado para solicitudes HTTP avanzadas.',
109
+ '',
110
+ 'webfetch { url, headers?, timeoutMs? }',
111
+ ' Descarga una pagina web y la convierte a Markdown estructurado (enlaces, botones, imagenes, texto).',
112
+ '',
113
+ 'scrape_site { url, selectors, limit?, headers? }',
114
+ ' Scraping avanzado con multiples selectores en una sola llamada.',
115
+ '',
116
+ 'web_search { query, lang?, limit? }',
89
117
  ' Busca en la web via DuckDuckGo. Retorna titulo, URL y snippet de los primeros resultados.',
90
118
  ' Si el usuario pide investigar algo, realiza la busqueda en lugar de explicar como hacerlo.',
91
119
  ' Ejemplo: {"type":"tool","tool":"web_search","args":{"query":"como usar puppeteer node"}}',
@@ -94,6 +122,17 @@ function getToolPromptText() {
94
122
  ' Descarga una pagina web y la convierte a texto legible (sin HTML).',
95
123
  ' Ideal para leer articulos, documentacion o contenido de paginas.',
96
124
  ' Ejemplo: {"type":"tool","tool":"web_read","args":{"url":"https://docs.example.com/guide"}}',
125
+ '',
126
+ '## Imagen profesional con Jimp',
127
+ '',
128
+ 'create_canvas_image { width, height, background?, elements?, format?, outputPath? }',
129
+ ' Crea imagenes desde cero usando Jimp con composicion por elementos.',
130
+ ' width/height son obligatorios. background puede ser color HEX (#RRGGBB o #RRGGBBAA).',
131
+ ' elements permite combinar rect, circle/ellipse, line, text e image.',
132
+ ' Usa este flujo profesional: definir lienzo -> capas base -> tipografia -> detalles -> exportacion.',
133
+ ' Ejemplo:',
134
+ ' {"type":"tool","tool":"create_canvas_image","args":{"width":1200,"height":628,"background":"#0f172a","format":"png","outputPath":"generated/cover.png","elements":[{"type":"rect","x":48,"y":48,"w":1104,"h":532,"radius":24,"fill":"#111827"},{"type":"text","x":96,"y":120,"fontSize":32,"text":"Quarterly Business Report"}]}}',
135
+ '',
97
136
  ].join('\n');
98
137
  }
99
138
 
@@ -131,12 +170,22 @@ function describeToolCall(call) {
131
170
  const sel = call.args.selector ? ` → ${shortText(call.args.selector, 30)}` : '';
132
171
  return `Fetch ${shortText(cleanedUrl, 50)}${sel}`;
133
172
  }
173
+ case 'fetch_http':
174
+ return `HTTP ${String(call.args.method || 'GET').toUpperCase()} ${shortText(cleanUrl(call.args.url || ''), 50)}`;
175
+ case 'fetch':
176
+ return `Fetch ${String(call.args.method || 'GET').toUpperCase()} ${shortText(cleanUrl(call.args.url || ''), 50)}`;
177
+ case 'webfetch':
178
+ return `WebFetch ${shortText(cleanUrl(call.args.url || ''), 50)}`;
179
+ case 'scrape_site':
180
+ return `Scraping ${shortText(cleanUrl(call.args.url || ''), 50)}`;
134
181
  case 'web_search':
135
182
  return `Buscando "${shortText(call.args.query || '', 50)}"`;
136
183
  case 'web_read': {
137
184
  const readUrl = cleanUrl(call.args.url || '');
138
185
  return `Leyendo ${shortText(readUrl, 60)}`;
139
186
  }
187
+ case 'create_canvas_image':
188
+ return `Creando imagen ${call.args.width || '?'}x${call.args.height || '?'}`;
140
189
  default:
141
190
  return call.tool;
142
191
  }
@@ -252,6 +301,35 @@ async function askConfirmation(rl, title, detail, paint, state) {
252
301
  return answer === 's' || answer === 'si' || answer === 'y' || answer === 'yes';
253
302
  }
254
303
 
304
+ function isIpHost(hostname = '') {
305
+ return /^(\d{1,3}\.){3}\d{1,3}$/.test(hostname)
306
+ || /^\[[0-9a-f:]+\]$/i.test(hostname)
307
+ || /^[0-9a-f:]+$/i.test(hostname);
308
+ }
309
+
310
+ async function requireIpConsent(urlValue, state, paint) {
311
+ let parsed;
312
+ try {
313
+ parsed = new URL(urlValue);
314
+ } catch {
315
+ return true;
316
+ }
317
+ if (!isIpHost(parsed.hostname)) return true;
318
+
319
+ if (state?.tuiConfirm) {
320
+ const answer = await state.tuiConfirm('Permiso obligatorio para IP', `Destino IP detectado: ${urlValue}\nConfirma acceso de red explícitamente.`);
321
+ return answer === 's' || answer === 'si' || answer === 'y' || answer === 'yes';
322
+ }
323
+ if (!state?.rl) return false;
324
+ console.error('');
325
+ console.error(` ${paint('!', 'yellow')} Permiso obligatorio para IP`);
326
+ console.error(` ${paint(`Destino IP detectado: ${urlValue}`, 'dim')}`);
327
+ const answer = (await state.rl.question(` ${paint('s/N', 'yellow')} ${paint('\u276F', 'yellow')} `))
328
+ .trim()
329
+ .toLowerCase();
330
+ return answer === 's' || answer === 'si' || answer === 'y' || answer === 'yes';
331
+ }
332
+
255
333
  async function listDirTool(args, state) {
256
334
  const targetPath = resolveInputPath(args.path ?? '.', state.cwd);
257
335
  const entries = await fsp.readdir(targetPath, { withFileTypes: true });
@@ -370,6 +448,7 @@ async function runCommandTool(args, state, paint) {
370
448
  return 'Comando cancelado por el usuario.';
371
449
  }
372
450
 
451
+
373
452
  const result = await runProcess('bash', ['-lc', command], {
374
453
  cwd: state.cwd,
375
454
  timeoutMs: 120000,
@@ -597,23 +676,37 @@ async function fetchUrlTool(args, state, paint) {
597
676
  if (!allowed) {
598
677
  return 'Fetch cancelado por el usuario.';
599
678
  }
679
+ if (!(await requireIpConsent(url, state, paint))) {
680
+ return 'Fetch a IP cancelado por falta de consentimiento explícito.';
681
+ }
600
682
 
601
683
  const axios = require('axios');
602
- const response = await axios({
603
- url,
604
- method: 'GET',
605
- headers: {
606
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
607
- 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
608
- 'Accept-Language': 'es-ES,es;q=0.9,en;q=0.8',
609
- ...(args.headers || {}),
610
- },
611
- timeout: 15000,
612
- maxContentLength: 512000,
613
- maxRedirects: 5,
614
- responseType: 'text',
615
- validateStatus: () => true,
616
- });
684
+ let response;
685
+ let lastErr;
686
+ for (let attempt = 0; attempt < 2; attempt += 1) {
687
+ try {
688
+ response = await axios({
689
+ url,
690
+ method: 'GET',
691
+ headers: {
692
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
693
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
694
+ 'Accept-Language': 'es-ES,es;q=0.9,en;q=0.8',
695
+ ...(args.headers || {}),
696
+ },
697
+ timeout: 15000,
698
+ maxContentLength: 512000,
699
+ maxRedirects: 5,
700
+ responseType: 'text',
701
+ validateStatus: () => true,
702
+ });
703
+ break;
704
+ } catch (err) {
705
+ lastErr = err;
706
+ if (attempt === 0) continue;
707
+ }
708
+ }
709
+ if (!response) throw lastErr || new Error('fetch_url fallo');
617
710
 
618
711
  const body = typeof response.data === 'string'
619
712
  ? response.data
@@ -660,45 +753,219 @@ async function fetchUrlTool(args, state, paint) {
660
753
  return truncateText(parts.join('\n'));
661
754
  }
662
755
 
663
- async function webSearchTool(args, state, paint) {
664
- const query = (args.query || '').trim();
665
- if (!query) throw new Error('web_search requiere query');
756
+ async function fetchHttpTool(args, state, paint) {
757
+ if (!args.url || typeof args.url !== 'string') throw new Error('fetch_http requiere url');
758
+ const method = String(args.method || 'GET').toUpperCase();
759
+ const url = cleanUrl(args.url);
760
+ const detail = `${method} ${url}`;
761
+ const allowed = await askConfirmation(state.rl, 'HTTP avanzado', detail, paint, state);
762
+ if (!allowed) return 'Solicitud cancelada.';
763
+ if (!(await requireIpConsent(url, state, paint))) {
764
+ return 'Solicitud a IP cancelada por falta de consentimiento explícito.';
765
+ }
666
766
 
667
- const allowed = await askConfirmation(
668
- state.rl, 'Buscar en la web', query, paint, state,
669
- );
670
- if (!allowed) return 'Busqueda cancelada.';
767
+ const headers = { ...(args.headers || {}) };
768
+ let body;
769
+ const isCatbox = /catbox\.moe/i.test(url);
770
+ if (args.json && typeof args.json === 'object') {
771
+ body = JSON.stringify(args.json);
772
+ headers['Content-Type'] = headers['Content-Type'] || 'application/json';
773
+ } else if (args.data !== undefined) {
774
+ body = String(args.data);
775
+ } else if (args.form && typeof args.form === 'object') {
776
+ const formPayload = { ...args.form };
777
+ if (isCatbox && !formPayload.reqtype) formPayload.reqtype = 'fileupload';
778
+ const form = new FormData();
779
+ for (const [k, v] of Object.entries(formPayload)) form.append(k, String(v));
780
+ if (Array.isArray(args.files)) {
781
+ for (const file of args.files) {
782
+ if (!file || !file.path || !file.field) continue;
783
+ const filePath = resolveInputPath(file.path, state.cwd);
784
+ const buffer = await fs.promises.readFile(filePath);
785
+ const blob = new Blob([buffer], { type: file.type || 'application/octet-stream' });
786
+ const fieldName = isCatbox ? 'fileToUpload' : String(file.field);
787
+ form.append(fieldName, blob, file.name || path.basename(file.path));
788
+ }
789
+ }
790
+ body = form;
791
+ } else if (Array.isArray(args.files) && args.files.length > 0) {
792
+ const form = new FormData();
793
+ if (isCatbox) form.append('reqtype', 'fileupload');
794
+ for (const file of args.files) {
795
+ if (!file || !file.path) continue;
796
+ const filePath = resolveInputPath(file.path, state.cwd);
797
+ const buffer = await fs.promises.readFile(filePath);
798
+ const blob = new Blob([buffer], { type: file.type || 'application/octet-stream' });
799
+ const fieldName = isCatbox ? 'fileToUpload' : String(file.field || 'file');
800
+ form.append(fieldName, blob, file.name || path.basename(file.path));
801
+ }
802
+ body = form;
803
+ }
804
+ if (body instanceof FormData) {
805
+ delete headers['Content-Type'];
806
+ delete headers['content-type'];
807
+ }
808
+ const finalUrl = new URL(url);
809
+ if (args.query && typeof args.query === 'object') {
810
+ for (const [k, v] of Object.entries(args.query)) finalUrl.searchParams.set(k, String(v));
811
+ }
812
+ let res;
813
+ let lastErr;
814
+ for (let attempt = 0; attempt < 2; attempt += 1) {
815
+ const controller = new AbortController();
816
+ const timeout = setTimeout(() => controller.abort(), Math.max(1000, Number(args.timeoutMs || 20000)));
817
+ try {
818
+ res = await fetch(finalUrl.toString(), { method, headers, body, signal: controller.signal });
819
+ clearTimeout(timeout);
820
+ break;
821
+ } catch (err) {
822
+ clearTimeout(timeout);
823
+ lastErr = err;
824
+ if (attempt === 0) continue;
825
+ }
826
+ }
827
+ if (!res) throw lastErr || new Error('fetch fallo');
828
+ const text = await res.text();
829
+ return truncateText(`Status: ${res.status}\nContent-Type: ${res.headers.get('content-type') || '-'}\n\n${text}`);
830
+ }
671
831
 
832
+ async function scrapeSiteTool(args, state, paint) {
833
+ if (!args.url || typeof args.url !== 'string') throw new Error('scrape_site requiere url');
834
+ if (!args.selectors || typeof args.selectors !== 'object') throw new Error('scrape_site requiere selectors objeto');
835
+ const url = cleanUrl(args.url);
836
+ const allowed = await askConfirmation(state.rl, 'Scrape site', `GET ${url}`, paint, state);
837
+ if (!allowed) return 'Scraping cancelado.';
838
+ if (!(await requireIpConsent(url, state, paint))) return 'Scraping a IP cancelado por falta de consentimiento explícito.';
672
839
  const axios = require('axios');
840
+ const res = await axios({
841
+ url,
842
+ method: 'GET',
843
+ headers: { 'User-Agent': 'Mozilla/5.0', ...(args.headers || {}) },
844
+ timeout: 15000,
845
+ responseType: 'text',
846
+ validateStatus: () => true,
847
+ });
848
+ const body = typeof res.data === 'string' ? res.data : String(res.data || '');
673
849
  const cheerio = require('cheerio');
674
- const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
850
+ const $ = cheerio.load(body);
851
+ const limit = Math.min(Number(args.limit) || 20, 100);
852
+ const out = {};
853
+ for (const [key, spec] of Object.entries(args.selectors)) {
854
+ const selector = typeof spec === 'string' ? spec : spec?.selector;
855
+ const attr = typeof spec === 'object' ? spec.attribute : null;
856
+ if (!selector) continue;
857
+ const arr = [];
858
+ $(selector).each((i, el) => {
859
+ if (i >= limit) return false;
860
+ const node = $(el);
861
+ const tag = String(el.tagName || '').toLowerCase();
862
+ let value = '';
863
+ if (attr) {
864
+ value = node.attr(attr) || '';
865
+ } else if (tag === 'meta') {
866
+ value = node.attr('content') || '';
867
+ } else if (tag === 'title') {
868
+ value = node.text().trim();
869
+ } else {
870
+ value = node.text().trim();
871
+ }
872
+ if (value) arr.push(value);
873
+ });
874
+ out[key] = arr;
875
+ }
876
+ return truncateText(JSON.stringify(out, null, 2));
877
+ }
675
878
 
879
+ function htmlToMarkdown(html) {
880
+ const cheerio = require('cheerio');
881
+ const $ = cheerio.load(html);
882
+ $('script,style,noscript').remove();
883
+ const lines = [];
884
+ const root = $('body').length ? $('body') : $.root();
885
+ root.find('h1,h2,h3,h4,h5,h6,p,li,pre,code,blockquote,a,img,button').each((_, el) => {
886
+ const tag = (el.tagName || '').toLowerCase();
887
+ const node = $(el);
888
+ const text = node.text().trim().replace(/\s+/g, ' ');
889
+ if (!text && !['img', 'a'].includes(tag)) return;
890
+ if (tag.startsWith('h')) lines.push(`${'#'.repeat(Number(tag[1]) || 1)} ${text}`);
891
+ else if (tag === 'li') lines.push(`- ${text}`);
892
+ else if (tag === 'a') lines.push(`[${text || 'link'}](${node.attr('href') || ''})`);
893
+ else if (tag === 'img') lines.push(`![${node.attr('alt') || 'image'}](${node.attr('src') || ''})`);
894
+ else if (tag === 'button') lines.push(`**[Button]** ${text}`);
895
+ else if (tag === 'blockquote') lines.push(`> ${text}`);
896
+ else if (tag === 'pre' || tag === 'code') lines.push(`\`\`\`\n${node.text()}\n\`\`\``);
897
+ else lines.push(text);
898
+ });
899
+ return lines.join('\n\n').trim();
900
+ }
901
+
902
+ async function webfetchTool(args, state, paint) {
903
+ const rawUrl = String(args.url || '').trim();
904
+ if (!rawUrl) throw new Error('webfetch requiere url');
905
+ const url = cleanUrl(rawUrl);
906
+ const allowed = await askConfirmation(state.rl, 'WebFetch HTML → Markdown', `GET ${url}`, paint, state);
907
+ if (!allowed) return 'WebFetch cancelado por el usuario.';
908
+ if (!(await requireIpConsent(url, state, paint))) {
909
+ return 'WebFetch a IP cancelado por falta de consentimiento explícito.';
910
+ }
911
+ const axios = require('axios');
676
912
  const res = await axios({
677
913
  url,
678
914
  method: 'GET',
679
915
  headers: {
680
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
681
- 'Accept-Language': 'es-ES,es;q=0.9,en;q=0.8',
916
+ 'User-Agent': 'Mozilla/5.0',
917
+ 'Accept': 'text/html,application/xhtml+xml',
918
+ ...(args.headers || {}),
682
919
  },
683
- timeout: 15000,
920
+ timeout: Math.max(1000, Number(args.timeoutMs || 20000)),
684
921
  responseType: 'text',
922
+ validateStatus: () => true,
685
923
  });
924
+ const ct = String(res.headers['content-type'] || '').toLowerCase();
925
+ if (!ct.includes('text/html')) {
926
+ throw new Error(`webfetch solo permite HTML. Content-Type recibido: ${ct || 'desconocido'}`);
927
+ }
928
+ const html = typeof res.data === 'string' ? res.data : String(res.data || '');
929
+ const markdown = htmlToMarkdown(html);
930
+ return truncateText(markdown || '[sin contenido markdown]');
931
+ }
686
932
 
687
- const $ = cheerio.load(res.data);
688
- const results = [];
933
+ async function webSearchTool(args, state, paint) {
934
+ const query = (args.query || '').trim();
935
+ if (!query) throw new Error('web_search requiere query');
689
936
 
690
- $('.result').each((i, el) => {
691
- if (i >= 10) return false;
692
- const title = $(el).find('.result__a').text().trim();
693
- const snippet = $(el).find('.result__snippet').text().trim();
694
- const href = $(el).find('.result__url').attr('href')
695
- || $(el).find('.result__a').attr('href') || '';
696
- if (title) results.push(`${i + 1}. ${title}\n ${href}\n ${snippet}`);
697
- });
937
+ const allowed = await askConfirmation(
938
+ state.rl, 'Buscar en la web', query, paint, state,
939
+ );
940
+ if (!allowed) return 'Busqueda cancelada.';
698
941
 
699
- return results.length > 0
700
- ? `Resultados para: ${query}\n\n${results.join('\n\n')}`
701
- : 'Sin resultados para esa busqueda.';
942
+ const cheerio = require('cheerio');
943
+ const lang = String(args.lang || (state.language === 'es' ? 'es-es' : 'us-en')).toLowerCase();
944
+ const limit = Math.max(1, Math.min(Number(args.limit) || 5, 20));
945
+ const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}&kl=${encodeURIComponent(lang)}`;
946
+ const res = await fetch(url, {
947
+ headers: {
948
+ 'User-Agent': 'Mozilla/5.0',
949
+ 'Accept-Language': lang.startsWith('es') ? 'es-ES,es;q=0.9,en;q=0.7' : 'en-US,en;q=0.9',
950
+ },
951
+ });
952
+ const html = await res.text();
953
+ const $ = cheerio.load(html);
954
+ const results = [];
955
+ $('.result').each((_, el) => {
956
+ if (results.length >= limit) return false;
957
+ const titleEl = $(el).find('.result__a').first();
958
+ const snippetEl = $(el).find('.result__snippet').first();
959
+ let href = titleEl.attr('href') || '';
960
+ const match = href.match(/uddg=([^&]+)/);
961
+ if (match) href = decodeURIComponent(match[1]);
962
+ const title = titleEl.text().trim();
963
+ const snippet = snippetEl.text().trim();
964
+ if (title && href) results.push(`${results.length + 1}. ${title}\n ${href}\n ${snippet}`);
965
+ });
966
+ return results.length
967
+ ? `Resultados para: ${query}\nIdioma: ${lang}\n\n${results.join('\n\n')}`
968
+ : 'Sin resultados para esa búsqueda.';
702
969
  }
703
970
 
704
971
  async function webReadTool(args, state, paint) {
@@ -716,6 +983,9 @@ async function webReadTool(args, state, paint) {
716
983
  state.rl, 'Leer pagina web', url, paint, state,
717
984
  );
718
985
  if (!allowed) return 'Lectura cancelada.';
986
+ if (!(await requireIpConsent(url, state, paint))) {
987
+ return 'Lectura a IP cancelada por falta de consentimiento explícito.';
988
+ }
719
989
 
720
990
  const axios = require('axios');
721
991
  const res = await axios({
@@ -751,6 +1021,9 @@ async function webReadTool(args, state, paint) {
751
1021
  }
752
1022
 
753
1023
  async function executeToolCall(call, state, ui) {
1024
+ if (!call || typeof call.tool !== 'string' || !REGISTERED_TOOLS.has(call.tool)) {
1025
+ throw new Error(`Herramienta no registrada: ${call?.tool || 'desconocida'}`);
1026
+ }
754
1027
  ui.logEvent(state, 'tool', describeToolCall(call));
755
1028
 
756
1029
  const startTime = Date.now();
@@ -790,12 +1063,27 @@ async function executeToolCall(call, state, ui) {
790
1063
  case 'fetch_url':
791
1064
  result = await fetchUrlTool(call.args, state, ui.paint);
792
1065
  break;
1066
+ case 'fetch_http':
1067
+ result = await fetchHttpTool(call.args, state, ui.paint);
1068
+ break;
1069
+ case 'fetch':
1070
+ result = await fetchHttpTool(call.args, state, ui.paint);
1071
+ break;
1072
+ case 'webfetch':
1073
+ result = await webfetchTool(call.args, state, ui.paint);
1074
+ break;
1075
+ case 'scrape_site':
1076
+ result = await scrapeSiteTool(call.args, state, ui.paint);
1077
+ break;
793
1078
  case 'web_search':
794
1079
  result = await webSearchTool(call.args, state, ui.paint);
795
1080
  break;
796
1081
  case 'web_read':
797
1082
  result = await webReadTool(call.args, state, ui.paint);
798
1083
  break;
1084
+ case 'create_canvas_image':
1085
+ result = await createCanvasImageTool(call.args, state, ui.paint);
1086
+ break;
799
1087
  default:
800
1088
  throw new Error(`Herramienta no soportada: ${call.tool}`);
801
1089
  }
@@ -819,6 +1107,9 @@ function buildOllamaInstallCommand() {
819
1107
 
820
1108
  function parseDirectAction(input) {
821
1109
  const text = input.trim();
1110
+ if (/^(git|npm|node|pnpm|yarn)\s+/.test(text)) {
1111
+ return { tool: 'run_command', args: { command: text } };
1112
+ }
822
1113
 
823
1114
  const runMatch = text.match(/^(?:ejecuta|corre)\s+(?:el\s+)?comando\s+([\s\S]+)$/i);
824
1115
  if (runMatch) {
@@ -835,6 +1126,19 @@ function parseDirectAction(input) {
835
1126
  };
836
1127
  }
837
1128
 
1129
+ const createRepoMatch = text.match(/^(?:crea|crear|create)\s+(?:un\s+)?(?:repo|repositorio)\s+(?:en\s+)?github\s+([a-z0-9._-]+)$/i);
1130
+ if (createRepoMatch) {
1131
+ return {
1132
+ tool: 'git_api_request',
1133
+ args: {
1134
+ provider: 'github',
1135
+ method: 'POST',
1136
+ path: '/user/repos',
1137
+ body: { name: createRepoMatch[1] },
1138
+ },
1139
+ };
1140
+ }
1141
+
838
1142
  const mkdirMatch = text.match(/^(?:crea|crear|haz)\s+(?:la\s+)?(?:carpeta|directorio)\s+([^\s]+)$/i);
839
1143
  if (mkdirMatch) {
840
1144
  return {
@@ -1002,3 +1306,251 @@ module.exports = {
1002
1306
  parseDirectAction,
1003
1307
  printTools,
1004
1308
  };
1309
+
1310
+
1311
+ function getGitSecretLabel(provider, name = '') {
1312
+ const key = normalizeProfileName(provider);
1313
+ if (key === 'custom') return name ? `custom:${name}` : 'custom';
1314
+ return key;
1315
+ }
1316
+
1317
+ function parseColor(value, fallback = 0x000000ff) {
1318
+ if (typeof value !== 'string' || !value.trim()) return fallback;
1319
+ const raw = value.trim().replace('#', '');
1320
+ if (/^[0-9a-f]{6}$/i.test(raw)) return Number.parseInt(`${raw}ff`, 16) >>> 0;
1321
+ if (/^[0-9a-f]{8}$/i.test(raw)) return Number.parseInt(raw, 16) >>> 0;
1322
+ return fallback;
1323
+ }
1324
+
1325
+ function drawRect(image, x, y, w, h, color) {
1326
+ const left = Math.max(0, Math.floor(Number(x) || 0));
1327
+ const top = Math.max(0, Math.floor(Number(y) || 0));
1328
+ const width = Math.max(1, Math.floor(Number(w) || 0));
1329
+ const height = Math.max(1, Math.floor(Number(h) || 0));
1330
+ image.scan(left, top, width, height, function (px, py, idx) {
1331
+ this.bitmap.data.writeUInt32BE(color >>> 0, idx);
1332
+ });
1333
+ }
1334
+
1335
+ function drawRoundRect(image, x, y, w, h, radius, color) {
1336
+ const left = Math.max(0, Math.floor(Number(x) || 0));
1337
+ const top = Math.max(0, Math.floor(Number(y) || 0));
1338
+ const width = Math.max(1, Math.floor(Number(w) || 0));
1339
+ const height = Math.max(1, Math.floor(Number(h) || 0));
1340
+ const r = Math.max(0, Math.min(Number(radius) || 0, Math.floor(width / 2), Math.floor(height / 2)));
1341
+ if (r <= 0) return drawRect(image, left, top, width, height, color);
1342
+
1343
+ image.scan(left, top, width, height, function (px, py, idx) {
1344
+ const cx = px < left + r ? left + r : px >= left + width - r ? left + width - r - 1 : px;
1345
+ const cy = py < top + r ? top + r : py >= top + height - r ? top + height - r - 1 : py;
1346
+ const dx = px - cx;
1347
+ const dy = py - cy;
1348
+ if ((dx * dx) + (dy * dy) <= r * r) {
1349
+ this.bitmap.data.writeUInt32BE(color >>> 0, idx);
1350
+ }
1351
+ });
1352
+ }
1353
+
1354
+ function drawLine(image, x1, y1, x2, y2, color) {
1355
+ const sx = Number(x1 || 0);
1356
+ const sy = Number(y1 || 0);
1357
+ const tx = Number(x2 || 0);
1358
+ const ty = Number(y2 || 0);
1359
+ const steps = Math.max(1, Math.ceil(Math.max(Math.abs(tx - sx), Math.abs(ty - sy))));
1360
+ for (let i = 0; i <= steps; i++) {
1361
+ const x = Math.round(sx + ((tx - sx) * i / steps));
1362
+ const y = Math.round(sy + ((ty - sy) * i / steps));
1363
+ if (x >= 0 && y >= 0 && x < image.bitmap.width && y < image.bitmap.height) {
1364
+ image.setPixelColor(color, x, y);
1365
+ }
1366
+ }
1367
+ }
1368
+
1369
+ async function createCanvasImageTool(args, state, paint) {
1370
+ const { Jimp, loadFont } = require('jimp');
1371
+ const pluginPrintMain = require.resolve('@jimp/plugin-print');
1372
+ const fontsPath = path.join(path.dirname(pluginPrintMain), 'fonts.js');
1373
+ const fonts = require(fontsPath);
1374
+ const width = Math.max(1, Number(args.width || 0));
1375
+ const height = Math.max(1, Number(args.height || 0));
1376
+ if (!width || !height) {
1377
+ throw new Error('create_canvas_image requiere width y height');
1378
+ }
1379
+
1380
+ const format = String(args.format || 'png').toLowerCase();
1381
+ const safeFormat = ['png', 'jpg', 'jpeg', 'webp', 'bmp', 'gif', 'tiff'].includes(format) ? format : 'png';
1382
+ const fileExt = safeFormat === 'jpeg' ? 'jpg' : safeFormat;
1383
+ const outputPath = resolveInputPath(args.outputPath || path.join('generated', `image-${Date.now()}.${fileExt}`), state.cwd);
1384
+
1385
+ const allowed = await askConfirmation(state.rl, 'Crear imagen', `${width}x${height}\nFormato: ${safeFormat}\nSalida: ${outputPath}`, paint, state);
1386
+ if (!allowed) return 'Creacion de imagen cancelada por el usuario.';
1387
+
1388
+ const bg = args.background && typeof args.background === 'object'
1389
+ ? args.background.color || args.background.fill || '#ffffff'
1390
+ : args.background || '#ffffff';
1391
+ const image = new Jimp({ width, height, color: parseColor(bg, 0xffffffff) });
1392
+ const elements = Array.isArray(args.elements) ? args.elements.filter(Boolean) : [];
1393
+
1394
+ for (const element of elements) {
1395
+ if (!element || typeof element !== 'object') continue;
1396
+ const type = String(element.type || 'text').toLowerCase();
1397
+ if (type === 'rect') {
1398
+ const color = parseColor(element.fill || element.color || '#000000', 0x000000ff);
1399
+ if (element.radius) drawRoundRect(image, element.x || 0, element.y || 0, element.w || element.width || 0, element.h || element.height || 0, element.radius || 0, color);
1400
+ else drawRect(image, element.x || 0, element.y || 0, element.w || element.width || 0, element.h || element.height || 0, color);
1401
+ continue;
1402
+ }
1403
+ if (type === 'line') {
1404
+ drawLine(image, element.x1 || 0, element.y1 || 0, element.x2 || 0, element.y2 || 0, parseColor(element.stroke || '#000000', 0x000000ff));
1405
+ continue;
1406
+ }
1407
+ if (type === 'circle' || type === 'ellipse') {
1408
+ const color = parseColor(element.fill || element.color || '#000000', 0x000000ff);
1409
+ const cx = Number(element.x || 0);
1410
+ const cy = Number(element.y || 0);
1411
+ const rx = Math.max(1, Number(element.rx || element.r || element.radius || element.width || 0));
1412
+ const ry = Math.max(1, Number(element.ry || element.r || element.radius || element.height || rx));
1413
+ image.scan(0, 0, image.bitmap.width, image.bitmap.height, function (px, py, idx) {
1414
+ const dx = (px - cx) / rx;
1415
+ const dy = (py - cy) / ry;
1416
+ if ((dx * dx) + (dy * dy) <= 1) {
1417
+ this.bitmap.data.writeUInt32BE(color >>> 0, idx);
1418
+ }
1419
+ });
1420
+ continue;
1421
+ }
1422
+ if (type === 'image') {
1423
+ const src = element.src || element.url || element.path;
1424
+ if (!src) continue;
1425
+ const loaded = await Jimp.read(src.startsWith('http') || src.startsWith('data:') ? src : resolveInputPath(src, state.cwd));
1426
+ const x = Number(element.x || 0);
1427
+ const y = Number(element.y || 0);
1428
+ const w = Math.max(1, Number(element.w || element.width || loaded.bitmap.width));
1429
+ const h = Math.max(1, Number(element.h || element.height || loaded.bitmap.height));
1430
+ const clone = loaded.clone().resize({ w, h });
1431
+ image.composite(clone, x, y);
1432
+ continue;
1433
+ }
1434
+
1435
+ const text = String(element.text || '');
1436
+ if (!text) continue;
1437
+ const size = Math.max(8, Math.min(64, Number(element.fontSize || 32)));
1438
+ const font = await loadFont(
1439
+ size <= 8 ? fonts.SANS_8_BLACK :
1440
+ size <= 16 ? fonts.SANS_16_BLACK :
1441
+ size <= 32 ? fonts.SANS_32_BLACK :
1442
+ fonts.SANS_64_BLACK,
1443
+ );
1444
+ image.print({ font, x: Number(element.x || 0), y: Number(element.y || 0), maxWidth: element.maxWidth ? Number(element.maxWidth) : undefined }, text);
1445
+ }
1446
+
1447
+ await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
1448
+ await image.write(outputPath);
1449
+ return [`Imagen creada: ${outputPath}`, `Formato: ${safeFormat}`, `Tamano: ${width}x${height}`, `Elementos: ${elements.length}`].join('\\n');
1450
+ }
1451
+
1452
+
1453
+ async function gitSecretSetTool(args) {
1454
+ const provider = normalizeProfileName(args.provider || '');
1455
+ if (!provider) throw new Error('git_secret_set requiere provider');
1456
+ if (!args.token || typeof args.token !== 'string') throw new Error('git_secret_set requiere token');
1457
+ const saved = upsertGitSecret(provider, args);
1458
+ return `Credencial guardada: ${getGitSecretLabel(provider, saved?.name || args.name || '')}`;
1459
+ }
1460
+
1461
+ async function gitSecretListTool(args) {
1462
+ const provider = normalizeProfileName(args.provider || '');
1463
+ const name = String(args.name || '').trim();
1464
+ const secrets = listGitSecrets();
1465
+ const filtered = secrets.filter(secret => {
1466
+ if (!provider) return true;
1467
+ if (provider === 'custom') return !name || secret.key === `custom:${name}`;
1468
+ return secret.key === provider;
1469
+ });
1470
+ if (!filtered.length) return 'No hay credenciales Git guardadas.';
1471
+ const maskToken = (token) => {
1472
+ const value = String(token || '');
1473
+ if (!value) return '-';
1474
+ if (value.length <= 8) return `${value.slice(0, 2)}***`;
1475
+ return `${value.slice(0, 4)}...${value.slice(-4)}`;
1476
+ };
1477
+ return filtered.map(secret => [
1478
+ `${secret.key}`,
1479
+ ` username: ${secret.username || '-'}`,
1480
+ ` apiBaseUrl: ${secret.apiBaseUrl || '-'}`,
1481
+ ` cloneBaseUrl: ${secret.cloneBaseUrl || '-'}`,
1482
+ ` authHeader: ${secret.authHeader || '-'}`,
1483
+ ` tokenMasked: ${maskToken(secret.token)}`,
1484
+ ].join('\n')).join('\n\n');
1485
+ }
1486
+
1487
+ async function gitSecretRemoveTool(args) {
1488
+ const provider = normalizeProfileName(args.provider || '');
1489
+ if (!provider) throw new Error('git_secret_remove requiere provider');
1490
+ const name = String(args.name || '').trim();
1491
+ const removed = removeGitSecret(provider, name);
1492
+ return removed ? `Credencial eliminada: ${getGitSecretLabel(provider, name)}` : 'No encontre esa credencial.';
1493
+ }
1494
+
1495
+ async function gitCloneRepoTool(args, state, paint) {
1496
+ const repoUrl = String(args.repoUrl || '').trim();
1497
+ if (!repoUrl) throw new Error('git_clone_repo requiere repoUrl');
1498
+ const provider = normalizeProfileName(args.provider || '');
1499
+ const name = String(args.name || '').trim();
1500
+ const profile = provider ? resolveGitProfile(provider, name) : null;
1501
+ const finalUrl = buildCloneUrl(repoUrl, profile || {});
1502
+ const destination = args.destination ? resolveInputPath(args.destination, state.cwd) : '';
1503
+ const timeoutMs = Math.max(1000, Number.isFinite(Number(args.timeoutMs)) ? Number(args.timeoutMs) : 10 * 60 * 1000);
1504
+
1505
+ const allowed = await askConfirmation(state.rl, 'Clonar repositorio', [
1506
+ repoUrl,
1507
+ profile ? `Provider: ${provider}${name ? ` (${name})` : ''}` : 'Provider: direct',
1508
+ destination ? `Destino: ${destination}` : null,
1509
+ `Timeout: ${timeoutMs}ms`,
1510
+ ].filter(Boolean).join('\n'), paint, state);
1511
+ if (!allowed) return 'Clonado cancelado por el usuario.';
1512
+
1513
+ const result = await runProcess('git', ['clone', ...(args.branch ? ['--branch', String(args.branch)] : []), finalUrl, ...(destination ? [destination] : [])], { cwd: state.cwd, timeoutMs });
1514
+ const lines = [`Exit code: ${result.code}`];
1515
+ if (result.timedOut) lines.push('Timeout: el clon fue detenido por tiempo.');
1516
+ if (result.stdout.trim()) lines.push(`STDOUT:\n${result.stdout.trim()}`);
1517
+ if (result.stderr.trim()) lines.push(`STDERR:\n${result.stderr.trim()}`);
1518
+ return lines.join('\n\n');
1519
+ }
1520
+
1521
+ async function gitApiRequestTool(args, state, paint) {
1522
+ const provider = normalizeProfileName(args.provider || '');
1523
+ if (!provider) throw new Error('git_api_request requiere provider');
1524
+ const pathValue = String(args.path || '').trim();
1525
+ if (!pathValue) throw new Error('git_api_request requiere path');
1526
+ const name = String(args.name || '').trim();
1527
+ const profile = resolveGitProfile(provider, name);
1528
+ if (!profile) throw new Error(`No hay credenciales para ${provider}${provider === 'custom' && name ? `:${name}` : ''}`);
1529
+ const baseUrl = getApiBaseUrl(provider, profile);
1530
+ if (!baseUrl) throw new Error(`No hay apiBaseUrl para ${provider}`);
1531
+ const url = `${baseUrl.replace(/\/+$/, '')}/${pathValue.replace(/^\/+/, '')}`;
1532
+ const timeoutMs = Math.max(1000, Number.isFinite(Number(args.timeoutMs)) ? Number(args.timeoutMs) : 15000);
1533
+ const method = String(args.method || 'GET').toUpperCase();
1534
+ const headers = buildApiHeaders(provider, profile, args.headers && typeof args.headers === 'object' ? args.headers : {});
1535
+
1536
+ const allowed = await askConfirmation(state.rl, 'Git API request', `${method} ${url}\nTimeout: ${timeoutMs}ms`, paint, state);
1537
+ if (!allowed) return 'Request cancelado por el usuario.';
1538
+
1539
+ const axios = require('axios');
1540
+ const response = await axios({
1541
+ url,
1542
+ method,
1543
+ headers: {
1544
+ 'User-Agent': 'Zyn/1.0',
1545
+ Accept: 'application/json, text/plain, */*',
1546
+ ...headers,
1547
+ },
1548
+ data: args.body && typeof args.body === 'object' ? args.body : args.body,
1549
+ timeout: timeoutMs,
1550
+ responseType: 'text',
1551
+ validateStatus: () => true,
1552
+ });
1553
+
1554
+ const text = typeof response.data === 'string' ? response.data : JSON.stringify(response.data, null, 2);
1555
+ return `Status: ${response.status}\n\n${text}`;
1556
+ }